Javascript 異常處理的一些經驗

来源:https://www.cnblogs.com/88223100/archive/2022/11/02/Some-experience-with-javascript-exception-handling.html
-Advertisement-
Play Games

為了提升應用穩定性,我們對前端項目開展了腳本異常治理的工作,對生產上報的js error進行了整體排查,試圖通過降低腳本異常的發生頻次來提升相關告警的準確率,結合最近在這方面閱讀的相關資料,嘗試階段性的做個總結,下麵我們來介紹下js異常處理的一些經驗。 ...


 

 

寫在前面

 

為了提升應用穩定性,我們對前端項目開展了腳本異常治理的工作,對生產上報的js error進行了整體排查,試圖通過降低腳本異常的發生頻次來提升相關告警的準確率,結合最近在這方面閱讀的相關資料,嘗試階段性的做個總結,下麵我們來介紹下js異常處理的一些經驗。

 

先說概念

 

什麼是異常

 

先來看一下官方的定義:

 

Error objects are thrown when runtime errors occur. The Error object can also be used as a base object for user-defined exceptions. 

 

描述的很簡單,我們總結一下就是代碼在執行過程中遇到了問題,程式已經無法正常運行了,Error對象會被拋出,這一點它不同於大部分編程語言里使用的異常對象Exception,甚至更適合稱之為錯誤,應該說事實也確實如此,Error對象在未被拋出時候和js里其他的普通對象沒有任何差別是不會引發異常的,同時Error 對象也可用於用戶自定義錯誤的基礎對象。

 

看下麵兩個例子:

try {
  const 123variable = 2;
} catch(e) {
  console.log('捕獲到了:', e)
}

↓↓↓執行結果↓↓↓

 

 

 結論:只有在執行過程中的異常可以被捕獲,語法解析階段的異常或者不在當前同步任務中的異常都無法被捕獲。

<script>
  function throwSomeError() {
    throw new Error('拋個異常玩玩');
    console.log('我估計是涼了,不會執行我了!');
  }

  throwSomeError();
  console.log('那麼我呢?')
</script>

<script>
  console.log('大家猜猜我會執行嗎?');
</script>

↓↓↓執行結果↓↓↓

 

 

 

以上紅色信息里包含了異常信息(message)和棧跟蹤(stack trace)信息,對於定位代碼中的問題起到重要作用,可以看到棧跟蹤是從底部文件位置21:15到頂部25:7位置的;前兩個console在遇到異常時候未被執行,第二個script標簽內的代碼被正常執行。

 

結論:當任務執行過程中出現未處理的異常,會一直沿著調用棧一層層向外拋出(有點像事件冒泡),最終會導致當前任務被終止執行。當前任務終止後JS 線程會繼續從任務隊列中提取下一個任務繼續執行。

 

異常的類型

錯誤名

描述

示例

EvalError

關於 eval [1]函數的錯誤,已不在當前ECMAScript規範中使用,不再會被運行時拋出。

throw new EvalError('EvalError', 'file.js', 10); // 可以由業務代碼主動拋出

RangeError

值不在允許的範圍內,典型的是試圖傳遞一個數值給一個範圍內不包含該數值的函數,此時應該引發RangeError。

const numObj = 123;

numObj.toFixed(-1); // Uncaught RangeError: toFixed() digits argument must be between 0 and 100 at Number.toFixed

ReferenceError

當一個不存在(或尚未初始化)的變數被引用時發生的錯誤。

const a = undefinedVariable; // Uncaught ReferenceError: undefinedVariable is not defined

SyntaxError

解析代碼階段,發現了不符合語法規範的代碼。

const 111variable = 1; // Uncaught SyntaxError: Invalid or unexpected token

TypeError

類型錯誤,用來表示值的類型是非預期類型。

const a = null;

a.doSomeThing(); // Uncaught TypeError: Cannot read properties of null (reading 'doSomeThing')

URIError

使用URI處理函數產生的錯誤

decodeURIComponent('%') // Uncaught URIError: URI malformed

 

1.以上這些異常很多都來會由Javascript引擎拋出,但異常類型都是實際的構造函數,旨在生成一個新的異常實例,所以你可以:

