1. <strong id="7actg"></strong>
    2. <table id="7actg"></table>

    3. <address id="7actg"></address>
      <address id="7actg"></address>
      1. <object id="7actg"><tt id="7actg"></tt></object>

        什么?原來前端錯誤上報這么簡單??!

        共 28775字,需瀏覽 58分鐘

         ·

        2024-08-06 09:15

           

        點(diǎn)擊上方 前端Q,關(guān)注公眾號

        回復(fù)加群,加入前端Q技術(shù)交流群

        前言

        項(xiàng)目上線之后,用戶如果出現(xiàn)錯誤(代碼報錯、資源加載失敗以及其他情況),基本上沒有辦法復(fù)現(xiàn),如果用戶出了問題但是不反饋或直接不用了,對開發(fā)者或公司來說都是損失。

        由于我這個項(xiàng)目比較小,只是一個迷你商城,所以不需要收集很復(fù)雜的數(shù)據(jù),只需要知道有沒有資源加載失敗、哪行代碼報錯就可以了,市面上有很多現(xiàn)成的監(jiān)控平臺比如sentry,在這里我選擇通過nodejs自己搭一個服務(wù)。

        概述

        我的項(xiàng)目是使用Vue2寫的,所以本文主要是講Vue相關(guān)的部署過程

        1、部署后臺服務(wù)(使用express)

        2、收集前端錯誤(主要是Vue)

        3、提交信息到后臺分析源碼位置及記錄日志

        js異常處理

        function test1 ({
            console.log('test1 Start');
            console.log(a);
            console.log('test1 End');
        }

        function test2 ({
            console.log('test2 Start');
            console.log('test2 End');
        }

        test1();
        test2();

        這里可以看到,當(dāng)js運(yùn)行報錯后,代碼就不往下執(zhí)行了,這是因?yàn)閖s是單線程,具體可以看看事件循環(huán),這里不做解釋

        接下來看看使用異步的方式執(zhí)行,可以看到?jīng)]有影響代碼的繼續(xù)運(yùn)行

        function test1 ({
            console.log('test1 Start');
            console.log(a);
            console.log('test1 End')
        }

        function test2 ({
            console.log('test2 Start');
            console.log('test2 End')
        }

        setTimeout(() => {
            test1();
        }, 0)

        setTimeout(() => {
            test2();
        }, 0)

        那報錯之后我們?nèi)绾问占e誤呢?

        try catch

        function test1 ({
            console.log('test1 Start');
            console.log(a);
            console.log('test1 End')
        }

        try {
          test1();
        catch (e) {
          console.log(e);
        }

        使用try catch將代碼包裹起來之后,當(dāng)運(yùn)行報錯時,會將收集到的錯誤傳到catch的形參中,打印之后我們可以拿到錯誤信息和錯誤的堆棧信息,但是try catch無法捕獲到異步的錯誤

        function test1 ({
            console.log('test1 Start');
            console.log(a);
            console.log('test1 End')
        }

        try {
          setTimeout(function({
            test1();
          }, 100);
        catch (e) {
          console.log(e);
        }

        可以看到try catch是無法捕獲到異步錯誤的,這時候就要用到windowerror事件

        監(jiān)聽error事件

        window.addEventListener('error', args => {
          console.log(args);
          return true;
        }, true)

        function test1 ({
            console.log('test1 Start');
            console.log(a);
            console.log('test1 End')
        }

        setTimeout(function({
          test1();
        }, 100);

        除了window.addEventListener可以監(jiān)聽error之后,window.onerror也可以監(jiān)聽error,但是window.onerrorwindow.addEventListener相比,無法監(jiān)聽網(wǎng)絡(luò)異常

        window.addEventListener

        <img src="https://www.baidu.com/abcdefg.gif">
        <script>
          window.addEventListener('error', args => {
            console.log(args);
            return true;
          }, true// 捕獲
        </script>

        window.onerror

        <img src="https://www.baidu.com/abcdefg.gif">
        <script>
          window.onerror = function(...args{
            console.log(args);
          }
        </script>

        由于無法監(jiān)聽到,這里就不放圖了

        unhandledrejection

        到目前為止,Promise已經(jīng)成為了開發(fā)者的標(biāo)配,加上新特性引入了async await,解決了回調(diào)地獄的問題,但window.onerrorwindow.addEventListener,對Promise報錯都是無法捕獲

        window.addEventListener('error', error => {
          console.log('window', error);
        })
        new Promise((resolve, reject) => {
          console.log(a);
        }).catch(error => {
          console.log('catch', error);
        })


        可以看到,監(jiān)聽window上的error事件是沒有用的,可以每一個Promise寫一個catch,如果覺得麻煩,那么就要使用一個新的事件,unhandledrejection

        window.addEventListener('unhandledrejection', error => {
          console.log('window', error);
        })
        new Promise((resolve, reject) => {
          console.log(a);
        })

        其中,reason中存放著錯誤相關(guān)信息,reason.message是錯誤信息,reason.stack是錯誤堆棧信息

        Promise錯誤也可以使用 try catch捕獲到,這里就不做演示了

        至此,js中同步、異步、資源加載Promise、async/await都有相對應(yīng)的捕獲方式

        window.addEventListener('unhandledrejection', error => {
          console.log('window', error);
          throw error.reason;
        })

        window.addEventListener('error', error => {
          console.log(error);
          return true;
        }, true)

        vue異常處理

        由于我的項(xiàng)目使用Vue2搭建的,所以還需要處理一下vue的報錯

        export default {
          name'App',
          mounted() {
            console.log(aaa);
          }
        }

        現(xiàn)在的項(xiàng)目基本上都是工程化的,通過工程化工具打包出來的代碼長這樣,上面的代碼打包后運(yùn)行

        通過報錯提示的js文件,查看后都是壓縮混淆之后的js代碼,這時候就需要打包時生成的source map文件了,這個文件中保存著打包后代碼和源碼對應(yīng)的位置,我們只需要拿到報錯的堆棧信息,通過轉(zhuǎn)換,就能通過source map找到對應(yīng)我們源碼的文件及出錯的代碼行列信息

        那我們怎么才能監(jiān)聽error事件呢?

        使用Vue的全局錯誤處理函數(shù)Vue.config.errorHandler

        src/main.js中寫入以下代碼

        Vue.config.errorHandler = (err, vm, info) => {
          console.log('Error: ', err);
          console.log('vm', vm);
          console.log('info: ', info);
        }

        現(xiàn)在打包vue項(xiàng)目

        打包vue之后然后通過端口訪問index.html,不建議你雙擊打開,如果你沒改過打包相關(guān)的東西,雙擊打開是不行的,可以通過vs code裝插件live server,然后將打包文件夾通過vs code打開

        上報錯誤數(shù)據(jù)

        經(jīng)過上述的異常處理后,我們需要將收集到的錯誤進(jìn)行整理,將需要的信息發(fā)送到后臺,我這里選擇使用ajax發(fā)請求到后端,當(dāng)然你也可以使用創(chuàng)建一個圖片標(biāo)簽,將需要發(fā)送的數(shù)據(jù)拼接到src上

        這里我選擇使用tracekit庫來解析錯誤的堆棧信息,axios發(fā)請求,dayjs格式化時間

        npm i tracekit
        npm i axios
        npm i dayjs

        安裝完成后在src/main.js中引入tracekit、axios、dayjs

        上報Vue錯誤

        import TraceKit from 'tracekit';
        import axios from 'axios';
        import dayjs from 'dayjs';

        const protcol = window.location.protocol;
        let errorMonitorUrl = `${protcol}//127.0.0.1:9999`;
        const errorMonitorVueInterFace = 'reportVueError'// vue錯誤上報接口
        TraceKit.report.subscribe((error) => {
          const { message, stack } = error || {};

          const obj = {
            message,
            stack: {
              column: stack[0].column,
              line: stack[0].line,
              func: stack[0].func,
              url: stack[0].url
            }
          };

          axios({
            method'POST',
            url`${errorMonitorUrl}/${errorMonitorVueInterFace}`,
            data: {
              error: obj,
              data: {
                errTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
                isMobile/iPhone|iPad|iPod|Android/i.test(navigator.userAgent), // 是否移動端
                isWechat/MicroMessenger/i.test(navigator.userAgent), // 是否微信瀏覽器
                isIOS/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream, // 兩個都是false就是未知設(shè)備
                isAndroid/Android/.test(navigator.userAgent) && !/Windows Phone/.test(navigator.userAgent)
              },
              browserInfo: {
                userAgent: navigator.userAgent,
                protcol: protcol
              }
            }
          }).then(() => {
            console.log('錯誤上報成功');
          }).catch(() => {
            console.log('錯誤上報失敗');
          });
        });

        Vue.config.errorHandler = (err, vm, info) => {
          TraceKit.report(err);
        }

        如果你還需要其他的數(shù)據(jù)就自己加

        打包vue之后然后通過端口訪問index.html,不建議你雙擊打開,如果你沒改過打包相關(guān)的東西,雙擊打開是不行的,可以通過vs code裝插件live server,然后將打包文件夾通過vs code打開

        現(xiàn)在去項(xiàng)目中看看發(fā)出去的請求參數(shù)是什么

        可以看到我們需要的數(shù)據(jù)都已經(jīng)收集到了,上報失敗是肯定的,因?yàn)槲覀冞€沒有寫好接口

        上報window錯誤

        接下來在監(jiān)聽windowerror事件,也向后臺發(fā)送一個錯誤上報請求

        const errorMonitorWindowInterFace = 'reportWindowError'// window錯誤上報接口
        window.addEventListener('error', args => {
          const err = args.target.src || args.target.href;
          const obj = {
            message'加載異常' + err
          };
          if (!err) {
            return true;
          }
          axios({
            method'POST',
            url`${errorMonitorUrl}/${errorMonitorWindowInterFace}`,
            data: {
              error: obj,
              data: {
                errTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
                isMobile/iPhone|iPad|iPod|Android/i.test(navigator.userAgent), // 是否移動端
                isWechat/MicroMessenger/i.test(navigator.userAgent), // 是否微信瀏覽器
                isIOS/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream, // 兩個都是false就是未知設(shè)備
                isAndroid/Android/.test(navigator.userAgent) && !/Windows Phone/.test(navigator.userAgent)
              },
              browserInfo: {
                userAgent: navigator.userAgent,
                protcol: protcol
              }
            }
          }).then(() => {
            console.log('錯誤上報成功');
          }).catch(() => {
            console.log('錯誤上報失敗');
          });
          return true;
        }, true);

        搭建監(jiān)控后臺

        創(chuàng)建一個文件夾,名字隨便,然后在終端中打開文件夾,初始化npm

        npm init -y

        初始化完成后創(chuàng)建一個server.js,這里我使用express進(jìn)行搭建后端,source-map用于解析js.map文件,這些庫后面會用到

        npm i express
        npm i nodemon
        npm i source-map

        下好包之后在server.js中輸入以下代碼,然后在終端輸入nodemon server.js

        const express = require('express');
        const path = require('path');
        const fs = require('fs');

        const PORT = 9999;

        const app = express();
        app.use(express.urlencoded({ extendedtrue }));
        app.use(express.json());

        app.get('/', (req, res) => {
          res.send('Hello World!').status(200);
        })

        app.listen(PORT, () => {
          console.log(`服務(wù)啟動成功,端口號為:${PORT}`)
        })


        服務(wù)啟動之后,訪問本地的9999端口,查看是否生效,當(dāng)看到屏幕上顯示Hello World!表示我們的后端服務(wù)成功跑起來了,接下來就是寫錯誤的上傳接口

        在這里我將為Vue和Window監(jiān)控分別寫一個接口(因?yàn)槲覒械靡粋€接口做判斷區(qū)分,如果你覺得兩個接口太麻煩,那你也可以自己優(yōu)化成一個接口)

        編寫Vue錯誤上報接口

        server.js中繼續(xù)添加

        const SourceMap = require('source-map');

        app.post('/reportVueError',async (req, res) => {
          const urlParams = req.body;
          console.log(`收到Vue錯誤報告`);
          console.log('urlParams', urlParams);

          const stack = urlParams.error.stack;
          // 獲取文件名
          const fileName = path.basename(stack.url);
          // 查找map文件
          const filePath = path.join(__dirname, 'uploads', fileName + '.map');
          const readFile = function (filePath{
            return new Promise((resolve, reject) => {
              fs.readFile(filePath, { encoding'utf-8'}, (err, data) => {
                if (err) {
                  console.log('readFileErr', err)
                  return reject(err);
                }
                resolve(JSON.parse(data));
              })
            })
          }

          async function searchSource({ filePath, line, column }{
            const rawSourceMap = await readFile(filePath);
            const consumer = await new SourceMap.SourceMapConsumer(rawSourceMap);
            const res = consumer.originalPositionFor({ line, column })

            consumer.destroy();
            return res;
          }

          let sourceMapParseResult = '';
          try {
            // 解析sourceMap結(jié)果
            sourceMapParseResult = await searchSource({ filePath, line: stack.line, column: stack.column });
          } catch (err) {
            sourceMapParseResult = err;
          }
          console.log('解析結(jié)果', sourceMapParseResult)
          res.send({
            data'錯誤上報成功',
            status200,
          }).status(200);
        })

        然后nodemon會自動重啟服務(wù),如果你不是用nodemon啟動的,那自己手動重啟一下

        打包vue之后然后通過端口訪問index.html,不建議你雙擊打開,如果你沒改過打包相關(guān)的東西,雙擊打開是不行的,可以通過vs code裝插件live server,然后將打包文件夾通過vs code打開,通過live server運(yùn)行,此時應(yīng)該會報跨域問題

        設(shè)置允許跨域

        可以自己手動設(shè)置響應(yīng)頭實(shí)現(xiàn)跨域,我這里選擇使用cors

        bash
        npm i cors
        const cors = require('cors');
        app.use(cors()); // 這條需要放在 const app = express(); 后

        此時重新運(yùn)行后臺,再觀察

        此時發(fā)現(xiàn),解析map文件報錯了,那是因?yàn)槲覀冞€沒有上傳map文件

        server.js同級目錄下創(chuàng)建一個uploads文件夾

        回到打包vue打包文件目錄dist,將js文件夾中所有js.map結(jié)尾的文件剪切到創(chuàng)建的文件夾中,如果你打包文件中沒有js.map,那是因?yàn)槟銢]有打開生成js.map的開關(guān),打開vue.config.js,在defineConfig中設(shè)置屬性productionSourceMaptrue,然后重新打包就可以了

        module.exports = defineConfig({
          productionSourceMaptrue// 設(shè)置為true,然后重新打包
          transpileDependenciestrue,
          lintOnSavefalse,
          configureWebpack: {
            devServer: {
              clientfalse
            }
          }
        })

        為什么是剪切?如果真正的項(xiàng)目上線時,你把js.map文件上傳了,別人拿到之后是可以知道你的源碼的,所以必須剪切,或者復(fù)制之后回到dist目錄刪掉所有js.map



        這時候我們再刷新網(wǎng)頁,然后看后臺的輸出,顯示src/App.vue的第10行有錯



        編寫window錯誤上傳接口

        // 處理Window報錯
        app.post('/reportWindowError',async (req, res) => {
          const urlParams = req.body;
          console.log(`收到Window錯誤報告`);
          console.log('urlParams', urlParams);

          res.send({
            data'錯誤上報成功',
            status200,
          }).status(200);
        })

        此時我們?nèi)ue項(xiàng)目中添加一個img標(biāo)簽,獲取一張不存在的圖片即可出發(fā)錯誤,由于不用解析,所以這里就不再上傳js.map


        寫入日志

        錯誤上報之后我們需要記錄下來,接下來我們改造一下接口,收到報錯之后寫一下日志

        我需要知道哪一天的日志報錯了,所有我在node項(xiàng)目中也下載dayjs用來格式化時間

        npm i dayjs

        此處的日志記錄內(nèi)容只是我自己需要的格式,如果你需要其他格式請自己另外添加

        vue錯誤寫入日志

        // let sourceMapParseResult = '';
        // try {
        //  // 解析sourceMap結(jié)果
        //  sourceMapParseResult = await searchSource({ filePath, line: stack.line, column: //stack.column });
        //} catch (err) {
        //  sourceMapParseResult = err;
        //}
        //console.log('解析結(jié)果', sourceMapParseResult)

        // 直接將下面的內(nèi)容粘貼在上面的log下面

        const today = dayjs().format('YYYY-MM-DD'// 今天

        const logDirPath = path.join(__dirname, 'log');
        const logFilePath = path.resolve(__dirname, 'log/' + `log-${today}.txt`)

        if (!fs.existsSync(logDirPath)) {
          console.log(`創(chuàng)建log文件夾`)
          fs.mkdirSync(logDirPath, { recursivetrue });
        }
        if (!fs.existsSync(logFilePath)) {
          console.log(`創(chuàng)建${today}日志文件`)
          fs.writeFileSync(logFilePath, '''utf8');
        }

        const writeStream = fs.createWriteStream(logFilePath, { flags'a' });
        writeStream.on('open', () => {
          // writeStream.write('UUID:' + urlParams.data.uuid + '\n');
          writeStream.write('錯誤類型:Window' + '\n');
          writeStream.write('錯誤發(fā)生時間:' + urlParams.data.errTime + '\n');
          writeStream.write('IP:' + req.ip + '\n');
          writeStream.write(`安卓: ${urlParams.data.isAndroid} IOS: ${urlParams.data.isIOS} 移動端: ${urlParams.data.isMobile} 微信: ${urlParams.data.isWechat} (安卓和ios同時為false表示未知設(shè)備)` + '\n');
          writeStream.write('用戶代理:' + urlParams.browserInfo.userAgent + '\n');
          writeStream.write('錯誤信息:' + urlParams.error.message + '\n');
          writeStream.write('---------------------------------- \n');

          writeStream.end(() => {
            console.log('vue錯誤日志寫入成功');
            console.log('---------------------');
            res.send({
              data'錯誤上報成功',
              status200,
            }).status(200);
          });
        })

        writeStream.on('error', err => {
          res.send({
            data'錯誤上報失敗',
            status404,
          }).status(404);
          console.error('發(fā)生錯誤:', err);
        })

        window錯誤寫入日志

        和vue寫入的方式差不多,存在優(yōu)化空間

        const today = dayjs().format('YYYY-MM-DD'// 今天

        const logDirPath = path.join(__dirname, 'log');
        const logFilePath = path.join(__dirname, 'log' + `/log-${today}.txt`)

        if (!fs.existsSync(logDirPath)) {
          console.log(`創(chuàng)建log文件夾`)
          fs.mkdirSync(logDirPath, { recursivetrue });
        }
        if (!fs.existsSync(logFilePath)) {
          console.log(`創(chuàng)建${today}日志文件`)
          fs.writeFileSync(logFilePath, '''utf8');
        }

        const writeStream = fs.createWriteStream(logFilePath, { flags'a' });
        writeStream.on('open', () => {
          writeStream.write('錯誤類型:Window' + '\n');
          writeStream.write('錯誤發(fā)生時間:' + urlParams.data.errTime + '\n');
          writeStream.write('IP:' + req.ip + '\n');
          writeStream.write(`安卓: ${urlParams.data.isAndroid} IOS: ${urlParams.data.isIOS} 移動端: ${urlParams.data.isMobile} 微信: ${urlParams.data.isWechat} (安卓和ios同時為false表示未知設(shè)備)` + '\n');
          writeStream.write('用戶代理:' + urlParams.browserInfo.userAgent + '\n');
          writeStream.write('錯誤信息:' + urlParams.error.message + '\n');
          writeStream.write('---------------------------------- \n');

          writeStream.end(() => {
            console.log('window錯誤日志寫入成功');
            console.log('---------------------');
            res.send({
              data'錯誤上報成功',
              status200,
            }).status(200);
          });
        })

        writeStream.on('error', err => {
          res.send({
            data'錯誤上報失敗',
            status404,
          }).status(404);
          console.error('發(fā)生錯誤:', err);
        })



        至此,收集錯誤,上報錯誤,寫入日志已經(jīng)全部完成。

        其他

        錯誤監(jiān)控持久化運(yùn)行在服務(wù)器

        這個可以使用pm2,在服務(wù)器上使用node全局安裝pm2

        pm2 ls #顯示所有pm2啟動的應(yīng)用
        pm2 start /xxx/xxx # 啟動/xxx/xxx應(yīng)用
        pm2 save # 保存當(dāng)前應(yīng)用列表
        pm2 stop id # id 通過pm2 ls查看
        pm2 logs id # 查看日志

        自動上傳js.map文件

        如果每次打包后都手動復(fù)制js.map文件的到uploads文件夾下,似乎有些麻煩

        雖然麻煩,但是我自己還是沒有自動上傳,原因是如果打包就自動上傳,那么如果項(xiàng)目還未發(fā)布,但是文件已經(jīng)替換掉之前的文件了,新版本未發(fā)布之前,vue的錯誤就無法解析了,當(dāng)然,如果你每次上傳都不刪除以前的文件也是可以的

        修改vue項(xiàng)目

        在vue項(xiàng)目src下創(chuàng)建一個plugin目錄,新建一個UploadSourceMap.js,將下面的代碼粘貼進(jìn)去

        const glob = require('glob')
        const path = require('path')
        const http = require('http')
        const fs = require('fs')

        class UploadSourceMap {
          constructor (options) {
            this.options = options
          }

          apply (compiler) {
            console.log('UploadSourceMap')

            // 在打包完成后運(yùn)行
            compiler.hooks.done.tap('UploadSourceMap'async stats => {
              const list = glob.sync(path.join(stats.compilation.outputOptions.path, '**/*.js.map'))
              for (const item of list) {
                const fileName = path.basename(item);
                console.log(`開始上傳${fileName}`)
                await this.upload(this.options.url, item)
                console.log(`上傳${fileName}完成`)
              }
            })
          }

          upload (url, file) {
            return new Promise((resolve, reject) => {
              const req = http.request(
                `${url}/upload?name=${path.basename(file)}`,
                {
                  method'POST',
                  headers: {
                    'Content-Type''application/octet-stream',
                    Connection'keep-alive',
                    'Transfer-Encoding''chunked'
                  }
                }
              )
              fs.createReadStream(file)
                .on('data', chunk => {
                  req.write(chunk)
                })
                .on('end', () => {
                  req.end()
                  // 刪除文件
                  fs.unlink(file, (err) => {
                    if (err) {
                      console.error(err)
                    }
                  })
                  resolve()
                })
            })
          }
        }

        module.exports = UploadSourceMap

        修改vue.config.js

        主要是引入UploadSourceMap,并且在configureWebpack => plugins下使用

        const { defineConfig } = require('@vue/cli-service')
        const UploadSourceMap = require('./src/plugin/UploadSourceMap')

        module.exports = defineConfig({
          productionSourceMaptrue,
          transpileDependenciestrue,
          lintOnSavefalse,
          configureWebpack: {
            plugins: [
              new UploadSourceMap({
                url'http://127.0.0.1:9999' // 后面換成自己的服務(wù)器地址
              })
            ]
          }
        })


        修改后臺

        修改server.js,新增一個上傳文件的接口

        app.post('/upload', (req, res) => {
          const fileName = req.query.name
          const filePath = path.join(__dirname, 'uploads', fileName)

          if (!fs.existsSync(path.dirname(filePath))) {
            fs.mkdirSync(path.dirname(filePath), { recursivetrue })
          }

          const writeStream = fs.createWriteStream(filePath)

          req.on('data', (chunk) => {
            writeStream.write(chunk)
          })

          req.on('end', () => {
            writeStream.end(() => {
              res.status(200).send(`File ${fileName} has been saved.`)
            })
          })

          writeStream.on('error', (err) => {
            fs.unlink(filePath, () => {
              console.error(`Error writing file ${fileName}${err}`)
              // res.status(500).send(`Error writing file ${fileName}.`)
            })
          })
        })

        然后現(xiàn)在重新打包,觀察打包輸出

        最后

        盡量是不要開啟跨域,否則誰都能給發(fā)請求到后臺,如果要開跨域,那需要做好判斷,主域名不符合的直接返回404終止這次請求。

        市面上的監(jiān)控有很多,有些甚至能實(shí)現(xiàn)錄制用戶操作生成gif,本文只是實(shí)現(xiàn)一個基本的錯誤監(jiān)控,如有錯誤請指出。

        源碼參考:https://github.com/liuxulin0626/error-monitor-demo

        原文地址:https://juejin.cn/post/7383955685368086562

        作者:用戶不會像你這么操作的

        如有錯誤,歡迎指正。

             

        往期推薦


        threejs做特效:實(shí)現(xiàn)物體的發(fā)光效果-EffectComposer詳解!
        Node.js + typescript 寫一個命令批處理輔助工具
        源碼視角,Vue3為什么推薦使用ref而不是reactive

        最后


        • 歡迎加我微信,拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...

        • 歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個專業(yè)的技術(shù)人...

        點(diǎn)個在看支持我吧

        瀏覽 163
        1點(diǎn)贊
        評論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        1點(diǎn)贊
        評論
        收藏
        分享

        手機(jī)掃一掃分享

        分享
        舉報
        1. <strong id="7actg"></strong>
        2. <table id="7actg"></table>

        3. <address id="7actg"></address>
          <address id="7actg"></address>
          1. <object id="7actg"><tt id="7actg"></tt></object>
            国产免费91 | 操大逼小说 | 思思热免费播放视频 | 欧美aaa在线 | 欧美黄色电影网 | 熟女伦乱| 色婷婷五月激情 | 婷婷色色五月 | 黄色无码视频在线观看 | 国产美女被菊爆在线播放 |