不一樣的 React context

不一樣的 React context

來自專欄前端新能源42 人贊了文章

context 為 React 中提供了跨層級組件傳遞數據的能力,避免在每個層級的組件中顯式傳遞 props。在 React16 以前,儘管被 React-Redux、React-Router 等第三方庫廣泛使用,但由於潛在的諸多問題,context 一直被官方標註為實驗性 API,不建議使用,而 React v16.3,推出了 New Context API,期望去解決之前 context 所存在的問題。

在此之前,社區對於 Legacy Context 的問題以及 New Context 的設計有過一定的闡述與討論,本文便不再老生常談,大家可以通過閱讀 精讀《如何安全地使用 React context》、從新的 Context API 看 React 應用設計模式 兩篇文章對 Legacy Context 與 New Context 做一個簡單回顧。而本文將著重聚焦於 context 在 React 內部的具體實現,期待透過 context 的本質能讓大家對 React 有更深入的理解。

揭秘 Legacy Context

class Child extends React.Component { render() { // 輸出 context1 console.log(this.context.value1); // 輸出 undefined console.log(this.context.value2); ... }}Child.contextTypes = { value1: PropTypes.string};class Parent extends React.Component { render() { return <Child /> }}class Ancestor extends React.Component { getChildContext() { return { value1: "context1", value2: "context2" }; } render() { return <Parent />; }}Ancestor.childContextTypes = { value1: PropTypes.string, value2: PropTypes.string};

首先我們需要先明確一下 Legacy Context 是如何去定義與獲取的,首先 Ancestor Component 需定義 childContextTypes,並利用 getChildContext 給全局的 context 賦值,Child Component 需定義 contextTypes 方可以獲取到所定義的 context。而 context 的訪問途徑有以下幾種:

