前端開發中的Error以及異常捕獲

lvxfcjf發表於2021-09-09

符合預期的CoyPan(id: Coy_Pan)
作者:CoyPan,BAT某廠符合預期的FE,正努力成為一名出色的工程師

寫在前面

在前端專案中,由於JavaScript本身是一個弱型別語言,加上瀏覽器環境的複雜性,網路問題等等,很容易發生錯誤。做好網頁錯誤監控,不斷最佳化程式碼,提高程式碼健壯性是一項很重要的工作。本文將從Error開始,講到如何捕獲頁面中的異常。文章較長,很多細節,請耐心觀看。

前端開發中的Error

JavaScript中的Error

JavaScript中,Error是一個建構函式,透過它建立一個錯誤物件。當執行時錯誤產生時,Error的例項物件會被丟擲。構造一個Error的語法如下:

// message: 錯誤描述
// fileName: 可選。被建立的Error物件的fileName屬性值。預設是呼叫Error構造器程式碼所在的檔案的名字。
// lineNumber: 可選。被建立的Error物件的lineNumber屬性值。預設是呼叫Error構造器程式碼所在的檔案的行號。

new Error([message[, fileName[, lineNumber]]])
ECMAScript標準:

Error有兩個標準屬性:

  • Error.prototype.name :錯誤的名字
  • Error.prototype.message:錯誤的描述

例如,在chrome控制檯中輸入以下程式碼:

var a = new Error('錯誤測試');
console.log(a); // Error: 錯誤測試
    			// at <anonymous>:1:9
console.log(a.name); // Error
console.log(a.message); // 錯誤測試

Error只有一個標準方法:

  • Error.prototype.toString:返回表示一個表示錯誤的字串。

接上面的程式碼:

a.toString();  // "Error: 錯誤測試"
非標準的屬性

各個瀏覽器廠商對於Error都有自己的實現。比如下面這些屬性:

  1. Error.prototype.fileName:產生錯誤的檔名。
  2. Error.prototype.lineNumber:產生錯誤的行號。
  3. Error.prototype.columnNumber:產生錯誤的列號。
  4. Error.prototype.stack:堆疊資訊。這個比較常用。

這些屬性均不是標準屬性,在生產環境中謹慎使用。不過現代瀏覽器差不多都支援了。

Error的種類

除了通用的Error建構函式外,JavaScript還有7個其他型別的錯誤建構函式。

  • InternalError: 建立一個代表Javascript引擎內部錯誤的異常丟擲的例項。 如: “遞迴太多”。非ECMAScript標準。
  • RangeError: 數值變數或引數超出其有效範圍。例子:var a = new Array(-1);
  • EvalError: 與eval()相關的錯誤。eval()本身沒有正確執行。
  • ReferenceError: 引用錯誤。 例子:console.log(b);
  • SyntaxError: 語法錯誤。例子:var a = ;
  • TypeError: 變數或引數不屬於有效範圍。例子:[1,2].split(’.’)
  • URIError: 給 encodeURI或 decodeURl()傳遞的引數無效。例子:decodeURI(’%2’)

當JavaScript執行過程中出錯時,會丟擲上8種(上述7種加上通用錯誤型別)錯誤中的其中一種錯誤。錯誤型別可以透過error.name拿到。

你也可以基於Error構造自己的錯誤型別,這裡就不展開了。

其他錯誤

上面介紹的都是JavaScript本身執行時會發生的錯誤。頁面中還會有其他的異常,比如錯誤地操作了DOM。

DOMException

DOMException是W3C DOM核心物件,表示呼叫一個Web Api時發生的異常。什麼是Web Api呢?最常見的就是DOM元素的一系列方法,其他還有XMLHttpRequest、Fetch等等等等,這裡就不一一說明了。直接看下面一個操作DOM的例子:

var node = document.querySelector('#app');
var refnode = node.nextSibling;
var newnode = document.createElement('div');
node.insertBefore(newnode, refnode);

// 報錯:Uncaught DOMException: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.

單從JS程式碼邏輯層面來看,沒有問題。但是程式碼的操作不符合DOM的規則。

DOMException建構函式的語法如下:

