避免React Context導致的重複渲染
React推出新的Context API時,很多人都高呼:"終於可以丟掉Redux了!"
But,事實是,新Context API出來也大半年了,依然不見它完全淘汰Redux。我個人也傾向於能用React解決的事情就不勞煩Redux這樣的第三方工具,但是,不得不承認,有些事情不要想得太天真,Redux說到底就是專門為狀態管理而誕生的,再差也能管理好狀態;React Context說到底只是元件,讓它去管理狀態,不得不多費一些心。
因為要多費一些心,所以還是有門檻的,如果你不費心,很容易就把事情做錯。
今天說的,就是Context沒用好,可能毀你應用的效能。
Redux有action和reducer,用這兩樣東西來更新狀態的確很囉嗦,現在,假設我們用Context來代替Redux,一個很大的動因就是不想太囉嗦,我們不想寫reducer和action,那該怎麼做呢?
第一感覺,是直接在Consumer中拿到value之後去修改其中的值,就像下面這樣,可是,這樣不行。
<SomeContext.Consumer> { value => ( <> <h1>value.foo</h1> {/* 下面這樣寫不行!!! */} <button onClick={() => {value.foo = 'bar'}}></button> </> ) } </SomeContext.Consumer>
上面這樣做,雖然真的能夠改變value上一個屬性的值,但是沒有改變value本身,簡單說就是React並不知道Context的value被修改了,所以React也不會通知其他Consumer這個變化,也不會引起任何重新渲染,但是,我們改value就是要引起重新渲染,可見這招不行。
你要是在上面的程式碼中直接去改value,像下面這樣,更沒有任何作用,因為你只不過改了一個函式引數值而已,甚至都沒有改變Context中的value。
<SomeContext.Consumer> { value => ( <> <h1>value.foo</h1> {/* 下面這樣連Context的value都沒有修改!!! */} <button onClick={() => {value = {foo: 'bar'}}></button> </> ) } </SomeContext.Consumer>
所以,還是需要其他辦法。
一個慣常的做法,是讓Context的value包含函式型別的屬性,讓Consumer可以通過呼叫這個函式來修改元件的state,從而引發重新渲染。
用一個簡單的切換主題(Theme)的例子來說明問題,程式碼如下。
const redTheme = { color: 'red', }; const greenTheme = { color: 'green', } class App extends React.Component { state = { theme: redTheme, } switchTheme = (theme) => { this.setState({theme}); } render() { console.log('render App'); return ( <Context.Provider value={{theme: this.state.theme, switchTheme: this.switchTheme}}> <div className="App"> <Header/> <Content /> </div> </Context.Provider> ); } }
上面的App元件是應用的最頂層元件,渲染出一個Context的Provider,Provider的子元件是應用中其他元件,這非常合理。
App元件的state儲存當前主題,給Provider的value裡,除了包含當前主題,還給一個函式switchTheme,給Consumer一個機會來切換主題,一個Consumer的例子就是Content元件,程式碼如下(這裡我也趕時髦用Hooks的useContext實現)。
function Content() { const {theme, switchTheme} = useContext(Context); return ( <> <h1 style={theme}>Hello world</h1> <button onClick={() => switchTheme(redTheme)}>Red Theme</button> <button onClick={() => switchTheme(greenTheme)}>Green Theme</button> </> ); }
介面是這樣,點選"Red Theme"或者"Green Theme"就可以切換Hello World的顏色。

我們來梳理一遍這個過程。
Context.Provider Context.Provider
步驟有一點多,但是的確就是這麼一個過程,一切都工作正常。
真的正常嗎?
功能雖然正常,但是有一件事不是我們想要的,就是當 Context.Provider
重新渲染的時候,它所有的子元件都被重新渲染了,比如上面例子中子元件有Header和Content,Content作為Consumer之一重畫沒問題,但是Header不是Consumer,也不依賴於Context的value,根本沒有必要重畫啊!
大家可以在這裡試驗一下 ofollow,noindex">context-rerender-demo - CodeSandbox ,在console裡可以看到,每一次切換主題,Header的render都會被呼叫。
在上面的例子中,如果除了Header還有其他比較重的元件,而且這些元件沒有用shouldComponentUpdate守住,那麼每一次Context的改變,都會引發整個應用元件樹的重畫,代價就有一點大了。
這就是我說的,如果不費心,就容易把事情做錯。
其實, Context.Provider
說到底還是元件,也按照元件基本法來辦事,當value發生變化時,它也可以不引發子元件的渲染,前提是,子元件作為一個屬性(this.props.children)也要保持不變才行,如果子元件變了, Context.Provider
也不知道你是不是以前的你,只好讓你重畫了。
表面上看,下面的JSX中 Context.Provider
的子元件任何一次渲染都是一樣的。
<Context.Provider value={{theme: this.state.theme, switchTheme: this.switchTheme}}> <div className="App"> <Header/> <Content /> </div> </Context.Provider>
其實並不是這樣,JSX會被轉譯成React.CreateElement,所以上面的JSX執行時是類似這樣。
React.createElement(Context.Provider, {value: ...}, React.createElement('div', {className: ...}, React.createElement(Header), React.createElement(Content), ) )
你看,每一次渲染都呼叫React.createElement,所以每一次渲染產生的子元件都是不一樣的啊。
所以,我們需要一個方法“說服” Context.Provider
,告訴他你的子元件沒有變化,方法也很簡單,就是建一個獨立的元件來管理state和Provider,把子元件的JSX寫在這個元件之外。
我們改進上面的程式碼,製造一個ThemeProvider,程式碼如下。
class ThemeProvider extends React.Component { state = { theme: redTheme, } switchTheme = (theme) => { this.setState({theme}); } render() { console.log('render ThemeProvider'); return ( <Context.Provider value={{theme: this.state.theme, switchTheme: this.switchTheme}}> {this.props.children} </Context.Provider> ); } } function App () { console.log('render App'); return ( <ThemeProvider> <div className="App"> <Header /> <Content /> </div> </ThemeProvider> ) }
現在App成了一個無狀態元件,只渲染一次,因為state改為ThemeProvider來管理,每次當ThemeProvider的state被switchTheme改變而重新渲染的時候,它看到的子元件(this.props.children)是App傳給他的,不需要重新用React.createElement穿件,所以this.props.children是不變的,於是 Context.Provider
也就不會讓this.props.children重新渲染了。
改進的程式碼在這裡 context-avoid-rerender-demo - CodeSandbox ,大家可以試試 ,現在切換Theme不會引發Header的重新渲染了。
總結一下,就是Context雖然是一個好東西,但是不要指望無腦使用就能用它替換掉Redux,你要是亂用的話,可能給自己帶來更大麻煩。
(P.S. 預告一下,我近期會出一個關於React設計模式的電子讀物,這次嘗試一下非傳統出版方式,希望大家能夠喜歡,敬請期待)