首頁 > 軟體

react資料管理機制React.Context原始碼解析

2022-11-25 14:00:11

開篇

在 React 中提供了一種「資料管理」機制:React.context,大家可能對它比較陌生,日常開發直接使用它的場景也並不多。

但提起 react-redux 通過 Providerstore 中的全域性狀態在頂層元件向下傳遞,大家都不陌生,它就是基於 React 所提供的 context 特性實現。

本文,將從概念、使用,再到原理分析,來理解 Context 在多級元件之間進行資料傳遞的機制。

一、概念

Context 提供了一個無需為每層元件手動新增 props,就能在元件樹間進行資料傳遞的方法。

通常,資料是通過 props 屬性自上而下(由父到子)進行傳遞,但這種做法對於某些型別的屬性而言是極其繁瑣的(例如:地區偏好,UI 主題),這些屬性是應用程式中許多元件都需要的。

Context 提供了一種在元件之間共用此類值的方式,而不必顯式地通過元件樹的逐層傳遞 props。

設計目的是為了共用那些對於一個元件樹而言是“全域性”的資料,例如當前認證的使用者、主題或首選語言。

二、使用

下面我們以 Hooks 函陣列件為例,展開介紹 Context 的使用。

2.1、React.createContext

首先,我們需要建立一個 React Context 物件。

const Context = React.createContext(defaultValue);

當 React 渲染一個訂閱了這個 Context 物件的元件,這個元件會從元件樹中的 Context.Provider 中讀取到當前的 context.value 值。

當元件所處的樹中沒有匹配到 Provider 時,其 defaultValue 引數才會生效。

2.2、Context.Provider

每個 Context 物件都會返回一個 Provider React 元件,它接收一個 value 屬性,可將資料向下傳遞給消費元件。當 Provider 的 value 值發生變化時,它內部的所有消費元件都會重新渲染。

注意,當 value 傳遞為一個複雜物件時,若想要更新,必須賦予 value 一個新的物件參照地址,直接修改物件屬性不會觸發消費元件的重渲染。

<Context.Provider value={/* 某個值,一般會傳遞物件 */}>

2.3、React.useContext

Context Provider 元件提供了向下傳遞的 value 資料,對於函陣列件,可通過 useContext API 拿到 Context value

const value = useContext(Context);

useContext 接收一個 context 物件(React.createContext 的返回值),返回該 context 的當前值。

當元件上層最近的 <Context.Provider> 更新時,當前元件會觸發重渲染,並讀取最新傳遞給 Context Provider 的 context value 值。

題外話:React.memo 只會針對 props 做優化,如果元件中 useContext 依賴的 context value 發生變化,元件依舊會進行重渲染。

2.4、Example

我們通過一個簡單範例來熟悉上述 Context 的使用。

const Context = React.createContext(null);
const Child = () => {
  const value = React.useContext(Context);
  return (
    <div>theme: {value.theme}</div>
  )
}
const App = () => {
  const [count, setCount] = React.useState(0);
  return (
    <Context.Provider value={{ theme: 'light' }}>
      <div onClick={() => setCount(count + 1)}>觸發更新</div>
      <Child />
    </Context.Provider>
  )
}
ReactDOM.render(<App />, document.getElementById('root'));

範例中,在 App 元件內使用 Providervalue 值向子樹傳遞,Child 元件通過 useContext 讀取 value,從而成為 Consumer 消費元件。

三、原理分析

從上面「使用」我們瞭解到:Context 的實現由三部分組成:

  • 建立 Context:React.createContext() 方法;
  • Provider 元件:<Context.Provider value={value}>
  • 消費 value:React.useContext(Context) 方法。

原理分析脫離不了原始碼,下面我們挑選出核心程式碼來看看它們的實現。

3.1、createContext 函數實現

createContext 原始碼定義在 react/src/ReactContext.js 位置。它返回一個 context 物件,提供了 ProviderConsumer 兩個元件屬性,_currentValue 會儲存 context.value 值。