// message: 可選,錯誤描述。
// name: 可選,錯誤名稱。常量,具體值可以在這裡找到:https://developer.mozilla.org/zh-CN/docs/Web/API/DOMException

new DOMException([message[, name]]);

DOMException有以下三個屬性:

  1. DOMException.code:錯誤編號。
  2. DOMException.message:錯誤描述。
  3. DOMException.name:錯誤名稱。

以上面那段錯誤程式碼為例,其丟擲的DOMException各屬性的值為:

code: 8
message: "Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node."
name: "NotFoundError"
Promise產生的異常

Promise中,如果Promisereject了,就會丟擲異常:PromiseRejectionEvent。注意,下面兩種情況都會導致Promisereject

  1. 業務程式碼本身呼叫了Promise.reject
  2. Promise中的程式碼出錯。

PromiseRejectionEvent的建構函式目前在瀏覽器中大多都不相容,這裡就不說了。

PromiseRejectionEvent的屬性有兩個:

  1. PromiseRejectionEvent.promise:被rejectPromise
  2. PromiseRejectionEvent.reasonPromisereject的原因。會傳遞給rejectPromsiecatch中的引數。
載入資源出錯

由於網路,安全等原因,網頁載入資源失敗,請求介面出錯等,也是一種常見的錯誤。

關於錯誤的小結

一個網頁在執行過程中,可能發生三種錯誤:

  1. JavaScript在執行過程,語言自身丟擲的異常。
  2. JavaScript在執行過程中,呼叫Web Api時發生異常。
  3. Promise中的拒絕。
  4. 網頁載入資源,呼叫介面時發生異常。

我認為,對於前兩種錯誤,我們在平時的開發過程中,不用特別去區分,可以統一成:【程式碼出錯】。

捕獲錯誤

網頁發生錯誤,開發者如何捕獲這些錯誤呢 ? 常見的有以下方法。

try…catch…

try...catch…大家都不陌生了。一般用來在具體的程式碼邏輯中捕獲錯誤。

try {
  throw new Error("oops");
}
catch (ex) {
  console.log("error", ex.message); // error oops
}

try-block中的程式碼發生異常時,可以在catck-block中將異常接住,瀏覽器便不會丟擲錯誤。但是,這種方式並不能捕獲非同步程式碼中的錯誤,如:

try {
    setTimeout(function(){
        throw new Error('lala');
    },0);
} catch(e) {
    console.log('error', e.message);
}

這個時候,瀏覽器依然會丟擲錯誤:Uncaught Error: lala

試想以下,如果我們將所有的程式碼合理的劃分,然後都用try catch包起來,是不是就可以捕獲到所有的錯誤了呢?可以透過編譯工具來實現這個功能。不過,try catch是比較耗費效能的。

window.onerror

window.onerror = function(message, source, lineno, colno, error) { ... }

函式引數:

  • message:錯誤資訊(字串)
  • source:發生錯誤的指令碼URL(字串)
  • lineno:發生錯誤的行號(數字)
  • colno:發生錯誤的列號(數字)
  • error:Error物件(物件)

注意,如果這個函式返回true,那麼將會阻止執行瀏覽器預設的錯誤處理函式。

window.addEventListener(‘error’)

window.addEventListener('error', function(event) { ... })

我們呼叫Object.prototype.toString.call(event),返回的是[object ErrorEvent]。可以看到eventErrorEvent物件的例項。ErrorEvent是事件物件在指令碼發生錯誤時產生,從Event繼承而來。由於是事件,自然可以拿到target屬性。ErrorEvent還包括了錯誤發生時的資訊。

  • ErrorEvent.prototype.message: 字串,包含了所發生錯誤的描述資訊。
  • ErrorEvent.prototype.filename: 字串,包含了發生錯誤的指令碼檔案的檔名。
  • ErrorEvent.prototype.lineno: 數字,包含了錯誤發生時所在的行號。
  • ErrorEvent.prototype.colno: 數字,包含了錯誤發生時所在的列號。
  • ErrorEvent.prototype.error: 發生錯誤時所丟擲的 Error 物件。

