啊哦,在 JavaScript 中處理錯誤很危險。如果你相信墨菲定律,會出錯的終究會出錯!在這篇文章中,我會深入研究 JavaScript 中的錯誤處理。我會涉及到一些陷阱和好的實踐。最後我們會討論非同步程式碼處理和 Ajax。
我認為 JavaScript 的事件驅動模型給這門語言新增了豐富的含義。我認為這種瀏覽器的事件驅動引擎和報錯機制沒什麼區別。每當發生錯誤,就相當於在某個時間點丟擲一個事件。理論上說,我們在 JavaScript 中可以像處理普通事件一樣去處理拋錯事件。如果對你來說這聽起來很陌生,那請集中注意力開始學習下面的旅程。本文只針對客戶端的 JavaScript。
這篇文章建立在《Exceptional Exception Handling in JavaScript》中解釋過的一些概念的基礎之上。套用裡面的話:“對於一個異常,JavaScript 會檢查異常處理的呼叫棧。”如果你不太熟悉,我建議先去讀一下這篇文章。我的目標是去探索處理異常時除了基本需求之外的更多方案。下次當你看到 try...catch
程式碼塊時,能讓你思考更多。
示例
本文章中用到的程式碼示例在 GitHub 上可以得到,目前頁面是這個樣子的:
單擊每個按鈕都會引發一個錯誤。它模擬產生一個 TypeError 型的 exception。下面是對這樣一個模組的定義及單元測試。
1 2 3 4 |
function error() { var foo = {}; return foo.bar(); } |
首先,這個函式定義了一個空的物件 foo
。請注意,bar()
方法沒有在任何地方定義。我們用單元測試來驗證這確實會引發報錯。
1 2 3 |
it('throws a TypeError', function () { should.throws(target, TypeError); }); |
這個單元測試使用 Mocha 和 Should.js 庫中的測試斷言。Mocha 是一個執行測試框架,should.js 是一個斷言庫。如果你不太熟悉,可以線上免費瀏覽他們的文件。一個測試用例通常以 it('description')
開始,以 should
中斷言的通過或者失敗結束。用這套框架的好處就是可以在 node 裡進行單元測試,而不必非在瀏覽器裡。我建議大家認真對待這些測試,因為它們驗證了 JavaScript 中很多關鍵的基本概念。
如上所示, error()
定義了一個空物件,然後試圖去呼叫其中的方法。因為在這個物件中不存在 bar()
這個方法,它會丟擲一個異常。相信我,在像 JavaScript 這種動態語言裡,任何人都有可能犯這類錯誤。
不好的示範
先來看看不佳的錯誤處理方式。我處理錯誤的動作抽象出來,繫結在按鈕上。下面是處理程式的單元測試的樣子:
1 2 3 4 5 6 |
function badHandler(fn) { try { return fn(); } catch (e) { } return null; } |
這個處理函式接收一個回撥函式 fn
作為依賴。接著在處理程式的內部呼叫了這個函式。這個單元測試示例瞭如何使用這個方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
it('returns a value without errors', function() { var fn = function() { return 1; }; var result = target(fn); result.should.equal(1); }); it('returns a null with errors', function() { var fn = function() { throw Error('random error'); }; var result = target(fn); should(result).equal(null); }); |
就像你看到的那樣,如果發生了錯誤,這個詭異的處理方法會返回一個 null
。這個回撥函式 fn()
會指向一個合法的方法或者錯誤。下面的單擊處理事件完成了剩下的部分。
1 2 3 4 5 6 7 8 9 10 |
(function (handler, bomb) { var badButton = document.getElementById('bad'); if (badButton) { badButton.addEventListener('click', function () { handler(bomb); console.log('Imagine, getting promoted for hiding mistakes'); }); } }(badHandler, error)); |
糟糕的是我剛剛得到的是個 null
。這讓我在想確定到底發生了什麼錯誤的時候非常迷茫。這種發生錯誤就沉默的策略覆蓋了從使用者體驗設計到資料損壞的各個環節。隨之而來令人沮喪的一面就是,我必須花費好幾個小時除錯但是卻看不到 try-catch 程式碼塊裡的錯誤。這種詭異的處理隱藏掉了程式碼中所有的報錯,它假設一切都是正常的。這在某些不注重程式碼質量的團隊中,能夠順利的執行。但是,這些被隱藏的錯誤最終會迫使你花幾個小時來除錯程式碼。在一種依賴於呼叫棧的多層解決方案中,有可能可以確定錯誤來自於何處。可能在極少數情況下對 try-catch 做故障靜默處理是合適的。但是如果遇到錯誤就去處理,也不是一個好方案。
這種失敗即沉默的策略會促使你在程式碼中對錯誤做更好的處理。JavaScript 提供了更優雅的方式來處理這類問題。
不易讀的方案
繼續,接下來來看看不太好理解的處理方式。我將會跳過與 DOM 緊耦合的部分。這部分與我們剛剛看過的不好的處理方式沒什麼不同。重點是下面單元測試中處理異常的部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function uglyHandler(fn) { try { return fn(); } catch (e) { throw Error('a new error'); } } it('returns a new error with errors', function () { var fn = function () { throw new TypeError('type error'); }; should.throws(function () { target(fn); }, Error); }); |
比起剛剛不好的處理方式,有一個很好的進步。異常在呼叫堆疊中被丟擲。我喜歡的地方是錯誤從堆疊中解放出來,這對於除錯有巨大的幫助。丟擲一個異常,直譯器就會在呼叫堆疊中一級級檢視找到下一個處理函式。這就提供了很多機會在呼叫堆疊的頂層去處理錯誤。不幸的是,因為他是一種不太好理解的錯誤,我看不到了原始錯誤的資訊。所以我必須沿著呼叫棧找過去,找到最原始的異常。但是至少我知道丟擲異常的地方發生了一個錯誤。
這種不易讀的錯誤處理雖然無傷大雅但是卻使得程式碼難以理解。讓我們看看瀏覽器如何處理錯誤的。
呼叫棧
那麼,丟擲異常的一種方式就是在呼叫堆疊的頂層新增 try...catch
程式碼塊。比如說:
1 2 3 4 5 6 7 |
function main(bomb) { try { bomb(); } catch (e) { // Handle all the error things } } |
但是,記得我說過瀏覽器是事件驅動的嗎?是的,JavaScript 中的一個異常不過就是一個事件。直譯器會在發生異常當前的上下文處停止程式,並丟擲異常。為了證實這一點,下面寫了一個我們能夠看到的全域性的事件處理函式 onerror
。它看上去就是這個樣子:
1 2 3 4 |
window.addEventListener('error', function (e) { var error = e.error; console.log(error); }); |
這個事件處理函式在執行環境中捕獲錯誤。錯誤事件會在各種各樣的地方產生各種錯誤。這種方式的重點是在程式碼中集中處理錯誤。就像其他的事件一樣,你可以用一個全域性的處理函式去處理各種不同的錯誤。這使得錯誤處理只有一個單一的目標,如果你遵守 SOLID (single responsibility 單一職責, open-closed 開閉, Liskov substitution 代換, interface segregation 介面分離 and dependency inversion 依賴倒置) 原則。你可以在任何時候註冊錯誤處理函式。直譯器會迴圈執行這些函式。程式碼從充滿 try...catch
的語句中解放出來,變得易於除錯。這種做法的關鍵是像處理 JavaScript 普通事件一樣處理髮生的錯誤。
現在,有了一種方法,用全域性處理函式來顯示出呼叫棧,我們可以用它來做什麼?終究,我們要利用呼叫棧。
記錄下呼叫棧
呼叫棧在處理修復 bug 上非常有用。好訊息是瀏覽器提供了這個資訊。就算目前,error 物件的 stack 屬性並不是標準,但是在比較新的瀏覽器裡都普遍支援這個屬性。
所以,我們能夠做的很酷的事情就是把它給伺服器列印出來:
1 2 3 4 5 6 7 8 9 10 |
window.addEventListener('error', function (e) { var stack = e.error.stack; var message = e.error.toString(); if (stack) { message += '\n' + stack; } var xhr = new XMLHttpRequest(); xhr.open('POST', '/log', true); xhr.send(message); }); |
在程式碼示例中可能不太明顯,但這個事件處理程式會被前面的錯誤程式碼觸發。如上所述,每個處理程式都有一個單一的目的,它使程式碼 DRY(don’t repeat yourself 不重複製造輪子)。我感興趣的是如何在伺服器上捕獲這些訊息。
下面是 node 執行時的截圖:
這個訊息來自於 Firefox Developer Edition 46 。有了適當的錯誤處理,就把問題很清楚的呈現出來。這裡不需要隱藏錯誤!只需要稍微看一下,就能知道哪裡發生了什麼錯誤。這種級別的透明性在前端程式碼除錯時給人感覺很棒。這些資訊可以被持久儲存下來,後面進行檢索,進一步瞭解在什麼條件觸發了什麼錯誤。
呼叫堆疊對除錯程式碼很有幫助。永遠不要低估呼叫棧的作用。
非同步處理
哦,處理非同步程式碼相當危險!JavaScript 將非同步程式碼從當前的執行環境中帶出來。這意味著下面這種 try...catch
語句有個問題。
1 2 3 4 5 6 7 |
function asyncHandler(fn) { try { setTimeout(function () { fn(); }, 1); } catch (e) { } } |
這個單元測試還有剩下的部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
it('does not catch exceptions with errors', function () { var fn = function () { throw new TypeError('type error'); }; failedPromise(function() { target(fn); }).should.be.rejectedWith(TypeError); }); function failedPromise(fn) { return new Promise(function(resolve, reject) { reject(fn); }); } |
我必須用一個 promise 來結束這個處理程式,以驗證異常。注意,儘管我的程式碼都在 try...catch
中,但是還是出現了未處理的異常。是的,try...catch
只在一個單獨的執行環境中有作用。當異常被丟擲時,直譯器的執行環境已經不是當前的 try-catch 塊了。這一行為的發生與 Ajax 呼叫相似。所以,現在有了兩種選擇。一種可選方案就是在非同步回撥中捕捉異常:
1 2 3 4 5 6 7 |
setTimeout(function () { try { fn(); } catch (e) { // Handle this async error } }, 1); |
這種方法雖然有用,但是還有很大的提升空間。首先,try...catch
程式碼塊在程式碼中處處出現。事實上,上世紀 70 年代程式設計呼叫,他們希望他們的程式碼能夠回退。另外,V8 引擎不鼓勵 在函式中使用 try…catch
程式碼塊 (V8 是 Chrome 瀏覽器和 Node 使用的 JavaScript 引擎)。他們推薦在呼叫堆疊頂層寫這些捕獲異常的程式碼塊。
所以,這告訴我們什麼?我上面說過的,在任何執行上下文中的全域性錯誤處理程式是有必要的。如果你將一個錯誤處理程式新增到 window 物件,那就是說,您已經完成了!遵守 DRY 和 SOLID 的原則不是很好嗎?一個全域性錯誤處理程式將保持你的程式碼易讀和乾淨。
下面就是伺服器端異常處理列印的報告。注意,如果你使用的示例中的程式碼,輸出的內容可能會根據你使用的瀏覽器不同有少許不同。
這個處理函式甚至可以告訴我哪個錯誤是出自於非同步程式碼。它告訴我錯誤來自於 setTimeout()
處理函式。太酷了!
總結
在處理錯誤這件事上至少有兩種方法。一種是失敗即沉默的方案,即在程式碼中忽略錯誤。另一種是快速發現和解決錯誤的方法,即在錯誤處停止並且重現。我想我已經把我贊成哪一種及為什麼贊成表達地很清楚。我的選擇:不要隱藏問題。沒有人會為你程式中的意外事件去指責你。這是可以接受的,去打斷點、重現、給使用者一個嘗試。在一個並不完美的世界中,給自己一個機會是很重要的。錯誤是不可避免的,為了解決錯誤你做的事情才是重要的。