詳聊前端異常原理

echa攻城獅 發佈 2022-09-28T04:55:54.058150+00:00

隨著近年來前端監控體系建設日益完善,前端工程師對異常更加關注。異常,Exception, 即預料之外的事件,在程序執行過程中發生,會打斷正常的程序運行。

大家好,我是Echa哥。

導讀

隨著近年來前端監控體系建設日益完善,前端工程師對異常更加關注。業界關於 JS 異常介紹大多只談了異常的捕獲方法,對產生的原因和處理辦法談的較少。本文將詳細的闡述異常原理,把筆者近 2 年在前端監控領域中與異常打交道的經驗分享給大家。

異常定義

異常,Exception, 即預料之外的事件,在程序執行過程中發生,會打斷正常的程序運行。

ECMA-262 白皮書 13 版中描述了 8 種異常

  • SyntaxError:語法異常
  • ReferenceError:引用異常
  • RangeError:範圍異常
  • Error:異常基類
  • InternalError:內部異常
  • TypeError: 類型異常
  • evalError: Eval 方法異常
  • URIError: URI 相關方法產生的異常

1. SyntaxError

在引擎執行代碼之前,編譯器需要對 js 進行編譯,編輯階段包括:詞法分析,語法分析;如圖:

編譯階段發生的異常都是 SyntaxError,但 SyntaxError 不完全都發生於編譯階段;

 const a = '3;

比如這行代碼,缺少一個引號,就會發生: SyntaxError: Invalid or unexpected token.

其他常見的 SyntaxError:

  • SyntaxError:Unexpected token u in JSON at position 0
  • SyntaxError:Unexpected token '<'
  • SyntaxError:Unexpected identifier

絕大部分 SyntaxError 都可以通過配置編輯器的校驗工具,從而在開發階段避免。

2. ReferenceError

引用異常,比較常見,類似於 Java 語言中最著名的空指針異常 (Null Pointer Exception,NPE).

  • ReferenceError:$ is not defined
  • ReferenceError:Can't find variable: $

上面舉的 2 個引用異常例子其實是同一個異常,第一個是發生在 Android,第二個是在 iOS 下,異常對象的 message 有著兼容性的差別。

什麼情況下會發生引用異常呢?

這裡需要先提一下 LHS 查詢和 RHS 查詢。

比如 const a = 2; ,對於這一行代碼,引擎會為變量 a 進行 LHS 查詢。另外一個查找的類型叫作 RHS,即在賦值語句的 Left Hand Side 和 Right Hand Side。RHS 查詢與簡單地查找某個變量的值別無二致,而 LHS 查詢則是試圖找到變量的容器本身,即作用域。

LHS 和 RHS 的含義是 「賦值操作的左側或右側」 並不一定意味著就是 「=」。比如 console.log(a) 也會進行異常 RHS。我們再來看一個例子:

 function foo(a) {
  var b = a;
   return a + b;
 }
 var c = foo(2);

其中有 function foo;Var c;A = 2;Var b 這 4 次 LHS 和 4 次 RHS

為什麼區分 LHS 和 RHS 是一件重要的事情?

因為在變量還沒有聲明的情況下,這兩種查詢的行為是不一樣的。

如果 RHS 查詢在所有嵌套的作用域中遍尋不到所需的變量,引擎就會拋出 ReferenceError。

如果 RHS 查詢找到了一個變量,但是你嘗試對這個變量的值進行不合理的操作,會拋出另外一種類型的異常,叫作 TypeError。

3. TypeError

TypeError 在對值進行不合理操作時會發生,比如試圖對一個非函數類型的值進行函數調用,或者引用 null 或 undefined 類型的值中的屬性,那麼引擎會拋出這種類型的異常。比如:

 TypeError:Cannot read property 'length' of undefined

這是個最常見的異常之一,在判斷數組長度時可能發生。

可以做前置條件判空,比如:

 if (obj) {
   res = obj.name;
 }

也可以改寫成邏輯與運算 && 的表達式寫法

 res = obj && obj.name;

但如果屬性較多,這種方法就很難看了,可以使用可選鏈的寫法,如下:

 res = obj && obj.a && obj.a.b && obj.a.b.name

 res = obj?.a?.b?.name;

