从页面死循环“命案”,探究React边界错误

从页面死循环“命案”,探究React边界错误

五月 07, 2021 本文共计: 1.4k 字 预计阅读时长: 5分钟

事故现场前夕

不妨先来看看子组件发生异常后,没有捕获错误的情况:

1
2
# 第一步:程序正常运行,自组件计时器开始运行
# 第二步:计时器开始计数到3,子组件抛出异常

alt

可以比较清晰的看到,页面是挂掉了,但是对于正常情况下,这样的情况是很不友好的,当页面组件足够多的时候,可能只是某一块内容挂掉而导致把全局全部挂掉,不是特别理想。

事故现场

为什么会死循环?什么导致的死循环?怎么避免呢?


划重点:当父组件使用了getDerivedStateFromError去处理捕获异常的时候,这时候没有后备渲染,就会导致异常无效,进而陷入死循环,更新<===>报错的往复

1
2
3
4
5
6
7
8
9
10
11
class ErrorBoundary extends React.Component{

// 稍加打印,但不启动后备渲染
static getDerivedStateFromError(err) {
console.log(err)
}

render() {
return this.props.children;
}
}

alt

事故溯源

  • 什么是错误边界?

    部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界。

    错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。

React 官网传送门

  • 异常分类

    先介绍下React 16之后的两个核心调度阶段render&commit阶段:

    alt

    图片来源于:https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

    而异常分类也发生在这两个阶段,且会有不同的表现:

    Render阶段的异常捕获流程

    alt

    有必要释疑上面无备用渲染时的场景,先上代码,后上动图。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    // Child 组件模拟 render 阶段异常
    const BugCounter = () => {
    const [state, setState] = useState(0);

    useEffect(() => {
    // setInterval(() => {
    // setState(pre => pre += 1);
    // }, 1000);
    }, []);

    useEffect(() => {
    console.log(state, "commit 阶段")
    })

    // if (state === 3) {
    // // Simulate a JS error
    // throw new Error('I crashed!');
    // }

    setTimeout(() => {
    throw new Error('I crashed!');
    }, 1000)

    return (
    <span>{state}</span>
    );
    };

    alt

    可以很明显看到,整个视图界面不会崩溃ℳ

    Commit阶段的异常捕获流程

    alt

CARE:

  • Render阶段注意点:

    父组件同时定义 getDerivedStateFromError 和 componentDidCatch,如果使用 getDerivedStateFromError 处理异常信息时,没有启用备用渲染, componentDidCatch 不会触发, 异常处理无效,最后还是使用 console.error 打印异常信息。

    这一点作一下特别解释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // BugCounter 组件(也即我们的子组件)
    const BugCounter = () => {
    const [state, setState] = useState(0);

    useEffect(() => {
    // setInterval(() => {
    // setState(pre => pre += 1);
    // }, 1000);
    }, []);

    useEffect(() => {
    console.log(state, "commit 阶段")
    })
    //
    // if (state === 3) {
    // // Simulate a JS error
    // throw new Error('I crashed!');
    // }

    throw new Error('I crashed!');

    return (
    <span>{state}</span>
    );
    };
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // ErrorBoundary (异常捕获组件,注意:这里并没有开启“备用渲染”)
    class ErrorBoundary extends React.Component{

    static getDerivedStateFromError(err) {
    console.log(err, 'getDerivedStateFromError')
    return {err};
    }

    componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.log(error, errorInfo, "didCatch")
    }

    render() {
    return this.props.children;
    }
    }

    alt

    使用 getDerivedStateFromError 处理子组件异常信息时,要启用备用渲染,否则异常处理无效,最后还是使用 console.error 打印异常信息。

  • Commit阶段注意点:

    子组件 commit 阶段发生异常,如果父组件定义 getDerivedStateFromError 且没有启用备用渲染,会导致异常处理无效,而且会陷入死循环。

  • Commit & Render阶段注意点:

    子组件发生异常,react 会寻找离它最近的且定义getDerivedStateFromError、componentDidCatch 的父组件进行异常处理。

    子组件发生异常,如果父组件没有捕获措施,react 会使用 console.error 打印异常信息。

事故补救

当同一种异常在ErrorTimeLimit秒内出现次数大于ErrorNumLimit次时显示错误页面,中断页面显示

大白话讲就是不想让页面一报错就崩掉。

但是,如果错误的异常请求是 XHR,且时间各为一秒,就会几率性绕过补救


最合适的方案:给页面内的组件最小粒度化添加错误边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import React, { FC } from 'react';
import { ErrorBoundary } from '@sentry/react';
import { ErrorBoundaryProps, FallbackRender } from '@sentry/react/esm/errorboundary';
import CareIcon from './care-icon.png';

import './index.less';

export type TooCatchErrorPropsExportForUser = 'fallback' | 'onError' | 'onMount' | 'onUnmount';
export type ToolCatchErrorProps = Pick<ErrorBoundaryProps, TooCatchErrorPropsExportForUser>;

const t = (txt): string => {
if (window.I18N_T && typeof txt === 'string') {
return window.I18N_T(txt);
}

return txt;
};

const DefaultFullback: FallbackRender = ({ componentStack }) => {

const modulePos = componentStack?.split?.('\n')?.[1];

return (
<div className="tool-catch-error-default-fullback">
<img src={CareIcon} alt="care-icon"/>
<span className="text">{`${t('模块渲染出错!')}${modulePos ? '(' + modulePos + ')' : ''}`}</span>
</div>
);
};

const ToolCatchError: FC<ToolCatchErrorProps> = ({ children, ...rest }) => {
const props: ToolCatchErrorProps = {
fallback: DefaultFullback,
...rest,
};

return (
<ErrorBoundary {...props}>
{ children }
</ErrorBoundary>
);
};

export default ToolCatchError;

这里直接用了 Sentry封装好的针对于 React错误边界的组件;

alt