注意,這裡的ErrorEvent.prototype.error對應的Error物件,就是上文提到的Error, InternalErrorRangeErrorEvalErrorReferenceErrorSyntaxErrorTypeErrorURIErrorDOMException中的一種。

window.addEventListener(‘unhandledrejection’)

window.addEventListener('unhandledrejection', function (event) { ... });

在使用Promise的時候,如果沒有宣告catch程式碼塊,Promise的異常會被丟擲。只能透過這個方法或者window.onunhandledrejection才能捕獲到該異常。

event就是上文提到的PromiseRejectionEvent。我們只需要關注其reason就行。

window.onerror 和 window.addEventListener(‘error’)的區別

  1. 首先是事件監聽器事件處理器的區別。監聽器只能宣告一次,後續的宣告會覆蓋之前的宣告。而事件處理器則可以繫結多個回撥函式。
  2. 資源( img 或 script )載入失敗時,載入資源的元素會觸發一個Event介面的error事件,並執行該元素上的onerror()處理函式。但這些error事件不會向上冒泡到window。不過,這些error事件能被window.addEventListener('error')捕獲。也就是說,面對資源載入失敗的錯誤,只能用window.addEventListerner('error')在事件捕獲階段監聽到,window.onerror無效。

關於錯誤捕獲的小結

我認為,在開發的過程中,對於容易出錯的地方,可以使用try{}catch(){}來進行錯誤的捕獲,做好兜底處理,避免頁面掛掉。而對於全域性的錯誤捕獲,在現代瀏覽器中,我傾向於只使用使用window.addEventListener('error')window.addEventListener('unhandledrejection')就行了。如果需要考慮相容性,需要加上window.onerror,三者同時使用,window.addEventListener('error')專門用來捕獲資源載入錯誤。

跨域指令碼錯誤,Script Error

在進行錯誤捕獲的過程中,很多時候並不能拿到完整的錯誤資訊,得到的僅僅是一個"Script Error"

產生原因

當載入自不同域的指令碼中發生語法錯誤時,為避免資訊洩露,語法錯誤的細節將不會報告,而是使用簡單的"Script error."代替

一般而言,頁面的JS檔案都是放在CDN的,和頁面自身的URL產生了跨域問題,所以引起了"Script Error"

解決辦法

服務端新增Access-Control-Allow-Origin,頁面在script標籤中配置 crossorigin="anonymous"。這樣,便解決了因為跨域而帶來的"Script Error"問題。

能繞過Script Error

上面介紹了"Script Error"的標準解決方案。但是,並不是所有的瀏覽器都支援crossorigin="anonymous",也不是所有的服務端都能及時配置Access-Control-Allow-Origin,這種情況下,還有什麼方法能在全域性捕獲到所有的錯誤,並拿到詳細資訊呢?

藉助try{}catch(){}劫持原生方法吧

看一個例子:

const nativeAddEventListener = EventTarget.prototype.addEventListener; // 先將原生方法儲存起來。
EventTarget.prototype.addEventListener = function (type, func, options) { // 重寫原生方法。
    const wrappedFunc = function (...args) { // 將回撥函式包裹一層try catch
        try { 
			return func.apply(this, args);
		} catch (e) {
			const errorObj = {
                ...
                error_name: e.name || '',
				error_msg: e.message || '',
				error_stack: e.stack || (e.error && e.error.stack),
				error_native: e,
                ...
			};
            // 接下來可以將errorObj統一進行處理。
        }
    }
    return nativeAddEventListener.call(this, type, wrappedFunc, options); // 呼叫原生的方法,保證addEventListener正確執行
}

我們劫持了原生的addEventListener程式碼,對addEventListener程式碼中的回撥函式加了一層try{}catch(){},這樣,回撥函式中丟擲的錯誤會被catch住,瀏覽器不會對try-catch 起來的異常進行跨域攔截,所以我們可以拿到詳細的錯誤資訊。透過上面的操作,我們可以拿到所有監聽事件的回撥函式中的錯誤啦。其他的場景怎麼辦呢?繼續劫持原生方法。

