前言
本文的背景是这样的:这两天在搞前端错误监控,然后就在代码的某处随便 console.log(xxx)
,xxx 是未定义的变量。期望错误边界能 catch 到对应的错误,从而渲染出备用的 ui,当然,按照故事的一般套路,结果肯定不是我所期待的,所以才有了这篇文章,不然到这里不就大结局了吗??哈哈哈哈~~ 那么接下来就由我带领大家,来探究 ErrorBoundary
的适用条件和不适用的场景,废话不多说,我们开始吧~
什么是错误边界(Error Boundaries)
我们可以从 React 官网 看到相应的概念:
部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界。 错误边界是一种 React 组件,这种组件可以捕获发生在其子组件树任何位置的 JavaScript 错误,并打印这些错误,同时展示降级 UI,而并不会渲染那些发生崩溃的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。
实质上 ErrorBoundary 就是有实例方法 componentDidCatch
或静态方法 getDerivedStateFromError
的 class 组件。
其模板代码如下(我们之后的所有例子都使用该 ErrorBoundary
组件,后续代码不再贴出):
class ErrorBoundary extends Component {
state = { error: null }
// 1.通过componentDidCatch
componentDidCatch(error: any, errorInfo: any) {
this.setState({ error })
console.log('捕获到错误', error, errorInfo)
}
// 2.通过 static getDerivedStateFromError
//static getDerivedStateFromError(error: Error) {
// return { error }
//}
render() {
if (this.state.error) {
return <div>我是备用ui</div>
}
return this.props.children
}
}
function App() {
return (
<ErrorBoundary>
<Child/>
</ErrorBoundary>
);
}
特别注意上面加粗部分,我们下面会通过多个实例,同时结合源码探究 错误边界
的适用的条件以及不适用的场景。
渲染期间报错
组件 render 期间发生错误,比如:
- 读取某个对象的属性,但该对应是 null 或 undefined,那么就会报空指针错误
- 声明了不存在的变量,那么执行到相应代码就报错
function Child() {
// Uncaught ReferenceError: xxx is not defined
console.log(xxx)
return <div>child</div>;
}
function App() {
return (
<ErrorBoundary>
<Child/>
</ErrorBoundary>
);
}
简单源码解析
相应源码是在构造组件树(实质是 fiber 树)的过程中:
do {
try {
// 构造fiber树的过程
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
}
} while (true);
当执行到 Child 的 console.log(xxx)
时抛出了错误,那么就会被 catch 到,从而进入 handleError
中。handleError 中有当前 Fiber,对应上面例子就是 Child 对应的 WIPFiber(WIP 即为 workInProgress,是一个全局变量,意为当前正在构造的 fiber,不知道 fiber 是什么的,本文你可以简单理解为组件),那么就会从该 fiber 往上一直找呀找呀,直到发现父组件是类组件,且带有 componentDidCatch
或静态方法 getDerivedStateFromError
,那么该父组件即为 ErrorBoundary。
// 错误边界是class组件
case ClassComponent:
// 报错信息
const errorInfo = value;
// ErrorBoundary类
const ctor = workInProgress.type;
// ErrorBoundary实例
const instance = workInProgress.stateNode;
/**
* 1.如果静态属性上有getDerivedStateFromError
* 2.或者实例组件上有componentDidCatch
* 则该fiber是错误边界,打上ShouldCapture的flag
*/
if (
(workInProgress.flags & DidCapture) === NoFlags &&
(typeof ctor.getDerivedStateFromError === 'function' ||
(instance !== null &&
typeof instance.componentDidCatch === 'function' &&
!isAlreadyFailedLegacyErrorBoundary(instance)))
) {
// 打上ShouldCapture,之后可以在completeUnitOfWork中的unwindWork识别到是错误边界的fiber
workInProgress.flags |= ShouldCapture;
...
// 创建错误边界的update,重新render会使用这个update
const update = createClassErrorUpdate(
workInProgress,
errorInfo,
lane,
);
enqueueCapturedUpdate(workInProgress, update);
// 找到错误边界后直接return,不再找
return;
}
break;
createClassErrorUpdate
即创建 class 组件的错误 update,有可能包含:
- getDerivedStateFromError 的 payload
- componentDidCatch 的 callback
function createClassErrorUpdate(
fiber: Fiber,
errorInfo: CapturedValue<mixed>,
lane: Lane,
): Update<mixed> {
const update = createUpdate(NoTimestamp, lane);
// update的tag打上CaptureUpdate
update.tag = CaptureUpdate;
// 拿静态属性getDerivedStateFromError
const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
if (typeof getDerivedStateFromError === 'function') {
/**
* 如果是函数,则将其加入到update的payload中,比如
* static getDerivedStateFromError(error) {
* return { hasError: true };
* }
*/
const error = errorInfo.value;
update.payload = () => {
logCapturedError(fiber, errorInfo);
// 参照上面例子,这里用return后,payload的值即为{ hasError: true }
return getDerivedStateFromError(error);
};
}
// 获取实例
const inst = fiber.stateNode;
if (inst !== null && typeof inst.componentDidCatch === 'function') {
/**
* 如果有componentDidCatch,如,将其放入update的callback中:
* componentDidCatch(error, errorInfo) {
* logErrorToMyService(error, errorInfo);
* }
*/
update.callback = function callback() {
if (typeof getDerivedStateFromError !== 'function') {
...
logCapturedError(fiber, errorInfo);
}
const error = errorInfo.value;
const stack = errorInfo.stack;
this.componentDidCatch(error, {
componentStack: stack !== null ? stack : '',
});
};
}
return update;
}
找到后,就从该错误边界类组件开始构造组件树,发现该组件上面有一个 update,如果有 getDerivedStateFromError
,则获取 return 的 state;如果有 componentDidCatch
,则可以 setState,上面两个方法之一都可以把 error 设置为非空(这里每个人写的不一样,你也可以声明一个 state 是 hasError,那么当 catch 到就设置为 true 也可以),再次 render 时this.state.error
满足条件,从而就渲染出备用的 ui 了。
以上结合第一个例子,顺便讲解了错误边界的原理,下面例子就不再赘述。
生命周期报错
componentDidMount、componentDidUpdate
componentDidMount 例子:
class ClassChild extends Component {
componentDidMount() {
// Uncaught ReferenceError: xxx is not defined
console.log('componentDidMount');
console.log(xxx);
}
render() {
return <div>classChild</div>
}
}
export default function App() {
return (
<ErrorBoundary>
<ClassChild />}
</ErrorBoundary>
);
}
componentDidUpdate 例子:
class ClassChild extends Component {
componentDidUpdate() {
// Uncaught ReferenceError: xxx is not defined
console.log('componentDidUpdate');
console.log(xxx);
}
render() {
return <div>classChild</div>
}
}
export default function App() {
const [count, addCount] = useCount();
return (
<ErrorBoundary>
<div>count: {count} <button onClick={addCount}>点击+1</button></div>
<ClassChild />
</ErrorBoundary>
);
}
componentDidMount 和 componentDidUpdate 都是在 react 的 commit 中的 Layout 阶段,具体源码如下:
try {
commitLayoutEffectOnFiber(root, current, fiber, committedLanes);
} catch (error) {
// 捕获到某个fiber发生错误,那么往上找错误边界,找到了就渲染备用ui
captureCommitPhaseError(fiber, fiber.return, error);
}
function commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
// 忽略一些无关代码
...
case ClassComponent: {
// class组件
const instance = finishedWork.stateNode;
// 根据有无current来决定的didMount还是didUpdate
if (current === null) {
// 真正调用componentDidMount的地方
instance.componentDidMount();
} else {
// 真正调用componentDidUpdate的地方
instance.componentDidUpdate(
prevProps,
prevState,
instance.__reactInternalSnapshotBeforeUpdate,
);
}
}
...
}
captureCommitPhaseError 的原理和上面的简单源码分析的逻辑基本一致,也是从该子组件往上找错误边界的父组件。
componentWillUnmount
例子如下:
class ClassChild extends Component {
componentWillUnmount() {
console.log('componentWillUnmount');
console.log(xxx);
}
render() {
return <div>classChild</div>
}
}
function App() {
const [hide, setHide] = useState(false);
return (
<ErrorBoundary>
<div><button onClick={() => setHide(true)}>点击卸载ClassChild</button></div>
{!hide && <ClassChild />}
</ErrorBoundary>
);
}
componentWillUnmount
是在 react 的 commit 中的 commitMutationtation 阶段,具体源码如下:
// 类组件
case ClassComponent: {
// 获取实例
const instance = current.stateNode;
if (typeof instance.componentWillUnmount === 'function') {
safelyCallComponentWillUnmount(
current,
nearestMountedAncestor,
instance,
);
}
return;
}
function safelyCallComponentWillUnmount(
current: Fiber,
nearestMountedAncestor: Fiber | null,
instance: any,
) {
try {
callComponentWillUnmountWithTimer(current, instance);
} catch (error) {
// 本例捕获到class组件componentWillUnmount报错,那么往上找错误边界,找到了就渲染备用ui
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
}
const callComponentWillUnmountWithTimer = function(current, instance) {
instance.props = current.memoizedProps;
...
// 真正调用componentWillUnmount的地方
instance.componentWillUnmount();
};
useEffect
useEffect 是在 commit 阶段异步调度的,具体执行回调函数(create)和销毁函数的源码如下:
function flushPassiveEffectsImpl() {
...
// 先执行销毁函数
commitPassiveUnmountEffects(root.current);
// 再执行回调
commitPassiveMountEffects(root, root.current);
...
}
回调报错
function Child() {
useEffect(() => {
console.log('useEffect');
console.log(xxx);
}, []);
return <div>child</div>;
}
export default function App() {
return (
<ErrorBoundary>
<Child />
</ErrorBoundary>
);
}
销毁函数报错
function Child({ count }) {
useEffect(() => {
return () => {
console.log('useEffect destroy');
console.log(xxx);
}
}, [count]);
return <div>child</div>;
}
function App() {
const [hide, setHide] = useState(false)
const [count, addCount] = useCount()
return (
<ErrorBoundary>
<div><button onClick={addCount}>点击+1</button></div>
<div><button onClick={() => setHide(true)}>点击卸载Child</button></div>
{!hide && <Child count={count}/>}
</ErrorBoundary>
);
}
销毁函数的执行是 count 增加的时候或者 Child 组件卸载的时候:
count 增加,有渲染出备用 ui
Child 组件卸载,发现没渲染出备用 ui
为何后者无法被错误边界捕获呢?我们来看下销毁函数执行的代码:
function safelyCallDestroy(
current: Fiber,
nearestMountedAncestor: Fiber | null,
destroy: () => void,
) {
try {
destroy();
} catch (error) {
// 本例捕获到useEffect销毁函数报错,那么往上找错误边界,找到了就渲染备用ui
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
}
我们发现确实也有 catch 到,但有一点要注意,对于 Child 组件被卸载,因为 useEffect 是异步调度的,等到执行销毁函数时发现 Child 组件对应 fiber 的 return(指向父fiber) 已经被设置为 null 了:
function commitDeletion(
finishedRoot: FiberRoot,
current: Fiber,
nearestMountedAncestor: Fiber,
): void {
...
detachFiberMutation(current);
}
// commitMutation会调用,因为useEffect是异步的,所以这里先执行,
// 等到useEffect执行销毁函数的时候发现其fiber的return为空了
function detachFiberMutation(fiber: Fiber) {
...
fiber.return = null;
}
所以当 catch 到错误的时候,还是会调用 captureCommitPhaseError
往上找 parent fiber,可是我们看到 detachFiberMutation
已经将 Child fiber 的 return 设置为 null 了,所以肯定就找不到错误边界了,而更新阶段触发 useEffect 的销毁函数,这时候 Child fiber 的 return 存在,那么就能找到错误边界。
useLayoutEffect
useLayoutEffect 是在 Layout 阶段调用回调,是同步执行的:
try {
commitLayoutEffectOnFiber(root, current, fiber, committedLanes);
} catch (error) {
// 本例捕获到函数组件useLayoutEffect回调报错,那么往上找错误边界,找到了就渲染备用ui
captureCommitPhaseError(fiber, fiber.return, error);
}
function commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
...
// 函数组件
case FunctionComponent:
...
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
break;
...
}
// 调用useLayoutEffect回调
function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
...
// 回调函数
const create = effect.create;
// 返回的销毁函数
effect.destroy = create();
...
}
useLayoutEffect 的销毁函数也是同步执行的,且其销毁函数在detachFiberMutation
(置空 fiber 的 return)之前:
function commitDeletion(
finishedRoot: FiberRoot,
current: Fiber,
nearestMountedAncestor: Fiber,
): void {
...
// useLayoutEffect的销毁函数会在这里同步执行,这个时候fiber.current还不为空
commitNestedUnmounts(finishedRoot, current, nearestMountedAncestor);
// 置空fiber的return
detachFiberMutation(current);
}
由于回调和销毁函数都是同步的,所以都能捕获到错误:
function Child({count}) {
useLayoutEffect(() => {
return () => {
console.log('useLayoutEffect destroy');
console.log(xxx);
}
}, [count]);
return <div>child</div>;
}
export default function App() {
const [hide, setHide] = useState(false)
const [count, addCount] = useCount()
return (
<ErrorBoundary>
<div><button onClick={addCount}>点击+1</button></div>
<div><button onClick={() => setHide(true)}>点击卸载Child</button></div>
{!hide && <Child count={count}/>}
</ErrorBoundary>
);
}
错误边界不起作用的场景
上面我们分析的错误边界使用的条件,那么下面就来分析不适用的场景。
组件外的报错
如下例子:
// child.js
console.log(xxx)
function Child() {
return <div>child</div>
}
这种情况肯定无法被 catch 到,所以错误边界也就无法起作用了
异步代码的报错
比如在生命周期、useEffect、useLayoutEffect 中使用了异步代码,等到执行回调报错,已经不在 catch 的作用域内,也就捕获不到了
如:
function Child() {
useLayoutEffect(() => {
setTimeout( () => {
console.log('useLayoutEffect');
console.log(xxx);
})
}, []);
return <div>child</div>;
}
export default function App() {
return (
<ErrorBoundary>
<Child />
</ErrorBoundary>
);
}
没渲染错误边界
事件函数中的报错
如:
function Child() {
// Uncaught ReferenceError: xxx is not defined
return <div onClick={()=> xxx}>child</div>;
}
export default function App() {
return (
<ErrorBoundary>
<Child />
</ErrorBoundary>
);
}
错误边界自身抛出的错误
class ErrorBoundary extends Component {
state = { error: null }
componentDidCatch(error: any, errorInfo: any) {
this.setState({ error })
console.log('捕获到错误', error, errorInfo)
}
// static getDerivedStateFromError(error: Error) {
// return { error }
// }
render() {
// Uncaught ReferenceError: xxx is not defined
console.log(xxx);
if (this.state.error) {
return <div>我是备用ui</div>
}
return this.props.children
}
}
错误边界的父组件报错
根据我们上面分析,组件抛出错误会往上找错误边界,但是如果是错误边界的父组件,那么往上无论再怎么找都肯定找不到。
函数组件被卸载,触发 useEffect 的销毁
这个我们上面分析了,这种情况下错误边界也不起作用
总结
ErrorBoundary
错误边界实际上就是子组件在渲染期间、调用生命周期、useEffect 和 useLayoutEffect 等这些场景下,利用 try catch
来捕获报错的组件:
即如果遇到错误,则会就被 catch 到,然后从该报错的组件往上找错误边界,只要父组件是类组件,且有实例属性 componentDidCatch
或静态属性 getDerivedStateFromError
,那么就判定为错误边界,同时在上述两个方法中可以修改 state,从而渲染出备用的 ui,而不至于直接让页面白屏。
同时我们分析了错误边界的适用条件和不适用场景,其分别如下:
适用条件:
- 组件渲染期间
- 生命周期
- useEffect 和 useLayoutEffect 的 create 和 destroy(排除组件卸载触发的 useEffect 的 destroy)
不适用条件
- 组件外的报错
- 异步代码的报错
- 事件函数中的报错
- 错误边界自身抛出的错误
- 错误边界的父组件
- 函数组件被卸载,触发 useEffect 的销毁
不适用的场景除了最后一个,其他都是因为没有被catch到错误。
最后
本文通过各种例子和相应的源码分析了什么是错误边界,错误边界的适用条件和不适用的场景,希望通过本文能让大家更加了解错误边界的原理,同时也要知道错误边界不是万能的,其也有适用的范围,错误的使用会导致错误边界不起作用。
感谢留下足迹,如果您觉得文章不错😄😄,还请动动手指😋😋,点赞+收藏+转发🌹🌹