前端效能與異常上報

counterxing發表於2019-03-04

概述

對於後臺開發來說,記錄日誌是一種非常常見的開發習慣,通常我們會使用try...catch程式碼塊來主動捕獲錯誤、對於每次介面呼叫,也會記錄下每次介面呼叫的時間消耗,以便我們監控伺服器介面效能,進行問題排查。

剛進公司時,在進行Node.js的介面開發時,我不太習慣每次排查問題都要通過跳板機登上伺服器看日誌,後來慢慢習慣了這種方式。

舉個例子:

/**
 * 獲取列表資料
 * @parma req, res
 */
exports.getList = async function (req, res) {
    //獲取請求引數
    const openId = req.session.userinfo.openId;
    logger.info(`handler getList, user openId is ${openId}`);

    try {
        // 拿到列表資料
        const startTime = new Date().getTime();
        let res = await ListService.getListFromDB(openId);
        logger.info(`handler getList, ListService.getListFromDB cost time ${new Date().getTime() - startDate}`);
        // 對資料處理,返回給前端
        // ...
    } catch(error) {
        logger.error(`handler getList is error, ${JSON.stringify(error)}`);
    }
};
複製程式碼

以下程式碼經常會出現在用Node.js的介面中,在介面中會統計查詢DB所耗時間、亦或是統計RPC服務呼叫所耗時間,以便監測效能瓶頸,對效能做優化;又或是對異常使用try ... catch主動捕獲,以便隨時對問題進行回溯、還原問題的場景,進行bug的修復。

而對於前端來說呢?可以看以下的場景。

最近在進行一個需求開發時,偶爾發現webgl渲染影像失敗的情況,或者說影像會出現解析失敗的情況,我們可能根本不知道哪張影像會解析或渲染失敗;又或如最近開發的另外一個需求,我們會做一個關於webgl渲染時間的優化和影像預載入的需求,如果缺乏效能監控,該如何統計所做的渲染優化和影像預載入優化的優化比例,如何證明自己所做的事情具有價值呢?可能是通過測試同學的黑盒測試,對優化前後的時間進行錄屏,分析從進入頁面到影像渲染完成到底經過了多少幀影像。這樣的資料,可能既不準確、又較為片面,設想測試同學並不是真正的使用者,也無法還原真實的使用者他們所處的網路環境。回過頭來發現,我們的專案,雖然在服務端層面做好了日誌和效能統計,但在前端對異常的監控和效能的統計。對於前端的效能與異常上報的可行性探索是有必要的。

異常捕獲

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

  • 介面呼叫情況;
  • 頁面邏輯是否錯誤,例如,使用者進入頁面後頁面顯示白屏;

對於介面呼叫情況,在前端通常需要上報客戶端相關引數,例如:使用者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打包後壓縮混淆的程式碼,所以我們可能會遇到這樣的問題,如圖所示:

前端效能與異常上報

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

!function(e){var n={};function r(o){if(n[o])return n[o].exports;var t=n[o]={i:o,l:!1,exports:{}};return e[o].call(t.exports,t,t.exports,r),t.l=!0,t.exports}r.m=e,r.c=n,r.d=function(e,n,o){r.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:o})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,n){if(1&n&&(e=r(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(r.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var t in e)r.d(o,t,function(n){return e[n]}.bind(null,t));return o},r.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(n,"a",n),n},r.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},r.p="",r(r.s=0)}([function(e,n){throw window.onerror=function(e,n,r,o,t){console.log("errorMessage: "+e),console.log("scriptURI: "+n),console.log("lineNo: "+r),console.log("columnNo: "+o),console.log("error: "+t);var l={errorMessage:e||null,scriptURI:n||null,lineNo:r||null,columnNo:o||null,stack:t&&t.stack?t.stack:null};if(XMLHttpRequest){var u=new XMLHttpRequest;u.open("post","/middleware/errorMsg",!0),u.setRequestHeader("Content-Type","application/json"),u.send(JSON.stringify(l))}},new Error("這是一個錯誤")}]);
複製程式碼

在我的開發過程中也遇到過這個問題,我在開發一個功能元件庫的時候,使用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>
複製程式碼

