天天看點

談談前端異常捕獲與上報

關于

  • 微信公衆号:前端呼啦圈(Love-FED)
  • 我的部落格:勞蔔的部落格
  • 知乎專欄:前端呼啦圈

前言

Hello,大家好,又與大家見面了,這次給大家分享下前端異常監控中需要了解的異常捕獲與上報機制的一些要點,同時包含了實戰性質的參考代碼和流程。

首先,我們為什麼要進行異常捕獲和上報呢?

正所謂百密一疏,一個經過了大量測試及聯調的項目在有些時候還是會有十分隐蔽的bug存在,這種複雜而又不可預見性的問題唯有通過完善的監控機制才能有效的減少其帶來的損失,是以對于直面使用者的前端而言,異常捕獲與上報是至關重要的。

雖然目前市面上已經有一些非常完善的前端監控系統存在,如sentry、bugsnag等,但是知己知彼,才能百戰不殆,唯有了解原理,摸清邏輯,使用起來才能得心應手。

異常捕獲方法

1. try catch

通常,為了判斷一段代碼中是否存在異常,我們會這一寫:

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

使用try catch能夠很好的捕獲異常并對應進行相應處理,不至于讓頁面挂掉,但是其存在一些弊端,比如需要在捕獲異常的代碼上進行包裹,會導緻頁面臃腫不堪,不适用于整個項目的異常捕獲。

2. window.onerror

相比try catch來說window.onerror提供了全局監聽異常的功能:

window.onerror = function(errorMessage, scriptURI, lineNo, columnNo, error) {
    console.log('errorMessage: ' + errorMessage); // 異常資訊
    console.log('scriptURI: ' + scriptURI); // 異常檔案路徑
    console.log('lineNo: ' + lineNo); // 異常行号
    console.log('columnNo: ' + columnNo); // 異常列号
    console.log('error: ' + error); // 異常堆棧資訊
};

console.log(a);
           

如圖:

談談前端異常捕獲與上報

window.onerror即提供了我們錯誤的資訊,還提供了錯誤行列号,可以精準的進行定位,如此似乎正是我們想要的,但是接下來便是填坑過程。

異常捕獲問題

1. Script error.

我們合乎情理地在本地頁面進行嘗試捕獲異常,如:

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

這裡我們把靜态資源放到異域上進行優化加載,但是捕獲的異常資訊卻是:

談談前端異常捕獲與上報

經過分析發現,跨域之後window.onerror是無法捕獲異常資訊的,是以統一傳回Script error.,解決方案便是script屬性配置 crossorigin=”anonymous” 并且伺服器添加Access-Control-Allow-Origin。

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

一般的CDN網站都會将Access-Control-Allow-Origin配置為*,意思是所有域都可以通路。

2. sourceMap

解決跨域或者将腳本存放在同域之後,你可能會将代碼壓縮一下再釋出,這時候便出現了壓縮後的代碼無法找到原始報錯位置的問題。如圖,我們用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')
    }
}
           

最後我們頁面引入的腳本檔案是這樣的:

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

是以我們看到的異常資訊是這樣的:

談談前端異常捕獲與上報

lineNo可能是一個非常小的數字,一般是1,而columnNo會是一個很大的數字,這裡是730,因為所有代碼都壓縮到了一行。

那麼該如何解決呢?聰明的童鞋可能已經猜到啟用source-map了,沒錯,我們利用webpack打包壓縮後生成一份對應腳本的map檔案就能進行追蹤了,在webpack中開啟source-map功能:

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

打包壓縮的檔案末尾會帶上這樣的注釋:

!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
           

意思是該檔案對應的map檔案為bundle.js.map。下面便是一個source-map檔案的内容,是一個JSON對象:

version: 3, // Source map的版本
sources: ["webpack:///webpack/bootstrap", ...], // 轉換前的檔案
names: ["installedModules", "__webpack_require__", ...], // 轉換前的所有變量名和屬性名
mappings: "aACA,IAAAA,KAGA,SAAAC...", // 記錄位置資訊的字元串
file: "bundle.js", // 轉換後的檔案名
sourcesContent: ["// The module cache var installedModules = {};..."], // 源代碼
sourceRoot: "" // 轉換前的檔案所在的目錄
           

如果你想詳細了解關于sourceMap的知識,可以前往:JavaScript Source Map 詳解

如此,既然我們拿到了對應腳本的map檔案,那麼我們該如何進行解析擷取壓縮前檔案的異常資訊呢?這個我會在下面異常上報的時候進行介紹。

3. MVVM架構

