談?wù)勄岸水惓2东@與上報(bào)

前言

Hello,大家好,又與大家見(jiàn)面了,這次給大家分享下前端異常監(jiān)控中需要了解的異常捕獲與上報(bào)機(jī)制的一些要點(diǎn),同時(shí)包含了實(shí)戰(zhàn)性質(zhì)的參考代碼和流程。

首先,我們?yōu)槭裁匆M(jìn)行異常捕獲和上報(bào)呢?

正所謂百密一疏,一個(gè)經(jīng)過(guò)了大量測(cè)試及聯(lián)調(diào)的項(xiàng)目在有些時(shí)候還是會(huì)有十分隱蔽的bug存在,這種復(fù)雜而又不可預(yù)見(jiàn)性的問(wèn)題唯有通過(guò)完善的監(jiān)控機(jī)制才能有效的減少其帶來(lái)的損失,因此對(duì)于直面用戶的前端而言,異常捕獲與上報(bào)是至關(guān)重要的。

雖然目前市面上已經(jīng)有一些非常完善的前端監(jiān)控系統(tǒng)存在,如sentrybugsnag等,但是知己知彼,才能百戰(zhàn)不殆,唯有了解原理,摸清邏輯,使用起來(lái)才能得心應(yīng)手。

異常捕獲方法

1. try catch

通常,為了判斷一段代碼中是否存在異常,我們會(huì)這一寫(xiě):

try {
    var a = 1;
    var b = a + c;
} catch (e) {
    // 捕獲處理
    console.log(e); // ReferenceError: c is not defined
}

使用try catch能夠很好的捕獲異常并對(duì)應(yīng)進(jìn)行相應(yīng)處理,不至于讓頁(yè)面掛掉,但是其存在一些弊端,比如需要在捕獲異常的代碼上進(jìn)行包裹,會(huì)導(dǎo)致頁(yè)面臃腫不堪,不適用于整個(gè)項(xiàng)目的異常捕獲。

2. window.onerror

相比try catch來(lái)說(shuō)window.onerror提供了全局監(jiān)聽(tīng)異常的功能:

window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {
    console.log('errorMessage: ' + errorMessage); // 異常信息
    console.log('scriptURI: ' + scriptURI); // 異常文件路徑
    console.log('lineNo: ' + lineNo); // 異常行號(hào)
    console.log('columnNo: ' + columnNo); // 異常列號(hào)
    console.log('error: ' + error); // 異常堆棧信息
};

console.log(a);

如圖:


window.onerror即提供了我們錯(cuò)誤的信息,還提供了錯(cuò)誤行列號(hào),可以精準(zhǔn)的進(jìn)行定位,如此似乎正是我們想要的,但是接下來(lái)便是填坑過(guò)程。

異常捕獲問(wèn)題

1. Script error.

我們合乎情理地在本地頁(yè)面進(jìn)行嘗試捕獲異常,如:

<!-- http://localhost:3031/ -->
<script>
window.onerror = function() {
    console.log(arguments);
};
</script>
<script src="http://cdn.xxx.com/index.js"></script>

這里我們把靜態(tài)資源放到異域上進(jìn)行優(yōu)化加載,但是捕獲的異常信息卻是:


經(jīng)過(guò)分析發(fā)現(xiàn),跨域之后window.onerror是無(wú)法捕獲異常信息的,所以統(tǒng)一返回Script error.,解決方案便是script屬性配置 crossorigin="anonymous" 并且服務(wù)器添加Access-Control-Allow-Origin。

<script src="http://cdn.xxx.com/index.js" crossorigin="anonymous"></script>

一般的CDN網(wǎng)站都會(huì)將Access-Control-Allow-Origin配置為*,意思是所有域都可以訪問(wèn)。

2. sourceMap

解決跨域或者將腳本存放在同域之后,你可能會(huì)將代碼壓縮一下再發(fā)布,這時(shí)候便出現(xiàn)了壓縮后的代碼無(wú)法找到原始報(bào)錯(cuò)位置的問(wèn)題。如圖,我們用webpack將代碼打包壓縮成bundle.js:

// webpack.config.js
var path = require('path');

// webpack 4.1.1
module.exports = {
    mode: 'development',
    entry: './client/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'client')
    }
}

最后我們頁(yè)面引入的腳本文件是這樣的:

!function(e){var o={};function n(r){if(o[r])return o[r].exports;var t=o[r]={i:r,l:!1,exports:{}}...;

所以我們看到的異常信息是這樣的:


lineNo可能是一個(gè)非常小的數(shù)字,一般是1,而columnNo會(huì)是一個(gè)很大的數(shù)字,這里是730,因?yàn)樗写a都?jí)嚎s到了一行。

