在开始今天的文章之前,大家不妨先想一下触发React组件re-render的原因有哪些,或者说什么时候React组件会发生re-render。


(相关资料图)

先说结论:

状态变化父组件re-renderContext变化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的核心逻辑:

将初始值存储在context._currentValue创建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的方式

直接使用Context.Consumer组件(也就是上面createContext时创建的Consumer)类组件中,可以通过静态属性contextType消费Context函数组件中,可以通过useContext消费Context

这三种方式内部都会调用prepareToReadContext和readContext处理Context。prepareToReadContext中主要是重置全局变量为readContext做准备。

接下来主要看下readContext:

export function readContext(  context: ReactContext,  observedBits: void | number | boolean,): T {  const contextItem = {    context: ((context: any): ReactContext),    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的核心逻辑:

构建contextItem并添加到workInProgress.dependencies链表(contextItem中保存了对当前context的引用,这样在后续更新时,就可以判断当前fiber是否依赖了context,从而判断是否需要re-render)返回对应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;    }}

核心逻辑:

从ContextProvider的节点出发,向下查找所有fiber.dependencies依赖当前Context的节点找到消费节点时,从当前节点出发,向上回溯标记父节点fiber.childLanes,标识其子节点需要更新,从而保证了所有消费了该Context的子节点都会被重新渲染,实现了Context的更新总结在消费阶段,消费者通过readContext获取最新状态,并通过fiber.dependencies关联当前Context在更新阶段,从ContextProvider节点出发查找所有消费了该context的节点如何避免 Context 引起的 re-render

从上面分析Context的整个工作流程,我们可以知道当ContextProvider接收到value变化时就会找到所有消费了该Context的组件进行re-render,若ContextProvider的value是一个对象时,即使没有使用到发生变化的value的组件也会造成多次不必要的re-render。

那我们怎么做优化呢?直接说方案:

将ContextProvider的值做memoize处理对数据和API做拆分(或者说是将getter(state)和setter(API)做拆分)对数据做拆分(细粒度拆分)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 (     {children}   );};const Counter1 = () => { console.log("count1 render");  const { count1, setCount1 } = React.useContext(StateContext); return (  <>   
Count1: {count1}
);};const Counter2 = () => { console.log("count2 render"); const { count2, setCount2 } = React.useContext(StateContext); return ( <>
Count2: {count2}
);};const App = () => { return ( );};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 (    
Count1: {count1} {Math.random()}
);};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 (
Count2: {count2} {Math.random()}
);};const StateProvider = ({ children }) => ( {children} );const App = () => ( );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];}

核心逻辑:

每次渲染时,通过selector和value获取最新的selected同时将useReducer对应的dispatch添加到listeners当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 (  
Count = {count}
);};

简单描述下:多次点击按钮「Inacrement」,在react的v17和v18版本分别会有什么表现?

先说结论:

v17:只有首次渲染会触发App组件的render,后续点击将不再触发re-renderv18:每次都会触发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);}

实现思路:

收集订阅函数subscribe的callback(即useSyncExternalStore内部的handleStoreChange)当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开销」。

参考https://www.developerway.com/posts/react-re-renders-guidehttps://react.dev/reference/react/StrictMode#enabling-strict-mode-for-entire-apphttps://github.com/dai-shi/use-context-selectorhttps://github.com/facebook/react/pull/22445https://github.com/facebook/react/issues/24596https://react.dev/reference/react/useSyncExternalStorehttps://juejin.cn/post/7197972831795380279https://github.com/reactjs/rfcs/pull/119case1:https://codesandbox.io/s/serverless-frost-9ryw2x?file=/src/App.jscase2:https://codesandbox.io/s/use-context-selector-vvs93q?file=/src/App.jscase3:https://codesandbox.io/s/elegant-montalcini-nkrvlh?file=/src/App.jscase4:https://codesandbox.io/s/use-context-selector-smsft3?file=/src/App.js

推荐内容