性能优化竟白屏,难道真是我的锅?
项目日渐“强壮”,性能优化方法之一是采用 React 框架提供的
Reat.lazy()
按需加载的方式,测试过程中,QA说我的优化代码导致了白屏,且看我如何狡辩~
随着项目日渐“强壮”,优化首屏加载渲染速度迫在眉睫,其中就采用了 React 框架提供的 Reat.lazy()
按需加载的方式,测试过程中,在我们的埋点监控平台上,发现了很多网络请求错误的日志,大部分来自分包资源下载失败!难道我的优化变成负优化了???
通过我们的统计平台量化数据可知,用户网络加载失败的概率还是比较大,实验发现,没法儿使用 try{}catch{}
捕获组件渲染错误,查询官方文档,有一个 Error Boundaries
的组件引入眼帘,提供了解决方法,那我们拿到了 demo 应该怎么完善并应用到我们的项目中,以及如何解决按需加载组件失败的场景。
一、背景
某天我在开发了某个功能组件时,发现这个组件引用了一个非常大的三方库,大概100kb,这么大,当然得使用按需加载啦,当我理所当然地觉得这一手“按需加载”的优化很稳,就交给测试同学测试了。
没过多久测试同学反馈,你这个功能咋老白屏?—— 怎么可能?我的代码不可能有BUG!
来到“事故现场”,稍加思索,打开浏览器控制台,发现按需加载的远程文件下载失败了。
emmm~,继续狡辩,这肯定是公司基建不行啊,网络这么不稳,这锅我不背!虽然极力狡辩,可是测试同学就不相信,就认定了是我的问题...
凡事讲证据,冷静下来想一想,万一真的是我的问题,岂不是很尴尬?
为了挽回局面,于是强装镇定说到:“这个问题是网络波动导致,虽然咱们的基建环境不太好,但是为了尽可能提升用户体验,我这尝试下看看如何优化,可通过增加错误监控重试机制,增强用户体验,追求极致!”,赶紧溜回去看看咋解决吧...
一、Error Boundaries
React官方对于“Error Boundaries”的介绍:https://reactjs.org/docs/error-boundaries.html
A JavaScript error in a part of the UI shouldn’t break the whole app. To solve this problem for React users, React 16 introduces a new concept of an “error boundary”.
简单翻译,在 UI 渲染中发生的错误不应该阻塞整个应用的运行,为此,React 16 中提供了一种新的概念“错误边界”。
也就是说,我们可以用“错误边界”来优雅地处理 React 中的 UI 渲染错误问题。
React 中的懒加载使用Suspense
包裹,其下的子节点发生了渲染错误,也就是下载组件文件失败,并不会抛出异常,也没法儿捕获错误,那么用 ErrorBoundary
就正好可以决定再子节点发生渲染错误(常见于白屏)时候的处理方式。
注意:Error boundaries 不能捕获如下类型的错误:
事件处理(了解更多) 异步代码 (例如 setTimeout 或 requestAnimationFrame 回调) 服务端渲染 来自ErrorBoundary组件本身的错误 (而不是来自它包裹子节点发生的错误)
二、借鉴
老夫作为“CV工程师”,自然是信手拈来:
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;
}
}
使用方法:
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
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
与此同时官方的建议:
In the event of an error, you can render a fallback UI with componentDidCatch() by calling setState, but this will be deprecated in a future release. Use static getDerivedStateFromError() to handle fallback rendering instead.
推荐大家在 getDerivedStateFromError()
中处理 fallback UI,而不是在 componentDidCatch()
方法中,componentDidCatch()
在未来的 React 版本中可能会被废弃,当然只是推荐,仅供参考。
三、修饰
官方的 demo 组件如果要嵌入业务代码中,还是有一些简陋,为了更好地适应业务代码以及更加通用,我们一步步来改造。
3.1 支持自定义fallback以及error callback
目标:满足些场景下,开发者需要自行设置 fallback
的UI,以及自定义错误处理回调
实现也非常简单,基于 TypeScript
,再加上一些类型声明,一个支持自定义fallback
和错误回调的 ErrorBoundary
就OK了!
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;
3.2 支持错误手动重试
我们的按需加载组件就像局部组件更新一样,当组件按需加载的渲染失败时候,理论上我们应该给用户提供手动/自动重试机制
手动重试机制,简单的做法就是,在 fallback UI
中设置重试按钮
static getDerivedStateFromError(error: Error) {
return { isShowErrorComponent: true };
}
constructor(props) {
super(props);
this.state = { isShowErrorComponent: false };
+ this.handleRetryClick = this.handleRetryClick.bind(this);
}
+ handleRetryClick() {
+ this.setState({
+ isShowErrorComponent: false,
+ });
+ }
render() {
const { fallback, children } = this.props;
if (this.state.isShowErrorComponent) {
if (fallback) {
return fallback;
}
+ return (
+ <div>
+ {/* CSS重置下按钮样式 */}
+ <button className="error-retry-btn" onClick={this.handleRetryClick}>
+ 渲染错误,请点击重试!
+ </button>
+ </div>
+ );
}
return children;
}
写一个普通的Counter(计数器)组件:
import React, { useState } from 'react';
const Counter = (props) => {
const [count, setCount] = useState(0);
const handleCounterClick = () => {
setCount(count+1);
}
const thr = () => {
throw new Error('render error')
}
return (
<div>
{count === 3 ? thr() : ''}
计数器:{count}
<br/>
<button onClick={handleCounterClick}>点击+1</button>
</div>
)
}
export default Counter;
我们使用这个 LegoErrorBoundary
组件包裹 Counter
计数器组件,Counter
组件中在第三次点击时候抛出一个异常,来看看 ErrorBoundary
的捕获处理情况!
表现效果:
如果咱不处理这个错误,就会导致“白屏”,也不利于研发同学排查问题,特别是涉及到一些异步渲染的问题。
3.3 支持发生错误自动重试渲染有限次数
手动重试,会增加用户的一个操作,这会增加用户的操作成本,为了更加便捷用户使用软件,提升用户体验,来瞅瞅采用自动重试有限次数的机制应该如何实现。
实现思路:
重试次数统计变量:retryCount
,记录重试渲染次数,超过次数则使用兜底渲染“错误提示”UI。
改造如下:
type IState = {
isShowErrorComponent: boolean;
+ retryCount: number;
};
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, 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,
+ });
+ }
}
render() {
const { fallback, children } = this.props;
if (this.state.isShowErrorComponent) {
if (fallback) {
return fallback;
}
+ return <>重试3次后,展示兜底错误提示!</>;
}
return children;
}
}
export default LegoErrorBoundary;
来看看效果:
改改Counter组件的代码,看看能否处理好异步错误的问题:
import React, { useEffect, useState } from 'react';
const Counter = (props) => {
const [count, setCount] = useState(0);
const handleCounterClick = () => {
setCount(count + 1);
}
const thr = () => {
throw new Error('render error')
}
useEffect(() => {
setTimeout(() => {
setCount(3)
}, 1000);
}, []);
return (
<div>
{ count === 3 ? thr() : '' }
计数器:{ count }
<br />
<button onClick={ handleCounterClick }>点击+1</button>
</div>
)
}
export default Counter;
表现:
也是OK的!这说明,属于业务逻辑的代码比如:网络数据请求、异步执行导致渲染出错的情况,“错误边界”组件都是可以拦截并处理。
当前结论:使用 Errorboundary
组件包裹,能够 handle 住子组件发生的渲染 error
。
四、异步加载组件网络错误
4.1 尝试处理
把 App.js
中的 Counter
组件引用改为按需加载,然后在浏览器中模拟分包的组件下载失败情况,看看是否能够拦住!
const LazyCounter = React.lazy(() => import('./components/counter/index'));
function App() {
return (
<div className="App">
<header className="App-header">
<img src={ logo } className="App-logo" alt="logo" />
<ErrorBoundary>
<LazyCounter></LazyCounter>
</ErrorBoundary>
</header>
</div>
);
}
结果白屏了!也可以看到 ErrorBoundary
组件中打印了捕获到的错误信息:
ChunkLoadError: Loading chunk 3 failed.
(error: http://localhost:5000/static/js/3.18a27ea8.chunk.js)
at Function.a.e ((index):1)
at App.js:7
at T (react.production.min.js:18)
at Hu (react-dom.production.min.js:269)
at Pi (react-dom.production.min.js:250)
at xi (react-dom.production.min.js:250)
at _i (react-dom.production.min.js:250)
at vi (react-dom.production.min.js:243)
at fi (react-dom.production.min.js:237)
at Gi (react-dom.production.min.js:285)
拦截到了,但是没有触发3次重试,componentDidCatch
中的 console.log('发生错误!', error);
只打印了一次错误日志,就挂了,看到大家的推荐做法是,发生一次错误就能够处理到,所以尝试把 retryCount
为 0
的时候就设置 isShowErrorComponent
的值,
this.setState({
isShowErrorComponent: true,
})
这时能够显示错误的fallback UI:
但没法儿实现自动重试有限次数异步组件的渲染,否则如果还按照之前的方案,就会继续向上抛出错误,如果没有后续 catch
处理错误,页面就会白屏!
然后尝试主动触发重新渲染,发现并没有发起二次请求,点击重试只是捕获到了错误~
4.2 定位原因
不生效,于是想到声明引入组件的代码如下:
const LazyCounter = React.lazy(() => import('./components/counter/index'));
经过测试验证,的确打印了错误日志,而只发起了一次网络请求的原因是,该 LazyCounter
组件并没有在组件中声明,重新渲染的时候,LazyCounter
组件作为组件外的全局变量,不受 rerender
影响。
4.3 解决方案
因此,想要解决网络加载错误问题并重试,就得在声明代码 import
的时候处理,因为import
返回的是一个Promise
,自然就可以用 .catch
捕获异常。
- const LazyCounter = React.lazy(() => import('./components/counter/index'));
+ const LazyCounter = React.lazy(() => import('./components/counter/index').catch(err => {
+ console.log('dyboy:', err);
+ }));
而 import()
代码执行的时候才会触发网络请求拉取分包资源文件,所以我们可以在异常捕获中重试,并且可以重试一定次数,所以需要实现一个工具函数,统一处理 import()
动态引入可能失败的问题。
该工具函数如下:
/**
*
* @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);
});
});
}
使用的时候只需要将 import()
包一下:
const LazyCounter = React.lazy(() => retryLoad(import('./components/counter/index')));
与此同时,为了多次请求下,“错误边界”组件能够捕获到错误,同时能够触发兜底渲染逻辑,把 ErrorBoundary
组件发生错误时候直接处理展示兜底逻辑,不做重复渲染。则将 ErrorBoundary
中的重渲染计数逻辑代码删除即可。
componentDidCatch(error) {
console.log('发生错误!', error);
this.setState({
isShowErrorComponent: true,
});
}
另外,如果我们既想要渲染出错后的重试,还需要保证多次网络出错后能有错误上报,那么只需要在 retryLoad
工具函数中增加错误日志远程上报逻辑,然后不抛出 reject()
。
4.4 表现效果
处理如下三种情况的效果:
正常按需加载组件成功 网络原因一直下载失败,展示兜底错误 网络原因,中途恢复,展示正常功能 录制的GIf比较大,微信上无法展示,可点击阅读全文查看效果!
当我把网络加载失败后的处理结果给到QA同学,QA同学赞许地说道:“老哥,稳!”
五、总结
通过针对业务优化场景中遇到的加载失败问题,尝试借助 ErrorBoundary
以及 import()
网络重试加载机制,保证了程序的健壮性,降低前端“白屏率”,换个角度说,一定层次上提升了用户的体验和质量,对于前端工程的收益是较为明显!
在本次的问题处理过程中,其实还有一些值得探究的地方:
ErrorBoundary
捕获错误的原理是啥?为什么不能处理本身错误?ErrorBoundary
除了接收JSX
,是否可以扩展接收组件等,是否fallback
可以和函数联动?ErrorBoundary
是否可以作为前端白屏监控?或更多应用场景?思考&扩展一下?
Reference
static getDerivedStateFromError componentDidCatch Suspense for Data Fetching (Experimental)