天天看點

如何優雅的使用 React Context

作者:閃念基因
如何優雅的使用 React Context

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

先說結論:

  • 狀态變化
  • 父元件 re-render
  • Context 變化
  • Hooks 變化

這裡有個誤解:props 變化也會導緻 re-render。

其實不會的,props 的變化往上追溯是因為父元件的 state 變化導緻父元件 re-render,進而引起了子元件的 re-render,與 props 是否變化無關的。隻有那些使用了 React.memo 和 useMemo 的元件,props 的變化才會觸發元件的 re-render。

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

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

Context 的工作原理

Context 是 React 提供的一種直接通路祖先節點上的狀态的方法,進而避免了多級元件層層傳遞 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.Provider 和 Context.Consumer 對應的 ReactElement 對象

在 fiber 樹渲染時,通過不同的 workInProgress.tag 處理 Context.Provider 和 Context.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

這三種方式内部都會調用 prepareToReadContext 和 readContext 處理 Context。prepareToReadContext 中主要是重置全局變量為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.Provider 的 re-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,若 ContextProvider 的 value 是一個對象時,即使沒有使用到發生變化的 value 的元件也會造成多次不必要的 re-render。

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

  1. 将 ContextProvider 的值做 memoize 處理
  2. 對資料和 API 做拆分(或者說是将 getter(state)和 setter(API)做拆分)
  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 的更新會引起 StateProvider 的 re-render,進而會導緻 StateProvider 的 value 生成全新的對象,觸發 ContextProvider 的 re-render,找到目前 Context 的所有消費者進行 re-render。

如何做到隻有使用到 Context 的 value 改變才觸發元件的 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

接下來我們主要分析下 createContext 和 useContextSelector 都做了什麼(官方還有其他的 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 ,将傳遞給原始的 createContext 的 value 通過 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. 每次渲染時,通過 selector 和 value 擷取最新的 selected
  2. 同時将 useReducer 對應的 dispatch 添加到 listeners
  3. 當 value 改變時,就會執行 listeners 中收集到 dispatch 函數,進而在觸發 reducer 内部邏輯,通過對比 value 和 selected 是否有變化,來決定是否觸發目前元件的 re-render

在 react v18 下的 bug

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

接下來再看下 useReducer 在 react v18 和 v17 版本到底有什麼不一樣呢?

看個簡單的 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」,在 react 的 v17 和 v18 版本分别會有什麼表現?

先說結論:

  • v17:隻有首次渲染會觸發 App 元件的 render,後續點選将不再觸發 re-render
  • v18:每次都會觸發 App 元件的 re-render(即使狀态沒有實質性的變化也會觸發 re-render)

這就要說到【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. 收集訂閱函數 subscribe 的 callback(即 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

作者:郭曉波

來源:微信公衆号:大轉轉FE

出處:https://mp.weixin.qq.com/s/mQyl3baPRvEI_34kT1Us_g

繼續閱讀