白屏/错误边界
导致白屏的原因, 主要是:
- 资源访问错误
-
网络连接不稳定、服务器响应缓慢或失败等,导致页面无法成功加载所需资源
-
资源加载问题:图片、字体、脚本等外部资源加载失败或超时,导致页面无法显示对应的内容。
- 代码执行错误
-
HTML 结构错误:HTML 代码中存在语法错误或标签闭合不完整等问题,导致浏览器无法正确解析和构建 DOM 树
-
CSS 问题:CSS 文件加载失败、样式表错误、选择器匹配问题等,导致页面无法正确渲染样式,显示为空白
-
JavaScript 问题:JavaScript 代码错误、执行阻塞、性能问题等,导致页面无法正常执行脚本,进而导致页面无法渲染和展示内容。
-
服务器端问题:服务器端处理逻辑错误、数据库连接问题等,导致无法正确生成页面内容并返回给客户端。
-
第三方插件或库问题:使用的第三方插件或库存在版本兼容性问题、加载失败等,影响了页面的渲染和展示。
- 长时间的 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
- static getDerivedStateFromError https://reactjs.org/docs/react-component.html#static-getderivedstatefromerror
- componentDidCatch https://reactjs.org/docs/react-component.html#componentdidcatch
- Suspense for Data Fetching (Experimental) https://reactjs.org/docs/concurrent-mode-suspense.html
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);
});
});
};