前端異常捕獲和日誌上報

一、異常捕獲

對於前端來說,我們需要的異常捕獲無非爲以下兩種:

  • 接口調用情況;
  • 頁面邏輯是否錯誤,例如,用戶進入頁面後頁面顯示白屏;

對於接口調用情況,在前端通常需要上報客戶端相關參數,例如:用戶OS與瀏覽器版本、請求參數(如頁面ID);而對於頁面邏輯是否錯誤問題,通常除了用戶OS與瀏覽器版本外,需要的是報錯的堆棧信息及具體報錯位置。

異常捕獲方法

全局捕獲

可以通過全局監聽異常來捕獲,通過window.onerror或者addEventListener,看以下例子:

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); // 異常堆棧信息
  // ...
  // 異常上報
};
throw new Error('這是一個錯誤');

 

 

通過window.onerror事件,可以得到具體的異常信息、異常文件的URL、異常的行號與列號及異常的堆棧信息,再捕獲異常後,統一上報至我們的日誌服務器。

亦或是,通過window.addEventListener方法來進行異常上報,道理同理:

window.addEventListener('error', function() {
  console.log(error);
  // ...
  // 異常上報
});
throw new Error('這是一個錯誤');

 

 

try... catch

使用try... catch雖然能夠較好地進行異常捕獲,不至於使得頁面由於一處錯誤掛掉,但try ... catch捕獲方式顯得過於臃腫,太多代碼使用try ... catch包裹,影響代碼可讀性。

常見問題

跨域腳本無法準確捕獲異常

通常情況下,我們會把靜態資源,如JavaScript腳本放到專門的靜態資源服務器,亦或者CDN,看以下例子:

<!DOCTYPE html>
<html>
<head>
  <title></title>
</head>
<body>
  <script type="text/javascript">
    // 在index.html
    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); // 異常堆棧信息
      // ...
      // 異常上報
    };

  </script>
  <script src="./error.js"></script>
</body>
</html>
// error.js
throw new Error('這是一個錯誤');

 

 

結果顯示,跨域之後window.onerror根本捕獲不到正確的異常信息,而是統一返回一個Script error

解決方案:對script標籤增加一個crossorigin=”anonymous”,並且服務器添加Access-Control-Allow-Origin

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

sourceMap

通常在生產環境下的代碼是經過webpack打包後壓縮混淆的代碼,所以我們可能會遇到這樣的問題,如圖所示:

 

我們發現所有的報錯的代碼行數都在第一行了,爲什麼呢?這是因爲在生產環境下,我們的代碼被壓縮成了一行。

在我的開發過程中也遇到過這個問題,我在開發一個功能組件庫的時候,使用npm link了我的組件庫,但是由於組件庫被npm link後是打包後的生產環境下的代碼,所有的報錯都定位到了第一行。

解決辦法是開啓webpacksource-map,我們利用webpack打包後的生成的一份.map的腳本文件就可以讓瀏覽器對錯誤位置進行追蹤了。此處可以參考webpack document

其實就是webpack.config.js中加上一行devtool: 'source-map',如下所示,爲示例的webpack.config.js

var path = require('path');
module.exports = {
    devtool: 'source-map',
    mode: 'development',
    entry: './client/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'client')
    }
}

webpack打包後生成對應的source-map,這樣瀏覽器就能夠定位到具體錯誤的位置:

 

開啓source-map的缺陷是兼容性,目前只有Chrome瀏覽器和Firefox瀏覽器纔對source-map支持。不過我們對這一類情況也有解決辦法。可以使用引入npm庫來支持source-map,可以參考mozilla/source-map。這個npm庫既可以運行在客戶端也可以運行在服務端,不過更爲推薦的是在服務端使用Node.js對接收到的日誌信息時使用source-map解析,以避免源代碼的泄露造成風險,如下代碼所示:

const express = require('express');
const fs = require('fs');
const router = express.Router();
const sourceMap = require('source-map');
const path = require('path');
const resolve = file => path.resolve(__dirname, file);
// 定義post接口
router.get('/error/', async function(req, res) {
    // 獲取前端傳過來的報錯對象
    let error = JSON.parse(req.query.error);
    let url = error.scriptURI; // 壓縮文件路徑
    if (url) {
        let fileUrl = url.slice(url.indexOf('client/')) + '.map'; // map文件路徑
        // 解析sourceMap
        let consumer = await new sourceMap.SourceMapConsumer(fs.readFileSync(resolve('../' + fileUrl), 'utf8')); // 返回一個promise對象
        // 解析原始報錯數據
        let result = consumer.originalPositionFor({
            line: error.lineNo, // 壓縮後的行號
            column: error.columnNo // 壓縮後的列號
        });
        console.log(result);
    }
});
module.exports = router;

如下圖所示,我們已經可以看到,在服務端已經成功解析出了具體錯誤的行號、列號,我們可以通過日誌的方式進行記錄,達到了前端異常監控的目的。

 

Vue捕獲異常

在我的項目中就遇到這樣的問題,使用了js-tracker這樣的插件來統一進行全局的異常捕獲和日誌上報,結果發現我們根本捕獲不到Vue組件的異常,查閱資料得知,在Vue中,異常可能被Vue自身給try ... catch了,不會傳到window.onerror事件觸發,那麼我們如何把Vue組件中的異常作統一捕獲呢?

使用Vue.config.errorHandler這樣的Vue全局配置,可以在Vue指定組件的渲染和觀察期間未捕獲錯誤的處理函數。這個處理函數被調用時,可獲取錯誤信息和Vue 實例。

