Skip to main content

白屏/错误边界

导致白屏的原因,主要是:

  1. 资源访问错误
  • 网络连接不稳定、服务器响应缓慢或失败等,导致页面无法成功加载所需资源

  • 资源加载问题:图片、字体、脚本等外部资源加载失败或超时,导致页面无法显示对应的内容。

  1. 代码执行错误
  • HTML 结构错误:HTML 代码中存在语法错误或标签闭合不完整等问题,导致浏览器无法正确解析和构建 DOM 树

  • CSS 问题:CSS 文件加载失败、样式表错误、选择器匹配问题等,导致页面无法正确渲染样式,显示为空白

  • JavaScript 问题:JavaScript 代码错误、执行阻塞、性能问题等,导致页面无法正常执行脚本,进而导致页面无法渲染和展示内容。

  • 服务器端问题:服务器端处理逻辑错误、数据库连接问题等,导致无法正确生成页面内容并返回给客户端。

  • 第三方插件或库问题:使用的第三方插件或库存在版本兼容性问题、加载失败等,影响了页面的渲染和展示。

  1. 长时间的 JS 线程繁忙阻塞渲染任务

资源访问错误

这里的资源特指 JavaScript 脚本、样式表、图片等静态资源,不包括服务调用等动态资源。 最典型的例子莫过于 React、Vue 等 SPA 框架构建的 Web 应用,一旦 [bundle|app].js 因为网络原因访问失败,便会引发页面白屏。 你可以访问 https://vue-ebgcbmiy3-b2d1.vercel.app/,按照如下步骤复现:打开 DevTools > Network,找到 app.3b315b6b.js,右键并选中 Block request URL,随后刷新页面。

代码执行出错

如果说资源访问错误还有回旋的余地,可能用户的网络不稳定,重试几次便能恢复正常。 那么在 render 过程中,代码执行出错无异于被宣判死刑,包括但不限于:

  • 读取 undefined null 的属性,null.a;
  • 对普通对象进行函数调用,const o = {}; o();
  • 将 null undefined 传递给 Object.keys,Objet.keys(null);
  • JSON 反序列化接受到非法值,JSON.parse({}); 你必须经历本地调试,CI、CD,构建部署等一系列措施、或者直接 rollback.

为什么 react read properties of undefined 就白屏了?

根本原因是自 React 16 起,任何未被错误边界捕获的错误将会导致整个 React 组件树被卸载。 翻译一下就是如果在组件的渲染期间内,发生了 Uncaught Errors,而又未被 Error Boundaries 捕获,整个 <App />所表示的 DOM 结构都被会移除,如下所示:

ReactDOM.render(null, document.getElementById("root"));

我们对这一决定有过一些争论,但根据我们的经验,把一个错误的 UI 留在那比完全移除它要更糟糕。例如,在类> 似 Messenger 的产品中,把一个异常的 UI 展示给用户可能会导致用户将信息错发给别人。同样,对于支付类> 应用而言,显示错误的金额也比不呈现任何内容更糟糕

错误边界库

https://github.com/bvaughn/react-error-boundary,可在生产环境中投入使用。

React 官方 Error Boudaries

React 官方对于“Error Boundaries”的介绍:https://reactjs.org/docs/error-boundaries.html

官方: 在 UI 渲染中发生的错误不应该阻塞整个应用的运行,为此,React 16 中提供了一种新的概念“错误边界”。 也就是说,我们可以用“错误边界”来优雅地处理 React 中的 UI 渲染错误问题。

比如: React 中的懒加载使用 Suspense 包裹,其下的子节点发生了渲染错误,也就是下载组件文件失败,并不会抛出异常,也没法儿捕获错误,那么用 ErrorBoundary 就正好可以决定再子节点发生渲染错误(常见于白屏)时候的处理方式

注意: Error boundaries 不能捕获如下类型的错误:

  • 事件处理(了解更多)
  • 异步代码 (例如 setTimeout 或 requestAnimationFrame 回调)
  • 服务端渲染
  • 来自 ErrorBoundary 组件本身的错误 (而不是来自它包裹子节点发生的错误)

属于业务逻辑的代码比如:网络数据请求、异步执行但是导致渲染出错的情况,“错误边界”组件都是可以拦截并处理。

Error Boundary

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;
}
}
  • static getDerivedStateFromError(error): 在 render phase 阶段,子节点发生 UI 渲染抛出错误时候执行,return 的{hasError: true} 用于更新 state 中的值,

该阶段不允许包含副作用的代码,触发重新渲染(渲染 fallback UI)内容。否则可能会重复执行?

  • componentDidCatch(error, errorInfo):在 commit phase 阶段,捕获子节点中发生的错误,因此在该方法中可以执行有副作用的代码,例如用于打印上报错误日志。

