韓國某著名男子天團以前在咱們平臺上架了一張重磅數字專輯,原本是一件喜大普奔的好事,結果上架後投訴蜂擁而至。部分用戶反饋頁面打開就崩潰,緊急排查後發現真兇就是下面這段代碼。html
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
剛好也是一個變量,這是一個純粹的邏輯錯誤。java
後來咱們緊急修復了這個 bug,一切趨於平靜。事情雖然到此爲止,可是有個聲音一直在我心中迴響 如何避免這種事故再次發生。 對於這種錯誤,堵是堵不住的,那麼咱們就應該思考設計一種兜底機制,可以隔離這種錯誤,保證在頁面部分組件出錯的狀況下,不影響整個頁面。node
從 React 16 開始,引入了 Error Boundaries 概念,它能夠捕獲它的子組件中產生的錯誤,記錄錯誤日誌,並展現降級內容,具體 官網地址。react
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 crashedgit
這個特性讓咱們眼前一亮,精神爲之振奮,彷彿在黑暗中看到了一絲亮光。可是通過研究發現,ErrorBoundary
只能捕獲子組件的 render 錯誤,有必定的侷限性,如下是沒法處理的狀況:github
ErrorBoundary
組件只要在 React.Component
組件裏面添加 static getDerivedStateFromError()
或者 componentDidCatch()
便可。前者在錯誤發生時進行降級處理,後面一個函數主要是作日誌記錄,官方代碼 以下web
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。npm
至此一個 ErrorBoundary 組件已經定義好了,使用時只要包裹一個子組件便可,以下。
<ErrorBoundary>
<MyWidget /> </ErrorBoundary>
複製代碼
看到 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
進行封裝,會改變原有的代碼結構,若是要後續再也不須要封裝刪除也很麻煩,方案實施成本高,很是棘手。
因此,咱們在考慮是否有一種方法能夠比較方便的處理上面的問題。
在碰到上訴困境問題以後,咱們的思路是:經過腳手架自動對子組件包裹錯誤處理組件。設計框架以下圖:
簡而言之分下面幾步:
判斷是不是 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
還要運行腳手架,效果不使人滿意。
在上述方案出來以後,很長時間都找不到一個優雅的方案,要麼太難用(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
npm install catch-react-error
複製代碼
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 }]
]
}
複製代碼
import catchreacterror from "catch-react-error";
複製代碼
@catchreacterror
Decorator@catchreacterror()
class Test extends React.Component {
render() {
return <Button text="click me" />; } } 複製代碼
catchreacterror
函數接受一個參數:ErrorBoundary
(不提供則默認採用 DefaultErrorBoundary
)
@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>
);
}
複製代碼
參考上面 如何建立一個 ErrorBoundary
組件, 而後改成本身所需便可,好比在 componentDidCatch
裏面上報錯誤等。
完整的 GitHub 代碼在此 catch-react-error。
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!