一個 bug 引發的血案
韓國某著名男子天團之前在我們平臺上架了一張重磅數字專輯,本來是一件喜大普奔的好事,結果上架後投訴蜂擁而至。部分使用者反饋頁面開啟就崩潰,緊急排查後發現真凶就是下面這段程式碼。
render() {
const { data, isCreator, canSignOut, canSignIn } = this.props;
const { supportCard, creator, fansList, visitorId, memberCount } = data;
let getUserIcon = (obj) => {
if (obj.userType == 4) {
return (<i className="icn u-svg u-svg-yyr_sml" />);
} else if (obj.authStatus == 1) {
return (<i className="icn u-svg u-svg-vip_sml" />);
} else if (obj.expertTags && creator.expertTags.length > 0) {
return (<i className="icn u-svg u-svg-daren_sml" />);
}
return null;
};
...
}
複製程式碼
這行 if (obj.expertTags && creator.expertTags.length )
裡面的 creator
應該是 obj
,由於手滑,不小心寫錯了。
對於上面這種情況,lint
工具無法檢測出來,因為 creator
恰好也是一個變數,這是一個純粹的邏輯錯誤。
後來我們緊急修復了這個 bug,一切趨於平靜。事情雖然到此為止,但是有個聲音一直在我心中迴響 如何避免這種事故再次發生。 對於這種錯誤,堵是堵不住的,那麼我們就應該思考設計一種兜底機制,能夠隔離這種錯誤,保證在頁面部分元件出錯的情況下,不影響整個頁面。
ErrorBoundary 介紹
從 React 16 開始,引入了 Error Boundaries 概念,它可以捕獲它的子元件中產生的錯誤,記錄錯誤日誌,並展示降級內容,具體 官網地址。
Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed
這個特性讓我們眼前一亮,精神為之振奮,彷彿在黑暗中看到了一絲亮光。但是經過研究發現,ErrorBoundary
只能捕獲子元件的 render 錯誤,有一定的侷限性,以下是無法處理的情況:
- 事件處理函式(比如 onClick,onMouseEnter)
- 非同步程式碼(如 requestAnimationFrame,setTimeout,promise)
- 服務端渲染
- ErrorBoundary 元件本身的錯誤。
如何建立一個 ErrorBoundary
元件
只要在 React.Component
元件裡面新增 static getDerivedStateFromError()
或者 componentDidCatch()
即可。前者在錯誤發生時進行降級處理,後面一個函式主要是做日誌記錄,官方程式碼 如下
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
複製程式碼
可以看到 getDerivedStateFromError
捕獲子元件發生的錯誤,設定 hasError
變數,render
函式裡面根據變數的值顯示降級的ui。
至此一個 ErrorBoundary 元件已經定義好了,使用時只要包裹一個子元件即可,如下。
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
複製程式碼
Error Boundaries 的普遍用法。
看到 Error Boundaries 的使用方法之後,大部分團隊的都會遵循官方的用法,寫一個 errorBoundaryHOC
,然後包裹一下子元件。下面 scratch 工程的一個例子
export default errorBoundaryHOC('Blocks')(
connect(
mapStateToProps,
mapDispatchToProps
)(Blocks)
);
複製程式碼
其中 Blocks
是一個 UI 展示元件,errorBoundaryHOC
就是錯誤處理元件,
具體原始碼可以看 這裡
普遍用法的困境
上面的方法在 export 的時候包裹一個 errorBoundaryHOC
。
對於新開發的程式碼,使用比較方便,但是對於已經存在的程式碼,會有比較大的問題。
因為 export 的格式有 多種
export class ClassName {...}
export { name1, name2, …, nameN };
export { variable1 as name1, variable2 as name2, …, nameN };
export * as name1 from …
複製程式碼
所以如果對原有程式碼用 errorBoundaryHOC
進行封裝,會改變原有的程式碼結構,如果要後續不再需要封裝刪除也很麻煩,方案實施成本高,非常棘手。
所以,我們在考慮是否有一種方法可以比較方便的處理上面的問題。
青銅時代 - BabelPlugin
在碰到上訴困境問題之後,我們的思路是:通過腳手架自動對子元件包裹錯誤處理元件。設計框架如下圖:
簡而言之分下面幾步:
-
判斷是否是 React 16 版本
-
讀取配置檔案
-
檢測是否已經包裹了
ErrorBoundary
元件。 如果沒有,走 patch 流程。如果有,根據force
標籤判斷是否重新包裹。 -
走包裹元件流程(圖中的 patch 流程):
a. 先引入錯誤處理元件
b. 對子元件用
ErrorBoundary
包裹
配置檔案如下(.catch-react-error-config.json):
{
"sentinel": {
"imports": "import ServerErrorBoundary from '$components/ServerErrorBoundary'",
"errorHandleComponent": "ServerErrorBoundary",
"filter": ["/actual/"]
},
"sourceDir": "test/fixtures/wrapCustomComponent"
}
複製程式碼
patch 前原始碼:
import React, { Component } from "react";
class App extends Component {
render() {
return <CustomComponent />;
}
}
複製程式碼
讀取配置檔案 patch 之後的程式碼為:
//isCatchReactError
import ServerErrorBoundary from "$components/ServerErrorBoundary";
import React, { Component } from "react";
class App extends Component {
render() {
return (
<ServerErrorBoundary isCatchReactError>
{<CustomComponent />}
</ServerErrorBoundary>
);
}
}
複製程式碼
可以看到頭部多了
import ServerErrorBoundary from '$components/ServerErrorBoundary'
然後整個元件也被 ServerErrorBoundary
包裹,isCatchReactError
用來標記位,主要是下次 patch 的時候根據這個標記位做對應的更新,防止被引入多次。
這個方案藉助了 babel plugin,在程式碼編譯階段自動匯入 ErrorBoundary 並批量元件包裹,核心程式碼:
const babelTemplate = require("@babel/template");
const t = require("babel-types");
const visitor = {
Program: {
// 在檔案頭部匯入 ErrorBoundary
exit(path) {
// string 程式碼轉換為 AST
const impstm = template.default.ast(
"import ErrorBoundary from '$components/ErrorBoundary'"
);
path.node.body.unshift(impstm);
}
},
/**
* 包裹 return jsxElement
* @param {*} path
*/
ReturnStatement(path) {
const parentFunc = path.getFunctionParent();
const oldJsx = path.node.argument;
if (
!oldJsx ||
((!parentFunc.node.key || parentFunc.node.key.name !== "render") &&
oldJsx.type !== "JSXElement")
) {
return;
}
// 建立被 ErrorBoundary 包裹之後的元件樹
const openingElement = t.JSXOpeningElement(
t.JSXIdentifier("ErrorBoundary")
);
const closingElement = t.JSXClosingElement(
t.JSXIdentifier("ErrorBoundary")
);
const newJsx = t.JSXElement(openingElement, closingElement, oldJsx);
// 插入新的 jxsElement, 並刪除舊的
let newReturnStm = t.returnStatement(newJsx);
path.remove();
path.parent.body.push(newReturnStm);
}
};
複製程式碼
此方案的核心是對子元件用自定義元件進行包裹,只不過這個自定義元件剛好是 ErrorBoundary。如果需要,自定義元件也可以是其他元件比如 log 等。
完整 GitHub 程式碼實現 這裡
雖然這種方式實現了錯誤的捕獲和兜底方案,但是非常複雜,用起來也麻煩,要配置 Webpack 和 .catch-react-error-config.json
還要執行腳手架,效果不令人滿意。
黃金時代 - Decorator
在上述方案出來之後,很長時間都找不到一個優雅的方案,要麼太難用(babelplugin), 要麼對於原始碼的改動太大(HOC), 能否有更優雅的實現。
於是就有了裝飾器 (Decorator) 的方案。
裝飾器方案的原始碼實現用了 TypeScript,使用的時候需要配合 Babel 的外掛轉為 ES 的版本,具體看下面的使用說明
TS 裡面提供了裝飾器工廠,類裝飾器,方法裝飾器,訪問器裝飾器,屬性裝飾器,引數裝飾器等多種方式,結合專案特點,我們用了類裝飾器。
類裝飾器介紹
類裝飾器在類宣告之前被宣告(緊靠著類宣告)。 類裝飾器應用於類建構函式,可以用來監視,修改或替換類定義。
下面是一個例子。
function SelfDriving(constructorFunction: Function) {
console.log('-- decorator function invoked --');
constructorFunction.prototype.selfDrivable = true;
}
@SelfDriving
class Car {
private _make: string;
constructor(make: string) {
this._make = make;
}
}
let car: Car = new Car("Nissan");
console.log(car);
console.log(`selfDriving: ${car['selfDrivable']}`);
複製程式碼
output:
-- decorator function invoked --
Car { _make: 'Nissan' }
selfDriving: true
複製程式碼
上面程式碼先執行了 SelfDriving
函式,然後 car 也獲得了 selfDrivable
屬性。
可以看到 Decorator 本質上是一個函式,也可以用@+函式名
裝飾在類,方法等其他地方。 裝飾器可以改變類定義,獲取動態資料等。
完整的 TS 教程 Decorator 請參照 官方教程
於是我們的錯誤捕獲方案設計如下
@catchreacterror()
class Test extends React.Component {
render() {
return <Button text="click me" />;
}
}
複製程式碼
catchreacterror
函式的引數為 ErrorBoundary
元件,使用者可以使用自定義的 ErrorBoundary
,如果不傳遞則使用預設的 DefaultErrorBoundary
元件;
catchreacterror
核心程式碼如下:
import React, { Component, forwardRef } from "react";
const catchreacterror = (Boundary = DefaultErrorBoundary) => InnerComponent => {
class WrapperComponent extends Component {
render() {
const { forwardedRef } = this.props;
return (
<Boundary>
<InnerComponent {...this.props} ref={forwardedRef} />
</Boundary>
);
}
}
};
複製程式碼
返回值為一個 HOC,使用 ErrorBoundary
包裹子元件。
增加服務端渲染錯誤捕獲
在 介紹 裡面提到,對於服務端渲染,官方的 ErrorBoundary
並沒有支援,所以對於 SSR 我們用 try/catch
做了包裹:
- 先判斷是否是服務端
is_server
:
function is_server() {
return !(typeof window !== "undefined" && window.document);
}
複製程式碼
- 包裹
if (is_server()) {
const originalRender = InnerComponent.prototype.render;
InnerComponent.prototype.render = function() {
try {
return originalRender.apply(this, arguments);
} catch (error) {
console.error(error);
return <div>Something is Wrong</div>;
}
};
}
複製程式碼
最後,就形成了 catch-react-error
這個庫,方便大家捕獲 React 錯誤。
catch-react-error 使用說明
1. 安裝 catch-react-error
npm install catch-react-error
複製程式碼
2. 安裝 ES7 Decorator babel plugin
npm install --save-dev @babel/plugin-proposal-decorators
npm install --save-dev @babel/plugin-proposal-class-properties
複製程式碼
新增 babel plugin
{
"plugins": [
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-proposal-class-properties", { "loose": true }]
]
}
複製程式碼
3. 匯入 catch-react-error
import catchreacterror from "catch-react-error";
複製程式碼
4. 使用 @catchreacterror
Decorator
@catchreacterror()
class Test extends React.Component {
render() {
return <Button text="click me" />;
}
}
複製程式碼
catchreacterror
函式接受一個引數:ErrorBoundary
(不提供則預設採用 DefaultErrorBoundary
)
5. 使用 @catchreacterror
處理 FunctionComponent
上面是對於ClassComponent
做的處理,但是有些人喜歡用函式元件,這裡也提供使用方法,如下。
const Content = (props, b, c) => {
return <div>{props.x.length}</div>;
};
const SafeContent = catchreacterror(DefaultErrorBoundary)(Content);
function App() {
return (
<div className="App">
<header className="App-header">
<h1>這是正常展示內容</h1>
</header>
<SafeContent/>
</div>
);
}
複製程式碼
6. 如何建立自己所需的 Custom Error Boundaries
參考上面 如何建立一個 ErrorBoundary
元件, 然後改為自己所需即可,比如在 componentDidCatch
裡面上報錯誤等。
完整的 GitHub 程式碼在此 catch-react-error。
本文釋出自 網易雲音樂前端團隊,文章未經授權禁止任何形式的轉載。我們一直在招人,如果你恰好準備換工作,又恰好喜歡雲音樂,那就 加入我們!