雖然條件判斷、邏輯與判斷、可選鏈判斷都可以避免報錯,但是還是有 2 個缺點:

  • js 對於變量進行 Bool 強制轉換的寫法還是不夠嚴謹,可能出現判斷失誤
  • 這樣寫法在為空時本行代碼不會報錯,但是後續邏輯可能還會出問題;只是減少了異常,並沒有辦法解決這種情況。對於重要的邏輯代碼建議使用 try/catch 來處理,必要時可以加上日誌來分析。

4. RangeError

範圍錯誤,比如:

  • new Array(-20) 會導致 RangeError: Invalid array length
  • 遞歸等消耗內存的程序會導致 RangeError: Maximum call stack size exceeded

遞歸可以使用循環 + 棧或尾遞歸的方式來優化

 //普通遞歸
 const sum = (n) => {
   if (n <= 1) return n;
   return n + sum(n-1)
 }

 //尾遞歸
 const sum = (n, prevSum = 0) => {
   if (n <= 1) return n + prevSum;
   return sum(n-1, n + prevSum)
 }

尾遞歸和一般的遞歸不同在對內存的占用,普通遞歸創建 stack 累積而後計算收縮,尾遞歸只會占用恆量的內存。當編譯器檢測到一個函數調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創建一個新的。

5. Error 與自定義異常

Error 是所有錯誤的基類,其他錯誤類型繼承該類型。所有錯誤類型都共享相同的屬性。

  • Error.prototype.message 錯誤消息。對於用戶創建的 Error 對象,這是構造函數的第一個參數提供的字符串。
  • Error.prototype.name 錯誤名稱。這是由構造函數決定的。
  • Error.prototype.stack 錯誤堆棧

通過繼承 Error 也可以創建自定義的錯誤類型。創建自定義錯誤類型時,需要提供 name 屬性和 message 屬性.

 class MyError extends Error {
     constructor(message) {
         super();
         this.name = 'MyError'
         this.message = message     }
 }

大多流行框架會封裝一些自定義異常,比如 Axios 和 React.

React 在 ErrorDecoder 模塊中對自定義錯誤做了介紹。每個錯誤都有 ID,比如 ID:185 錯誤是:在 componentDidUpdate 函數中調用了 this.setState() 方法,導致 componentDidUpdate 陷入死循環。在報錯後會輸出帶有異常介紹連結的日誌.

https://reactjs.org/docs/error-decoder.html/?invariant = 異常 ID.

利用連結打開可視化連結,如下:

6. Error: script Error.

它是 Error 類型中最常見的一種;由於沒有具體異常堆棧和代碼行列號,成為可最神秘的異常之一。

由於瀏覽器基於安全考慮效避免敏感信息無意中被第三方 (不受控制的) 腳本捕獲到,瀏覽器只允許同域下的腳本捕獲具體的錯誤信息。

但大部分的 JS 文件都存放在 CDN 上面,跟頁面的域名不一致。做異常監控只能捕獲 Error: Script Error. 無法捕獲堆棧和準確的信息。2 步解決:

1、給 script 標籤增加 crossorigin 屬性,讓瀏覽器允許頁面請求資源。

 <scrpit src="http://def.com/demo.js" crossorigin="anonymous"></script>

這樣請求頭 sec-fetch-mode 值就會變成 cors, 默認是 no-cors.

但有些瀏覽器還不兼容此方法,加上 crossorigin 後仍不能發出 sec-fetch-mode:cors 請求

2、給靜態資源伺服器增加響應頭允許跨域標記。

 Access-Control-Allow-Origin: *.58.com

大部分主流 CDN 默認添加了 Access-Control-Allow-Origin 屬性。

整個過程可以參考以下流程圖:

在加上跨域請求頭、響應頭後可能還有大量的 ScriptError,就要考慮以下幾種情況

  • 通過 append Script 標籤異步加載 JS
  • JSONP 請求
  • 第三方 SDK

7. 其他異常

InternalError

這種異常極為少見,在 JS 引擎內部發生,示例場景通常為某些成分過大,例如:

  • 「too many switch cases」(過多 case 子句);
  • 「too many parentheses in regular expression」(正則表達式中括號過多);
  • 「array initializer too large」(數組初始化器過大);

EvalError

在 eval() 方法執行過程中拋出 EvalError 異常。

