前端工程師都知道 JavaScript 有基本的異常處理能力。我們可以 throw new Error(),瀏覽器也會在我們呼叫 API 出錯時拋出異常。但估計絕大多數前端工程師都沒考慮收集這些異常資訊
反正只要 JavaScript 出錯後刷新不復現,那用戶就可以透過刷新解決問題,瀏覽器不會崩潰,當沒有發生過好了。這種假設在 Single Page App 流行之前還是成立的。現在的 Single Page App 運行一段時間後狀態複雜無比,用戶可能進行了若干輸入操作才來到這裡的,說刷新就刷新啊?之前的操作豈不要完全重做?所以我們還是有必要捕捉和分析這些異常資訊的,然後我們就可以修改程式碼避免影響使用者體驗。
捕捉異常的方式
我們自己寫的 throw new Error() 想要捕獲當然可以捕獲,因為我們很清楚 throw 寫在哪裡了。但是呼叫瀏覽器 API 時發生的異常就不一定那麼容易捕獲了,有些 API 在標準裡就寫著會拋出異常,有些 API 只有個別瀏覽器因為實現差異或者有缺陷而拋出異常。對於前者我們還能透過 try-catch 捕獲,對於後者我們必須監聽全域的異常然後捕獲。
try-catch
如果有些瀏覽器 API 是已知會拋出異常的,那我們就需要把呼叫放到 try-catch 裡面,避免因為出錯而導致整個程式進入非法狀態。例如說 window.localStorage 就是這樣的 API,在寫入資料超過容量限制後就會拋出異常,在 Safari 的隱私瀏覽模式下也會如此。
對於 try-catch 覆蓋不到的地方,如果出現異常就只能透過 window.onerror 來捕捉了。
屬性遺失
假設我們有一個 reportError 函數用來收集捕獲到的異常,然後批量發送到伺服器端儲存以便查詢分析,那麼我們會想要收集哪些資訊呢?比較有用的資訊包括:錯誤類型(name)、錯誤訊息(message)、腳本檔案位址(script)、行號(line)、列號(column)、堆疊追蹤(stack)。如果一個異常是透過 try-catch 捕獲到的,這些資訊都在 Error 物件上(主流瀏覽器都支援),所以 reportError 也能收集到這些資訊。但如果是透過 window.onerror 捕捉到的,我們都知道這個事件函數只有 3 個參數,所以這 3 個參數意外的資訊就遺失了。
序列化訊息
如果 Error 物件是我們自己創建的話,那麼 error.message 就是由我們控制的。基本上我們把什麼放進 error.message 裡面,window.onerror 的第一個參數(message)就會是什麼。 (瀏覽器其實會略作修改,例如加上'Uncaught Error: ' 前綴。)因此我們可以把我們關注的屬性序列化(例如JSON.Stringify)後存放到error.message 裡面,然後在window.onerror 讀取出來反序列化就可以了。當然,這僅限於我們自己創建的 Error 物件。
第五個參數
瀏覽器廠商也知道大家在使用 window.onerror 時受到的限制,所以開始往 window.onerror 上面加入新的參數。考慮到只有行號沒有列號好像不是很對稱的樣子,IE 先把列號加上了,放在第四個參數。然而大家更關心的是能否拿到完整的堆疊,於是 Firefox 說不如把堆疊放在第五個參數。但 Chrome 說那不如把整個 Error 物件放在第五個參數,大家想讀取什麼屬性都可以了,包括自訂屬性。結果由於 Chrome 動作比較快,在 Chrome 30 實現了新的 window.onerror 簽名,導致標準草案也就跟著這樣寫了。
我們之前討論到的Error 物件屬性,其名稱都是基於Chrome 命名方式的,然而不同瀏覽器對Error 物件屬性的命名方式各不相同,例如腳本檔案地址在Chrome 叫做script 但在Firefox 叫做filename 。因此,我們還需要一個專門的函數來對 Error 物件進行正規化處理,也就是把不同的屬性名稱都映射到統一的屬性名稱上。具體做法可以參考這篇文章。儘管瀏覽器實作會更新,但人手維護一份這樣的映射表並不會太難。
類似的是堆疊追蹤(stack)的格式。這個屬性以純文字的形式保存一份異常在發生時的堆疊信息,由於各個瀏覽器使用的文本格式不一樣,所以也需要人手維護一份正則表達,用於從純文本中提取每一幀的函數名(identifier)、文件(script)、行號(line)和列號(column)。
安全限制
如果你也遇到過訊息為 'Script error.' 的錯誤,你會明白我在說什麼的,這其實是瀏覽器針對不同來源(origin)腳本檔案的限制。這個安全限制的理由是這樣的:假設一家網路銀在使用者登入後回傳的 HTML 跟匿名使用者看到的 HTML 不一樣,一個第三方網站就能把這家網銀的 URI 放到 script.src 屬性裡面。 HTML 當然不可能被當成 JS 解析啦,所以瀏覽器會拋出異常,而這個第三方網站就能透過解析異常的位置來判斷使用者是否有登入。為此瀏覽器對於不同來源腳本檔案拋出的異常一律進行過濾,過濾得只剩下 'Script error.' 這樣一條不變的訊息,其它屬性統統消失。
對於有一定規模的網站來說,腳本檔案放在 CDN 上,不同來源是很正常的。現在就算是自己做個小網站,常見框架如 jQuery 和 Backbone 都能直接引用公共 CDN 上的版本,加速使用者下載。所以這個安全限制確實造成了一些麻煩,導致我們從 Chrome 和 Firefox 收集到的異常資訊都是無用的 'Script error.'。
CORS
想要繞過這個限制,只要確保腳本檔案和頁面本身同源即可。但把腳本檔案放在不經 CDN 加速的伺服器上,豈不降低使用者下載速度?一個解決方案是,腳本檔案繼續放在 CDN 上,利用 XMLHttpRequest 透過 CORS 把內容下載回來,再建立 <script> 標籤注入到頁面當中。在頁面當中內嵌的程式碼當然是同源的啦。 </script>
這說起來很簡單,但實作起來卻有很多細節問題。用一個簡單的例子來說:
如果我們已經有一整套工具來產生網站上不同頁面的 <script> 標籤的話,我們就需要調整一下這套工具讓它對 <script> 標籤做出改變:</script>
當然,由於我們沒辦法保證每個腳本檔案只有 1000 行,也有可能有些腳本檔案明顯小於 1000 行,所以其實不需要固定分配 1000 行的區間給每一個 <script> 標籤。我們可以根據實際腳本行數來分配區間,只要保證每一個 <script> 標籤所使用的區間互不重疊就可以了。 </script>
crossorigin 屬性
瀏覽器對於不同來源的內容進行的安全限制當然不僅限於 <script> 標籤。既然 XMLHttpRequest 可以透過 CORS 來突破這個限制,為什麼直接透過標籤引用的資源就不行呢?這當然是可以的。 </script>
針對 <script> 標籤引用不同來源腳本檔案的限制同樣作用於 <img alt="JavaScript 異常處理 詳解_javascript技巧" > 標籤引用不同來源圖片檔案。如果一個 <img alt="JavaScript 異常處理 詳解_javascript技巧" > 標籤是不同來源的話,一旦在 <canvas> 繪圖時用到了,該 <canvas> 將變成只寫狀態,保證網站不能透過 JavaScript 竊取未經授權的不同來源圖片資料。後來 <img alt="JavaScript 異常處理 詳解_javascript技巧" > 標籤透過引入 crossorigin 屬性解決了這個問題。如果使用 crossorigin="anonymous",則相當於匿名 CORS;如果使用 `crossorigin=“use-credentials”,則相當於具有認證的 CORS。 </script>
既然 標籤能這樣做,為什麼 <script> 標籤就不能這樣做?於是瀏覽器廠商就為 <script> 標籤加入了相同的 crossorigin 屬性來解決上述安全限制問題。現在 Chrome 和 Firefox 對這個屬性的支援是完全沒有問題的。 Safari 則會把 crossorigin="anonymous" 當做 crossorigin="use-credentials" 處理,結果是如果伺服器只支援匿名 CORS 則 Safari 會當做認證失敗。由於 CDN 伺服器出於效能的考量而設計為只能傳回靜態內容,因此不可能動態的根據請求返回認證 CORS 所需的 HTTP Header,Safari 相當於不能利用此特性來解決上述問題。 </script>
總結
JavaScript 異常處理看起來很簡單,跟其它語言沒什麼區別,但真的要把異常都捕獲了然後對屬性做分析,其實還不是那麼容易的事情。現在儘管有一些第三方服務提供捕捉 JavaScript 異常的類 Google Analytics 服務,但如果要弄清楚其中的細節和原理還是必須自己親手做一次。