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