const REACT_PROVIDER_TYPE = Symbol.for('react.provider');
const REACT_CONTEXT_TYPE = Symbol.for('react.context');
export function createContext<T>(defaultValue: T): ReactContext<T> {
  const context: ReactContext<T> = {
    $$typeof: REACT_CONTEXT_TYPE,
    _calculateChangedBits: calculateChangedBits,
    // 並行渲染器方案,分為主渲染器和輔助渲染器
    _currentValue: defaultValue,
    _currentValue2: defaultValue,
    _threadCount: 0, // 跟蹤此上下文當前有多少個並行渲染器
    Provider: (null: any),
    Consumer: (null: any),
  };
  context.Provider = {
    $$typeof: REACT_PROVIDER_TYPE,
    _context: context,
  };
  context.Consumer = context;
  return context;
}

儘管在這裡我們只看到要返回一個物件,卻看不出別的名堂,只需記住它返回的物件結構資訊即可,我們接著往下看

3.2、 JSX 編譯

我們所編寫的 JSX 語法在進入 render 時會被 babel 編譯成 ReactElement 物件。我們可以在 babel repl 線上平臺 轉換檢視。

JSX 語法最終會被轉換成 React.createElement 方法,我們在 example 環境下執行方法,返回的結果是一個 ReactElement 元素物件。

物件的 props 儲存了 context 要向下傳遞的 value,而物件的 type 則儲存的是 context.Provider

context.Provider = {
  $$typeof: REACT_PROVIDER_TYPE,
  _context: context,
};

有了物件描述結構,接下來進入渲染流程並在 Reconciler/beginWork 階段為其建立 Fiber 節點。

3.3、消費元件 - useContext 函數實現

在介紹 Provider Fiber 節點處理前,我們需要先了解下 Consumer 消費元件如何使用 context value,以便於更好理解 Provider 的實現。

useContext 接收 context 物件作為引數,從 context._currentValue 中讀取 value 值。

不過,除了讀取 value 值外,還會將 context 資訊儲存在當前元件 Fiber.dependencies 上。

目的是為了在 Provider value 發生更新時,可以查詢到消費元件並標記上更新,執行元件的重渲染邏輯。

function useContext(Context) {
  // 將 context 記錄在當前 Fiber.dependencies 節點上,在 Provider 檢測到 value 更新後,會查詢消費元件標記更新。
  const contextItem = {
    context: context,
    next: null, // 一個元件可能註冊多個不同的 context
  };
  if (lastContextDependency === null) {
    lastContextDependency = contextItem;
    currentlyRenderingFiber.dependencies = {
      lanes: NoLanes,
      firstContext: contextItem,
      responders: null
    };
  } else {
    // Append a new context item.
    lastContextDependency = lastContextDependency.next = contextItem;
  }
  return context._currentValue;
}

3.4、Context.Provider 在 Fiber 架構下的實現機制

經過上面 useContext 消費元件的分析,我們需要思考兩點:

  • <Provider> 元件上的 value 值何時更新到 context._currentValue
  • Provider.value 值發生更新後,如果能夠讓消費元件進行重渲染 ?

這兩點都會在這裡找到答案。

在 example 中,點選「觸發更新」div 後,React 會進入排程更新階段。我們通過斷點定位到 Context.Provider Fiber 節點的 Reconciler/beginWork 之中。

Provider Fiber 型別為 ContextProvider,因此進入 tag switch case 中的 updateContextProvider

function beginWork(current, workInProgress, renderLanes) {
  ...
  switch (workInProgress.tag) {
    case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);
  }
}

首先,更新 context._currentValue,比較新老 value 是否發生變化。

注意,這裡使用的是 Object.is,通常我們傳遞的 value 都是一個複雜物件型別,它將比較兩個物件的參照地址是否相同。

若參照地址未發生變化,則會進入 bailout 複用當前 Fiber 節點。

在 bailout 中,會檢查該 Fiber 的所有子孫 Fiber 是否存在 lane 更新。若所有子孫 Fiber 本次都沒有更新需要執行,則 bailout 會直接返回 null,整棵子樹都被跳過更新。

