淺談JavaScript錯誤

and80506發表於2018-07-10

本文主要從前端開發者的角度談一談大多數前端開發者都會遇到的js錯誤,對錯誤產生的原因、發生階段,以及如何應對錯誤進行分析、歸納和總結,希望得到一些有益的結論用來指導日常開發工作。

概念辨析

錯誤(Error)和異常(Exception)

對於Java來說錯誤和異常是兩個相近但是不同的概念,而在JavaScript中可以認為錯誤和異常是等同的,js裡只有Error關鍵字,並無Exception關鍵字。下文指的js錯誤也指通常理解的js異常。

js錯誤和bug

js錯誤:

通常是非程式設計的原因導致的錯誤,大部分是發生在應用環境中的外部錯誤,比如硬體故障導致的I/O Error、網路不穩定導致的Network Error,呼叫不被信任的外部方法,DOM操作,使用new Image、new FileReader載入資源。錯誤可以被忽略或者捕獲,程式碼可以通過預設錯誤處理流程,例如使用了重試機制的程式碼可以通過重試有可能讓程式恢復到正常狀態。

bug:

通常是程式設計的原因導致的計算機程式或系統中的缺陷,能夠引發錯誤或意外結果,或使程式或系統以非預期方式執行。通常無法繼續和恢復,需要程式設計師進入程式並且修改程式碼來修復。

javaScript錯誤發生階段

由於JavaScript語言解釋型的特性,js錯誤發生在執行時,這一點和編譯型語言相比錯誤更加難以發現。幸運的是技術發展到今天已經有非常多成熟的工具比如Eslint、IDE的程式碼檢查,可以幫助我們在早期不需要執行程式的階段發現錯誤。還有一些JavaScript語言的超集語言,為語言新增了可選的靜態型別,也可以幫助在早期發現錯誤。程式執行開始後,在進行使用者互動之前,一些語法錯誤,常見的比如Uncaught SyntaxError是非常容易發現的。而剩下的那些,基本上需要通過使用者互動來觸發,較難以發現,本文著重討論這部分錯誤。

錯誤的幾種應對方式

不捕獲錯誤

如果判斷程式當前位置可能會發生錯誤,不捕獲錯誤是一種消極的應對方式。依賴全域性window.onerror錯誤監聽能夠獲取未捕獲的錯誤資訊。

捕獲錯誤,不處理,丟擲

如果捕獲錯誤後馬上丟擲,丟擲的error物件就是原來的物件,相當於啥也沒幹,等同於不捕獲錯誤。一般來說採用這種應對方式時都會幹點什麼,比如丟擲一個自定義的錯誤,而不是原始錯誤物件。

捕獲錯誤,不處理,不丟擲

要小心不處理不丟擲意味著全域性window.onerror錯誤監聽也無法獲取到該錯誤資訊,一般來說這種應對方式用在一些不影響程式主流程的錯誤處理上,比如呼叫DOM節點的focus方法,但還是需要註釋合理的理由。 捕獲錯誤後靜默處理的示例:

try {
    obj[0].focus();
} catch (e) {
    // IE8 can throw "Can't move focus to the control because it is invisible,
    // not enabled, or of a type that does not accept the focus." for all kinds of
    // reasons that are too expensive and fragile to test.
}
複製程式碼

捕獲錯誤,處理

通常會在處理方法體中使用錯誤日誌上報、失效保護、重試恢復等技術。失效保護即降級處理,舉一個比較簡明的例子,try { a = JSON.parse(b); } catch (e) { a = {}; }

幾個引申出的問題

不處理錯誤造成的影響?

這個問題缺乏前提,到底是捕獲了不處理還是未捕獲錯誤。前者已有解答,這裡說未捕獲錯誤可能會造成的影響。錯誤發生後,在出錯位置之前的程式碼已經執行過了,之後的程式碼不再執行,這種情況基本上就是bug了。由於js的併發模型與事件迴圈機制,如果在出錯位置之前執行過非同步程式碼,比如setTimeout、new Promise,非同步程式碼中的回撥函式仍然會在“執行棧”中的所有同步任務執行完畢之後按照回撥函式在任務佇列裡的順序執行。值得注意的是,利用這種特性,可以將錯誤包裝在一個非同步執行的函式中使用非同步丟擲錯誤的技術,能夠避免阻斷錯誤處之後的程式碼執行。 非同步丟擲錯誤示例:

const asyncThrowError = (error) => {
    setTimeout(function() {
        throw error || new Error('非同步丟擲的異常');
    });
};
try {
    let a = JSON.parse('{a: a}');
} catch(e) {
    // 使用非同步丟擲異常的技術,依靠window.onerror捕獲異常並記錄日誌
    asyncThrowError(e);
}
複製程式碼

什麼時候應該捕獲錯誤?

當然是預感程式某一處可能會出現問題的時候啦。具體什麼時候則見仁見智啦,依賴程式設計師的經驗。

什麼時候應該丟擲錯誤,錯誤丟擲後會怎麼樣?

人為主動丟擲的錯誤和應用環境中的發生的錯誤同樣會導致錯誤位置之後的程式碼無法執行。常見的一種用法是在函式檢查傳入的引數是否合法,不合法就使程式快速失效(Fail Fast),提醒開發者修復問題。還可以將丟擲錯誤技術用在收集使用者填寫的不合法的表單資料上,在內層程式碼中丟擲錯誤,在外層程式碼中捕獲錯誤並判斷錯誤型別,獲取到錯誤資訊。

