自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

如何優(yōu)雅的使用 React Context

開發(fā) 前端
回到 React Context? 工作原理來看,只要有消費者訂閱了該 Context?,在該 Context? 發(fā)生變化時就會觸達所有的消費者。也就是說整個工作流程都是以 Context? 為中心的,那只要把 Context? 拆分的粒度足夠小就不會帶來額外的渲染負擔。

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

先說結論:

  • 狀態(tài)變化
  • 父組件 re-render
  • Context 變化
  • Hooks 變化

這里有個誤解:props 變化也會導致 re-render。其實不會的,props 的變化往上追溯是因為父組件的 state 變化導致父組件 re-render,從而引起了子組件的 re-render,與 props 是否變化無關的。只有那些使用了 React.memo 和 useMemo 的組件,props 的變化才會觸發(fā)組件的 re-render。

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

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

Context 的工作原理

Context 是 React 提供的一種直接訪問祖先節(jié)點上的狀態(tài)的方法,從而避免了多級組件層層傳遞 props 的頻繁操作。

創(chuàng)建 Context

通過 React.createContext 創(chuàng)建 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. 創(chuàng)建 Context.Provider 和 Context.Consumer 對應的 ReactElement 對象

在 fiber 樹渲染時,通過不同的 workInProgress.tag 處理 Context.Provider 和 Context.Consumer 類型的節(jié)點。

主要看下針對 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 時創(chuàng)建的 Consumer)
  2. 類組件中,可以通過靜態(tài)屬性 contextType 消費 Context
  3. 函數(shù)組件中,可以通過 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 的引用,這樣在后續(xù)更新時,就可以判斷當前 fiber 是否依賴了 context ,從而判斷是否需要 re-render)
  2. 返回對應 context 的 _currentValue 值

更新 Context

當觸發(fā) 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 未發(fā)生變化時,直接走 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;
    }
    // 從子節(jié)點開始匹配是否存在消費了當前 Context 的節(jié)點
    while (fiber !== null) {
        let nextFiber;

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

            let dependency = list.firstContext;
            while (dependency !== null) {
                // 1. 判斷 fiber 節(jié)點的 context 和當前 context 是否匹配
                if (dependency.context === context) {
                    // 2. 匹配時,給當前節(jié)點調度一個更新任務
                    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 的節(jié)點出發(fā),向下查找所有 fiber.dependencies 依賴當前 Context 的節(jié)點
  2. 找到消費節(jié)點時,從當前節(jié)點出發(fā),向上回溯標記父節(jié)點 fiber.childLanes,標識其子節(jié)點需要更新,從而保證了所有消費了該 Context 的子節(jié)點都會被重新渲染,實現(xiàn)了 Context 的更新

總結

  1. 在消費階段,消費者通過 readContext 獲取最新狀態(tài),并通過 fiber.dependencies 關聯(lián)當前 Context
  2. 在更新階段,從 ContextProvider 節(jié)點出發(fā)查找所有消費了該 context 的節(jié)點

如何避免 Context 引起的 re-render

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

那我們怎么做優(yōu)化呢?直接說方案:

  1. 將 ContextProvider 的值做 memoize 處理
  2. 對數(shù)據(jù)和 API 做拆分(或者說是將 getter(state)和 setter(API)做拆分)
  3. 對數(shù)據(jù)做拆分(細粒度拆分)
  4. Context Selector

具體的 case 可參考上述提到的優(yōu)化文章: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;

?

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

?

通過上面的 case,我們會發(fā)現(xiàn)在 count1 觸發(fā)更新時,即使 Counter2 沒有使用 count1 也會進行 re-render。這是因為 count1 的更新會引起 StateProvider 的 re-render,從而會導致 StateProvider 的 value 生成全新的對象,觸發(fā) ContextProvider 的 re-render,找到當前 Context 的所有消費者進行 re-render。

如何做到只有使用到 Context 的 value 改變才觸發(fā)組件的 re-render 呢?社區(qū)有一個對應的解決方案 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(后續(xù) value 改變后派發(fā)更新時就需要通過 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 改變時,就會執(zhí)行 listeners 中收集到 dispatch 函數(shù),從而在觸發(fā) reducer 內部邏輯,通過對比 value 和 selected 是否有變化,來決定是否觸發(fā)當前組件的 re-render

在 react v18 下的 bug

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

接下來再看下 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 版本分別會有什么表現(xiàn)?

先說結論:

  • v17:只有首次渲染會觸發(fā) App 組件的 render,后續(xù)點擊將不再觸發(fā) re-render
  • v18:每次都會觸發(fā) App 組件的 re-render(即使狀態(tài)沒有實質性的變化也會觸發(fā) re-render)

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

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

回歸到 useContextSelector ,無優(yōu)化版本的 useReducer 又是如何每次都觸發(fā)組件 re-render 呢?

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

通過 useSyncExternalStore 優(yōu)化

通過分析知道造成 re-render 的原因是使用了 useReducer,那就不再依賴該 hook,使用 react v18 新的 hook useSyncExternalStore 來實現(xiàn) useContextSelector(優(yōu)化后的 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);
}

實現(xiàn)思路:

  1. 收集訂閱函數(shù) subscribe 的 callback(即 useSyncExternalStore 內部的 handleStoreChange )
  2. 當 value 發(fā)生變化時,觸發(fā) listeners 收集到的 callback ,也就是執(zhí)行 handleStoreChange 函數(shù),通過 getSnapshot 獲取新舊值,并通過 Object.is 進行對比,判斷當前組件是否需要更新,從而實現(xiàn)了 useContextSelector 的精確更新

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

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

總結

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

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

最后,對于 React Context 的使用,個人推薦:「不頻繁更改的全局狀態(tài)(比如說:自定義主題、賬戶信息、權限信息等)可以合理使用 Context,而對于其他頻繁修改的全局狀態(tài)可以通過其他數(shù)據(jù)流方式維護,可以更好的避免不必要的 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
相關推薦

2022-10-27 11:23:26

GoFrame共享變量

2021-08-10 07:41:24

ContextWaitGroupGoroutine

2022-05-13 08:48:50

React組件TypeScrip

2023-12-21 10:26:30

??Prettier

2015-11-26 10:53:45

LinuxWindowsMac OS

2017-07-26 11:32:50

NETRabbitMQ系統(tǒng)集成

2021-09-26 09:40:25

React代碼前端

2022-07-31 19:57:26

react項目VSCode

2021-08-26 14:55:55

開發(fā)React代碼

2021-03-28 09:17:18

JVM場景鉤子函數(shù)

2022-09-14 08:16:48

裝飾器模式對象

2021-12-07 08:16:34

React 前端 組件

2021-12-13 14:37:37

React組件前端

2022-06-02 10:02:47

Kubectl更新應用Linux

2021-03-12 18:25:09

開發(fā)前端React

2020-12-20 10:02:17

ContextReactrender

2019-11-15 09:58:04

LinuxAsciinemapython

2022-11-11 07:48:56

ORM鏈式輪播圖

2022-11-15 07:50:47

ORM鏈式操作刪除

2023-06-28 08:25:14

事務SQL語句
點贊
收藏

51CTO技術棧公眾號