// 獲取分頁數據
const getPagedData = (pageIndex, pageSize) => {
  if(pageIndex < 0 || pageSize < 0 || pageSize > 1000) {
    throw new RangeError(`pageIndex 必須大於0, pageSize必須在0和1000之間`);
  }

  return [];
}

// 轉換時間格式
const dateFormat = (dateObj) => {
  if(dateObj instanceof Date) {
    return 'formated date string';
  }

  throw new TypeError('傳入的日期類型錯誤');
}

2.Error實例被創建時不能被稱之為異常,只有在使用throw關鍵字將其拋出時才會引發異常;

new Error('出錯了!');
console.log('我吃嘛嘛香,喝嘛嘛棒!'); // 正常輸出 '我吃嘛嘛香,喝嘛嘛棒!'

3.技術上來講,你可以拋出任何類型的異常,而不僅僅是Error的實例,但請不要這麼做,總是拋出正確的錯誤對象會讓我們更容易定位問題,同時可以保持錯誤處理的一致性,捕獲異常時候也總能夠拿到Error實例上的message和stack;

// bad
throw '出錯了';
throw 123;
throw [];
throw null;

 

異常捕獲

前面有提到如果引發異常後不做任何處理會冒泡似的在你的調用棧中向頂部傳播,直到導致當前任務崩潰。有時候發生致命錯誤時候我們確實希望安全的停止程式的運行,如果希望程式得以恢復一般我們會用到try...catch...finally代碼結構,它是js中處理異常的標準方式;

try {
  // 要運行的代碼,可能引發異常
  doSomethingMightThrowError();
} 
catch (error) {
  // 處理異常的代碼塊,當發生異常時將會被捕獲,如果不繼續throw則不會再向上傳播
  // error為捕獲的異常對象
  // 這裡一般能讓程式恢復的代碼
  doRecovery();
}
finally {
  // 無論是否出現異常,始終都會執行的代碼
  doFinally();
}

被忽略的finally:此語句塊會在try和catch語句結束之後執行,無論結果是否報錯。

 

同時要註意,非同步中的發生的異常無法被上層捕獲,比如:

// Timeout
try {
  setTimeout(() => {
    throw Error("定時器出錯了!");
  }, 1000);
} catch (error) {
  console.error(error.message);
}

// Events
try {
  window.addEventListener("click", function() {
    throw Error("點擊事件出錯了!");
  });
} catch (error) {
  console.error(error.message);
}

Promise本身是就可以捕獲異常,語法上也類似於try catch,一旦發生異常,程式跳過promise內的代碼繼續執行;可以使用了catch方法捕獲後進行處理,也可以使用then方法中的第二個參數處理異常。promise的異常對象同樣是冒泡的,前者捕獲了就不會拋給後者,參見示例:

const promiseA = new Promise((resolve,reject)=>{
   throw new Error('Promise出錯了!');
});

const doSomethingWhenResolve = () => {};

const doSomethingWhenReject = (error) => {
  logger.log(error)
}

// 使用catch捕獲
const promiseB = promiseA.then(doSomethingWhenResolve).catch(doSomethingWhenReject);
// 等價於
const promiseB = promise.then(doSomethingWhenResolve, doSomethingWhenResolve);

promiseB.then(() => {
  console.log('我又可以正常進到then方法了!');
}).catch(()=>{
  console.log('不會來這裡!');
})

如何處理異常

 

異常的發生不可避免,所以在軟體開發中,合理的異常處理就成為了高質量代碼不可或缺的一部分,只有處理好了異常我們才能對程式中的意外情況進行有效的控制。我們最容易容易犯的一個問題就是將異常處理和業務的流程混為一談。

 

根據Clean Code的建議,面對異常我們可以遵循以下一些原則,提高代碼質量:

 

Prefer Exceptions to Returning Error Codes 

 

優先選擇異常而不是錯誤碼。

 

