我在大廠寫React學(xué)到了什么?性能優(yōu)化篇
我工作中的技術(shù)棧主要是 React + TypeScript,這篇文章我想總結(jié)一下如何在項目中運(yùn)用 React 的一些技巧去進(jìn)行性能優(yōu)化,或者更好的代碼組織。
性能優(yōu)化的重要性不用多說,谷歌發(fā)布的很多調(diào)研精確的展示了性能對于網(wǎng)站留存率的影響,而代碼組織優(yōu)化則關(guān)系到后續(xù)的維護(hù)成本,以及你同事維護(hù)你代碼時候“口吐芬芳”的頻率?,本篇文章看完,你一定會有所收獲。
神奇的 children
我們有一個需求,需要通過 Provider 傳遞一些主題信息給子組件:
看這樣一段代碼:
import?React,?{?useContext,?useState?}?from?"react";
const?ThemeContext?=?React.createContext();
export?function?ChildNonTheme()?{
??console.log("不關(guān)心皮膚的子組件渲染了");
??return?<div>我不關(guān)心皮膚,皮膚改變的時候別讓我重新渲染!div>;
}
export?function?ChildWithTheme()?{
??const?theme?=?useContext(ThemeContext);
??return?<div>我是有皮膚的哦~?{theme}div>;
}
export?default?function?App()?{
??const?[theme,?setTheme]?=?useState("light");
??const?onChangeTheme?=?()?=>?setTheme(theme?===?"light"???"dark"?:?"light");
??return?(
????<ThemeContext.Provider?value={theme}>
??????<button?onClick={onChangeTheme}>改變皮膚button>
??????<ChildWithTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
????ThemeContext.Provider>
??);
}
這段代碼看起來沒啥問題,也很符合擼起袖子就干的直覺,但是卻會讓 ChildNonTheme 這個不關(guān)心皮膚的子組件,在皮膚狀態(tài)更改的時候也進(jìn)行無效的重新渲染。

這本質(zhì)上是由于 React 是自上而下遞歸更新, 這樣的代碼會被 babel 翻譯成 React.createElement(ChildNonTheme) 這樣的函數(shù)調(diào)用,React官方經(jīng)常強(qiáng)調(diào) props 是immutable 的,所以在每次調(diào)用函數(shù)式組件的時候,都會生成一份新的 props 引用。
來看下 createElement 的返回結(jié)構(gòu):
const?childNonThemeElement?=?{
??type:?'ChildNonTheme',
??props:?{}?//?<-?這個引用更新了
}
正是由于這個新的 props 引用,導(dǎo)致 ChildNonTheme 這個組件也重新渲染了。
那么如何避免這個無效的重新渲染呢?關(guān)鍵詞是「巧妙利用 children」。
import?React,?{?useContext,?useState?}?from?"react";
const?ThemeContext?=?React.createContext();
function?ChildNonTheme()?{
??console.log("不關(guān)心皮膚的子組件渲染了");
??return?<div>我不關(guān)心皮膚,皮膚改變的時候別讓我重新渲染!div>;
}
function?ChildWithTheme()?{
??const?theme?=?useContext(ThemeContext);
??return?<div>我是有皮膚的哦~?{theme}div>;
}
function?ThemeApp({?children?})?{
??const?[theme,?setTheme]?=?useState("light");
??const?onChangeTheme?=?()?=>?setTheme(theme?===?"light"???"dark"?:?"light");
??return?(
????<ThemeContext.Provider?value={theme}>
??????<button?onClick={onChangeTheme}>改變皮膚button>
??????{children}
????ThemeContext.Provider>
??);
}
export?default?function?App()?{
??return?(
????<ThemeApp>
??????<ChildWithTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
??????<ChildNonTheme?/>
????ThemeApp>
??);
}
沒錯,唯一的區(qū)別就是我把控制狀態(tài)的組件和負(fù)責(zé)展示的子組件給抽離開了,通過 children 傳入后直接渲染,由于 children 從外部傳入的,也就是說 ThemeApp 這個組件內(nèi)部不會再有 React.createElement 這樣的代碼,那么在 setTheme 觸發(fā)重新渲染后,children 完全沒有改變,所以可以直接復(fù)用。
讓我們再看一下被 ThemeApp 包裹下的 ,它會作為 children 傳遞給 ThemeApp,ThemeApp 內(nèi)部的更新完全不會觸發(fā)外部的 React.createElement,所以會直接復(fù)用之前的 element 結(jié)果:
//?完全復(fù)用,props 也不會改變。
const?childNonThemeElement?=?{
??type:?ChildNonTheme,
??props:?{}
}