function updateContextProvider(current, workInProgress, renderLanes) {
  var providerType = workInProgress.type;
  var context = providerType._context;
  var newProps = workInProgress.pendingProps;
  var oldProps = workInProgress.memoizedProps;
  var newValue = newProps.value;
  var oldValue = oldProps.value;
  // 1、更新 value prop 到 context 中
  context._currentValue = nextValue;
  // 2、比較前後 value 是否有變化,這裡使用 Object.is 進行比較(對於物件,僅比較參照地址是否相同)
  if (objectIs(oldValue, newValue)) {
    // children 也相同,進入 bailout,結束子樹的協調
    if (oldProps.children === newProps.children && !hasContextChanged()) {
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  } else {
    // 3、context value 發生變化,深度優先遍歷查詢 consumer 消費元件,標記更新
    propagateContextChange(workInProgress, context, changedBits, renderLanes);
  }
  // ... reconciler children
}

context.value 發生變化,呼叫 propagateContextChange 對 Fiber 子樹向下深度優先遍歷,目的是為了查詢 Context 消費元件,併為其標記 lane 更新,即讓其後續進入 Reconciler/beginWork 階段後不滿足 bailout 條件 !includesSomeLane(renderLanes, updateLanes)

function propagateContextChange(workInProgress, context, changedBits, renderLanes) {
  var fiber = workInProgress.child;
  while (fiber !== null) {
    var nextFiber;
    var list = fiber.dependencies; // 若 fiber 屬於一個 Consumer 元件,dependencies 上記錄了 context 物件
    if (list !== null) {
      var dependency = list.firstContext; // 拿出第一個 context
      while (dependency !== null) {
        // Check if the context matches.
        if (dependency.context === context) {
          if (fiber.tag === ClassComponent) {
            var update = createUpdate(NoTimestamp, pickArbitraryLane(renderLanes));
            update.tag = ForceUpdate;
            enqueueUpdate(fiber, update);
          }
          // 標記元件存在更新,!includesSomeLane(renderLanes, updateLanes) 
          fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
          // 在上層 Fiber 樹的節點上標記 childLanes 存在更新
          scheduleWorkOnParentPath(fiber.return, renderLanes);
          ...
          break
        }
      }
    }
  }
}

3.5、總結

通常,一個元件的更新可通過執行內部 setState 來生成,其方式也是標記 Fiber.lane 讓元件不進入 bailout;

對於 Context,當 Provider.value 發生更新後,它會查詢子樹找到消費元件,為消費元件的 Fiber 節點標記 lane。

當元件(函陣列件)進入 Reconciler/beginWork 階段進行處理時,不滿足 bailout,就會重新被呼叫進行重渲染,這時執行 useContext,就會拿到最新的 context.__currentValue

這就是 React.context 實現過程。

四、注意事項

React 效能一大關鍵在於,減少不必要的 render。Context 會通過 Object.is(),即 === 來比較前後 value 是否嚴格相等。這裡可能會有一些陷阱:當註冊 Provider 的父元件進行重渲染時,會導致消費元件觸發意外渲染。

如下例子,當每一次 Provider 重渲染時,以下的程式碼會重渲染所有消費元件,因為 value 屬性總是被賦值為新的物件:

class App extends React.Component {
  render() {
    return (
      <MyContext.Provider value={{something: 'something'}}>
        <Toolbar />
      </MyContext.Provider>
    );
  }
}

為了防止這種情況,可以將 value 狀態提升到父節點的 state 裡:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: { something: 'something' },
    };
  }
  render() {
    return (
      <Provider value={this.state.value}>
        <Toolbar />
      </Provider>
    );
  }
}

五、對比 useSelector

從「注意事項」可以考慮:要想使消費元件進行重渲染,context value 必須返回一個全新物件,這將導致所有消費元件都進行重渲染,這個開銷是非常大的,因為有一些元件所依賴的值可能並未發生變化。

當然有一種直觀做法是將「狀態」分離在不同 Context 之中。

react-redux useSelector 則是採用訂閱 redux store.state 更新,去通知消費元件「按需」進行重渲染(比較所依賴的 state 前後是否發生變化)。

  • 提供給 Context.Provider 的 value 物件地址不會發生變化,這使得子元件中使用了 useSelector -> useContext,但不會因頂層資料而進行重渲染。
  • store.state 資料變化元件如何更新呢?react-redux 訂閱了 redux store.state 發生更新的動作,然後通知元件「按需」執行重渲染。

以上就是react資料管理機制React.Context原始碼解析的詳細內容,更多關於React.Context 資料管理的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com