天天看點

務實用得上的前端異常的捕獲與處理

按鍵無法點選、元素不展示、頁面白屏,這些都是我們前端不想看到的場景。在計算機程式運作的過程中,也總是會出現各種各樣的異常。下面就讓我們聊一聊有哪些異常以及怎麼處理它們。

一、前言

什麼是異常,異常就是預料之外的事件,往往影響了程式的正确運作。例如下面幾種場景:

  • 頁面元素異常(例如按鈕無法點選、元素不展示)
  • 頁面卡頓
  • 頁面白屏

這些情況都是極其影響使用者體驗的。對于前端來說,異常雖然不會導緻計算機當機,但是往往會導緻使用者的操作被阻塞。雖然異常不可完全杜絕,但是我們有充分的理由去了解異常、學習處理異常。

異常處理在程式設計中的重要性是毋庸置疑的。任何有影響力的 Web 應用程式都需要一套完善的異常處理機制,但實際上,通常隻有服務端團隊會在異常處理機制上投入較大精力。雖然用戶端應用程式的異常處理也同樣重要,但真正受到重視,還是最近幾年的事。作為新世紀的傑出前端開發人員,我們必須了解有哪些異常,當發生異常時我們有哪些手段和工具可以利用。

二、異常分類

從根本上來說,異常就是一個資料結構,它存了異常發生時相關資訊,譬如錯誤碼、錯誤資訊等。其中 message 屬性是唯一一個能夠保證所有浏覽器都支援的屬性,除此之外,IE、Firefox、Safari、Chrome 以及 Opera 都為事件對象添加了其它相關資訊。譬如 IE 添加了與 message 屬性完全相同的 description 屬性,還添加了儲存這内部錯誤數量的 number 屬性。Firefox 添加了 fileName、lineNumber 和 stack(包含堆棧屬性)。是以,在考慮浏覽器相容性時,最好還是隻使用 message 屬性。

執行 JS 期間可能會發生的錯誤有很多類型。每種錯誤都有對應的錯誤類型,而當錯誤發生的時候就會抛出響應的錯誤對象。ECMA-262 中定義了下列 7 種錯誤類型:

  • Error:錯誤的基類,其他錯誤都繼承自該類型
  • EvalError:Eval 函數執行異常
  • RangeError:數組越界
  • ReferenceError:嘗試引用一個未被定義的變量時,将會抛出此異常
  • SyntaxError:文法解析不合理
  • TypeError:類型錯誤,用來表示值的類型非預期類型時發生的錯誤
  • URIError:以一種錯誤的方式使用全局 URI 處理函數而産生的錯誤

三、異常處理

ECMA-262 第 3 版中引入了 try-catch 語句,作為 JavaScript 中處理異常的一種标準方式,基本的文法如下所示。這和 Java 中的 try-catch 語句是全完相同的。

try {
  // 可能會導緻錯誤的代碼
} catch (error) {
  // 在錯誤發生時怎麼處理
}      

如果 try 塊中的任何代碼發生了錯誤,就會立即退出代碼執行過程,然後執行 catch 塊。此時 catch 塊會接收到一個包含錯誤資訊的對象,這個對象中包含的資訊因浏覽器而異,但共同的是有一個儲存着錯誤資訊的 message 屬性。

finally 子句在 try-catch 語句中是可選的,但是 finally 子句一經使用,其代碼無論如何都會執行。換句話說,try 語句塊中代碼全部正常執行,finally 子句會執行;如果因為出錯執行了 catch 語句,finally 子句照樣會執行。隻要代碼中包含 finally 子句,則無論 try 或 catch 語句中包含什麼代碼——甚至是 return 語句,都不會阻止 finally 子句執行。來看下面函數的執行結果:

function testFinally {
  try {
    return "出去玩";
  } catch (error) {
    return "看電視";
  } finally {
    return "做作業";
  }
  return "睡覺";
}      

表面上調用這個函數會傳回 "出去玩",因為傳回 "出去玩" 的語句位于 try 語句塊中,而執行此語句又不會出錯。實際上傳回 "做作業",因為最後還有 finally 子句,結果就會導緻 try 塊裡的 return 語句被忽略,也就是說調用的結果隻能傳回 "做作業"。如果把 finally 語句拿掉,這個函數将傳回 "出去玩"。是以,在使用 finally 子句之前,一定要非常清楚你想讓代碼怎麼樣。(思考一下如果 catch 塊和 finally 塊都抛出異常,catch 塊的異常是否能抛出)