在改變皮膚之后,控制臺空空如也!優(yōu)化達(dá)成。
總結(jié)下來,就是要把渲染比較費(fèi)時,但是不需要關(guān)心狀態(tài)的子組件提升到「有狀態(tài)組件」的外部,作為 children 或者props傳遞進(jìn)去直接使用,防止被帶著一起渲染。
神奇的 children - 在線調(diào)試地址
當(dāng)然,這個優(yōu)化也一樣可以用 React.memo 包裹子組件來做,不過相對的增加維護(hù)成本,根據(jù)場景權(quán)衡選擇吧。
Context 讀寫分離
想象一下,現(xiàn)在我們有一個全局日志記錄的需求,我們想通過 Provider 去做,很快代碼就寫好了:
import?React,?{?useContext,?useState?}?from?"react";
import?"./styles.css";
const?LogContext?=?React.createContext();
function?LogProvider({?children?})?{
??const?[logs,?setLogs]?=?useState([]);
??const?addLog?=?(log)?=>?setLogs((prevLogs)?=>?[...prevLogs,?log]);
??return?(
????<LogContext.Provider?value={{?logs,?addLog?}}>
??????{children}
????LogContext.Provider>
??);
}
function?Logger1()?{
??const?{?addLog?}?=?useContext(LogContext);
??console.log('Logger1?render')
??return?(
????<>
??????<p>一個能發(fā)日志的組件1p>
??????<button?onClick={()?=>?addLog("logger1")}>發(fā)日志button>
????>
??);
}
function?Logger2()?{
??const?{?addLog?}?=?useContext(LogContext);
??console.log('Logger2?render')
??return?(
????<>
??????<p>一個能發(fā)日志的組件2p>
??????<button?onClick={()?=>?addLog("logger2")}>發(fā)日志button>
????>
??);
}
function?LogsPanel()?{
??const?{?logs?}?=?useContext(LogContext);
??return?logs.map((log,?index)?=>?<p?key={index}>{log}p>);
}
export?default?function?App()?{
??return?(
????<LogProvider>
??????{/*?寫日志?*/}
??????<Logger1?/>
??????<Logger2?/>
??????{/*?讀日志?*/}
??????<LogsPanel?/>
??????div>
????</LogProvider>
??);
}
我們已經(jīng)用上了上一章節(jié)的優(yōu)化小技巧,單獨(dú)的把 LogProvider 封裝起來,并且把子組件提升到外層傳入。
先思考一下最佳的情況,Logger 組件只負(fù)責(zé)發(fā)出日志,它是不關(guān)心logs的變化的,在任何組件調(diào)用 addLog 去寫入日志的時候,理想的情況下應(yīng)該只有 LogsPanel 這個組件發(fā)生重新渲染。
但是這樣的代碼寫法卻會導(dǎo)致每次任意一個組件寫入日志以后,所有的 Logger 和 LogsPanel 都發(fā)生重新渲染。

這肯定不是我們預(yù)期的,假設(shè)在現(xiàn)實(shí)場景的代碼中,能寫日志的組件可多著呢,每次一寫入就導(dǎo)致全局的組件都重新渲染?這當(dāng)然是不能接受的,發(fā)生這個問題的本質(zhì)原因官網(wǎng) Context 的部分已經(jīng)講得很清楚了:

當(dāng) LogProvider 中的 addLog 被子組件調(diào)用,導(dǎo)致 LogProvider重渲染之后,必然會導(dǎo)致傳遞給 Provider 的 value 發(fā)生改變,由于 value 包含了 logs 和 setLogs 屬性,所以兩者中任意一個發(fā)生變化,都會導(dǎo)致所有的訂閱了 LogProvider 的子組件重新渲染。
那么解決辦法是什么呢?其實(shí)就是讀寫分離,我們把 logs(讀)和 setLogs(寫)分別通過不同的 Provider 傳遞,這樣負(fù)責(zé)寫入的組件更改了 logs,其他的「寫組件」并不會重新渲染,只有真正關(guān)心 logs 的「讀組件」會重新渲染。
function?LogProvider({?children?})?{
??const?[logs,?setLogs]?=?useState([]);
??const?addLog?=?useCallback((log)?=>?{
????setLogs((prevLogs)?=>?[...prevLogs,?log]);
??},?[]);
??return?(
????<LogDispatcherContext.Provider?value={addLog}>
??????<LogStateContext.Provider?value={logs}>
????????{children}
??????LogStateContext.Provider>
????LogDispatcherContext.Provider>
??);
}
我們剛剛也提到,需要保證 value 的引用不能發(fā)生變化,所以這里自然要用 useCallback 把 addLog 方法包裹起來,才能保證 LogProvider 重渲染的時候,傳遞給的LogDispatcherContext的value 不發(fā)生變化。
現(xiàn)在我從任意「寫組件」發(fā)送日志,都只會讓「讀組件」LogsPanel 渲染。

Context 讀寫分離 - 在線調(diào)試
Context 代碼組織
上面的案例中,我們在子組件中獲取全局狀態(tài),都是直接裸用 useContext:
import?React?from?'react'
import?{?LogStateContext?}?from?'./context'
function?App()?{
??const?logs?=?React.useContext(LogStateContext)
}
但是是否有更好的代碼組織方法呢?比如這樣:
import?React?from?'react'
import?{?useLogState?}?from?'./context'
function?App()?{
??const?logs?=?useLogState()
}
//?context
import?React?from?'react'
const?LogStateContext?=?React.createContext();
export?function?useLogState()?{
??return?React.useContext(LogStateContext)
}
在加上點(diǎn)健壯性保證?
import?React?from?'react'
const?LogStateContext?=?React.createContext();
const?LogDispatcherContext?=?React.createContext();
export?function?useLogState()?{
??const?context?=?React.useContext(LogStateContext)
??if?(context?===?undefined)?{
????throw?new?Error('useLogState?must?be?used?within?a?LogStateProvider')
??}
??return?context
}
export?function?useLogDispatcher()?{
??const?context?=?React.useContext(LogDispatcherContext)
??if?(context?===?undefined)?{
????throw?new?Error('useLogDispatcher?must?be?used?within?a?LogDispatcherContext')
??}
??return?context
}
如果有的組件同時需要讀寫日志,調(diào)用兩次很麻煩?
export?function?useLogs()?{
??return?[useLogState(),?useLogDispatcher()]
}
export?function?App()?{
??const?[logs,?addLogs]?=?useLogs()
??//?...
}
根據(jù)場景,靈活運(yùn)用這些技巧,讓你的代碼更加健壯優(yōu)雅~
組合 Providers
假設(shè)我們使用上面的辦法管理一些全局的小狀態(tài),Provider 變的越來越多了,有時候會遇到嵌套地獄的情況:
const?StateProviders?=?({?children?})?=>?(
??<LogProvider>
????<UserProvider>
??????<MenuProvider>
????????<AppProvider>
??????????{children}
????????AppProvider>
??????MenuProvider>
????UserProvider>
??LogProvider>
)
function?App()?{
??return?(
????<StateProviders>
??????<Main?/>
????StateProviders>
??)
}
有沒有辦法解決呢?當(dāng)然有,我們參考 redux 中的 compose 方法,自己寫一個 composeProvider 方法:
function?composeProviders(...providers)?{
??return?({?children?})?=>
????providers.reduce(
??????(prev,?Provider)?=>?<Provider>{prev}Provider>,
??????children,
????)
}
代碼就可以簡化成這樣:
const?StateProviders?=?composeProviders(
??LogProvider,
??UserProvider,
??MenuProvider,
??AppProvider,
)
function?App()?{
??return?(
????<StateProvider>
??????<Main?/>
????Provider>
??)
}
總結(jié)
本篇文章主要圍繞這 Context 這個 API,講了幾個性能優(yōu)化和代碼組織的優(yōu)化點(diǎn),總結(jié)下來就是:
盡量提升渲染無關(guān)的子組件元素到「有狀態(tài)組件」的外部。 在需要的情況下對 Context 進(jìn)行讀寫分離。 包裝Context 的使用,注意錯誤處理。 組合多個 Context,優(yōu)化代碼。
推薦閱讀