一個前端專案中,除了事件監聽,介面請求也是一個頻繁出現的場景。接著上面的程式碼,下面我們來劫持一下Ajax


    if (!XMLHttpRequest) {
        return;
    }

    const nativeAjaxSend = XMLHttpRequest.prototype.send; // 首先將原生的方法儲存。
    const nativeAjaxOpen = XMLHttpRequest.prototype.open;


    XMLHttpRequest.prototype.open = function (mothod, url, ...args) { // 劫持open方法,是為了拿到請求的url
        const xhrInstance = this; 
        xhrInstance._url = url;
        return nativeAjaxOpen.apply(this, [mothod, url].concat(args));
    }

    XMLHttpRequest.prototype.send = function (...args) { // 對於ajax請求的監控,主要是在send方法裡處理。

        const oldCb = this.onreadystatechange;
        const oldErrorCb = this.onerror;
        const xhrInstance = this;

        xhrInstance.addEventListener('error', function (e) { // 這裡捕獲到的error是一個ProgressEvent。e.target 的值為 XMLHttpRequest的例項。當網路錯誤(ajax並沒有發出去)或者發生跨域的時候,會觸發XMLHttpRequest的error, 此時,e.target.status 的值為:0,e.target.statusText 的值為:''
          
            const errorObj = {
                ...
                error_msg: 'ajax filed',
                error_stack: JSON.stringify({
                    status: e.target.status,
                    statusText: e.target.statusText
                }),
                error_native: e,
                ...
            }
          
            /*接下來可以對errorObj進行統一處理*/
          
        });


        xhrInstance.addEventListener('abort', function (e) { // 主動取消ajax的情況需要標註,否則可能會產生誤報
            if (e.type === 'abort') { 
                xhrInstance._isAbort = true;
            }
        });


        this.onreadystatechange = function (...innerArgs) {
            if (xhrInstance.readyState === 4) {
                if (!xhrInstance._isAbort && xhrInstance.status !== 200) { // 請求不成功時,拿到錯誤資訊
                   const errorObj = {
                        error_msg: JSON.stringify({
                            code: xhrInstance.status,
                            msg: xhrInstance.statusText,
                            url: xhrInstance._url
                        }),
                        error_stack: '',
                        error_native: xhrInstance
                    };
                    
                    /*接下來可以對errorObj進行統一處理*/
                    
                }
                
            }
            oldCb && oldCb.apply(this, innerArgs);
        }
        return nativeAjaxSend.apply(this, args);
    }
}

我們引用框架時,某些框架會用console.error的方法丟擲錯誤。我們可以劫持console.error,來捕獲錯誤。

        const nativeConsoleError = window.console.error;
        window.console.error = function (...args) {
            args.forEach(item => {
                if (typeDetect.isError(item)) {
                   ...
                } else {
                   ...
                }
            });
            nativeConsoleError.apply(this, args);
        }

原生的方法有很多,還比如fetchsetTimeout等。這裡不一一列舉了。但是使用劫持原生方法以覆蓋所有的場景是十分困難的。

上報錯誤

捕獲到錯誤後,如何上報呢?最常見、最簡單的方式就是透過<img>了。程式碼簡單,且沒有跨域煩惱。

function logError(error){
    var img = new Image();
    img.onload = img.onerror = function(){
        img = null;
    }
    img.src = `${上報地址}?${processErrorParam(error)}`;
}

當上報資料比較多時,可以使用post的方式進行上報。

錯誤的上報其實是一項複雜的工程,涉及到上報策略、上報分類等等。特別是在專案的業務比較複雜的時候,更應該關注上報的質量,避免影響到業務功能的正常執行。使用了打包工具處理的程式碼,往往還需要結合sourceMap進行程式碼定位。本文就不做介紹了。

寫在後面

要建立一套完整、可用的前端錯誤監控體系是一項複雜、浩大的工程。但是,這項工程往往是必備的。本文主要介紹了你可能沒關注過的Error的一些細節,以及如何捕獲頁面中的錯誤。

符合預期的CoyPan(id: Coy_Pan)
作者:CoyPan,BAT某廠符合預期的FE,正努力成為一名出色的工程師

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/3486/viewspace-2824010/,如需轉載,請註明出處,否則將追究法律責任。

相關文章