此項目爲雲音樂營收組穩定性工程的前端部分,本文做者 章偉東,項目其餘參與者 趙祥濤
韓國某著名男子天團以前在咱們平臺上架了一張重磅數字專輯,原本是一件喜大普奔的好事,結果上架後投訴蜂擁而至。部分用戶反饋頁面打開就崩潰,緊急排查後發現真兇就是下面這段代碼。javascript
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
,因爲手滑,不當心寫錯了。html
對於上面這種狀況,lint
工具沒法檢測出來,由於 creator
剛好也是一個變量,這是一個純粹的邏輯錯誤。前端
後來咱們緊急修復了這個 bug,一切趨於平靜。事情雖然到此爲止,可是有個聲音一直在我心中迴響 如何避免這種事故再次發生。 對於這種錯誤,堵是堵不住的,那麼咱們就應該思考設計一種兜底機制,可以隔離這種錯誤,保證在頁面部分組件出錯的狀況下,不影響整個頁面。java
從 React 16 開始,引入了 Error Boundaries 概念,它能夠捕獲它的子組件中產生的錯誤,記錄錯誤日誌,並展現降級內容,具體 官網地址。node
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 錯誤,有必定的侷限性,如下是沒法處理的狀況:react
ErrorBoundary
組件只要在 React.Component
組件裏面添加 static getDerivedStateFromError()
或者 componentDidCatch()
便可。前者在錯誤發生時進行降級處理,後面一個函數主要是作日誌記錄,官方代碼 以下git
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。github
至此一個 ErrorBoundary 組件已經定義好了,使用時只要包裹一個子組件便可,以下。web
<ErrorBoundary> <MyWidget /> </ErrorBoundary>
看到 Error Boundaries 的使用方法以後,大部分團隊的都會遵循官方的用法,寫一個 errorBoundaryHOC
,而後包裹一會兒組件。下面 scratch 工程的一個例子npm
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
進行封裝,會改變原有的代碼結構,若是要後續再也不須要封裝刪除也很麻煩,方案實施成本高,很是棘手。
因此,咱們在考慮是否有一種方法能夠比較方便的處理上面的問題。
在碰到上訴困境問題以後,咱們的思路是:經過腳手架自動對子組件包裹錯誤處理組件。設計框架以下圖:
簡而言之分下面幾步:
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。
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!