效能監控

最簡單的效能監控

最常見的效能監控需求則是需要我們統計使用者從開始請求頁面到所有DOM元素渲染完成的時間,也就是俗稱的首屏載入時間,DOM提供了這一介面,監聽documentDOMContentLoaded事件與windowload事件可統計頁面首屏載入時間即所有DOM渲染時間:

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <script type="text/javascript">
    // 記錄頁面載入開始時間
    var timerStart = Date.now();
  </script>
  <!-- 載入靜態資源,如樣式資源 -->
</head>
<body>
  <!-- 載入靜態JS資源 -->
  <script type="text/javascript">
    document.addEventListener('DOMContentLoaded', function() {
      console.log("DOM 掛載時間: ", Date.now() - timerStart);
      // 效能日誌上報
    });
    window.addEventListener('load', function() {
      console.log("所有資源載入完成時間: ", Date.now()-timerStart);
      // 效能日誌上報
    });
  </script>
</body>
</html>
複製程式碼

對於使用框架,如Vue或者說React,元件是非同步渲染然後掛載到DOM的,在頁面初始化時並沒有太多的DOM節點,可以參考下文關於首屏時間採集自動化的解決方案來對渲染時間進行打點。

performance

但是以上時間的監控過於粗略,例如我們想統計文件的網路載入耗時、解析DOM的耗時與渲染DOM的耗時,就不太好辦到了,所幸的是瀏覽器提供了window.performance介面,具體可見MDN文件

前端效能與異常上報

幾乎所有瀏覽器都支援window.performance介面,下面來看看在控制檯列印window.performance可以得到些什麼:

前端效能與異常上報

可以看到,window,performance主要包括有memorynavigationtiming以及timeOriginonresourcetimingbufferfull方法。

  • navigation物件提供了在指定的時間段裡發生的操作相關資訊,包括頁面是載入還是重新整理、發生了多少次重定向等等。
  • timing物件包含延遲相關的效能資訊。這是我們頁面載入效能優化需求中主要上報的相關資訊。
  • memoryChrome新增的一個非標準擴充套件,這個屬性提供了一個可以獲取到基本記憶體使用情況的物件。在其它瀏覽器應該考慮到這個API的相容處理。
  • timeOrigin則返回效能測量開始時的時間的高精度時間戳。如圖所示,精確到了小數點後四位。
  • onresourcetimingbufferfull方法,它是一個在resourcetimingbufferfull事件觸發時會被呼叫的event handler。這個事件當瀏覽器的資源時間效能緩衝區已滿時會觸發。可以通過監聽這一事件觸發來預估頁面crash,統計頁面crash概率,以便後期的效能優化,如下示例所示:
function buffer_full(event) {
  console.log("WARNING: Resource Timing Buffer is FULL!");
  performance.setResourceTimingBufferSize(200);
}
function init() {
  // Set a callback if the resource buffer becomes filled
  performance.onresourcetimingbufferfull = buffer_full;
}
<body onload="init()">
複製程式碼

計算網站效能

使用performancetiming屬性,可以拿到頁面效能相關的資料,這裡在很多文章都有提到關於利用window.performance.timing記錄頁面效能的文章,例如alloyteam團隊寫的初探 performance – 監控網頁與程式效能,對於timing的各項屬性含義,可以藉助摘自此文的下圖理解,以下程式碼摘自此文作為計算網站效能的工具函式參考:

前端效能與異常上報