要理解這句話還是得結合例子,下麵的第一段代碼定義了一個Laptop類,在它的sendShutDown方法實現中,用if語句去檢查了getID的返回值中是否存在無效的deviceID,錯誤檢查會使調用者的代碼變得複雜不易閱讀業務邏輯,同時如果這個錯誤檢查被遺漏也會導致代碼出現問題,這個錯誤的處理可以交給語言讓整個過程更加優雅。第二段代碼中則將異常處理隔離了兩個不同的邏輯,這樣做會帶來一些優勢:

 

1.業務流程更加清晰易讀,我們把異常和業務流程理解為兩個不同的問題,可以分開去處理;

2.分開來的兩個邏輯都更加聚焦,代碼更簡潔;

3.將處理程式異常的職責交給了編程語言,明確了邊界;

// Dirty
class Laptop {
  sendShutDown() {
    const deviceID = getID(DEVICE_LAPTOP);
    if (deviceID !== DEVICE_STATUS.INVALID) {
      pauseDevice(deviceID);
      clearDeviceWorkQueue(deviceID);
      closeDevice(deviceID);
    } else {
      logger.log('Invalid handle for: ' + DEVICE_LAPTOP.toString());
    }
  }
  getID(status) {
    ...
    // 總是會返回deviceID,無論是不是合法有效的
    return deviceID;
  }
}
// Clean
class Laptop {
  sendShutDown() {
    try {
      tryToShutDown();
    } catch (error) {
      logger.log(error);
    }
  }

  tryToShutDown() {
    const deviceID = getID(DEVICE_LAPTOP);
    pauseDevice(deviceID);
    clearDeviceWorkQueue(deviceID);
    closeDevice(deviceID);
  }

  getID(status) {
    ...
    throw new DeviceShutDownError('Invalid handle for: ' + deviceID.toString());
    ...
    return deviceID;
  }
}

Don't ignore caught error!

捕獲到異常後不要忽略異常處理!

 

在之前的代碼評審中就經常有看到我們同學會在catch塊中什麼都不做,或者迫於eslint的檢查會寫一個console.log(error),這同樣意味著什麼都沒有做。屬於眼睜睜看到異常發生了不採取任何措施,這樣的處理方式非常危險,因為這些異常通常由我們沒有考慮到的意外情況引起,從中能發現業務邏輯中不易發現的問題,一旦我們捕獲了這些異常,頂層的錯誤監控也不能主動捕獲到這些問題,程式也許沒有崩潰但如果沒有用戶告知我們,我們就無法發現用戶的哪些功能無法正常使用了,因此最起碼也要對這些異常做日誌上報;

// bad
try {
  doSomethingMightThrowError();
} catch (error) {
  console.log(error);
}

// good
try {
  doSomethingMightThrowError();
} catch (error){
  console.error(error);
  message.error(error.message);
  logger.log(error);
}

Don't ignore rejected promises!

 

不要輕易忽略Promise的異常,除非你確定它已經被處理了!

 

這一塊我們還是有血淚教訓的,在接入AEM的項目中曾經在腳本異常的上報里將disable_unhandled_rejection開啟,禁止捕獲了所有Promise異常,當時是基於我們線上應用大部分的promise異常都是umi-request請求介面出錯和antd表單驗證錯誤,且未帶來什麼線上問題,於是就天真的認為未捕獲的promise異常毫無危害;這個想法同樣危險,因為深入跟蹤發現介面請求出錯請求庫捕獲了異常並使用了message.error進行處理,表單驗證錯誤的異常同樣是antd在處理完之後選擇繼續向上拋出,這兩者確實沒什麼危害,可當我們面對這些更多未做處理的Promise異常時候(比如介面返回成功但約定的數據格式錯誤)同時又不做上報,我們就損失了很多線上問題的案發現場,只能抓瞎去盲猜復現,依賴用戶反饋。

 

查看以下案例:

// bad
fetchData().then(doSomethingMightThrowError).catch(console.log);

// good
fetchData()
 .then(doSomethingMightThrowError)
 .catch(error => {
   console.error(error);
   message.error(error.message);
   logger.log(error);
 });

Exceptions Hierarchy

 

使用自定義異常,讓異常層次結構分明。

 

