性能優(yōu)化竟白屏,難道真是我的鍋?
項(xiàng)目日漸“強(qiáng)壯”,性能優(yōu)化方法之一是采用 React 框架提供的
Reat.lazy()按需加載的方式,測(cè)試過(guò)程中,QA說(shuō)我的優(yōu)化代碼導(dǎo)致了白屏,且看我如何狡辯~
隨著項(xiàng)目日漸“強(qiáng)壯”,優(yōu)化首屏加載渲染速度迫在眉睫,其中就采用了 React 框架提供的 Reat.lazy() 按需加載的方式,測(cè)試過(guò)程中,在我們的埋點(diǎn)監(jiān)控平臺(tái)上,發(fā)現(xiàn)了很多網(wǎng)絡(luò)請(qǐng)求錯(cuò)誤的日志,大部分來(lái)自分包資源下載失敗!難道我的優(yōu)化變成負(fù)優(yōu)化了???

通過(guò)我們的統(tǒng)計(jì)平臺(tái)量化數(shù)據(jù)可知,用戶(hù)網(wǎng)絡(luò)加載失敗的概率還是比較大,實(shí)驗(yàn)發(fā)現(xiàn),沒(méi)法兒使用 try{}catch{} 捕獲組件渲染錯(cuò)誤,查詢(xún)官方文檔,有一個(gè) Error Boundaries 的組件引入眼簾,提供了解決方法,那我們拿到了 demo 應(yīng)該怎么完善并應(yīng)用到我們的項(xiàng)目中,以及如何解決按需加載組件失敗的場(chǎng)景。
一、背景
某天我在開(kāi)發(fā)了某個(gè)功能組件時(shí),發(fā)現(xiàn)這個(gè)組件引用了一個(gè)非常大的三方庫(kù),大概100kb,這么大,當(dāng)然得使用按需加載啦,當(dāng)我理所當(dāng)然地覺(jué)得這一手“按需加載”的優(yōu)化很穩(wěn),就交給測(cè)試同學(xué)測(cè)試了。
沒(méi)過(guò)多久測(cè)試同學(xué)反饋,你這個(gè)功能咋老白屏?—— 怎么可能?我的代碼不可能有BUG!

來(lái)到“事故現(xiàn)場(chǎng)”,稍加思索,打開(kāi)瀏覽器控制臺(tái),發(fā)現(xiàn)按需加載的遠(yuǎn)程文件下載失敗了。
emmm~,繼續(xù)狡辯,這肯定是公司基建不行啊,網(wǎng)絡(luò)這么不穩(wěn),這鍋我不背!雖然極力狡辯,可是測(cè)試同學(xué)就不相信,就認(rèn)定了是我的問(wèn)題...

凡事講證據(jù),冷靜下來(lái)想一想,萬(wàn)一真的是我的問(wèn)題,豈不是很尷尬?
為了挽回局面,于是強(qiáng)裝鎮(zhèn)定說(shuō)到:“這個(gè)問(wèn)題是網(wǎng)絡(luò)波動(dòng)導(dǎo)致,雖然咱們的基建環(huán)境不太好,但是為了盡可能提升用戶(hù)體驗(yàn),我這嘗試下看看如何優(yōu)化,可通過(guò)增加錯(cuò)誤監(jiān)控重試機(jī)制,增強(qiáng)用戶(hù)體驗(yàn),追求極致!”,趕緊溜回去看看咋解決吧...