Vue.config.errorHandler = function (err, vm, info) {
  // handle error
  // `info` 是 Vue 特定的錯誤信息,比如錯誤所在的生命週期鉤子
  // 只在 2.2.0+ 可用
}

React中,可以使用ErrorBoundary組件包括業務組件的方式進行異常捕獲,配合React 16.0+新出的componentDidCatch API,可以實現統一的異常捕獲和日誌上報。

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

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

使用方式如下:

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

二、日誌上報

單獨的日誌域名

對於日誌上報使用單獨的日誌域名的目的是避免對業務造成影響。其一,對於服務器來說,我們肯定不希望佔用業務服務器的計算資源,也不希望過多的日誌在業務服務器堆積,造成業務服務器的存儲空間不夠的情況。其二,我們知道在頁面初始化的過程中,會對頁面加載時間、PV、UV等數據進行上報,這些上報請求會和加載業務數據幾乎是同時刻發出,而瀏覽器一般會對同一個域名的請求量有併發數的限制,如Chrome會有對併發數爲6個的限制。因此需要對日誌系統單獨設定域名,最小化對頁面加載性能造成的影響。

跨域的問題

對於單獨的日誌域名,肯定會涉及到跨域的問題,採取的解決方案一般有以下兩種:

  • 一種是構造空的Image對象的方式,其原因是請求圖片並不涉及到跨域的問題;
var url = 'xxx';
new Image().src = url;
  • 利用Ajax上報日誌,必須對日誌服務器接口開啓跨域請求頭部Access-Control-Allow-Origin:*,這裏Ajax就並不強制使用GET請求了,即可克服URL長度限制的問題。
if (XMLHttpRequest) {
  var xhr = new XMLHttpRequest();
  xhr.open('post', 'https://log.xxx.com', true); // 上報給node中間層處理
  xhr.setRequestHeader('Content-Type', 'application/json'); // 設置請求頭
  xhr.send(JSON.stringify(errorObj)); // 發送參數
}

在我的項目中使用的是第一種的方式,也就是構造空的Image對象,但是我們知道對於GET請求會有長度的限制,需要確保的是請求的長度不會超過閾值。

省去響應主體

對於我們上報日誌,其實對於客戶端來說,並不需要考慮上報的結果,甚至對於上報失敗,我們也不需要在前端做任何交互,所以上報來說,其實使用HEAD請求就夠了,接口返回空的結果,最大地減少上報日誌造成的資源浪費。

合併上報

類似於雪碧圖的思想,如果我們的應用需要上報的日誌數量很多,那麼有必要合併日誌進行統一的上報。

解決方案可以是嘗試在用戶離開頁面或者組件銷燬時發送一個異步的POST請求來進行上報,但是嘗試在卸載(unload)文檔之前向web服務器發送數據。保證在文檔卸載期間發送數據一直是一個困難。因爲用戶代理通常會忽略在卸載事件處理器中產生的異步XMLHttpRequest,因爲此時已經會跳轉到下一個頁面。所以這裏是必須設置爲同步的XMLHttpRequest請求嗎?

window.addEventListener('unload', logData, false);

function logData() {
    var client = new XMLHttpRequest();
    client.open("POST", "/log", false); // 第三個參數表明是同步的 xhr
    client.setRequestHeader("Content-Type", "text/plain;charset=UTF-8");
    client.send(analyticsData);
}

使用同步的方式勢必會對用戶體驗造成影響,甚至會讓用戶感受到瀏覽器卡死感覺,對於產品而言,體驗非常不好,通過查閱MDN文檔,可以使用sendBeacon()方法,將會使用戶代理在有機會時異步地向服務器發送數據,同時不會延遲頁面的卸載或影響下一導航的載入性能。這就解決了提交分析數據時的所有的問題:使它可靠,異步並且不會影響下一頁面的加載。此外,代碼實際上還要比其他技術簡單!

下面的例子展示了一個理論上的統計代碼模式——通過使用sendBeacon()方法向服務器發送數據。

window.addEventListener('unload', logData, false);

function logData() {
    navigator.sendBeacon("/log", analyticsData);
}

小結

作爲前端開發者而言,要對產品保持敬畏之心,時刻保持對性能追求極致,對異常不可容忍的態度。前端的性能監控與異常上報顯得尤爲重要。

代碼難免有問題,對於異常可以使用window.onerror或者addEventListener的方式添加全局的異常捕獲偵聽函數,但可能使用這種方式無法正確捕獲到錯誤:對於跨域的腳本,需要對script標籤增加一個crossorigin=”anonymous”;對於生產環境打包的代碼,無法正確定位到異常產生的行數,可以使用source-map來解決;而對於使用框架的情況,需要在框架統一的異常捕獲處埋點。

而對於性能的監控,所幸的是瀏覽器提供了window.performance API,通過這個API,很便捷地獲取到當前頁面性能相關的數據。

而這些異常和性能數據如何上報呢?一般說來,爲了避免對業務產生的影響,會單獨建立日誌服務器和日誌域名,但對於不同的域名,又會產生跨域的問題。我們可以通過構造空的Image對象來解決,亦或是通過設定跨域請求頭部Access-Control-Allow-Origin:*來解決。此外,如果上報的性能和日誌數據高頻觸發,則可以在頁面unload時統一上報,而unload時的異步請求又可能會被瀏覽器所忽略,且不能改爲同步請求。此時navigator.sendBeacon API可算幫了我們大忙,它可用於通過HTTP將少量數據異步傳輸到Web服務器。而忽略頁面unload時的影響。

 

 

 

發佈了122 篇原創文章 · 獲贊 87 · 訪問量 52萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章