但令人遺憾的是,try-catch 無法處理異步代碼和一些其他場景。接下來讓我具體分析幾種異常場景及其處理方案。

四、異常分析

1. JS 代碼錯誤

下面為我司内部錯誤監控平台一次日常報錯的調用堆棧截圖:

務實用得上的前端異常的捕獲與處理

錯誤還是比較明顯的,this 指向導緻的問題。onOk 使用普通函數時,函數内執行語句的 this 上下文為 Antd.Modal 元件的執行個體,而 Antd.Modal 元件不存在 changeFilterType 這個方法。将 onOK 方法像 onCancel 方法一樣改成箭頭函數,将 this 指向父元件即可。

​TypeError​ 類型在 JavaScript 中會經常遇到,在變量中儲存着意外類型時,或者在通路不存在的方法時,都會導緻這種錯誤。錯誤的原因雖然多種多樣,但歸根結底還是由于在執行特定類型的操作時,變量的類型并不符合要求所緻。再看幾個例子:

class People {
  constructor(name) {
    this.name = name;
  }
  sing() {}
}
const xiaoming = new People("小明");
xiaoming.dance(); // 抛出 TypeError
xiaoming.girlfriend.name; // 抛出 TypeError      

代碼錯誤一般在開發和測試階段就能發現。用 try-catch 也能捕獲到:

// 代碼
try {
  xiaoming.girlfriend.name;
} catch (error) {
  console.log(xiaoming.name + "沒有女朋友", error);
}
// 運作結果
// 小明沒有女朋友 TypeError: Cannot read property 'name' of undefined      

2. JS 文法錯誤

我們修改一下代碼,我們把英文分号改成中文分号:

try {
  xiaoming.girlfriend.name;// 結尾是中文分号
} catch(error) {
  console.log(xiaoming.name + "沒有女朋友", error);
}
// 運作結果
// Uncaught SyntaxError: Invalid or unexpected token      

​SyntaxError​ 文法錯誤我們無法通過 try-catch 捕獲到,不過文法錯誤在我們開發階段就可以看到,應該不會順利上到線上環境。

不過凡事總有例外,線上還是能收到一些文法錯誤的告警,但多半是 JSON 解析出錯和浏覽器相容性導緻。

務實用得上的前端異常的捕獲與處理

再看幾個例子:

JSON.parse('{name:xiaoming}');      // Uncaught SyntaxError: Unexpected token n in JSON at position 1
JSON.parse('{"name":xiaoming}');    // Uncaught SyntaxError: Unexpected token x in JSON at position 8
JSON.parse('{"name":"xiaoming"}');  // 正常
var testFunc () => { };             // 在 IE 下會抛出 SyntaxError,因為 IE 不支援箭頭函數,需要通過Babel等工具事先轉譯下      

使用 JSON.parse 解析時出現異常就是一個很好的使用 try-catch 的場景:

try {
  JSON.parse(remoteData); // remoteData 為服務端傳回的資料
} catch {
  console.error("服務端資料格式傳回異常,無法解析", remoteData);
}      

并不是捕獲到錯誤就結束了,捕獲到錯誤後,我們需要思考當錯誤發生時:

  • 錯誤是否是緻命的,會不會導緻其它連帶錯誤
  • 後續的代碼邏輯還能不能繼續執行,使用者還能不能繼續操作
  • 是不是需要将錯誤資訊回報給使用者,提示使用者如何處理該錯誤
  • 是不是需要将錯誤上報服務端

對應上面的問題這裡就會有很多解決方案了,譬如:

  1. 如果是伺服器未知異常導緻,可以阻塞使用者操作,彈窗提示使用者"伺服器異常,請稍後重試"。并提供給使用者一個重新整理的按鈕;
try {
  return JSON.parse(remoteData);
} catch (error) {
  Modal.fail("伺服器異常,請稍後重試");
  return false;
}      
  1. 如果是資料異常導緻,可阻塞使用者操作,彈窗提示使用者"伺服器異常,請聯系客服處理~",同時将錯誤資訊上報異常伺服器,開發人員通過異常堆棧和使用者埋點定位問題原因;