一、Error Boundaries
React官方對(duì)于“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”.
簡(jiǎn)單翻譯,在 UI 渲染中發(fā)生的錯(cuò)誤不應(yīng)該阻塞整個(gè)應(yīng)用的運(yùn)行,為此,React 16 中提供了一種新的概念“錯(cuò)誤邊界”。
也就是說(shuō),我們可以用“錯(cuò)誤邊界”來(lái)優(yōu)雅地處理 React 中的 UI 渲染錯(cuò)誤問(wèn)題。
React 中的懶加載使用Suspense包裹,其下的子節(jié)點(diǎn)發(fā)生了渲染錯(cuò)誤,也就是下載組件文件失敗,并不會(huì)拋出異常,也沒(méi)法兒捕獲錯(cuò)誤,那么用 ErrorBoundary 就正好可以決定再子節(jié)點(diǎn)發(fā)生渲染錯(cuò)誤(常見(jiàn)于白屏)時(shí)候的處理方式。
注意:Error boundaries 不能捕獲如下類(lèi)型的錯(cuò)誤:
事件處理(了解更多) 異步代碼 (例如 setTimeout 或 requestAnimationFrame 回調(diào)) 服務(wù)端渲染 來(lái)自ErrorBoundary組件本身的錯(cuò)誤 (而不是來(lái)自它包裹子節(jié)點(diǎn)發(fā)生的錯(cuò)誤)
二、借鑒
老夫作為“CV工程師”,自然是信手拈來(lái):
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階段,子節(jié)點(diǎn)發(fā)生UI渲染拋出錯(cuò)誤時(shí)候執(zhí)行,return 的{hasError: true}用于更新 state 中的值,不允許包含副作用的代碼,觸發(fā)重新渲染(渲染fallback UI)內(nèi)容。componentDidCatch(error, errorInfo):在commit phase階段,捕獲子節(jié)點(diǎn)中發(fā)生的錯(cuò)誤,因此在該方法中可以執(zhí)行有副作用的代碼,例如用于打印上報(bào)錯(cuò)誤日志。
官方案例在線演示地址:https://codepen.io/gaearon/pen/wqvxGa?editors=0010
與此同時(shí)官方的建議:
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() 在未來(lái)的 React 版本中可能會(huì)被廢棄,當(dāng)然只是推薦,僅供參考。
三、修飾
官方的 demo 組件如果要嵌入業(yè)務(wù)代碼中,還是有一些簡(jiǎn)陋,為了更好地適應(yīng)業(yè)務(wù)代碼以及更加通用,我們一步步來(lái)改造。
3.1 支持自定義fallback以及error callback
目標(biāo):滿(mǎn)足些場(chǎng)景下,開(kāi)發(fā)者需要自行設(shè)置 fallback 的UI,以及自定義錯(cuò)誤處理回調(diào)
實(shí)現(xiàn)也非常簡(jiǎn)單,基于 TypeScript,再加上一些類(lèi)型聲明,一個(gè)支持自定義fallback 和錯(cuò)誤回調(diào)的 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 <>加載失敗,請(qǐng)刷新重試!</>;
}
return children;
}
}
export default LegoErrorBoundary;
3.2 支持錯(cuò)誤手動(dòng)重試
我們的按需加載組件就像局部組件更新一樣,當(dāng)組件按需加載的渲染失敗時(shí)候,理論上我們應(yīng)該給用戶(hù)提供手動(dòng)/自動(dòng)重試機(jī)制
手動(dòng)重試機(jī)制,簡(jiǎn)單的做法就是,在 fallback UI 中設(shè)置重試按鈕
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}>
+ 渲染錯(cuò)誤,請(qǐng)點(diǎn)擊重試!
+ </button>
+ </div>
+ );
}
return children;
}
寫(xiě)一個(gè)普通的Counter(計(jì)數(shù)器)組件:
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() : ''}
計(jì)數(shù)器:{count}
<br/>
<button onClick={handleCounterClick}>點(diǎn)擊+1</button>
</div>
)
}
export default Counter;
我們使用這個(gè) LegoErrorBoundary 組件包裹 Counter 計(jì)數(shù)器組件,Counter 組件中在第三次點(diǎn)擊時(shí)候拋出一個(gè)異常,來(lái)看看 ErrorBoundary 的捕獲處理情況!
表現(xiàn)效果:

如果咱不處理這個(gè)錯(cuò)誤,就會(huì)導(dǎo)致“白屏”,也不利于研發(fā)同學(xué)排查問(wèn)題,特別是涉及到一些異步渲染的問(wèn)題。
3.3 支持發(fā)生錯(cuò)誤自動(dòng)重試渲染有限次數(shù)
手動(dòng)重試,會(huì)增加用戶(hù)的一個(gè)操作,這會(huì)增加用戶(hù)的操作成本,為了更加便捷用戶(hù)使用軟件,提升用戶(hù)體驗(yàn),來(lái)瞅瞅采用自動(dòng)重試有限次數(shù)的機(jī)制應(yīng)該如何實(shí)現(xiàn)。
實(shí)現(xiàn)思路:
重試次數(shù)統(tǒng)計(jì)變量:retryCount,記錄重試渲染次數(shù),超過(guò)次數(shù)則使用兜底渲染“錯(cuò)誤提示”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次后,展示兜底錯(cuò)誤提示!</>;
}
return children;
}
}
export default LegoErrorBoundary;
來(lái)看看效果:

改改Counter組件的代碼,看看能否處理好異步錯(cuò)誤的問(wèn)題:
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() : '' }
計(jì)數(shù)器:{ count }
<br />
<button onClick={ handleCounterClick }>點(diǎn)擊+1</button>
</div>
)
}
export default Counter;
表現(xiàn):

也是OK的!這說(shuō)明,屬于業(yè)務(wù)邏輯的代碼比如:網(wǎng)絡(luò)數(shù)據(jù)請(qǐng)求、異步執(zhí)行導(dǎo)致渲染出錯(cuò)的情況,“錯(cuò)誤邊界”組件都是可以攔截并處理。
當(dāng)前結(jié)論:使用 Errorboundary 組件包裹,能夠 handle 住子組件發(fā)生的渲染 error。
四、異步加載組件網(wǎng)絡(luò)錯(cuò)誤
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>
);
}
結(jié)果白屏了!也可以看到 ErrorBoundary 組件中打印了捕獲到的錯(cuò)誤信息:
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)
攔截到了,但是沒(méi)有觸發(fā)3次重試,componentDidCatch 中的 console.log('發(fā)生錯(cuò)誤!', error); 只打印了一次錯(cuò)誤日志,就掛了,看到大家的推薦做法是,發(fā)生一次錯(cuò)誤就能夠處理到,所以嘗試把 retryCount 為 0 的時(shí)候就設(shè)置 isShowErrorComponent 的值,
this.setState({
isShowErrorComponent: true,
})
這時(shí)能夠顯示錯(cuò)誤的fallback UI:

但沒(méi)法兒實(shí)現(xiàn)自動(dòng)重試有限次數(shù)異步組件的渲染,否則如果還按照之前的方案,就會(huì)繼續(xù)向上拋出錯(cuò)誤,如果沒(méi)有后續(xù) catch 處理錯(cuò)誤,頁(yè)面就會(huì)白屏!
然后嘗試主動(dòng)觸發(fā)重新渲染,發(fā)現(xiàn)并沒(méi)有發(fā)起二次請(qǐng)求,點(diǎn)擊重試只是捕獲到了錯(cuò)誤~
4.2 定位原因
不生效,于是想到聲明引入組件的代碼如下:
const LazyCounter = React.lazy(() => import('./components/counter/index'));
經(jīng)過(guò)測(cè)試驗(yàn)證,的確打印了錯(cuò)誤日志,而只發(fā)起了一次網(wǎng)絡(luò)請(qǐng)求的原因是,該 LazyCounter 組件并沒(méi)有在組件中聲明,重新渲染的時(shí)候,LazyCounter 組件作為組件外的全局變量,不受 rerender 影響。
4.3 解決方案
因此,想要解決網(wǎng)絡(luò)加載錯(cuò)誤問(wèn)題并重試,就得在聲明代碼 import 的時(shí)候處理,因?yàn)?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(40, 202, 113);">import 返回的是一個(gè)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() 代碼執(zhí)行的時(shí)候才會(huì)觸發(fā)網(wǎng)絡(luò)請(qǐng)求拉取分包資源文件,所以我們可以在異常捕獲中重試,并且可以重試一定次數(shù),所以需要實(shí)現(xiàn)一個(gè)工具函數(shù),統(tǒng)一處理 import() 動(dòng)態(tài)引入可能失敗的問(wèn)題。
該工具函數(shù)如下:
/**
*
* @param {() => Promise} fn 需要重試執(zhí)行的函數(shù)
* @param {number} retriesLeft 剩余重試次數(shù)
* @param {number} interval 間隔重試請(qǐng)求時(shí)間,單位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) {
// 遠(yuǎn)程上報(bào)錯(cuò)誤日志代碼
reject(err);
// coding...
console.log(err)
return;
}
retryLoad(fn, retriesLeft - 1, interval).then(resolve, reject);
}, interval);
});
});
}
使用的時(shí)候只需要將 import() 包一下:
const LazyCounter = React.lazy(() => retryLoad(import('./components/counter/index')));
與此同時(shí),為了多次請(qǐng)求下,“錯(cuò)誤邊界”組件能夠捕獲到錯(cuò)誤,同時(shí)能夠觸發(fā)兜底渲染邏輯,把 ErrorBoundary 組件發(fā)生錯(cuò)誤時(shí)候直接處理展示兜底邏輯,不做重復(fù)渲染。則將 ErrorBoundary 中的重渲染計(jì)數(shù)邏輯代碼刪除即可。
componentDidCatch(error) {
console.log('發(fā)生錯(cuò)誤!', error);
this.setState({
isShowErrorComponent: true,
});
}
另外,如果我們既想要渲染出錯(cuò)后的重試,還需要保證多次網(wǎng)絡(luò)出錯(cuò)后能有錯(cuò)誤上報(bào),那么只需要在 retryLoad 工具函數(shù)中增加錯(cuò)誤日志遠(yuǎn)程上報(bào)邏輯,然后不拋出 reject()。
4.4 表現(xiàn)效果
處理如下三種情況的效果:
正常按需加載組件成功 網(wǎng)絡(luò)原因一直下載失敗,展示兜底錯(cuò)誤 網(wǎng)絡(luò)原因,中途恢復(fù),展示正常功能 錄制的GIf比較大,微信上無(wú)法展示,可點(diǎn)擊閱讀全文查看效果!
當(dāng)我把網(wǎng)絡(luò)加載失敗后的處理結(jié)果給到QA同學(xué),QA同學(xué)贊許地說(shuō)道:“老哥,穩(wěn)!”

五、總結(jié)
通過(guò)針對(duì)業(yè)務(wù)優(yōu)化場(chǎng)景中遇到的加載失敗問(wèn)題,嘗試借助 ErrorBoundary 以及 import() 網(wǎng)絡(luò)重試加載機(jī)制,保證了程序的健壯性,降低前端“白屏率”,換個(gè)角度說(shuō),一定層次上提升了用戶(hù)的體驗(yàn)和質(zhì)量,對(duì)于前端工程的收益是較為明顯!
在本次的問(wèn)題處理過(guò)程中,其實(shí)還有一些值得探究的地方:
ErrorBoundary捕獲錯(cuò)誤的原理是啥?為什么不能處理本身錯(cuò)誤?ErrorBoundary除了接收JSX,是否可以擴(kuò)展接收組件等,是否fallback可以和函數(shù)聯(lián)動(dòng)?ErrorBoundary是否可以作為前端白屏監(jiān)控?或更多應(yīng)用場(chǎng)景?思考&擴(kuò)展一下?
Reference
static getDerivedStateFromError componentDidCatch Suspense for Data Fetching (Experimental)
