手寫個(gè)前端小玩具—錯(cuò)誤捕獲定位工具
前言
作為一個(gè)兢兢業(yè)業(yè)的前端er,雖然每天都被各種CRUD的需求包圍著,但總歸還是有一顆愛玩的心。
正文
我們在平時(shí)的工作中,開發(fā)功能的同時(shí)不可能把場景考慮的面面俱到,而生產(chǎn)環(huán)境往往情況是非常復(fù)雜的,用戶錄入進(jìn)去的數(shù)據(jù)總是千奇百怪,那如果遇到問題的話,我們又要如何進(jìn)行排查呢?總不可能讓用戶錄個(gè)屏吧哈哈~所以我們就出現(xiàn)了前端埋點(diǎn)的操作,不過埋點(diǎn)的方向以及文章都挺多的,也都挺復(fù)雜的,這篇文章我們就講一個(gè)比較有趣的錯(cuò)誤捕獲思路。
我們平時(shí)在使用框架開發(fā)遇到bug時(shí),比如Vue,如果是在本地環(huán)境,我們在控制臺(tái)可以很容易的找到出現(xiàn)問題的文件,甚至點(diǎn)擊進(jìn)入即可直接定位到我們的文件中對(duì)應(yīng)報(bào)錯(cuò)的位置,這樣排查問題就比較方便。而在生產(chǎn)環(huán)境,我們可以配置sourcemap,就也能比較方便的定位到問題出現(xiàn)的地方。但這樣的話就會(huì)出現(xiàn)一個(gè)問題,首先上傳到服務(wù)器的包體積就會(huì)因?yàn)樯闪撕芏鄊ap文件而變得很大,其次我們的網(wǎng)站代碼會(huì)非常容易暴露甚至是直接被調(diào)試,而且這樣子也僅僅是我們自測的時(shí)候去發(fā)現(xiàn)問題,無法監(jiān)測到用戶端到底是做了什么操作才出現(xiàn)的問題。
那么,有沒有一個(gè)方法是可以監(jiān)控到客戶端用戶操作時(shí),出現(xiàn)問題的代碼位置呢?
思考:
綜上,我們這次要做的這個(gè)工具的目的就比較明確了:
- 錯(cuò)誤捕獲
- 錯(cuò)誤分析/錯(cuò)誤定位
- 錯(cuò)誤收集/日志輸出
前置
在錯(cuò)誤捕獲之前,我們先提前了解一個(gè)服務(wù)端的庫——source-map
使用source-map庫,我們可以通過向該庫暴露出的方法中傳入bug出現(xiàn)的文件對(duì)應(yīng)的map文件,以及錯(cuò)誤的行數(shù)和列數(shù),通過對(duì)應(yīng)的方法解析后,我們可以得到該錯(cuò)誤出現(xiàn)的源文件以及具體在源文件中的定位。
至此,我們明確了錯(cuò)誤捕獲中,我們主要就是想拿四個(gè)信息:
- 錯(cuò)誤的message信息
- 錯(cuò)誤出現(xiàn)的文件名
- 錯(cuò)誤行數(shù)
- 錯(cuò)誤列數(shù)
那么,我們可不可以設(shè)計(jì)這樣一個(gè)流程呢?
- 1.在配置文件中將sourcemap的配置打開,從而使得項(xiàng)目打包后會(huì)生成map文件。
- 2.通過編寫webpack插件,監(jiān)聽webpack打包完成鉤子,在打包完成后觸發(fā),將生成的map文件自動(dòng)上傳到我們的服務(wù)器上。
- 3.然后在前端,通過錯(cuò)誤捕獲,將報(bào)錯(cuò)信息傳給我們的服務(wù)器,由服務(wù)器根據(jù)報(bào)錯(cuò)信息再結(jié)合map文件,最終解析出我們的報(bào)錯(cuò)行數(shù),同時(shí)形成日志輸出出來并記錄下來。
這樣的話,我們就可以非常方便的捕獲錯(cuò)誤,監(jiān)控生產(chǎn)問題,同時(shí)也實(shí)現(xiàn)了一個(gè)簡單的webpack插件(又可以拿去和面試官吹水了~)。
錯(cuò)誤捕獲
onerror
前端的錯(cuò)誤捕獲我們最常見的當(dāng)然是window.onerror了,我們可以通過定義window.onerror函數(shù)來對(duì)全局錯(cuò)誤進(jìn)行捕獲。
// main.js
window.onerror = function(message, source, lineno, colno) {
console.log(message)
console.log(source)
console.log(lineno)
console.log(colno)
}
通過window.onerror我們很容易可以拿到我們想要的具體信息。
圖片
errorHandler
但window.onerror并不能捕獲到框架組件生命周期的錯(cuò)誤,所以我們可以再補(bǔ)充一個(gè)框架的錯(cuò)誤捕獲,以Vue為例:
// main.js
...
const app = createApp(App)
app.use(store).use(router).mount('#app')
app.config.errorHandler = function (err, vm, info) {
console.log(err)
console.log(vm)
console.log(info)
};
我們在errorHandler事件中,可以拿到錯(cuò)誤對(duì)象err,vue實(shí)例,錯(cuò)誤信息。這里我們并不能像上面onerror錯(cuò)誤捕獲一樣很方便的取出出錯(cuò)的行數(shù)和列數(shù),但我們能夠拿到一個(gè)完整的錯(cuò)誤堆棧對(duì)象,那么我們就可以對(duì)錯(cuò)誤對(duì)象的堆棧信息進(jìn)行處理,提取出我們想要的行數(shù)和列數(shù)。
這里用到了一個(gè)堆棧解析工具——StackTrace-Parser
npm install stacktrace-parser
app.config.errorHandler = function (err, vm, info) {
const errInfo = stackTraceParser.parse(err.stack)[0]
const message = err.message // 錯(cuò)誤message
const lineno = errInfo.lineNumber // 錯(cuò)誤行數(shù)
const colno = errInfo.column // 錯(cuò)誤列數(shù)
const source = errInfo.file // 錯(cuò)誤出現(xiàn)的文件名
...
};
補(bǔ)充
錯(cuò)誤捕獲還有一個(gè)onunhandledrejection的事件,用于捕獲Promise類型的錯(cuò)誤,但是經(jīng)過嘗試發(fā)現(xiàn)不是很好去拿到錯(cuò)誤的定位信息,同時(shí),考慮到一般Promise我們會(huì)使用catch去處理異常的操作,所以這里就暫時(shí)不處理這個(gè)類型的錯(cuò)誤事件了。
至此,我們的捕獲相關(guān)的邏輯已經(jīng)完成,剩下的就是如何設(shè)計(jì)服務(wù)端,如何將這些信息傳遞給服務(wù)端并完成解析了。
錯(cuò)誤分析/錯(cuò)誤定位
服務(wù)端,我們設(shè)計(jì)兩個(gè)接口,一個(gè)用于上傳map文件(upload),一個(gè)用于接收錯(cuò)誤信息(sendErrorLog)。
上傳接口就不多說了,主要就是在前端打包完成之后,服務(wù)端接收傳過來的map文件。我們主要看一下接收錯(cuò)誤信息的接口邏輯。
const handleErrorMessage = require("./utils/index");
...
app.post("/sendErrorLog", (req, res) => {
handleErrorMessage(req.body);
res.send("hello");
});
// utils/index.js
const fs = require("fs");
const { SourceMapConsumer } = require("source-map");
const path = require("path");
// 讀取壓縮代碼和對(duì)應(yīng)的source map
const arr = fs.readdirSync(path.resolve(__dirname, "../uploads"));
const sourceMap = {};
for (let i = 0; i < arr.length; i++) {
fs.readFile(
path.resolve(__dirname, "../uploads", arr[i]),
"utf-8",
function (err, data) {
if (err) {
return err;
}
sourceMap[arr[i]] = data;
}
);
}
module.exports = function handleErrorMessage(message) {
const errorLine = message.lineno;
const errorCol = message.colno;
const jsName = message.source.split("/").pop();
const sourceName = jsName + ".map";
// 服務(wù)器因?yàn)槭且恢眴?dòng)狀態(tài),所以如果是在啟動(dòng)后最新上傳的文件,則需要事實(shí)進(jìn)行讀取對(duì)應(yīng)的map文件
if (!sourceMap[sourceName]) {
sourceMap[sourceName] = fs.readFileSync(
path.resolve(__dirname, "../uploads", sourceName),
"utf-8"
);
}
SourceMapConsumer.with(sourceMap[sourceName], null, (consumer) => {
// 在源碼堆棧中定位報(bào)錯(cuò)位置
const originalPosition = consumer.originalPositionFor({
line: errorLine,
column: errorCol,
});
console.log("Error occurred at:");
console.log("file:" + originalPosition.source);
console.log("line:" + originalPosition.line);
console.log("column:" + originalPosition.column);
console.log("message:" + message.message);
});
};
整體的思路就是:
- 服務(wù)器啟動(dòng)時(shí)讀取upload文件夾下的所有map文件,將對(duì)應(yīng)文件的內(nèi)容讀取出來
- 在sendErrorLog接口被調(diào)用后,通過source-map庫去解析錯(cuò)誤信息
- 輸出錯(cuò)誤日志
這里考慮到一般服務(wù)器我們都是一直啟動(dòng)的狀態(tài),所以在調(diào)用解析邏輯之前,先判斷souceMap數(shù)據(jù)是否已經(jīng)讀取出來,如果沒有讀取出來,再同步去讀取,之后再去解析錯(cuò)誤信息。
完善前端邏輯
接口已經(jīng)有了,這里我們再回過頭完善一下前端的邏輯。
首先,我們根據(jù)前面對(duì)錯(cuò)誤捕獲的了解,完成一下錯(cuò)誤上傳的邏輯,:
// main.js
import axios from 'axios'
import * as stackTraceParser from 'stacktrace-parser';
...
// 生產(chǎn)環(huán)境再去做上傳錯(cuò)誤處理
if (process.env.NODE_ENV == "production") {
// 捕獲框架內(nèi)部錯(cuò)誤
app.config.errorHandler = function (err, vm, info) {
const errInfo = stackTraceParser.parse(err.stack)[0]
const message = err.message
const lineno = errInfo.lineNumber
const colno = errInfo.column
const source = errInfo.file
axios
.post("http://127.0.0.1:3000/sendErrorLog", {
message,
lineno,
colno,
source,
})
.then((data) => {
console.log(data);
});
};
// 捕獲js報(bào)錯(cuò)
window.onerror = function(message, source, lineno, colno) {
axios
.post("http://127.0.0.1:3000/sendErrorLog", {
message,
lineno,
colno,
source,
})
.then((data) => {
console.log(data);
});
}
}
然后,我們開始實(shí)現(xiàn)map文件上傳的邏輯。
我們先去找一個(gè)webpack打包完成輸出文件后的鉤子——afterEmit。
圖片
在這個(gè)鉤子觸發(fā)時(shí),說明打包文件已經(jīng)被輸出出來了,我們可以去讀取打包文件的js文件夾,從中過濾出map文件,上傳至服務(wù)器,同時(shí)在打包文件中將map文件進(jìn)行刪除操作。
const pluginName = "SendMapWebpackPlugin";
const fs = require("fs");
const axios = require("axios");
const path = require('path')
class SendMapWebpackPlugin {
apply(compiler) {
const outputPath = compiler.options.output.path;
compiler.hooks.afterEmit.tap(pluginName, (compilation) => {
console.log("webpack 構(gòu)建");
console.log(process.env.NODE_ENV);
if (process.env.NODE_ENV == "production") {
fs.readdir(outputPath + "/js", function (err, data) {
if (data) {
data.forEach((v) => {
// 如果讀取到的數(shù)據(jù)是以map結(jié)尾,則將map文件上傳到服務(wù)器
if (v.endsWith(".map")) {
const file = fs.readFileSync(
path.resolve(__dirname, "../dist/js", v),
"utf-8"
);
axios({
url: "http://127.0.0.1:3000/upload",
method: "post",
data: { file, fileName: v },
headers: {
"Content-Type": "application/octet-stream",
},
})
.then((res) => {
console.log("success");
fs.rm(path.resolve(__dirname, "../dist/js", v), (err) => {
if(err) {
console.log(err)
return
}
console.log('delete success')
})
})
.catch((err) => {
console.log(err);
});
}
});
}
});
}
});
}
}
...
測試效果
邏輯寫完了,我們在前端代碼中留下一些bug來測試一下效果。
圖片
圖片
然后,我們執(zhí)行npm run build打包操作。
可以看到我們打包完成后的dist文件夾中,已經(jīng)沒有了map文件:
圖片
而在服務(wù)端,我們接收到了這些map文件。
圖片
上傳map文件邏輯沒有問題,接下來,我們看一下錯(cuò)誤解析邏輯。
我們可以在本地安裝一個(gè)serve包,便于我們快捷的以dist文件夾為基礎(chǔ)起一個(gè)小型服務(wù)器。
將dist文件夾在終端中打開,執(zhí)行執(zhí)行serve -p 8080。
圖片
點(diǎn)擊按鈕觸發(fā)bug,我們可以看到錯(cuò)誤已被成功捕獲,并將對(duì)應(yīng)的信息通過接口傳遞給服務(wù)端。
圖片
圖片
在服務(wù)端的輸出中,我們可以看到已對(duì)錯(cuò)誤進(jìn)行了解析,錯(cuò)誤發(fā)生的定位信息已經(jīng)輸出出來了,對(duì)照前端文件中錯(cuò)誤發(fā)生的位置也是沒有問題的。