韩国某著名男子天团之前在我们平台上架了一张重磅数字专辑,本来是一件喜大普奔的好事,结果上架后投诉蜂拥而至。部分用户反馈页面打开就崩溃,紧急排查后发现真凶就是下面这段代码。
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,一切趋于平静。事情虽然到此为止,但是有个声音一直在我心中回响 如何避免这种事故再次发生。 对于这种错误,堵是堵不住的,那么我们就应该思考设计一种兜底机制,能够隔离这种错误,保证在页面部分组件出错的情况下,不影响整个页面。
从 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 组件本身的错误。
只要在 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 的使用方法之后,大部分团队的都会遵循官方的用法,写一个 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 错误。
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()
class Test extends React.Component {
render() {
return <Button text="click me" />;
}
}
catchreacterror
函数接受一个参数:ErrorBoundary
(不提供则默认采用 DefaultErrorBoundary
)
上面是对于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。
本文发布自 网易云音乐前端团队,文章未经授权禁止任何形式的转载。我们一直在招人,如果你恰好准备换工作,又恰好喜欢云音乐,那就 加入我们!