此项目为云音乐营收组稳定性工程的前端部分,本文做者 章伟东,项目其余参与者 赵祥涛
韩国某著名男子天团以前在咱们平台上架了一张重磅数字专辑,原本是一件喜大普奔的好事,结果上架后投诉蜂拥而至。部分用户反馈页面打开就崩溃,紧急排查后发现真凶就是下面这段代码。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。
本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们!