談談前端異常捕獲與上報

勞卜發表於2018-03-26

關於

前言

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

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

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

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

異常捕獲方法

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的異常錯誤資訊,這點需要注意。

最終大致的流程圖如下:

談談前端異常捕獲與上報

結語

前端異常捕獲與上報是前端異常監控的前提,瞭解並做好了異常資料的收集和分析才能實現一個完善的錯誤響應和處理機制,最終達成資料視覺化。本文詳細例項程式碼地址:github.com/luozhihao/e…

相關文章