  • this.context
  • constructor(props, context)
  • componentWillReceiveProps(nextProps, nextContext)
  • shouldComponentUpdate(nextProps, nextState, nextContext)
  • componentWillUpdate(nextProps, nextState, nextContext)

在解讀 Legacy Context 具體的實現方式之前,我們需要先了解一些全局的定義,在 react-reconciler/src/ReactFiberContext.js 中有兩個堆棧指針,contextStackCursor 指向當前的 context 值,didPerformWorkStackCursor 指向當前 context 是否發生變化,後續會用於判斷是否需要 rerender,以及 previousContext 存儲上一次也就是父級的 context,那麼為何需要 previousContext?下文會為你揭曉答案。

Ancestor Component,主要負責全局 context 的整合,將自身提供的 childContext 與 previousContext 合併,塞入全局的 contextStackCursor 中供自己的 Child Component 使用。這裡需要注意的是,Component 的 this.context 是不能獲取自己定義的 childContext 的,因此只能使用 previousContext。

// From React v16.3.2 release react-reconciler/src/ReactFiberContext.jsfunction getUnmaskedContext(workInProgress: Fiber): Object { const hasOwnContext = isContextProvider(workInProgress); if (hasOwnContext) { // If the fiber is a context provider itself, when we read its context // we have already pushed its own child context on the stack. A context // provider should not "see" its own child context. Therefore we read the // previous (parent) context instead for a context provider. return previousContext; } return contextStackCursor.current;}

Child Component 會先獲取全局 context,稱之為 unmaskedContext,根據 contextTypes 的定義,從 unmaskedContext 割取為 maskedContext,生命周期函數中獲取的 context 均由此而來。

在更新過程中,實例上依然會保留原有的 context,新的 context 則會從更新後的 unmaskedContext 中重新獲取。如果 Parent Component 間發生 shouldComponentUpdate 為 false,那麼 Child Component 將不會觸發 updateClassInstance 這個過程,不會去重新獲取 context,也不會因為 context 的變化而 rerender,因此 context 的更新就中斷了。

揭秘 New Context

從 Legacy Context 與 New Context 儘管是 context 的一次重大重構,但從底層的實現上只有可以忽略的交集,因此他們能完全共存在 React v16.3 中,在使用上也基本不衝突。如果將兩種 context 混用,Legacy Context 的更新可能會造成 New Context 所涉及的組件在未更新的情況下使發生 rerender。

const Context = React.createContext();class Child extends React.Component { render() { return ( <Context.Consumer> {({ value1, value2 }) => { // 輸出 context1 console.log(value1); // 輸出 context2 console.log(value2); ... }} </Context.Consumer> ); }}class Parent extends React.Component { render() { return <Child /> }}class Ancestor extends React.Component { render() { return ( <Context.Provider value={{ value1: "context1", value2: "context2", }}> <Parent /> </Context.Provider> ); }}

首先通過 createContext,會生成 Provider 與 Consumer,他們是 fiber 中具有特殊類型($$typeof)的節點,同時是內存共享的,這對後續 Consumer 接收 Provider 的值,以及實現跨層級更新都非常重要。

// From React v16.3.2 release react/src/ReactContext.jsconst context: ReactContext<T> = { $$typeof: REACT_CONTEXT_TYPE, _calculateChangedBits: calculateChangedBits, _defaultValue: defaultValue, _currentValue: defaultValue, _changedBits: 0, // These are circular Provider: (null: any), Consumer: (null: any),};context.Provider = { $$typeof: REACT_PROVIDER_TYPE, _context: context,};context.Consumer = context;

別被上圖所嚇到,New Context 的傳遞原理其實是比較簡單的,可以只用 mount 部分來理解,Provider 所對應的 fiber 節點在創建時,會把所接收到的 value 保存到 context._currentValue,當 Consumer 對應的 fiber 節點在創建時,是可以直接獲取到 context._currentValue 來使用的。

那麼你可能會問,假如 Provider 與 Consumer 之間,比如示例代碼中 Parent Component 使用了 shouldComponentUpdate,並且返回了 false,那麼當 Provider 的 value 發生改變後,Consumer 還能接受到更新後的 value 么?答案當然是肯定的,而奧秘就在 propagateContextChange 函數之中。

在 propagateContextChange 中,以當前 fiber 節點為根的子樹中尋找相匹配 Consumer 節點,一旦找到後,會不斷向父節點回溯,回溯到沒有 expirationTime 的節點給予 expirationTime,expirationTime 是 fiber 架構中的時間分片概念,如果 fiber 節點沒有 expirationTime 就不會被 update,關於 expirationTime 及相應的時間分片我們將會在以後的文章再去詳細講解。因此,雖然 shouldComponentUpdate 造成了 Child Component 無法獲得 expirationTime,但 Provider 的 propagateContextChange 能使 Child 組件重新獲得 expirationTime,從而能夠被 rerender。

// From React v16.3.2 release react-reconciler/src/ReactFiberBeginWork.jsfunction propagateContextChange() { ... // Dont scan deeper than a matching consumer. When we render the // consumer, well continue scanning from that point. This way the // scanning work is time-sliced. nextFiber = null; ...}

需要注意的是,對於匹配到的 Consumer 節點,將不再遍歷它的子孫節點,而是向上回溯遍歷它自身或祖先的兄弟節點,Consumer 的子孫節點將會在 Consumer update 時被遍歷,繼續向下傳播 context 變更,這是為了時間分片而考慮。

const Context = React.createContext();class Parent extends React.Component { shouldComponentUpdate() { return false; } render() { console.log(Parent render); return <Child />; }}class Child extends React.Component { render() { console.log(Child render); return ( <Context.Consumer> {({ index, changeIndex }) => { console.log(Child Consumer render); return <button onClick={changeIndex}>{index} +</button> }} </Context.Consumer> ); }}class Ancestor extends React.Component { constructor(props) { super(props); this.state = { index: 1 }; this.changeIndex = this.changeIndex.bind(this); } changeIndex() { this.setState(preState => { return { index: preState.index + 1 }; }); } render() { console.log(Ancestor render); return ( <Context.Provider value={{ index: this.state.index, changeIndex: this.changeIndex }} > <Parent /> </Context.Provider> ); }}// mount 結果// Ancestor render// Parent render// Child render// Child Consumer render// update 結果// Ancestor render// Child Consumer render

讀者可自行思考一下以上代碼 mount 與 update 時的結果,Provider 更新時 propagateContextChange 尋找到相匹配的 Consumer,Consumer 到 Provider 上的 fiber 節點均會被給予時間分片,但由於 Parent 存在 shouldComponentUpdate 為 false,會導致自身與 Child 均無法被 rerender,但 Consumer 仍保留有時間分片,因此能夠被更新。

changedBits/observedBits 粒度控制

記得 New Context 剛橫空出世時,社區曾一度掀起廣泛討論,是否可將 Redux 取而代之?同時出現了下面的這種寫法,期望把 New Context 的 value 作為 Redux 的 state 與 action,來完成全局共享。

const Context = React.createContext();<Context.Provider value={{ value, change: () => { this.setState((preState) => { return { value: preState.value + 1, }; }); }}}> <Context.Consumer> {({ value, change }) => { return <button onClick={change}>增加 {value}</button> }} </Context.Consumer></Context.Provider>

如果你很仔細看過上一節 Provider 的更新過程,你會發現新舊 value 只是進行了簡單的字元串比較和引用比較,那麼問題就來了,value 作為對象傳入,newValue 與 oldValue 必定為兩個不相等的對象,必然會觸發 propagateContextChange,由此類推,即使存在 shouldComponentUpdate,仍會觸發 Provider 所有匹配 Consumer 的 rerender,很可能根本沒有必要。

其實 New Context 已經內置讓開發者手動控制更新粒度的方式,React.createContext 的第一個參數是 defaultValue,它還擁有第二個參數 calculateChangedBits,它是一個接受 newValue 與 oldValue 的函數,返回值作為 changedBits,在 Provider 中,當 changedBits = 0,將不再觸發更新。而在 Consumer 中有一個不穩定的 props,unstable_observedBits,若 Provider 的changedBits & observedBits = 0,也將不觸發更新。下面給出一段 New Context 的測試代碼,幫助大家來理解:

// From React v16.3.2 release react-reconciler/src/__tests__/ReactNewContext-test.internal.jsit(can skip consumers with bitmask, () => { const Context = React.createContext({foo: 0, bar: 0}, (a, b) => { let result = 0; if (a.foo !== b.foo) { result |= 0b01; } if (a.bar !== b.bar) { result |= 0b10; } return result; }); function Provider(props) { return ( <Context.Provider value={{foo: props.foo, bar: props.bar}}> {props.children} </Context.Provider> ); } function Foo() { return ( <Context.Consumer unstable_observedBits={0b01}> {value => { ReactNoop.yield(Foo); return <span prop={Foo: + value.foo} />; }} </Context.Consumer> ); } function Bar() { return ( <Context.Consumer unstable_observedBits={0b10}> {value => { ReactNoop.yield(Bar); return <span prop={Bar: + value.bar} />; }} </Context.Consumer> ); } class Indirection extends React.Component { shouldComponentUpdate() { return false; } render() { return this.props.children; } } function App(props) { return ( <Provider foo={props.foo} bar={props.bar}> <Indirection> <Indirection> <Foo /> </Indirection> <Indirection> <Bar /> </Indirection> </Indirection> </Provider> ); } // 表示首次渲染 ReactNoop.render(<App foo={1} bar={1} />); // ReactNoop.yield(Foo); 與 ReactNoop.yield(Bar); 均執行了 expect(ReactNoop.flush()).toEqual([Foo, Bar]); // 實際渲染結果校驗 expect(ReactNoop.getChildren()).toEqual([span(Foo: 1), span(Bar: 1)]); // 更新 foo 的值為 2 // 此時 a.foo !== b.foo,changedBits = 0b01,Provider 發生更新 ReactNoop.render(<App foo={2} bar={1} />); // Foo 的 Consumer changedBits(0b01) & observedBits(0b01) != 0,發生更新 // Bar 的 Consumer changedBits(0b01) & observedBits(0b10) != 0,不發生更新 // 只執行了 ReactNoop.yield(Foo); expect(ReactNoop.flush()).toEqual([Foo]); expect(ReactNoop.getChildren()).toEqual([span(Foo: 2), span(Bar: 1)]); // 同理 ReactNoop.render(<App foo={3} bar={3} />); expect(ReactNoop.flush()).toEqual([Foo, Bar]); expect(ReactNoop.getChildren()).toEqual([span(Foo: 3), span(Bar: 3)]);});

初窺 readContext

實際上 New Context 也並不是那樣完美,我們只能通過 Consumer 去獲取到 Provider 的 value,這樣是否會過於局限呢?在這個 Experimental API for reading context from within any render phase function pull request 中,New Context 添加了一個新實驗性 API,Context.unstable_read,可以在 Provider 下屬任意 Component 中讀取 context value,Consumer 獲取 Provider 的 value 也依賴於它。

// From React master react/src/ReactContext.jsexport function readContext<T>( context: ReactContext<T>, observedBits: void | number | boolean,): T {}...context.unstable_read = readContext.bind(null, context);...

unstable_read 將與所對應的 context 完成綁定,取值的過程非常簡單,直接返回 context._currentValue即可。而難點在於 unstable_read 後的 observable,因此在 fiber 節點上又引入了 ContextDependency 的概念,這又是什麼呢?

fiber 節點上的 contextDependencyList 會記錄當前節點依賴有哪些 context,當 Provider 發生更新時,它所對應的 context 會在 contextDependencyList 查找是否存在,如果存在,說明有依賴,需要類似於 Consumer 給予時間分片並更新。同理,prepareToReadContext 主要是為了能夠繼續向下傳播 context 變更。

由此 React context 的相關內容就全部介紹完成,New Context 儘管在業務研發中並無太多用武之地,並且還在不斷更新和完善,但對於一些狀態管理框架卻意義深遠,React-Redux 已經基於 New Context 開展了重寫的工作,讓我們對 New Context 的未來有了更多的期待,有興趣的讀者可自行了解一下,React 16 experiment #2: rewrite React-Redux to use new context。我們正在深入研究 React16,歡迎社區小夥伴和我們一起前行,想加入我們的話,歡迎私聊或投遞簡歷 dancang.hj@alibaba-inc.com。


推薦閱讀:

Faster-RCNN四步交替法源碼閱讀筆記
ROS導航包源碼學習6 --- recovery
TiDB 源碼閱讀系列文章(四)Insert 語句概覽
Spark源碼剖析(三):Executor啟動流程

TAG:React | 源碼閱讀 |