錯誤的重試與恢復

如果前提得是可恢復型別的錯誤,在程式中加入重試機制才有意義。可恢復的錯誤通常是外部原因導致的,典型例子如網路錯誤。重試機制根據是否需要使用者互動觸發可分為自動重試和手動重試。自動重試的一種設計是,通過線性增長的間隔時間或者成指數增長的間隔時間迴圈重試直到沒有錯誤發生,尤其是指數增長的間隔時間迴圈重試機制可以避免程式太快將計算機資源佔滿。手動重試的一個設計例子是,在一些關鍵業務流程,比如電商場景中的新增到購物車,當介面返回失敗時,可以通過讓使用者手動點選重試按鈕觸發新的一輪介面呼叫。 自動重試的一種設計方案:

const sendMesg = (errorResendTimes = 0) => {
    console.log(errorResendTimes);
    try {
        throw 'hahaha';
    } catch (e) {
        setTimeout(
            () => sendMesg(errorResendTimes), 
            100 * Math.pow(2, errorResendTimes) // 使用指數增長的時間間隔
            // 100 * errorResendTimes // 使用線性增長的時間間隔
        );
        errorResendTimes++;
    }
};
複製程式碼

錯誤的隔離與降級

這個是js中捕獲並處理錯誤的最核心的出發點,有經驗的程式設計師為了避免未捕獲的錯誤阻斷程式執行造成bug,需要在程式中設定陷阱(使用try/catch語句)捕獲錯誤。當錯誤發生的時候,使用了錯誤捕獲技術的地方可以防止錯誤影響當前呼叫棧之後的程式碼執行,隔離了錯誤的影響範圍,將影響範圍限制在當前函式作用域。錯誤的降級處理指的是當錯誤發生後,雖然已使用錯誤捕獲技術隔離了影響範圍,但是假如當前呼叫棧之後的程式碼仍然依賴當前的執行結果,錯誤導致執行結果為非預期的資料型別,那麼之後的程式碼使用該執行結果必然又會出錯。 錯誤的降級處理示例:

let a;
try {
    a = JSON.parse('{a: a}');
} catch (e) {
    a = {};
}
console.log(a.a);
複製程式碼

如何上報錯誤資訊到日誌伺服器

這個問題分為兩個步驟,首先要獲取到錯誤資訊,其次是預處理這些錯誤資訊,通常情況下使用一些技術手段合併、降頻,最後再上報日誌伺服器。這裡著重談如何獲取到更全面細緻的錯誤資訊。直接看示例:

// 監聽js執行時異常
window.onerror = (message, source, lineno, colno, error) => {
    var errorMesg = wrapError({message, source, lineno, colno, error});
    console.log(message)
    sendErrorToServer('js執行時異常:' + errorMesg);
}

// 監聽document資源載入異常
window.addEventListener('error', function(e) {
    // 過濾非window上捕獲的異常
    if (e.target === window) {
        return;
    }
    var errorMesg = wrapError(e);
    sendErrorToServer('document資源載入異常:' + errorMesg);
}, true);

// 監聽未捕獲的Promise異常
window.addEventListener('unhandledrejection', function(e) {
    var errorMesg = e.reason;
    sendErrorToServer('未捕獲的Promise異常:' + errorMesg);
});

// 監聽最初未捕獲稍後又被捕獲的Promise異常
window.addEventListener('rejectionhandled', function(e) {
    var errorMesg = e.reason;
    sendErrorToServer('最初未捕獲稍後又被捕獲的Promise異常:' + errorMesg);
});

// 已捕獲的Promise異常
new Promise(function(resolve, reject) {
    // reject('我被捕獲了');
    throw new Error('我被捕獲了');
}).catch(function(reason) {
    var errorMesg = reason;
    sendErrorToServer('已捕獲的Promise異常:' + errorMesg);
});

// 未捕獲的Promise異常
new Promise(function(resolve, reject) {
    reject('我沒有被捕獲');
});

// 最初未捕獲稍後又被捕獲的Promise異常:
var p1 = new Promise(function(resolve, reject) {
    reject('我後來被捕獲了');
});
setTimeout(function(){
    p1.catch(function(e) {
    });
}, 200);

function sendErrorToServer(error) {
    const img = new Image();
    var from = window.location.href;
    error += '\n' + from;
    img.src = "http://www.baidu.com?error=" + error;
}
function wrapError({message='', source='', lineno='', colno='', error, target}) {
    var returned = '';
    var mesg = error && error.toString() || message;
    var stack = error && error.stack || '';
    returned += '\n' + mesg;
    returned += '\n' + source;
    returned += '\n' + lineno;
    returned += '\n' + colno;
    returned += '\n' + stack;
    if (target && target.outerHTML) {
        returned += '\n' + target.outerHTML;
    }
    return returned;
}
複製程式碼

名詞解釋

捕獲錯誤:

使用try/catch塊的try語句將可能出錯的程式碼包含進去。

處理錯誤:

在try/catch塊的catch語句中存在非註釋的程式碼邏輯可以認為錯誤經過處理。

不處理錯誤:

與之相反,catch(e) {}內部沒有程式碼邏輯,可以認為錯誤沒有經過處理。

丟擲錯誤:

使用throw語句後跟錯誤物件,throw new Error('error message')throw 'error message'

最後發個廣告,網易七魚招聘前端開發工程師,挑戰高薪傳送門

相關文章