管理好業務代碼中的異常是非常酷的一件事,上面章節有介紹到Javascript給我們提供的一些基礎的異常類型,這些異常類型並不與我們的業務相關。所以使用這些異常來控制代碼中的錯誤也顯得不那麼恰當,我們的代碼正是對我們業務的建模。同樣的,我們也要將與業務相關的這些異常建模管理,對異常進行語義化,併在業務邏輯發生特定情況時觸發。否則就算調用方捕獲了異常也不知道該如何去處理。

 

這樣做往往會帶來一些好處:

 

1.使用error instanceof CustomBizError更容易識別異常,會讓判斷邏輯更簡潔且已讀,更容易處理捕獲到的異常並恢復程式。

2.通過標準化我們的自定義錯誤類,讓我們更容易做上層處理,比如上面有提到的介面異常我可以選擇不作為腳本異常全局上報,因為通常在介面異常里就已經上報了該信息;

 

參考以下例子

 

export class RequestException extends Error {
  constructor(message) {
    super(`RequestException: ${mesage}`);
   }
}

export class AccountException extends Error {
  constructor(message) {
    super(`AccountException: ${message}`);
  }
}

const AccountController = {
  getAccount: (id) => {
    ...
    throw new RequestException('請求賬戶信息失敗!');
    ...
  }
}

// 客戶端代碼,創建賬戶
const id = 1;
const account = AccountController.getAccount(id);
if(account){
  throw new AccountException('賬戶已存在!');
}

Provide context with exceptions

 

提供異常上下文

 

異常一旦發生了,一般都會有異常信息(message)和棧跟蹤(stack trace)信息還有文件名之類的來定位發生錯誤的現場,但哪怕是這樣在定位起來還是比較困難,所以一般建議去豐富異常信息讓我們定位問題更加的快速。可以是在捕獲到異常的地方解釋我們的意圖,同時這些額外的信息也都應該只是面向我們開發者用以定位問題,不需要讓使用者去感知這些異常上下文,不在用戶界面中進行體現。

 

結合上一條的自定義錯誤,我們還要為這些自定義錯誤提供更加豐富個上下文。

 

React 中的建議

 

局部UI的JS Error不應該導致整個應用崩潰白屏,我們應該把他的影響範圍控制在最小,這是一個容易形成共識的結論,於是React 16引入了錯誤邊界(Error Boundaries)的概念。

 

React Error Boundaries 官方文檔[2] 里提到:

 

錯誤邊界是一種 React 組件,這種組件可以捕獲發生在其子組件樹任何位置的 JavaScript 錯誤,並列印這些錯誤,同時展示降級 UI,而並不會渲染那些發生崩潰的子組件樹。錯誤邊界可以捕獲發生在整個子組件樹的渲染期間、生命周期方法以及構造函數中的錯誤。

 

ProComponents[3]的很多組件應該都有使用Error Boundaries比如ProTable,用以異常發生時只對局部UI產生影響,查看@ant-design/pro-utils中的源碼可以看到和官網的處理別無二致,更多的信息查看官網有非常詳細的介紹:

import { Result } from 'antd';
import type { ErrorInfo } from 'react';
import React from 'react';

// eslint-disable-next-line @typescript-eslint/ban-types
class ErrorBoundary extends React.Component<
  { children?: React.ReactNode },
  { hasError: boolean; errorInfo: string }
> {
  state = { hasError: false, errorInfo: '' };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, errorInfo: error.message };
  }

  componentDidCatch(error: any, errorInfo: ErrorInfo) {
    // You can also log the error to an error reporting service
    // eslint-disable-next-line no-console
    console.log(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <Result status="error" title="Something went wrong." extra={this.state.errorInfo} />;
    }
    return this.props.children;
  }
}

export { ErrorBoundary };

所以給我們的啟示是組件庫或者業務系統中的塊級的一些東西(spm模型中的c位)一定要考慮好組件級別的異常處理。

 

異常的全局上報

 

基本上這是對付不可預知異常的終極解法,自動收集錯誤報告併在達到閾值時做出告警,屬於在理想情況下異常發生後能讓研發同學們能第一時間發現並定位解決問題,主要會使用2個全局事件:

 

window.onerror事件

 