try {
  return JSON.parse(remoteData);
} catch (error) {
  Modal.fail("伺服器異常,請聯系客服處理~");
  logger.error("JSON資料解析出現異常", error);
  return false;
}      
  1. 如果資料解析出錯屬于預料之中的情況,也有替代的預設值,那麼當解析出錯時直接使用預設值也可以;
try {
  return JSON.parse(remoteData);
} catch (error) {
  console.error("服務端資料格式傳回異常,使用本地緩存資料", erorr);
  return localData;
}      

任何錯誤處理政策中最重要的一個部分,就是确定錯誤是否緻命。

3. 異步錯誤

try {
  setTimeout(() => {
    undefined.map(v => v);
  }, 1000)
} catch(e) {
  console.log("捕獲到異常:", e);
}

Uncaught TypeError: Cannot read property 'map' of undefined
  at <anonymous>:3:15      

并沒有捕獲到異常,​

​try-catch​

​ 對文法和異步錯誤卻無能為力,捕獲不到,這是需要我們特别注意的地方。

五、異常捕獲

5.1 window.onerror

當 ​

​JS​

​​ 運作時錯誤發生時,​

​window​

​​ 會觸發一個 ​

​ErrorEvent​

​​ 接口的 ​

​error​

​​ 事件,并執行​

​window.onerror()​

​。

/**
 * @param {String}  message    錯誤資訊
 * @param {String}  source     出錯檔案
 * @param {Number}  lineno     行号
 * @param {Number}  colno      列号
 * @param {Object}  error      Error對象(對象)
 */
window.onerror = function (message, source, lineno, colno, error) {
  console.log("捕獲到異常:", { message, source, lineno, colno, error });
};      

同步錯誤可以捕獲到,但是,請注意 ​

​window.error​

​ 無法捕獲靜态資源異常和 JS 代碼錯誤。

5.2 靜态資源加載異常

​方法一:onerror 來捕獲​

<script>
function errorHandler(error) {
console.log("捕獲到靜态資源加載異常", error);
  }
</script>
<script src="http://cdn.xxx.com/js/test.js" onerror="errorHandler(this)"></script>
<link rel="stylesheet" href="http://cdn.xxx.com/styles/test.css" onerror="errorHandler(this)">      

這樣可以拿到靜态資源的錯誤,但缺點很明顯,代碼的侵入性太強了,每一個靜态資源标簽都要加上 onerror 方法。

​方法二:addEventListener("error")​

<!DOCTYPE html>
<html lang="zh">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>error</title>
<script>
window.addEventListener('error', (error) => {
console.log('捕獲到異常:', error);
    }, true)
</script>
</head>

<body>
<img src="https://itemcdn.zcycdn.com/15af41ec-e6cb-4478-8fad-1a47402f0f25.png">
</body>

</html>      

由于網絡請求異常不會事件冒泡,是以必須在捕獲階段将其捕捉到才行,但是這種方式雖然可以捕捉到網絡請求的異常,但是無法判斷 HTTP 的狀态是 404 還是其他比如 500 等等,是以還需要配合服務端日志才進行排查分析才可以。

5.3 Promise 異常

Promise 中的異常不能被 try-catch 和 window.onerror 捕獲,這時候我們就需要監聽 unhandledrejection 來幫我們捕獲這部分錯誤。

window.addEventListener("unhandledrejection", function (e) {
  e.preventDefault();
  console.log("捕獲到 promise 錯誤了");
  console.log("錯誤的原因是", e.reason);
  console.log("Promise 對象是", e.promise);
  return true;
});

Promise.reject("promise error");
new Promise((resolve, reject) => {
  reject("promise error");
});
new Promise((resolve) => {
  resolve();
}).then(() => {
  throw "promise error";
});      

5.4 React 異常

React 處理異常的方式不同。雖然 try-catch 适用于許多非普通 JavaScript 應用程式,但它隻适用于指令式代碼。因為 React 元件是聲明性的,是以 try-catch 不是一個可靠的選項。為了彌補這一點,React 實作了所謂的錯誤邊界。錯誤邊界是 React 元件,它“捕獲子元件樹中的任何地方的 JavaScript 錯誤”,同時還記錄錯誤并顯示回退使用者界面。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // 展示出錯的UI
    this.setState({ hasError: true });
    // 将錯誤資訊上報到日志伺服器
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // 可以展示自定義的錯誤樣式
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}      