官方案例在线演示地址:https://codepen.io/gaearon/pen/wqvxGa?editors=0010

官方: 推荐大家在 getDerivedStateFromError() 中处理 fallback UI,而不是在 componentDidCatch() 方法中,componentDidCatch() 在未来的 React 版本中可能会被废弃,当然只是推荐,仅供参考。

升级:支持自定义 fallback 以及 自定义 error callback

利用 TypeScript,再加上一些类型声明,一个支持自定义 fallback 和错误回调的 ErrorBoundary

type IProps = {
fallback?: ReactNode | null,
onError?: () => void,
children: ReactNode,
};

type IState = {
isShowErrorComponent: boolean,
};

class LegoErrorBoundary extends React.Component<IProps, IState> {
static getDerivedStateFromError(error: Error) {
return { isShowErrorComponent: true };
}

constructor(props: IProps | Readonly<IProps>) {
super(props);
this.state = { isShowErrorComponent: false };
}

componentDidCatch(error: Error) {
this.props.onError?.();
}

render() {
const { fallback, children } = this.props;
if (this.state.isShowErrorComponent) {
if (fallback) {
return fallback;
}
return <>加载失败,请刷新重试!</>;
}
return children;
}
}

export default LegoErrorBoundary;

支持错误重试

在 fallback UI 中设置重试按钮


handleRetryClick() {
this.setState({
isShowErrorComponent: false,
});
}

<button className="error-retry-btn" onClick={this.handleRetryClick}>
渲染错误,请点击重试!
</button>

支持发生错误自动重试渲染有限次数

实现思路: 重试次数统计变量:retryCount,记录重试渲染次数,超过次数则使用兜底渲染“错误提示”UI。

   this.state = { isShowErrorComponent: false, retryCount: 0 };
this.handleErrorRetryClick = this.handleErrorRetryClick.bind(this);

componentDidCatch(error: Error) {
if (this.state.retryCount > 2) {
this.setState({
isShowErrorComponent: true,
})
} else {
this.setState({
retryCount: this.state.retryCount + 1,
});
}
}

如何降低白屏的“破坏力”

  • 依赖不可信,npm 的 Breaking Change

  • 调用不可信,HTTP/RPC 等 API 调用不仅会失败,还会返回约定之外的数据,不兼容过时版本

  • 输入不可信,用户常常会输入一些边界值、非法值 能尽可能避免异常。

    借助于 ErrorBoundary,它能捕获任意子组件在渲染期间发生的 Uncaught Errors,从而避免整体组件树的卸载,把白屏扼杀在摇篮中。 除此之外,它还能对渲染错误的组件做兜底,具体的处理措施有两种:熔断和降级。

熔断(直接不渲染

<ErrorBoundary fallbackRender={() => null}>

import { ErrorBoundary } from "react-error-boundary";

const Other = () => <h1>I AM OTHER</h1>;

const Bug = () => {
const [val, setVal] = useState({});

const triggerError = () => {
setVal(undefined);
};

return (
<>
<button onClick={triggerError}>trigger render error</button>
<h1>I HAVE BUG, DO NOT CLICK ME</h1>
{Object.keys(val)}
</>
);
};

const App = () => (
<>
<Other />
<ErrorBoundary fallbackRender={() => null}>
<Bug />
</ErrorBoundary>
</>
);

组件优雅降级

优雅降级指使用 替代渲染出错的组件,并做符合功能场景,用户心智的提示。

import { ErrorBoundary } from "react-error-boundary";

function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}

const App = () => (
<>
<Other />
<ErrorBoundary fallbackRender={ErrorFallback}>
<Bug />
</ErrorBoundary>
</>
);

主动 throw error 导致白屏

遇到对于非预期的行为,一定要主动 throw error,并做好组件熔断及降级。

异步加载组件网络错误

想要解决网络加载错误问题并重试,就得在声明代码 import 的时候处理,因为 import 返回的是一个 Promise,自然就可以用 .catch 捕获异常。

const LazyCounter = React.lazy(() => import('./components/counter/index').catch(err => {+   console.log('dyboy:', err);+ }));
/**
*
* @param {() => Promise} fn 需要重试执行的函数
* @param {number} retriesLeft 剩余重试次数
* @param {number} interval 间隔重试请求时间,单位ms
* @returns Promise<any>
*/ export const retryLoad = (fn, retriesLeft = 5, interval = 1000) => {
return new Promise((resolve, reject) => {
fn()
.then(resolve)
.catch((err) => {
setTimeout(() => {
if (retriesLeft === 1) {
// 远程上报错误日志代码reject(err);
// coding...console.log(err)
return;
}
retryLoad(fn, retriesLeft - 1, interval).then(resolve, reject);
}, interval);
});
});
};