現在越來越多的項目開始使用前端架構,在MVVM架構中如果你一如既往的想使用window.onerror來捕獲異常,那麼很可能會竹籃打水一場空,或許根本捕獲不到,因為你的異常資訊被架構自身的異常機制捕獲了。比如Vue 2.x中我們應該這樣捕獲全局異常:

Vue.config.errorHandler = function (err, vm, info) {
    let { 
        message, // 異常資訊
        name, // 異常名稱
        script,  // 異常腳本url
        line,  // 異常行号
        column,  // 異常列号
        stack  // 異常堆棧資訊
    } = err;

    // vm為抛出異常的 Vue 執行個體
    // info為 Vue 特定的錯誤資訊,比如錯誤所在的生命周期鈎子
}
           

目前script、line、column這3個資訊列印出來是undefined,不過這些資訊在stack中都可以找到,可以通過正則比對去進行擷取,然後進行上報。

同樣的在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 });

        // 将異常資訊上報給伺服器
        logErrorToMyService(error, info); 
    }

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

        return this.props.children;
    }
}
           

然後我們就可以這樣使用該元件:

<ErrorBoundary>
    <MyWidget />
</ErrorBoundary>
           

詳見官方文檔:Error Handling in React 16

異常上報

以上介紹了前端異常捕獲的相關知識點,那麼接下來我們既然成功捕獲了異常,那麼該如何上報呢?

在腳本代碼沒有被壓縮的情況下可以直接捕獲後上傳對應的異常資訊,這裡就不做介紹了,下面主要講解常見的處理壓縮檔案上報的方法。

1. 送出異常

當捕獲到異常時,我們可以将異常資訊傳遞給接口,以window.onerror為例:

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

    // 建構錯誤對象
    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); // 上報給node中間層處理
        xhr.setRequestHeader('Content-Type', 'application/json'); // 設定請求頭
        xhr.send(JSON.stringify(errorObj)); // 發送參數
    }
}
           

2. sourceMap解析

其實source-map格式的檔案是一種資料類型,既然是資料類型那麼肯定有解析它的辦法,目前市面上也有專門解析它的相應工具包,在浏覽器環境或者node環境下比較流行的是一款叫做’source-map’的插件。

通過require該插件,前端浏覽器可以對map檔案進行解析,但因為前端解析速度較慢,是以這裡不做推薦,我們還是使用伺服器解析。如果你的應用有node中間層,那麼你完全可以将異常資訊送出到中間層,然後解析map檔案後将資料傳遞給背景伺服器,中間層代碼如下:

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; // 擷取前端傳過來的報錯對象
    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')); // 傳回一個promise對象

        smc.then(function(result) {

            // 解析原始報錯資料
            let ret = result.originalPositionFor({
                line: error.lineNo, // 壓縮後的行号
                column: error.columnNo // 壓縮後的列号
            });

            let url = ''; // 上報位址

            // 将異常上報至背景
            fetch(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    errorMessage: error.errorMessage, // 報錯資訊
                    source: ret.source, // 報錯檔案路徑
                    line: ret.line, // 報錯檔案行号
                    column: ret.column, // 報錯檔案列号
                    stack: error.stack // 報錯堆棧
                })
            }).then(function(response) {
                return response.json();
            }).then(function(json) {
                res.json(json);         
            });
        })
    }
});

module.exports = router;
           

這裡我們通過前端傳過來的異常檔案路徑擷取伺服器端map檔案位址,然後将壓縮後的行列号傳遞給sourceMap傳回的promise對象進行解析,通過originalPositionFor方法我們能擷取到原始的報錯行列号和檔案位址,最後通過ajax将需要的異常資訊統一傳遞給背景存儲,完成異常上報。下圖可以看到控制台列印出了經過解析後的真是報錯位置和檔案:

談談前端異常捕獲與上報

附:source-map API

3. 注意點

以上是異常捕獲和上報的主要知識點和流程,還有一些需要注意的地方,比如你的應用通路量很大,那麼一個小異常都可能會把你的伺服器搞挂,是以上報的時候可以進行資訊過濾和采樣等,設定一個調控開關,伺服器也可以對相似的異常進行過濾,在一個時間段内不進行多次存儲。另外window.onerror這樣的異常捕獲不能捕獲promise的異常錯誤資訊,這點需要注意。

最終大緻的流程圖如下:

談談前端異常捕獲與上報

結語

前端異常捕獲與上報是前端異常監控的前提,了解并做好了異常資料的收集和分析才能實作一個完善的錯誤響應和處理機制,最終達成資料可視化。本文詳細執行個體代碼位址:https://github.com/luozhihao/error-catch-report