那么該如何解決呢?聰明的童鞋可能已經(jīng)猜到啟用source-map了,沒(méi)錯(cuò),我們利用webpack打包壓縮后生成一份對(duì)應(yīng)腳本的map文件就能進(jìn)行追蹤了,在webpack中開(kāi)啟source-map功能:

module.exports = {
    ...
    devtool: '#source-map',
    ...
}

打包壓縮的文件末尾會(huì)帶上這樣的注釋?zhuān)?/p>

!function(e){var o={};function n(r){if(o[r])return o[r].exports;var t=o[r]={i:r,l:!1,exports:{}}...;
//# sourceMappingURL=bundle.js.map

意思是該文件對(duì)應(yīng)的map文件為bundle.js.map。下面便是一個(gè)source-map文件的內(nèi)容,是一個(gè)JSON對(duì)象:

version: 3, // Source map的版本
sources: ["webpack:///webpack/bootstrap", ...], // 轉(zhuǎn)換前的文件
names: ["installedModules", "__webpack_require__", ...], // 轉(zhuǎn)換前的所有變量名和屬性名
mappings: "aACA,IAAAA,KAGA,SAAAC...", // 記錄位置信息的字符串
file: "bundle.js", // 轉(zhuǎn)換后的文件名
sourcesContent: ["http:// The module cache var installedModules = {};..."], // 源代碼
sourceRoot: "" // 轉(zhuǎn)換前的文件所在的目錄

如果你想詳細(xì)了解關(guān)于sourceMap的知識(shí),可以前往:JavaScript Source Map 詳解

如此,既然我們拿到了對(duì)應(yīng)腳本的map文件,那么我們?cè)撊绾芜M(jìn)行解析獲取壓縮前文件的異常信息呢?這個(gè)我會(huì)在下面異常上報(bào)的時(shí)候進(jìn)行介紹。

3. MVVM框架

現(xiàn)在越來(lái)越多的項(xiàng)目開(kāi)始使用前端框架,在MVVM框架中如果你一如既往的想使用window.onerror來(lái)捕獲異常,那么很可能會(huì)竹籃打水一場(chǎng)空,或許根本捕獲不到,因?yàn)槟愕漠惓P畔⒈豢蚣茏陨淼漠惓C(jī)制捕獲了。比如Vue 2.x中我們應(yīng)該這樣捕獲全局異常

Vue.config.errorHandler = function (err, vm, info) {
    let { 
        message, // 異常信息
        name, // 異常名稱
        script,  // 異常腳本url
        line,  // 異常行號(hào)
        column,  // 異常列號(hào)
        stack  // 異常堆棧信息
    } = err;
    
    // vm為拋出異常的 Vue 實(shí)例
    // info為 Vue 特定的錯(cuò)誤信息,比如錯(cuò)誤所在的生命周期鉤子
}

目前script、line、column這3個(gè)信息打印出來(lái)是undefined,不過(guò)這些信息在stack中都可以找到,可以通過(guò)正則匹配去進(jìn)行獲取,然后進(jìn)行上報(bào)。

同樣的在react也提供了異常處理的方式,在 React 16.x 版本中引入了 Error Boundary:

class ErrorBoundary extends React.Component {
    constructor(props) {
        super(props);
        this.state = { hasError: false };
    }

    componentDidCatch(error, info) {
        this.setState({ hasError: true });
        
        // 將異常信息上報(bào)給服務(wù)器
        logErrorToMyService(error, info); 
    }

    render() {
        if (this.state.hasError) {
            return '出錯(cuò)了';
        }
    
        return this.props.children;
    }
}

然后我們就可以這樣使用該組件:

<ErrorBoundary>
    <MyWidget />
</ErrorBoundary>

詳見(jiàn)官方文檔:Error Handling in React 16

異常上報(bào)

以上介紹了前端異常捕獲的相關(guān)知識(shí)點(diǎn),那么接下來(lái)我們既然成功捕獲了異常,那么該如何上報(bào)呢?

在腳本代碼沒(méi)有被壓縮的情況下可以直接捕獲后上傳對(duì)應(yīng)的異常信息,這里就不做介紹了,下面主要講解常見(jiàn)的處理壓縮文件上報(bào)的方法。

1. 提交異常

當(dāng)捕獲到異常時(shí),我們可以將異常信息傳遞給接口,以window.onerror為例:

window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {

    // 構(gòu)建錯(cuò)誤對(duì)象
    var errorObj = {
        errorMessage: errorMessage || null,
        scriptURI: scriptURI || null,
        lineNo: lineNo || null,
        columnNo: columnNo || null,
        stack: error && error.stack ? error.stack : null
    };

    if (XMLHttpRequest) {
        var xhr = new XMLHttpRequest();
    
        xhr.open('post', '/middleware/errorMsg', true); // 上報(bào)給node中間層處理
        xhr.setRequestHeader('Content-Type', 'application/json'); // 設(shè)置請(qǐng)求頭
        xhr.send(JSON.stringify(errorObj)); // 發(fā)送參數(shù)
    }
}

