• react源码分析:深度理解React.Context


    开篇

    在 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);
    
    • 1

    当 React 渲染一个订阅了这个 Context 对象的组件,这个组件会从组件树中的 Context.Provider 中读取到当前的 context.value 值。

    当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。

    2.2、Context.Provider

    每个 Context 对象都会返回一个 Provider React 组件,它接收一个 value 属性,可将数据向下传递给消费组件。当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染。

    注意,当 value 传递为一个复杂对象时,若想要更新,必须赋予 value 一个新的对象引用地址,直接修改对象属性不会触发消费组件的重渲染。

    <Context.Provider value={/* 某个值,一般会传递对象 */}>
    
    • 1

    2.3、React.useContext

    Context Provider 组件提供了向下传递的 value 数据,对于函数组件,可通过 useContext API 拿到 Context value

    const value = useContext(Context);
    
    • 1

    useContext 接收一个 context 对象(React.createContext 的返回值),返回该 context 的当前值。

    当组件上层最近的 更新时,当前组件会触发重渲染,并读取最新传递给 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'));
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    示例中,在 App 组件内使用 Providervalue 值向子树传递,Child 组件通过 useContext 读取 value,从而成为 Consumer 消费组件。

    三、原理分析

    从上面「使用」我们了解到:Context 的实现由三部分组成:

    1. 创建 Context:React.createContext() 方法;
    2. Provider 组件:
    3. 消费 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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    尽管在这里我们只看到要返回一个对象,却看不出别的名堂,只需记住它返回的对象结构信息即可,我们接着往下看。相关参考视频讲解:进入学习

    3.2、 JSX 编译

    我们所编写的 JSX 语法在进入 render 时会被 babel 编译成 ReactElement 对象。我们可以在 babel repl 在线平台 转换查看。

    JSX 语法最终会被转换成 React.createElement 方法,我们在 example 环境下执行方法,返回的结果是一个 ReactElement 元素对象。

    截屏2022-08-29 下午8.46.48.png

    对象的 props 保存了 context 要向下传递的 value,而对象的 type 则保存的是 context.Provider

    context.Provider = {
      $$typeof: REACT_PROVIDER_TYPE,
      _context: context,
    };
    
    • 1
    • 2
    • 3
    • 4

    有了对象描述结构,接下来进入渲染流程并在 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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    3.4、Context.Provider 在 Fiber 架构下的实现机制

    经过上面 useContext 消费组件的分析,我们需要思考两点:

    1. 组件上的 value 值何时更新到 context._currentValue
    2. 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);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    首先,更新 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    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
            }
          }
        }
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    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>
        );
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    为了防止这种情况,可以将 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>
        );
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    五、对比 useSelector

    从「注意事项」可以考虑:要想使消费组件进行重渲染,context value 必须返回一个全新对象,这将导致所有消费组件都进行重渲染,这个开销是非常大的,因为有一些组件所依赖的值可能并未发生变化。

    当然有一种直观做法是将「状态」分离在不同 Context 之中。

    react-redux useSelector 则是采用订阅 redux store.state 更新,去通知消费组件「按需」进行重渲染(比较所依赖的 state 前后是否发生变化)。

    1. 提供给 Context.Provider 的 value 对象地址不会发生变化,这使得子组件中使用了 useSelector -> useContext,但不会因顶层数据而进行重渲染。

    2. store.state 数据变化组件如何更新呢?react-redux 订阅了 redux store.state 发生更新的动作,然后通知组件「按需」执行重渲染。

    最后

    感谢阅读,如有不足之处,欢迎指出讨论。

  • 相关阅读:
    haproxy负载均衡
    如何用PHP语言使用爬虫ip代码示例
    【MySQL】DQL相关操作
    常用框架分析(7)-Flutter
    Linux:进程的本质和fork初识
    查看进程时,遇到process information unavailable的解决方法
    《复盘+》把经验转化为能力
    redis主从复制
    BSV区块链协会上线首个版本的ARC交易处理器
    ELK:开源搜索与分析技术栈(1)
  • 原文地址:https://blog.csdn.net/weixin_59558923/article/details/128186571