根據 Ecma2018 版以後,此異常不再會被拋出,但是 EvalError 對象仍然保持兼容性。

URIError

用來表示以一種錯誤的方式使用全局 URI 處理函數而產生的錯誤.

decodeURI, decodeURIComponent, encodeURI, encodeURIComponent 這四個方法會產生這種異常;

比如執行 decodeURI('%%') 的異常:Uncaught URIError: URI malformed

異常處理

ECMA-262 第 3 版新增了 try/catch 語句,作為在 JavaScript 中處理異常的一種方式。基本的語法如下所示,跟 Java 中的 try/catch 語句一樣。

1. finally

finally 在 try-catch 語句中是可選的,finally 子句一經使用,其代碼無論如何都會執行。

 function a () {
     try {
         return '約會'
     } catch (e) {
         return '約會失敗'
     } finally {
         return '睡覺';
     }
 }
 console.log('函數結果:', a()) // '睡覺'

上述代碼的結果是 ' 睡覺 ',finally 會阻止 return 語句的終止.

2. throw

 throw new Error('Boom');

什麼時候應該手動拋出異常呢?

一個指導原則就是可預測程序在某種情況下不能正確進行下去,需要告訴調用者異常的詳細信息,而不僅僅是異常內容本身。比如上文提到的 React 自定義異常;

一個健壯的函數,會對參數進行類型有效性判斷;通常在實參不合理時,為了避免報錯阻斷程序運行,開發者會通過默認值,return 空等方式處理。

這種方式雖然沒有報錯,但是程序的結果未必符合預期,默認值設計不合理會造成語義化誤解;另外,也可能無法避免後續的代碼報錯;

3. 斷言

上文提到可預測,很容易聯想到 Node 中的斷言 assert,如果表達式不符合預期,就拋出一個錯誤。

assert 方法接受兩個參數,當第一個參數對應的布爾值為 true 時,不會有任何提示,返回 undefined。當第一個參數對應的布爾值為 false 時,會拋出一個錯誤,該錯誤的提示信息就是第二個參數設定的字符串。

 var assert = require('assert');
 function add (a, b) {
   return a + b;
 }
 var expected = add(1,1);
 assert( expected === 2, '預期1加1等於2');

通常在 TDD 開發模式中,會用於編寫測試用例;

不過 ECMA 還沒有類似的設計,感興趣可以簡單封裝一個 assert 方法。瀏覽器環境中的 console 對象有類似的 assert 方法。

4. 異步中的異常

非同步的代碼,在事件循環中執行的,就無法通過 try catch 到。

主要注意的是,Promise 的 catch 方法用於處理 rejected 狀態,而非處理異常。Rejected 狀態未處理的話會觸發 Uncaught Rejection. 後者可以通過如下方式進行統一的監聽。

 window.onunhandledrejection = (event) => {
   console.warn(`REJECTION: ${event.reason}`);
 };

tips: await 這種 Promise 的同步寫法,通常會被開發者忽略 rejected 的處理,可以用 try catch 來捕獲。

5. 異常監控

服務端通常會通過伺服器的日誌進行異常監控,比如觀察單台伺服器的日誌輸出,或 kibana 可視化查詢。
前端異常監控與之最大的不同,就是需要把客戶端發生的異常數據通過網絡再收集起來。

可以使用下面幾個方式來收集數據:

  • window.onerror 捕獲語法異常
  • 可以重寫 setTimeout、setInterval 等異步方法,用同步的寫法包裹 try 來捕獲異步函數中發生的錯誤
  • window.addEventListener (『unhandledrejection』,・・・); 捕獲未處理的異步 reject
  • window.addEventListener (『error』, …) 捕獲資源異常
  • 重寫 fetch, XMLHttpRequest 來捕獲接口狀態

總結

本文詳細講解了 ECMA 中 8 種異常的產生原理,涉及了 LHS&RHS、遞歸優化、ScriptError、finally、Promise 等知識點,希望在處理異常的工作中能給你帶來幫助。

參考

  • ecma-262: https://www.ecma-international.org/publications-and-standards/standards/ecma-262/
  • ES6th 白皮書: https://262.ecma-international.org/6.0
  • React Error Decoder: https://reactjs.org/docs/error-decoder.html/?invariant=1
  • 《Js 高級程序設計 第四版》
  • 《你不知道的 JS》
關鍵字: