如何優雅的使用 React Context

在開始今天的文章之前,大家不妨先想一下觸發 React 組件 re-render 的原因有哪些,或者說什麼時候 React 組件會發生 re-render

先說結論:

這裏有個誤解:props 變化也會導致 re-render
其實不會的,props 的變化往上追溯是因爲父組件的 state 變化導致父組件 re-render,從而引起了子組件的 re-render,與 props 是否變化無關的。只有那些使用了 React.memouseMemo 的組件,props 的變化纔會觸發組件的 re-render

針對上述造成 re-render 的原因,又該通過怎樣的策略優化呢?感興趣的朋友可以看這篇文章:React re-renders guide: everything, all at once。

接下來開始我們今天的主題:如何優雅的使用 React Context。上面我們提到了 Context 的變化也會觸發組件的 re-render,那 React Context 又是怎麼工作呢?先簡單介紹一下 Context 的工作原理。

Context 的工作原理

ContextReact 提供的一種直接訪問祖先節點上的狀態的方法,從而避免了多級組件層層傳遞 props 的頻繁操作。

創建 Context

通過 React.createContext 創建 Context 對象

export function createContext(
  defaultValue
) {
  const context = {
    $$typeof: REACT_CONTEXT_TYPE,
    _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;
}

React.createContext 的核心邏輯:

  1. 將初始值存儲在 context._currentValue

  2. 創建 Context.ProviderContext.Consumer 對應的 ReactElement 對象

fiber 樹渲染時,通過不同的 workInProgress.tag 處理 Context.ProviderContext.Consumer 類型的節點。

主要看下針對 Context.Provider 的處理邏輯:

function updateContextProvider(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  const providerType = workInProgress.type;
  const context = providerType._context;
  
  const newProps = workInProgress.pendingProps;
  const oldProps = workInProgress.memoizedProps;
  
  const newValue = newProps.value;

  pushProvider(workInProgress, context, newValue);

  if (oldProps !== null) {
    // 更新 context 的核心邏輯
  }

  const newChildren = newProps.children;
  reconcileChildren(current, workInProgress, newChildren, renderLanes);
  return workInProgress.child;
}

消費 Context

React 中提供了 3 種消費 Context 的方式

  1. 直接使用 Context.Consumer 組件(也就是上面 createContext 時創建的 Consumer

  2. 類組件中,可以通過靜態屬性 contextType 消費 Context

  3. 函數組件中,可以通過 useContext 消費 Context

這三種方式內部都會調用 prepareToReadContextreadContext 處理 ContextprepareToReadContext 中主要是重置全局變量爲readContext 做準備。

接下來主要看下readContext

export function readContext<T>(
  context: ReactContext<T>,
  observedBits: void | number | boolean,
): T {
  const contextItem = {
    context: ((context: any): ReactContext<mixed>),
    observedBits: resolvedObservedBits,
    next: null,
  };

  if (lastContextDependency === null) {
    lastContextDependency = contextItem;
    currentlyRenderingFiber.dependencies = {
      lanes: NoLanes,
      firstContext: contextItem,
      responders: null,
    };
  } else {
    lastContextDependency = lastContextDependency.next = contextItem;
  }

  // 2. 返回 currentValue
  return isPrimaryRenderer ? context._currentValue : context._currentValue2;
}

readContext的核心邏輯:

  1. 構建 contextItem 並添加到 workInProgress.dependencies 鏈表(contextItem 中保存了對當前 context 的引用,這樣在後續更新時,就可以判斷當前 fiber 是否依賴了 context ,從而判斷是否需要 re-render

  2. 返回對應 context_currentValue

更新 Context

當觸發 Context.Providerre-render 時,重新走 updateContextProvider 中更新的邏輯:

function updateContextProvider(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  // ...
  // 更新邏輯
  if (oldProps !== null) {
      const oldValue = oldProps.value;
      if (is(oldValue, newValue)) {
        // 1. value 未發生變化時,直接走 bailout 邏輯
        if (
          oldProps.children === newProps.children &&
          !hasLegacyContextChanged()
        ) {
          return bailoutOnAlreadyFinishedWork(
            current,
            workInProgress,
            renderLanes,
          );
        }
      } else {
        // 2. value 變更時,走更新邏輯
        propagateContextChange(workInProgress, context, renderLanes);
      }
  //...
}

接下來看下 propagateContextChange (核心邏輯在 propagateContextChange_eager 中) 的邏輯:

function propagateContextChange_eager < T > (
    workInProgress: Fiber,
    context: ReactContext < T > ,
    renderLanes: Lanes,
): void {
    let fiber = workInProgress.child;
    if (fiber !== null) {
        fiber.return = workInProgress;
    }
    // 從子節點開始匹配是否存在消費了當前 Context 的節點
    while (fiber !== null) {
        let nextFiber;

        const list = fiber.dependencies;
        if (list !== null) {
            nextFiber = fiber.child;

            let dependency = list.firstContext;
            while (dependency !== null) {
                // 1. 判斷 fiber 節點的 context 和當前 context 是否匹配
                if (dependency.context === context) {
                    // 2. 匹配時,給當前節點調度一個更新任務
                    if (fiber.tag === ClassComponent) {}

                    fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
                    const alternate = fiber.alternate;
                    if (alternate !== null) {
                        alternate.lanes = mergeLanes(alternate.lanes, renderLanes);
                    }
                    // 3. 向上標記 childLanes
                    scheduleContextWorkOnParentPath(
                        fiber.return,
                        renderLanes,
                        workInProgress,
                    );

                    list.lanes = mergeLanes(list.lanes, renderLanes);
                    break;
                }
                dependency = dependency.next;
            }
        } else if (fiber.tag === ContextProvider) {} else if (fiber.tag === DehydratedFragment) {} else {}

        // ...
        fiber = nextFiber;
    }
}

核心邏輯:

  1. ContextProvider 的節點出發,向下查找所有 fiber.dependencies 依賴當前 Context 的節點

  2. 找到消費節點時,從當前節點出發,向上回溯標記父節點 fiber.childLanes,標識其子節點需要更新,從而保證了所有消費了該 Context 的子節點都會被重新渲染,實現了 Context 的更新

總結

  1. 在消費階段,消費者通過 readContext 獲取最新狀態,並通過 fiber.dependencies 關聯當前 Context

  2. 在更新階段,從 ContextProvider 節點出發查找所有消費了該 context 的節點

如何避免 Context 引起的 re-render

從上面分析 Context 的整個工作流程,我們可以知道當 ContextProvider 接收到 value 變化時就會找到所有消費了該 Context 的組件進行 re-render,若 ContextProvidervalue 是一個對象時,即使沒有使用到發生變化的 value 的組件也會造成多次不必要的 re-render

那我們怎麼做優化呢?直接說方案:

  1. ContextProvider 的值做 memoize 處理

  2. 對數據和 API 做拆分(或者說是將 getterstate)和 setterAPI)做拆分)

  3. 對數據做拆分(細粒度拆分)

  4. Context Selector

具體的 case 可參考上述提到的優化文章:React re-renders guide: everything, all at once。

接下來開始我們今天的重點:Context Selector。開始之前先來個 case1:

import React, { useState } from "react";
const StateContext = React.createContext(null);

const StateProvider = ({ children }) ={
 console.log("StateProvider render");
 
 const [count1, setCount1] = useState(1);
 const [count2, setCount2] = useState(1);
 return (
  <StateContext.Provider 
   value={{ count1, setCount1, count2, setCount2 }}>
   {children}
  </StateContext.Provider>
 );
};

const Counter1 = () ={
 console.log("count1 render");
 
 const { count1, setCount1 } = React.useContext(StateContext);
 return (
  <>
   <div>Count1: {count1}</div>
   <button 
    onClick={() => setCount1((n) => n + 1)}>setCount1</button>
 </>
);
};

const Counter2 = () ={
 console.log("count2 render");
 
 const { count2, setCount2 } = React.useContext(StateContext);
 
 return (
  <>
   <div>Count2: {count2}</div>
   <button onClick={() => setCount2((n) => n + 1)}>setCount2</button>
  </>
 );
};

const App = () ={
 return (
  <StateProvider>
   <Counter1 />
   <Counter2 />
  </StateProvider>
 );
};

export default App;

開發環境記得關閉 StrictMode 模式,否則每次 re-render 都會走兩遍。具體使用方式和 StrictMode 的意義可參考官方文檔。

通過上面的 case,我們會發現在 count1 觸發更新時,即使 Counter2 沒有使用 count1 也會進行 re-render。這是因爲 count1 的更新會引起 StateProviderre-render,從而會導致 StateProvidervalue 生成全新的對象,觸發 ContextProviderre-render,找到當前 Context 的所有消費者進行 re-render

如何做到只有使用到 Contextvalue 改變才觸發組件的 re-render 呢?社區有一個對應的解決方案 dai-shi/use-context-selector: React useContextSelector hook in userland。

接下來我們改造一下上述的 case2:

import React, { useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';

const context = createContext(null);

const Counter1 = () ={
  const count1 = useContextSelector(context, v => v[0].count1);
  const setState = useContextSelector(context, v => v[1]);
  const increment = () => setState(s =({
    ...s,
    count1: s.count1 + 1,
  }));
  return (
    <div>
      <span>Count1: {count1}</span>
      <button type="button" onClick={increment}>+1</button>
      {Math.random()}
    </div>
  );
};

const Counter2 = () ={
  const count2 = useContextSelector(context, v => v[0].count2);
  const setState = useContextSelector(context, v => v[1]);
  const increment = () => setState(s =({
    ...s,
    count2: s.count2 + 1,
  }));
  return (
    <div>
      <span>Count2: {count2}</span>
      <button type="button" onClick={increment}>+1</button>
      {Math.random()}
    </div>
  );
};

const StateProvider = ({ children }) =(
  <context.Provider value={useState({ count1: 0, count2: 0 })}>
    {children}
  </context.Provider>
);

const App = () =(
  <StateProvider>
    <Counter1 />
    <Counter2 />
  </StateProvider>
);

export default App

這時候問題來了,不是說好精準渲染的嗎?怎麼還是都會進行 re-render
解決方案:將 react 改爲 v17 版本(v17 對應的 case3),後面我們再說具體原因(只想說好坑..)。

use-context-selector

接下來我們主要分析下 createContextuseContextSelector 都做了什麼(官方還有其他的 API ,感興趣的朋友可以自行查看,核心還是這兩個 API)。

createContext

簡化一下,只看核心邏輯:

import { createElement, useLayoutEffect, useRef, createContext as createContextOrig } from 'react'
const CONTEXT_VALUE = Symbol();
const ORIGINAL_PROVIDER = Symbol();

const createProvider = (
  ProviderOrig
) ={
  const ContextProvider = ({ value, children }) ={
    const valueRef = useRef(value);
    const contextValue = useRef();
    
    if (!contextValue.current) {
      const listeners = new Set();
      contextValue.current = {
        [CONTEXT_VALUE]{
          /* "v"alue     */ v: valueRef,
          /* "l"isteners */ l: listeners,
        },
      };
    }
    useLayoutEffect(() ={
      valueRef.current = value;
  contextValue.current[CONTEXT_VALUE].l.forEach((listener) ={
          listener({ v: value });
        });
    }[value]);
    
    return createElement(ProviderOrig, { value: contextValue.current }, children);
  };
  return ContextProvider;
};

export function createContext(defaultValue) {
  const context = createContextOrig({
    [CONTEXT_VALUE]{
      /* "v"alue     */ v: { current: defaultValue },
      /* "l"isteners */ l: new Set(),
    },
  });
  context[ORIGINAL_PROVIDER] = context.Provider;
  context.Provider = createProvider(context.Provider);
  delete context.Consumer; // no support for Consumer
  return context;
}

對原始的 createContext 包一層,同時爲了避免 value 的意外更新造成消費者的不必要 re-render ,將傳遞給原始的 createContextvalue 通過 uesRef 進行存儲,這樣在 React 內部對比新舊 value 值時就不會再操作 re-render(後續 value 改變後派發更新時就需要通過 listener 進行 re-render 了),最後返回包裹後的 createContext 給用戶使用。

useContextSelector

接下來看下簡化後的 useContextSelector

export function useContextSelector(context, selector) {
 const contextValue = useContextOrig(context)[CONTEXT_VALUE];
 const {
 /* "v"alue */ v: { current: value },
 /* "l"isteners */ l: listeners
 } = contextValue;
 
 const selected = selector(value);
 const [state, dispatch] = useReducer(
  (prev, action) ={
   if ("v" in action) {
    if (Object.is(prev[0], action.v)) {
     return prev; // do not update
    }
    const nextSelected = selector(action.v);
    if (Object.is(prev[1], nextSelected)) {
     return prev; // do not update
    }
    return [action.v, nextSelected];
   }
  },
  [value, selected]
 );
 
 useLayoutEffect(() ={
  listeners.add(dispatch);
  return () ={
   listeners.delete(dispatch);
  };
 
 }[listeners]);
 
 return state[1];
}

核心邏輯:

  1. 每次渲染時,通過 selectorvalue 獲取最新的 selected

  2. 同時將 useReducer 對應的 dispatch 添加到 listeners

  3. value 改變時,就會執行 listeners 中收集到 dispatch 函數,從而在觸發 reducer 內部邏輯,通過對比 valueselected 是否有變化,來決定是否觸發當前組件的 re-render

在 react v18 下的 bug

回到上面的 casereact v18 的表現和在原始 Context 的表現幾乎一樣,每次都會觸發所有消費者的 re-render。再看 use-context-selector 內部是通過 useReducer 返回的 dispatch 函數派發組件更新的。

接下來再看下 useReducerreact v18v17 版本到底有什麼不一樣呢?
看個簡單的 case

import React, { useReducer } from "react";

const initialState = 0;
const reducer = (state, action) ={
 switch (action) {
  case "increment":
   return state;
  default:
   return state;
 }

};

export const App = () ={
 console.log("UseReducer Render");
 const [count, dispatch] = useReducer(reducer, initialState);
 
 return (
  <div>
   <div>Count = {count}</div>
   <button onClick={() => dispatch("increment")}>Inacrement</button>
  </div>
 );
};

簡單描述下:多次點擊按鈕「Inacrement」,在 reactv17v18 版本分別會有什麼表現?

先說結論:

這就要說到【eager state 策略】了,在 React 內部針對多次觸發更新,而最後狀態並不會發生實質性變化的情況,組件是沒有必要渲染的,提前就可以中斷更新了。

也就是說 useReducer 內部是有做一定的性能優化的,而這優化會存在一些 bug,最後 React 團隊也在 v18 後移除了該優化策略(注:useState 還是保留該優化),詳細可看該相關 PR Remove usereducer eager bailout。當然該 PR 在社區也存在一些討論(Bug: useReducer and same state in React 18),畢竟無實質性的狀態變更也會觸發 re-render,對性能還是有一定影響的。

迴歸到 useContextSelector ,無優化版本的 useReducer 又是如何每次都觸發組件 re-render 呢?

具體原因:在上面 useReducer 中,是通過 Object.is 判斷 value 是否發生了實質性變化,若沒有,就返回舊的狀態,在 v17 有優化策略下,就不會再去調度更新任務了,而在 v18 沒有優化策略的情況下,每次都會調度新的更新任務,從而引發組件的 re-render

通過 useSyncExternalStore 優化

通過分析知道造成 re-render 的原因是使用了 useReducer,那就不再依賴該 hook,使用 react v18 新的 hook useSyncExternalStore 來實現 useContextSelector(優化後的 case4)。

export function useContextSelector(context, selector) {
 const contextValue = useContextOrig(context)[CONTEXT_VALUE];
 const {
 /* "v"alue */ v: { current: value },
 /* "l"isteners */ l: listeners
 } = contextValue;
 
 const lastSnapshot = useRef(selector(value));
 const subscribe = useCallback(
  (callback) ={
   listeners.add(callback);
   return () ={
    listeners.delete(callback);
   };
  },
  [listeners]
 );
 
 const getSnapshot = () ={
  const {
  /* "v"alue */ v: { current: value }
  } = contextValue;
  
  const nextSnapshot = selector(value);
  lastSnapshot.current = nextSnapshot;
  return nextSnapshot;
 };
 
 return useSyncExternalStore(subscribe, getSnapshot);
}

實現思路:

  1. 收集訂閱函數 subscribecallback(即 useSyncExternalStore 內部的 handleStoreChange

  2. value 發生變化時,觸發 listeners 收集到的 callback ,也就是執行 handleStoreChange 函數,通過 getSnapshot 獲取新舊值,並通過 Object.is 進行對比,判斷當前組件是否需要更新,從而實現了 useContextSelector 的精確更新

當然除了 useReducer 對應的性能問題,use-context-selector 還存在其他的性能,感興趣的朋友可以查看這篇文章從 0 實現 use-context-selector。同時,use-context-selector 也是存在一些限制,比如說不支持 Class 組件、不支持 Consumer

針對上述文章中,作者提到的問題二和問題三,個人認爲這並不是 use-context-selector 的問題,而是 React 底層自身帶來的問題。
比如說:問題二,React 組件是否 re-render 跟是否使用了狀態是沒有關係的,而是和是否觸發了更新狀態的 dispatch 有關,如果一定要和狀態綁定一起,那不就是 Vue 了嗎。
對於問題三,同樣是 React 底層的優化策略處理並沒有做到極致這樣。

總結

回到 React Context 工作原理來看,只要有消費者訂閱了該 Context,在該 Context 發生變化時就會觸達所有的消費者。也就是說整個工作流程都是以 Context 爲中心的,那隻要把 Context 拆分的粒度足夠小就不會帶來額外的渲染負擔。但是這樣又會帶來其他問題:ContextProvider 會嵌套多層,同時對於粒度的把握對開發者來說又會帶來一定的心智負擔。

從另一條路出發:Selector 機制,通過選擇需要的狀態從而規避掉無關的狀態改變時帶來的渲染開銷。除了社區提到的 use-context-selector ,React 團隊也有一個相應的 RFC 方案 RFC: Context selectors,不過這個 RFC 從 19 年開始目前還處於持續更新階段。

最後,對於 React Context 的使用,個人推薦:「不頻繁更改的全局狀態(比如說:自定義主題、賬戶信息、權限信息等)可以合理使用 Context,而對於其他頻繁修改的全局狀態可以通過其他數據流方式維護,可以更好的避免不必要的 re-render 開銷」

參考

  1. https://www.developerway.com/posts/react-re-renders-guide

  2. https://react.dev/reference/react/StrictMode#enabling-strict-mode-for-entire-app

  3. https://github.com/dai-shi/use-context-selector

  4. https://github.com/facebook/react/pull/22445

  5. https://github.com/facebook/react/issues/24596

  6. https://react.dev/reference/react/useSyncExternalStore

  7. https://juejin.cn/post/7197972831795380279

  8. https://github.com/reactjs/rfcs/pull/119

  9. case1:https://codesandbox.io/s/serverless-frost-9ryw2x?file=/src/App.js

  10. case2:https://codesandbox.io/s/use-context-selector-vvs93q?file=/src/App.js

  11. case3:https://codesandbox.io/s/elegant-montalcini-nkrvlh?file=/src/App.js

  12. case4:https://codesandbox.io/s/use-context-selector-smsft3?file=/src/App.js

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mQyl3baPRvEI_34kT1Us_g