JS運行中的大部分異常(包括語法錯誤),都會觸發window上的error事件執行註冊的函數,不同於try catch,onerror既可以感知同步異常也可以感知非同步任務的異常(除了promise異常),使用方法如下:

// message:錯誤信息(字元串)。
// source:發生錯誤的腳本URL(字元串)
// lineno:發生錯誤的行號(數字)
// colno:發生錯誤的列號(數字)
// error:Error對象(對象)
window.onerror = function(message, source, lineno, colno, error) {
   logger.log('捕獲到異常:',{ message, source, lineno, colno, error });
}

unhandledrejection事件

 

作為以上方案的補充版,promise異常的捕獲依賴於全局註冊unhandledrejection,使用方法如下

window.addEventListener('unhandledrejection', (e) => {
   console.error('catch', e)
 }, true)

寫在最後

 

其實總結下來我們的異常處理主要也只是乾兩件事情:

 

1.將面向開發的異常信息轉換成更友好的用戶界面提示;

2.將異常信息上報到服務端讓研發同學去解決這些異常;

希望大家看了本篇文章有所收穫!

 

參考鏈接:

[1]https://developer.mozilla.org/zh-CN/Core_JavaScript_1.5_Reference/Global_Functions/eval

[2]https://reactjs.org/docs/error-boundaries.html

[3]https://procomponents.ant.design/

 

作 者 | 肖榮強(路遷)

本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Some-experience-with-javascript-exception-handling.html


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 先製作一個正方形,讓圓點在正方形的最外側 <style> body { margin: 0; } .loading { width: 200px; height: 200px; background: skyblue; margin: 100px auto 0px; position: relati ...
  • 第四期 · 將部分數據存儲至Mysql,使用axios通過golang搭建的http伺服器獲取數據。 新建資料庫 DROP DATABASE VUE; create database if not exists vue; use vue; JSON TO MYSQL JSON to MySQL (t ...
  • 1、媒體元素 音頻和視頻 <!-- 音頻和視頻 src:資源路徑 controls:控制條 autoplay:自動播放--> <video src="" controls outoplay></video><audio src="" controls outoplay></autio> 2、頁面結構 ...
  • 好家伙,本篇介紹如何實現"刪"功能 來看效果, 資料庫 (自然是沒什麼毛病) "增"搞定了,其實"刪"非常簡單 (我不會告訴你我是為了水一篇博客才把他們兩個分開寫,嘿嘿) 邏輯簡潔明瞭: 首先,看見你要刪除的數據,點"刪除", 隨後,①拿到當前這條數據的Id,向後臺發請求網路, 然後,②後端刪除該字 ...
  • 怎麼樣子盒子能撐起父盒子? 從行內元素跟塊元素來看: 一般情況下,行內元素只能包含數據和其他行內元素。 而塊級元素可以包含行內元素和其他塊級元素. 塊級元素內部可以嵌套塊級元素或行內元素。 建議行內元素裡面只嵌套行內元素。 行內元素只能包含內容或者其它行內元素,寬度和長度依據內容而定,不可以設置,可 ...
  • ####事件組成,事件三要素 1.事件源:事件觸發的按鈕,比如滑鼠點擊某個圖標跳轉頁面,那個圖標就稱為事件源。 比如, <button>我是一個按鈕,也是事件源</button> 2.事件類型:事件觸發的方式,怎麼觸發一個事件,比如滑鼠點擊(oncilck),滑鼠經過,還是按下鬆開觸發。 3.事件處 ...
  • ES標準下中的let,var和const let會報重覆聲明,var則比較隨意,重不重覆無所謂 // 使用 var 的時候重覆聲明變數是沒問題的,只不過就是後面會把前面覆蓋掉 var num = 100 var num = 200 // 使用 let 重覆聲明變數的時候就會報錯了 let num = ...
  • There are a thousand Hamlets in a thousand people's eyes. 威廉·莎士比亞 免責聲明:以下充滿個人觀點,辯證學習 React 目前開發以函數組件為主,輔以 hooks 實現大部分的頁面邏輯。目前數棧的 react 版本是 16.13.1,該版本 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...