// 獲取 performance 資料
var performance = {  
    // memory 是非標準屬性,只在 Chrome 有
    // 財富問題:我有多少記憶體
    memory: {
        usedJSHeapSize:  16100000, // JS 物件(包括V8引擎內部物件)佔用的記憶體,一定小於 totalJSHeapSize
        totalJSHeapSize: 35100000, // 可使用的記憶體
        jsHeapSizeLimit: 793000000 // 記憶體大小限制
    },
 
    //  哲學問題:我從哪裡來?
    navigation: {
        redirectCount: 0, // 如果有重定向的話,頁面通過幾次重定向跳轉而來
        type: 0           // 0   即 TYPE_NAVIGATENEXT 正常進入的頁面(非重新整理、非重定向等)
                          // 1   即 TYPE_RELOAD       通過 window.location.reload() 重新整理的頁面
                          // 2   即 TYPE_BACK_FORWARD 通過瀏覽器的前進後退按鈕進入的頁面(歷史記錄)
                          // 255 即 TYPE_UNDEFINED    非以上方式進入的頁面
    },
 
    timing: {
        // 在同一個瀏覽器上下文中,前一個網頁(與當前頁面不一定同域)unload 的時間戳,如果無前一個網頁 unload ,則與 fetchStart 值相等
        navigationStart: 1441112691935,
 
        // 前一個網頁(與當前頁面同域)unload 的時間戳,如果無前一個網頁 unload 或者前一個網頁與當前頁面不同域,則值為 0
        unloadEventStart: 0,
 
        // 和 unloadEventStart 相對應,返回前一個網頁 unload 事件繫結的回撥函式執行完畢的時間戳
        unloadEventEnd: 0,
 
        // 第一個 HTTP 重定向發生時的時間。有跳轉且是同域名內的重定向才算,否則值為 0 
        redirectStart: 0,
 
        // 最後一個 HTTP 重定向完成時的時間。有跳轉且是同域名內部的重定向才算,否則值為 0 
        redirectEnd: 0,
 
        // 瀏覽器準備好使用 HTTP 請求抓取文件的時間,這發生在檢查本地快取之前
        fetchStart: 1441112692155,
 
        // DNS 域名查詢開始的時間,如果使用了本地快取(即無 DNS 查詢)或持久連線,則與 fetchStart 值相等
        domainLookupStart: 1441112692155,
 
        // DNS 域名查詢完成的時間,如果使用了本地快取(即無 DNS 查詢)或持久連線,則與 fetchStart 值相等
        domainLookupEnd: 1441112692155,
 
        // HTTP(TCP) 開始建立連線的時間,如果是持久連線,則與 fetchStart 值相等
        // 注意如果在傳輸層發生了錯誤且重新建立連線,則這裡顯示的是新建立的連線開始的時間
        connectStart: 1441112692155,
 
        // HTTP(TCP) 完成建立連線的時間(完成握手),如果是持久連線,則與 fetchStart 值相等
        // 注意如果在傳輸層發生了錯誤且重新建立連線,則這裡顯示的是新建立的連線完成的時間
        // 注意這裡握手結束,包括安全連線建立完成、SOCKS 授權通過
        connectEnd: 1441112692155,
 
        // HTTPS 連線開始的時間,如果不是安全連線,則值為 0
        secureConnectionStart: 0,
 
        // HTTP 請求讀取真實文件開始的時間(完成建立連線),包括從本地讀取快取
        // 連線錯誤重連時,這裡顯示的也是新建立連線的時間
        requestStart: 1441112692158,
 
        // HTTP 開始接收響應的時間(獲取到第一個位元組),包括從本地讀取快取
        responseStart: 1441112692686,
 
        // HTTP 響應全部接收完成的時間(獲取到最後一個位元組),包括從本地讀取快取
        responseEnd: 1441112692687,
 
        // 開始解析渲染 DOM 樹的時間,此時 Document.readyState 變為 loading,並將丟擲 readystatechange 相關事件
        domLoading: 1441112692690,
 
        // 完成解析 DOM 樹的時間,Document.readyState 變為 interactive,並將丟擲 readystatechange 相關事件
        // 注意只是 DOM 樹解析完成,這時候並沒有開始載入網頁內的資源
        domInteractive: 1441112693093,
 
        // DOM 解析完成後,網頁內資源載入開始的時間
        // 在 DOMContentLoaded 事件丟擲前發生
        domContentLoadedEventStart: 1441112693093,
 
        // DOM 解析完成後,網頁內資源載入完成的時間(如 JS 指令碼載入執行完畢)
        domContentLoadedEventEnd: 1441112693101,
 
        // DOM 樹解析完成,且資源也準備就緒的時間,Document.readyState 變為 complete,並將丟擲 readystatechange 相關事件
        domComplete: 1441112693214,
 
        // load 事件傳送給文件,也即 load 回撥函式開始執行的時間
        // 注意如果沒有繫結 load 事件,值為 0
        loadEventStart: 1441112693214,
 
        // load 事件的回撥函式執行完畢的時間
        loadEventEnd: 1441112693215
 
        // 字母順序
        // connectEnd: 1441112692155,
        // connectStart: 1441112692155,
        // domComplete: 1441112693214,
        // domContentLoadedEventEnd: 1441112693101,
        // domContentLoadedEventStart: 1441112693093,
        // domInteractive: 1441112693093,
        // domLoading: 1441112692690,
        // domainLookupEnd: 1441112692155,
        // domainLookupStart: 1441112692155,
        // fetchStart: 1441112692155,
        // loadEventEnd: 1441112693215,
        // loadEventStart: 1441112693214,
        // navigationStart: 1441112691935,
        // redirectEnd: 0,
        // redirectStart: 0,
        // requestStart: 1441112692158,
        // responseEnd: 1441112692687,
        // responseStart: 1441112692686,
        // secureConnectionStart: 0,
        // unloadEventEnd: 0,
        // unloadEventStart: 0
    }
};
複製程式碼
// 計算載入時間
function getPerformanceTiming() {
    var performance = window.performance;
    if (!performance) {
        // 當前瀏覽器不支援
        console.log('你的瀏覽器不支援 performance 介面');
        return;
    }
    var t = performance.timing;
    var times = {};
    //【重要】頁面載入完成的時間
    //【原因】這幾乎代表了使用者等待頁面可用的時間
    times.loadPage = t.loadEventEnd - t.navigationStart;
    //【重要】解析 DOM 樹結構的時間
    //【原因】反省下你的 DOM 樹巢狀是不是太多了!
    times.domReady = t.domComplete - t.responseEnd;
    //【重要】重定向的時間
    //【原因】拒絕重定向!比如,http://example.com/ 就不該寫成 http://example.com
    times.redirect = t.redirectEnd - t.redirectStart;
    //【重要】DNS 查詢時間
    //【原因】DNS 預載入做了麼?頁面內是不是使用了太多不同的域名導致域名查詢的時間太長?
    // 可使用 HTML5 Prefetch 預查詢 DNS ,見:[HTML5 prefetch](http://segmentfault.com/a/1190000000633364)            
    times.lookupDomain = t.domainLookupEnd - t.domainLookupStart;
    //【重要】讀取頁面第一個位元組的時間
    //【原因】這可以理解為使用者拿到你的資源佔用的時間,加異地機房了麼,加CDN 處理了麼?加頻寬了麼?加 CPU 運算速度了麼?
    // TTFB 即 Time To First Byte 的意思
    // 維基百科:https://en.wikipedia.org/wiki/Time_To_First_Byte
    times.ttfb = t.responseStart - t.navigationStart;
    //【重要】內容載入完成的時間
    //【原因】頁面內容經過 gzip 壓縮了麼,靜態資源 css/js 等壓縮了麼?
    times.request = t.responseEnd - t.requestStart;
    //【重要】執行 onload 回撥函式的時間
    //【原因】是否太多不必要的操作都放到 onload 回撥函式裡執行了,考慮過延遲載入、按需載入的策略麼?
    times.loadEvent = t.loadEventEnd - t.loadEventStart;
    // DNS 快取時間
    times.appcache = t.domainLookupStart - t.fetchStart;
    // 解除安裝頁面的時間
    times.unloadEvent = t.unloadEventEnd - t.unloadEventStart;
    // TCP 建立連線完成握手的時間
    times.connect = t.connectEnd - t.connectStart;
    return times;
}
複製程式碼

日誌上報

單獨的日誌域名

對於日誌上報使用單獨的日誌域名的目的是避免對業務造成影響。其一,對於伺服器來說,我們肯定不希望佔用業務伺服器的計算資源,也不希望過多的日誌在業務伺服器堆積,造成業務伺服器的儲存空間不夠的情況。其二,我們知道在頁面初始化的過程中,會對頁面載入時間、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時的影響。

相關文章