2. sourceMap解析

其實(shí)source-map格式的文件是一種數(shù)據(jù)類(lèi)型,既然是數(shù)據(jù)類(lèi)型那么肯定有解析它的辦法,目前市面上也有專(zhuān)門(mén)解析它的相應(yīng)工具包,在瀏覽器環(huán)境或者node環(huán)境下比較流行的是一款叫做'source-map'的插件。

通過(guò)require該插件,前端瀏覽器可以對(duì)map文件進(jìn)行解析,但因?yàn)榍岸私馕鏊俣容^慢,所以這里不做推薦,我們還是使用服務(wù)器解析。如果你的應(yīng)用有node中間層,那么你完全可以將異常信息提交到中間層,然后解析map文件后將數(shù)據(jù)傳遞給后臺(tái)服務(wù)器,中間層代碼如下:

const express = require('express');
const fs = require('fs');
const router = express.Router();
const fetch = require('node-fetch');
const sourceMap = require('source-map');
const path = require('path');
const resolve = file => path.resolve(__dirname, file);

// 定義post接口
router.post('/errorMsg/', function(req, res) {
    let error = req.body; // 獲取前端傳過(guò)來(lái)的報(bào)錯(cuò)對(duì)象
    let url = error.scriptURI; // 壓縮文件路徑

    if (url) {
        let fileUrl = url.slice(url.indexOf('client/')) + '.map'; // map文件路徑

        // 解析sourceMap
        let smc = new sourceMap.SourceMapConsumer(fs.readFileSync(resolve('../' + fileUrl), 'utf8')); // 返回一個(gè)promise對(duì)象
        
        smc.then(function(result) {
        
            // 解析原始報(bào)錯(cuò)數(shù)據(jù)
            let ret = result.originalPositionFor({
                line: error.lineNo, // 壓縮后的行號(hào)
                column: error.columnNo // 壓縮后的列號(hào)
            });
            
            let url = ''; // 上報(bào)地址
        
            // 將異常上報(bào)至后臺(tái)
            fetch(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    errorMessage: error.errorMessage, // 報(bào)錯(cuò)信息
                    source: ret.source, // 報(bào)錯(cuò)文件路徑
                    line: ret.line, // 報(bào)錯(cuò)文件行號(hào)
                    column: ret.column, // 報(bào)錯(cuò)文件列號(hào)
                    stack: error.stack // 報(bào)錯(cuò)堆棧
                })
            }).then(function(response) {
                return response.json();
            }).then(function(json) {
                res.json(json);         
            });
        })
    }
});

module.exports = router;

這里我們通過(guò)前端傳過(guò)來(lái)的異常文件路徑獲取服務(wù)器端map文件地址,然后將壓縮后的行列號(hào)傳遞給sourceMap返回的promise對(duì)象進(jìn)行解析,通過(guò)originalPositionFor方法我們能獲取到原始的報(bào)錯(cuò)行列號(hào)和文件地址,最后通過(guò)ajax將需要的異常信息統(tǒng)一傳遞給后臺(tái)存儲(chǔ),完成異常上報(bào)。下圖可以看到控制臺(tái)打印出了經(jīng)過(guò)解析后的真是報(bào)錯(cuò)位置和文件:

附:source-map API

3. 注意點(diǎn)

以上是異常捕獲和上報(bào)的主要知識(shí)點(diǎn)和流程,還有一些需要注意的地方,比如你的應(yīng)用訪問(wèn)量很大,那么一個(gè)小異常都可能會(huì)把你的服務(wù)器搞掛,所以上報(bào)的時(shí)候可以進(jìn)行信息過(guò)濾和采樣等,設(shè)置一個(gè)調(diào)控開(kāi)關(guān),服務(wù)器也可以對(duì)相似的異常進(jìn)行過(guò)濾,在一個(gè)時(shí)間段內(nèi)不進(jìn)行多次存儲(chǔ)。另外window.onerror這樣的異常捕獲不能捕獲promise的異常錯(cuò)誤信息,這點(diǎn)需要注意。

最終大致的流程圖如下:


結(jié)語(yǔ)

前端異常捕獲與上報(bào)是前端異常監(jiān)控的前提,了解并做好了異常數(shù)據(jù)的收集和分析才能實(shí)現(xiàn)一個(gè)完善的錯(cuò)誤響應(yīng)和處理機(jī)制,最終達(dá)成數(shù)據(jù)可視化。本文詳細(xì)實(shí)例代碼地址:https://github.com/luozhihao/error-catch-report

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容