眾所周知,React的單向資料流模式導致狀態只能一級一級的由父元件傳遞到子元件,在大中型應用中較為繁瑣不好管理,通常我們需要使用Redux來幫助我們進行管理,然而隨著React 16.3的釋出,新context api成為了新的選擇。
一、Redux的簡介以及缺陷
Redux來源於Flux並借鑑了Elm的思想,主要原理如下圖所示:
可以看到,Redux的資料流其實非常簡單,外部事件通過actionCreator函式呼叫dipsatch釋出action到reducers中,然後各自的reducer根據action的型別(action.type
) 來按需更新整個應用的state。
redux設計有以下幾個要點:
- state是單例模式且不可變的,單例模式避免了不同store之間的資料交換的複雜性,而不可變資料提供了十分快捷的撤銷重做、“時光旅行”等功能。
- state只能通過reducer來更新,不可以直接修改。
- reducer必須是純函式,形如
(state,action) => newState
redux本身是個非常純粹的狀態管理庫,需要通過react-redux這個庫的幫助來管理react的狀態。react-redux主要包含兩個部分。
- Provider元件:可以將store注入到子元件的cotext中,所以一般放在應用的最頂層。
- connect函式: 返回一個高階函式,把context中由Provider注入的store取出來然後通過props傳遞到子元件中,這樣子元件就能順利獲取到store了。
雖然redux在React專案中得到了普遍的認可與使用率,然而在現實專案中redux還是存在著很多缺點:
1.樣板程式碼過多:增加一個action往往需要同時定義相應的actionType然後再寫N個相關的reducer。例如當新增一個非同步載入事件時,需要同時定義載入中、載入失敗以及載入完成三個actionType,需要一個相對應的reducer通過switch分支來處理對應的actionType,冗餘程式碼過多。
2.更新效率問題:由於使用不可變資料模式,每次更新state都需要拷貝一份完整的state造成了記憶體的浪費以及效能的損耗。
3.資料傳遞效率問題:由於react-redux採用的舊版context API,context的傳遞存在著效率問題。
其中,第一個問題目前已經存在著非常多的解決方案,諸如dva、rematch以及mirror等等,筆者也造過一個類似的輪子restated這裡不做過多闡述。
第二個問題首先redux以及react-redux中已經做了非常詳盡的優化了,其次擅用shouldComponentUpdate方法也可以避免很多不必要的更新,最後,也可以使用一些不可變資料結構如immutable
、Immr
等來從根本上解決拷貝開銷問題。
第三個問題屬於React自身API的侷限,從第三方庫的角度上來說,能做的很有限。
二、Context API
context API主要用來解決跨元件傳參氾濫的問題(prop drilling),舊的context API的語法形式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// 傳遞者,生成資料並放入context中 class DeliverComponent extends Component { getChildContext() { return { color: "purple" }; } render() { return <MidComponent /> } } DeliverComponent.childContextTypes = { color: PropTypes.string }; // 中間與context無關的元件 const MidComponent = (props) => <ReceiverComponent />; // 接收者,需要用到context中的資料 const ReceiverComponent = (props, context) => <div style={{ color: context.color }}> Hello, this is receiver. </div>; ReceiverComponent.contextTypes = { color: PropTypes.string }; ReactDOM.render( <DeliverComponent> <MidComponent> <ReceiverComponent /> </MidComponent> </DeliverComponent>, document.getElementById('root')); |
可以看到,使用context api可以把DeliverComponent
中的引數color
直接跨越MidComponent
傳遞到ReceiverComponent
中,不需要冗餘的使用props引數傳遞,特別是ReceiverComponent
層級特別深的時候,使用context api能夠很大程度上節省重複程式碼避免bug。
舊Context API的缺陷
舊的context api主要存在如下的缺陷:
1.程式碼冗餘:提供context的元件要定義childContextTypes
與getChildContext
才能把context傳下去。同時接收context的也要先定義contextTypes才能正確拿到資料。
2.傳遞效率:雖然功能上context可以跨層級傳遞,但是本質上context也是同props一樣一層一層的往下傳遞的,當層級過深的時候還是會出現效率問題。
3.shouldComponentUpdate:由於context的傳遞也是一層一層傳遞,因此它也會受到shouldComponent的阻斷。換句話說,當傳遞元件的context變化時,如果其下面某一箇中間元件的shouldComponentUpdate方法返回false,那麼之後的接收元件將不會受到任何context變化。
為了解決舊版本的shouldComponentUpdate問題,保證所有的元件都能收到store的變化,react-redux只能傳遞一個getState
方法給各個元件用於獲取最新的state(直接傳遞state可能會被阻斷,後面的元件將接收不到state的變化),然後每個connect元件都需要直接或間接監聽state的變化,當state發生改變時,通過內部notifyNestedSubs
方法從上往下依次觸發各個子元件通過getState
方法獲取最新的state更新檢視。這種方式效率較低而且比較hack。
三、新Context API
React自16.3開始提供了一個新的context api,徹底解決了舊Context API存在的種種問題。
下面是新context api(右)與使用舊context api的react-redux(左)資料流的比較:
可以看到,新的context api可以直接將context資料傳遞到傳遞到子元件中而不需要像舊context api那樣級聯傳遞。因此也可以突破shouldComponentUpdate的限制。新版的context api的定義如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
type Context<T> = { Provider: Provider<T>, Consumer: Consumer<T>, }; interface React { createContext<T>(defaultValue: T): Context<T>; } type Provider<T> = React.Component<{ value: T, children?: React.Node, }>; type Consumer<T> = React.Component<{ children: (value: T) => React.Node, }>; |
下面是一個比較簡單的應用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
import React, { Component, createContext } from 'react'; const DEFAULT_STATE = {color: 'red'}; const { Provider, Consumer } = createContext(DEFAULT_STATE); // 傳遞者,生成資料並放入context中 class DeliverComponent extends Component { state = { color: "purple" }; render() { return ( <Provider value={this.state}> <MidComponent /> </Provider> ) } } // 中間與context無關的元件 const MidComponent = (props) => <ReceiverComponent />; // 接收者,需要用到context中的資料 const ReceiverComponent = (props) => ( <Consumer> {context => ( <div style={{ color: context.color }}> Hello, this is receiver. </div> )} </Consumer> ); ReactDOM.render( <DeliverComponent> <MidComponent> <ReceiverComponent /> </MidComponent> </DeliverComponent>, document.getElementById('root')); |
可以看到新的context api主要包含一個Provider和Consumer對,在Provider輸入的資料可以在Consumer中獲得。 新context api的要點如下:
Provider
和Consumer
必須來自同一次React.createContext
呼叫。也就是說NameContext.Provider
和AgeContext.Consumer
是無法搭配使用的。React.createContext
方法接收一個預設值作為引數。當Consumer
外層沒有對應的Provider
時就會使用該預設值。Provider
元件的value
prop 值發生變更時,其內部元件樹中對應的Consumer
元件會接收到新值並重新執行children
函式。此過程不受 shouldComponentUpdete 方法的影響。Provider
元件利用Object.is
檢測value
prop 的值是否有更新。注意Object.is
和===
的行為不完全相同。具體細節請參考Object.is
的 MDN 文件頁。Consumer
元件接收一個函式作為children
prop 並利用該函式的返回值生成元件樹的模式被稱為 Render Props 模式。詳細介紹請參考相關 React 文件
四、新Context API的應用
新的Context API大大簡化了react狀態傳遞的問題,也出現了一些基於它的狀態管理庫,諸如:unstated、react-waterfall 等等。下面我們主要嘗試使用新context api來造一個react-redux的輪子。
1. Provider
由於新的context api傳遞過程中不會被shouldComponentUpdate阻斷,所以我們只需要在Provider裡面監聽store變化即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import React, { PureComponent, Children } from 'react'; import { IContext, IStore } from '../helpers/types'; import { Provider } from '../context'; interface IProviderProps { store: IStore; } export default class EnhancedProvider extends PureComponent<IProviderProps, IContext> { constructor(props: IProviderProps) { super(props); const { store } = props; if (store == null) { throw new Error(`Store should not omit in <Provider />`); } this.state = { // 得到當前的state state: store.getState(), dispatch: store.dispatch, } store.subscribe(() => { // 單純的store.getState函式是不變的,需要得到其結果state才能觸發元件更新。 this.setState({ state: store.getState() }); }) } render() { return <Provider value={this.state}>{Children.only(this.props.children)}</Provider>; } }; |
2. connect
相比較於react-redux,connect中的高階元件邏輯就簡單的多,不需要監聽store變化,直接獲得Provider傳入的state然後再傳遞給子元件即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import React, { Component, PureComponent } from 'react'; import { IState, Dispatch, IContext } from './helpers/types'; import { isFunction } from './helpers/common'; import { Consumer } from './context'; export default (mapStateToProps: (state: IState) => any, mapDispatchToProps: (dispatch: Dispatch) => any) => (WrappedComponent: React.ComponentClass) => class ConnectedComponent extends Component<any>{ render() { return <Consumer> {(context: IContext) => { const { dispatch, state } = context; const filterProps = {}; if (isFunction(mapStateToProps)) { Object.assign(filterProps, mapStateToProps(state)); } if (isFunction(mapDispatchToProps)) { Object.assign(filterProps, mapDispatchToProps(dispatch)); } return <WrappedComponent {...this.props} {...filterProps} /> }} </Consumer> } }; |
好了,至此整個React-redux的介面和功能都已經基本cover了,下面繼續介紹一些比較重要的效能優化。
3. 效能優化 – 減少重複渲染
效能優化最大的一部分就是要減少無意義的重複渲染,當WrappedComponent
的引數值沒有變化時我們應該阻止其重新渲染。可以通過手寫shouldComponentUpdate方法實現,也可以直接通過PureComponent元件來達到我們的目標:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// ... render() { return <Consumer> {(context: IContext) => { const { dispatch, state } = context; const filterProps = {}; if (isFunction(mapStateToProps)) { Object.assign(filterProps, mapStateToProps(state)); } if (isFunction(mapDispatchToProps)) { // mapDispatchToProps 返回值始終不變,可以memory this.dpMemory = this.dpMemory || mapDispatchToProps(dispatch); Object.assign(filterProps, this.dpMemory); } return <Prevent combinedProps={{ ...this.props, ...filterProps }} WrappedComponent={WrappedComponent} /> }} </Consumer> } //... // PureComponent內部自動實現了前後引數的淺比較 class Prevent extends PureComponent<any> { render() { const { combinedProps, WrappedComponent } = this.props; return <WrappedComponent {...combinedProps} />; } } |
這裡需要注意的是,本示例的mapDispatchToProps
未支援ownProps引數,因此可以把它的返回值看成是不變的,否則每次呼叫它返回的action函式都是新建立的,從而導致Prevent接收到的引數始終是不同的,達不到預期效果。更為複雜的情況請參考react-redux原始碼中selector相關的部分。
4. 效能優化 – 減少層級巢狀
效能優化另一個要點就是減少元件的層級巢狀,新context api在獲取context值的時候需要巢狀一層Consumer元件,這也是其比舊context api劣勢的地方。除此之外,我們應該儘量減少層級的巢狀。因此在前一個效能優化中我們不應該再次巢狀一個PureComponent,取而代之的是,我們可以直接在Cunsumer中實現一個memory機制,實現程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
// ... private shallowEqual(prev: any, next: any) { const nextKeys = Object.keys(next); const prevKeys = Object.keys(prev); if (nextKeys.length !== prevKeys.length) return false; for (const key of nextKeys) { if (next[key] !== prev[key]) { return false; } } return true; } render() { return <Consumer> {(context: IContext) => { const { dispatch, state } = context; const filterProps = {}; if (isFunction(mapStateToProps)) { Object.assign(filterProps, mapStateToProps(state)); } if (isFunction(mapDispatchToProps)) { // mapDispatchToProps 返回值始終不變 this.dpMemory = this.dpMemory || mapDispatchToProps(dispatch); Object.assign(filterProps, this.dpMemory); } const combinedProps = { ...this.props, ...filterProps }; if (this.prevProps && this.shallowEqual(this.prevProps, combinedProps)) { // 如果props一致,那麼直接返回快取之前的結果 return this.prevComponent; } else { this.prevProps = combinedProps; // 對當前的子節點進行快取 this.prevComponent = <WrappedComponent {...combinedProps} />; return this.prevComponent; } }} </Consumer> } |
下面是前後chrome開發人員工具中元件層級的對比,可以看到巢狀層級成功減少了一層,兩層巢狀是新context api的侷限,如果要保持react-redux的介面模式則無法再精簡了。
五、That’s all
本文的程式碼以及demo可以訪問我的repo檢視: react-restated