但是需要注意的是, error boundaries 并不會捕捉下面這些錯誤:

  • 事件處理器
  • 異步代碼
  • 服務端的渲染代碼
  • 在 error boundaries 區域内的錯誤

我們可以這樣使用 ErrorBoundary:

<ErrorBoundary>
<MyWidget />
</ErrorBoundary      

5.5 Vue 異常

Vue.config.errorHandler = (err, vm, info) => {
  console.error("通過vue errorHandler捕獲的錯誤");
  console.error(err);
  console.error(vm);
  console.error(info);
};      

5.6 請求異常

以最常用的 HTTP 請求庫 axios 為例,模拟接口響應 401 的情況:

// 請求
axios.get(/api/test/401")
// 結果
Uncaught (in promise) Error: Request failed with status code 401
at createError (axios.js:1207)
at settle (axios.js:1177)
at XMLHttpRequest.handleLoad (axios.js:1037)      

可以看出來 axios 的異常可以當做 Promise 異常來處理:

// 請求
axios.get("http://localhost:3000/api/uitest/sentry/401")
.then(data => console.log('接口請求成功', data))
.catch(e => console.log('接口請求出錯', e));
// 結果
接口請求出錯 Error: Request failed with status code 401
at createError (createError.js:17)
at settle (settle.js:18)
at XMLHttpRequest.handleLoad (xhr.js:62)      

一般接口 401 就代表使用者未登入,就需要跳轉到登入頁,讓使用者進行重新登入,但如果每個請求方法都需要寫一遍跳轉登入頁的邏輯就很麻煩了,這時候就會考慮使用 axios 的攔截器來做統一梳理,同理能統一處理的異常也可以在放在攔截器裡處理。

// Add a response interceptor
axios.interceptors.response.use(
  function (response) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
  },
  function (error) {
    if (error.response.status === 401) {
      goLogin(); // 跳轉登入頁
    } else if (error.response.status === 502) {
      alert(error.response.data.message || "系統更新中,請稍後重試");
    }
    return Promise.reject(error.response);
  }
);      

5.7 總結

異常一共七大類,處理時需厘清是緻命錯誤還是非緻命錯誤。

  • 可疑區域增加 ​

    ​try-catch​

  • 全局監控 ​

    ​JS​

    ​ 異常 ​

    ​window.onerror​

  • 全局監控靜态資源異常 ​

    ​window.addEventListener​

  • 捕獲沒有 ​

    ​catch​

    ​ 的 ​

    ​Promise​

    ​ 異常用 ​

    ​unhandledrejection​

  • ​Vue errorHandler​

    ​ 和 ​

    ​React componentDidCatch​

  • ​Axios​

    ​ 請求統一異常處理用攔截器 ​

    ​interceptors​

  • 使用日志監控服務收集使用者錯誤資訊

六、異常上報

即使我們前端開發完成後,會有一系列的 Web 應用的上線前的驗證,如自測、QA 測試、code review 等,以確定應用能在生産上沒有事故。

但是事與願違,很多時候我們都會接到客戶回報的一些線上問題,這些問題有時候可能是你自己代碼的問題。這樣的問題一般能夠在測試環境重制,我們很快的能定位到問題關鍵位置。但是,很多時候有一些問題,我們在測試中并未發現,可是線上上卻有部分人出現了,問題确确實實存在的,這個時候我們測試環境又不能重制,還有一些偶現的生産的偶現問題,這些問題都很難定位到問題的原因,讓我們前端工程師頭疼不已。

而我們不可能每次都遠端給使用者解決問題,或者讓使用者按 F12 打開浏覽器控制台把錯誤資訊截圖給我們吧。這時候,我們不得不借助一些工具來解決這一系列令人頭疼的問題。

前端錯誤監控日志系統就應用而生。目前端代碼在生産運作中出現錯誤的時候,第一時間傳遞給監控系統,進而第一時間定位并且解決問題。

有很多成熟的方案可供選擇:ARMS、fundebug、BadJS、Sentry。政采雲目前使用的是 Sentry 的開源版本,并結合業務進行一些改造:

  • 與建構系統結合,建構項目時自動生成 Sentry 項目,注入 Sentry 腳本
  • 客服端注入 Sentry 用戶端腳本後,按項目、頁面等不同粒度配置告警事件的過濾規則
  • 對接釘釘消息系統,将告警消息推送到訂閱群
  • 過濾接口錯誤和優化 Promise 錯誤上報資訊

​看完兩件事​