深入 React Context 源码与实现原理

1,208 阅读13分钟

前置知识

本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0

Context API

image.png

React 渲染流程

React 渲染分为 render 阶段和 commit 阶段,其中 render 阶段分为两步(深度优先遍历)

  1. beginWork(进入节点的过程向下遍历,协调子元素)
  2. completeUnitOfWork(离开节点的过程向上回溯)

区别 render 和 beginWork

为了避免与上面的阶段混淆,以下 render 都代指开发者层面的 render,即指类组件执行 render 方法或函数组件执行

  • 如果一个组件发生更新,当前组件到 fiber root 上的父级链上的所有 fiber,都会执行 beginWork,但执行 beginWork,不代表触发了组件的 render(fiber 会检查组件是否需要进行渲染,不需要则会跳过复用旧的 fiber 节点)所以 render 不等于 beginWork
  • 如果组件 render 执行了,则一定经历了 beginWork 流程,触发了 beginWork

综上 beginWork 的工作是进入节点时协调子元素,如果 fiber 类型是类组件或者函数组件,则需检测比较组件是否需要执行 render,不需要则会跳过复用旧的 fiber 节点

React.createContext 原理

const MyContext = React.createContext(defaultValue)

创建一个 Context 对象。只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效

源码位置:packages/react/src/ReactContext.js

createContext 函数的核心逻辑是返回一个 context 对象,其中包括三个重要属性:

  • ProviderConsumer 两个组件(React Element 对象)属性
  • _currentValue :保存 context 的值,用来保存传递给 Provider 的 value 属性)

下列是精简去除类型定义和引入的源码,后面源码举例都这么处理,为了方便直观的看:

const REACT_PROVIDER_TYPE = Symbol.for('react.provider')
const REACT_CONTEXT_TYPE = Symbol.for('react.context')

export function createContext(defaultValue) {
  const context = {
    $$typeof: REACT_CONTEXT_TYPE, // 本质就是 Consumer Element 类型
    _currentValue: defaultValue, // 保存 context 的值
    _currentValue2: defaultValue, // 为了支持多个并发渲染器,适配不同的平台
    _threadCount: 0, // 跟踪当前有多少个并发渲染器
    Provider: null,
    Consumer: null,
  }
  // 添加 Provider 属性,本质就是 Provider Element 类型
  context.Provider = {
    $$typeof: REACT_PROVIDER_TYPE,
    _context: context,
  }
  // 添加 Consumer 属性
  context.Consumer = context

  return context
}

JSX 语法在进入 render 时会被编译成 React Element 对象

Context.Provider 原理

<MyContext.Provider value={/* 某个值 */}>

image.png

先来了解 Provider 的特性:

  • 每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化
  • Provider 接收一个  value  属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。
  • 只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效
  • 多个相同的 Provider 也可以嵌套使用,里层的会覆盖外层的数据。
  • 当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染,可跳过 shouldComponentUpdate 强制更新

如果一个组件发生更新,那么当前组件到 fiber root 上的父级链上的所有 fiber,更新优先级都会升高,都会触发 beginWork,但不一定会 render

当初次 Fiber 树渲染,进入 beginWork 方法,其中对应的节点处理函数是 updateContextProvider

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes)
  }
}

进入 updateContextProvider 方法:

function updateContextProvider(current, workInProgress, renderLanes) {
  const providerType = workInProgress.type
  const context = providerType._context

  const newProps = workInProgress.pendingProps
  const oldProps = workInProgress.memoizedProps
  // 新的 value 值
  const newValue = newProps.value
  // 获取 Provider 上的 value
  pushProvider(workInProgress, context, newValue)

  // 更新阶段
  if (oldProps !== null) {
    const oldValue = oldProps.value
    // 使用 Object.is 来比较新旧值是否发生变化
    if (is(oldValue, newValue)) {
      // context 值没有变更,则提前退出
      if (
        oldProps.children === newProps.children &&
        !hasLegacyContextChanged()
      ) {
        return bailoutOnAlreadyFinishedWork(
          current,
          workInProgress,
          renderLanes,
        )
      }
    } else {
      // context 值发生改变,深度优先遍历查找 consumer 消费组件,标记更新
      propagateContextChange(workInProgress, context, renderLanes)
    }
  }

  // 继续向下调和子代 fiber
  const newChildren = newProps.children
  reconcileChildren(current, workInProgress, newChildren, renderLanes)
  return workInProgress.child
}

// 使用栈存储 context._currentValue 值,设置 context._currentValue 为最新值
function pushProvider(providerFiber, context, nextValue) {
  // 压栈
  push(valueCursor, context._currentValue, providerFiber)
  // 修改 context 的值
  context._currentValue = nextValue
}
  • 首次执行时,保存 workInProgress.pendingProps.value 值作为最新值,然后调用 pushProvider 方法设置context._currentValue
  • pushProvider:存储 context 值的函数,利用栈先进后出的特性,先把 context._currentValue 压栈;与后面流程的 popProvider(出栈)函数相对应。
  • 更新阶段时通过浅比较(Object.is)来判断新旧 context 值是否发生改变,没发生改变则调用 bailoutOnAlreadyFinishedWork 进入 bailout,复用当前 Fiber 节点,改变则调用propagateContextChange方法

我们总结下 Context.Provider 的 Fiber 更新方法 —— updateContextProvider的核心逻辑

  1. 将 Provider 的 value 属性赋值给 context._currentValue(压栈)
  2. 通过 Object.is 浅比较 context 新旧值是否发生变化
  3. 发生变化时,调用 propagateContextChange 走更新的流程,深度优先遍历查找消费组件来标记更新

propagateContextChange 逻辑:深度优先遍历所有的子代 fiber ,然后找到里面具有 dependencies 的属性,对比 dependencies 中的 context 和当前 Provider 的 context 是否是同一个,如果是同一个,会提高 fiber 的更新优先级,让 fiber 在接下来的调和过程中,处于一个高优先级待更新的状态,而高优先级的 fiber 都会 beginWork

消费 Context 原理

由上文知识我们简略粗暴的说:Provider 一顿操作核心就是修改 context._currentValue 的值,那么消费 Context 值的原理也就是想方设法读取 context._currentValue 的值了。

image.png

Context.Consumer(函数组件)

<MyContext.Consumer>
  {value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>

一个 React 组件可以订阅 context 的变更,此组件可以让你在函数式组件中可以订阅 context。这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。传递给函数的 value 值等价于组件树上方离这个 context 最近的 Provider 提供的 value 值

当 context 值更新时,Fiber 树渲染时,进入 beginWork 方法,beginWork 中对于 ContextConsumer 的节点处理函数是 updateContextConsumer

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ContextConsumer:
      return updateContextConsumer(current, workInProgress, renderLanes)
  }
}

updateContextConsumer的核心逻辑:

  1. 调用 prepareToReadContextreadContext 读取最新的 context 值。
  2. 通过 render props 函数,传入最新的 context value 值,得到最新的 children 。
  3. 调和 children
function updateContextConsumer(current, workInProgress, renderLanes) {
  // 拿到 context
  let context = workInProgress.type
  context = context._context

  const newProps = workInProgress.pendingProps
  // 获取 Consumer 组件的 render props children
  const render = newProps.children
  // 读取 context 前的准备工作
  prepareToReadContext(workInProgress, renderLanes)
  // 读取最新 context._currentValue 值
  const newValue = readContext(context)

  let newChildren
  // 最新的 children element
  newChildren = render(newValue)

  // 进入主流程,调和 children
  reconcileChildren(current, workInProgress, newChildren, renderLanes)
  return workInProgress.child
}

useContext(函数组件)

const value = useContext(MyContext)

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

看如下代码,useContext Hook 挂载阶段和更新阶段,本质都是调用 readContext 函数,readContext 函数会返回 context._currentValue。而且也是调用了 prepareToReadContextreadContext

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      )
  }
}

function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps,
  renderLanes,
) {
  prepareToReadContext(workInProgress, renderLanes)
  // 处理各种hooks逻辑
  nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes,
  )
  // ...
}

renderWithHooks 函数是调用函数组件的主要函数

function renderWithHooks(
  current,
  workInProgress,
  Component,
  nextProps,
  context,
  renderLanes,
) {
  // ...
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount // 挂载阶段
      : HooksDispatcherOnUpdate // 更新阶段
}

// 确保 Hooks 只能在函数组件内部或自定义 Hooks 中使用,提供正确的调度程序
function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current
  return dispatcher
}

function useContext(Context) {
  const dispatcher = resolveDispatcher()
  return dispatcher.useContext(Context)
}

const HooksDispatcherOnMount = {
  useContext: readContext,
  // ...
}
const HooksDispatcherOnUpdate = {
  useContext: readContext,
  // ...
}

Class.contextType(类组件)

class MyClass extends React.Component {
  componentDidMount() {
    let value = this.context
    /* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
  }
  componentDidUpdate() {
    let value = this.context
    /* ... */
  }
  componentWillUnmount() {
    let value = this.context
    /* ... */
  }
  render() {
    let value = this.context
    /* 基于 MyContext 组件的值进行渲染 */
  }
}
MyClass.contextType = MyContext

挂载在 class 上的 contextType 属性可以赋值为由 React.createContext() 创建的 Context 对象。此属性可以让你使用 this.context 来获取最近 Context 上的值。你可以在任何生命周期中访问到它,包括 render 函数中。

  • 类组件会判断类组件上是否有静态属性 contextType
  • 如果有则调用 readContext 方法,并赋值给类实例的 context 属性,所以我们才可以使用 this.context 获取 context 值
function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case ClassComponent:
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      )
  }
}

function updateClassComponent(
  current,
  workInProgress,
  Component,
  nextProps,
  renderLanes,
) {
  // ...
  prepareToReadContext(workInProgress, renderLanes)
  mountClassInstance(workInProgress, Component, nextProps, renderLanes)
  // ...
}

function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
  // ...
  const instance = workInProgress.stateNode
  // 判断类组件上是否有静态属性 contextType
  const contextType = ctor.contextType
  // 有则调用 readContext
  if (typeof contextType === 'object' && contextType !== null) {
    // 赋值给类实例的 context 属性
    instance.context = readContext(contextType)
  }
}

综上,以上三种方式只是 React 根据不同使用场景封装的 API,它们在消费/订阅 context 的共同操作:

  1. 先调用 prepareToReadContext 进行准备工作
  2. 再调用 readContext 方法读取 context 值(readContext 方法返回 context._currentValue 最新值)

上文提到 propagateContextChange ,如果组件订阅了 context,不管是函数组件还是类组件,都会将 fiber.lanes 设置为 renderLanes。在 beginWork 阶段,发现 fiber.lanes 等于 renderLanes,则走 beginWork 的逻辑,强制组件更新

prepareToReadContext 和 readContext 逻辑

prepareToReadContext 的核心逻辑:

  • 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber,并重置lastContextDependency 等全局变量
function prepareToReadContext(workInProgress, renderLanes) {
  // 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber, 为 readContext 做准备
  currentlyRenderingFiber = workInProgress
  // 用于构造 dependencies 列表
  lastContextDependency = null
  // 将全局变量 lastFullyObservedContext (保存的是 context 对象) 重置为 null
  lastFullyObservedContext = null

  const dependencies = workInProgress.dependencies
  if (dependencies !== null) {
    const firstContext = dependencies.firstContext
    if (firstContext !== null) {
      if (includesSomeLane(dependencies.lanes, renderLanes)) {
        // Context list has a pending update. Mark that this fiber performed work.
        markWorkInProgressReceivedUpdate()
      }
      // 重置 fiber context 依赖
      dependencies.firstContext = null
    }
  }
}

readContext 的核心逻辑:

  • 收集组件依赖的所有不同的 context,如果组件订阅了 context,则将 context 添加到 fiber.dependencies 链表中
  • 返回context._currentValue, 并构造一个contextItem添加到workInProgress.dependencies 链表之后。
function readContext(context) {
  return readContextForConsumer(currentlyRenderingFiber, context)
}

function readContextForConsumer(consumer, context) {
  // ReactDOM 中 isPrimaryRenderer 为 true,则一直返回 context._currentValue
  const value = isPrimaryRenderer
    ? context._currentValue
    : context._currentValue2

  // 相等说明是同一个 Context,不处理为了防止重复添加依赖
  if (lastFullyObservedContext === context) {
    // Nothing to do. We already observe everything in this context.
  } else {
    const contextItem = {
      context: context,
      memoizedValue: value,
      next: null,
    }
    // 构造一个 contextItem, 加入到 workInProgress.dependencies 链表之后
    if (lastContextDependency === null) {
      lastContextDependency = contextItem
      // dependencies 属性用于判定是否依赖了 ContextProvider 中的值
      consumer.dependencies = {
        lanes: NoLanes,
        firstContext: contextItem,
      }
    } else {
      // 将 context 添加到 fiber.dependencies 链表末尾
      lastContextDependency = lastContextDependency.next = contextItem
    }
  }
  // 返回 context._currentValue
  return value
}

Context 原理八连问

上面源码实际上还是讲解不够完整的,在这推荐一篇文章:【React 源码系列】React Context 原理,如何合理设计共享状态,个人认为相对讲得很清晰了。

想知道自己对原理的理解,除了输出就是回答解决一些提问了,这里列举了一些原理相关的问题,写下简略的解答,看看自己是否了解。

Provider 如何传递 context?

通过将 Provider 的 value 属性值赋值给 context._currentValue

没有 Provider 包裹,为什么读不到最新的 context 值?

render() {
  return (
    <>
      <TestContext.Provider value={10}>
       {/* 可读到 context 值最新值 10 */}
        <Test />
      </TestContext.Provider>
      {/* 只能读到 context 初始值(createContext 函数的参数 defaultValue) */}
      <Test />,
    </>
  )
}

消费 context 时是读取 context._currentValue 值,理论上其它组件也是读取该最新值的。Provider 其中一个特性是只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。所以没有被 Provider 包裹的组件,是只能读到默认值的。

React 在深度优先遍历 fiber 树时,最外层 Provider 开始 beginWork,会先将 context._currentValue 的旧值保存起来,赋新的值给 context._currentValue(所以在里层的组件都能读到最新值),在离开 Provider 节点时会调用 completeUnitOfWork 完成工作,在此会将 context._currentValue 恢复成旧值,到遍历第二个 <Test /> 节点时就读的是 context 的默认值(不被 Provider 包裹的组件 render 时 beginWork 的时候就读到旧值了)。

相同 Provider 嵌套使用,里层的会覆盖外层的数据是怎么实现的?

render() {
  return (
    <>
      <TestContext.Provider value={10}>
        <Test1 />
        <TestContext.Provider value={100}>
          <Test2 />
        </TestContext.Provider>
      </TestContext.Provider>
    </>
  )
}

在这场景下, <Test1 /><Test2 /> 组件读取的值分别是 10 和 100。

为了实现嵌套的机制,React 利用的是的特性(后入先出),通过 pushProviderpopProvider

Fiber 深度优先遍历时:

  • 最外层 Provider 将 value 值 10 压入栈 pushProvider,此时栈顶是 10
  • 遍历里层 Provider 时将 value 值 100 压入栈 pushProvider,此时栈顶是 100,即context._currentValue 的值为 100

消费组件 <Test2 />读取时,在其所在 Provider 范围内先读取栈顶的值,所以读取的是 100;里层的 Provider 完成遍历工作离开时,弹出栈顶 popProvider的值 100,此时栈顶的值是 10, 即 context._currentValue 的值为 10,<Test1 /> 里面读到的值也就为 10 了。

由于 React 调和过程就是 Fiber 树深度优先遍历的过程, 向下遍历(beginWork)和向上回溯(completeWork)恰好符合栈的特性(入栈和出栈),Context 的嵌套读取就是利用了这个特性。

三种消费 context 的原理

  • useContext:本质上调用 readContext 方法
  • Context.Consumer:本质上是类型为 REACT_CONTEXT_TYPE 的 React Element 对象,context 本身就存在 Consumer 里面,本质也是调用 readContext
  • Class.contextType:通过静态属性 contextType 建立联系 ,在类组件实例化的时候被使用,本质上也是调用 readContext

三种方式只是 React 根据不同使用场景封装的 API,本质都是调用了 readContext 方法读取 context._currentValue

context 的存取发生在 React 渲染的哪些阶段

context 的存取就是发生在 beginWork 阶段,在 beginWork 阶段,如果当前组件订阅了 context,则从 context 中读取 _currentValue

消费 context 的组件,context 改变为什么会订阅更新?

  • 当 Provider 的 context value 值更新时,会调用 updateContextProvider 方法,里面的 propagateContextChange 方法会对 fiber 子树向下深度优先遍历所有的 fiber 节点,目的是为了找到消费组件标记更新。如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,就会被标记更新。
  • 而消费组件调用的 readContext 方法则会把 fiber.dependencies 和 context 对象建立关联,fiber.dependencies 用于判断是否依赖了 ContextProvider 中的值
  • context 值更新时消费 context 的 fiber 和父级链都会提高更新优先级,向上遍历时,会设置消费节点的父路径上所有节点的 fiber.childLanes 属性,(childLanes 属性用于判断子节点是否需要更新)需要更新则子节点就会进入更新逻辑(开始 beginWork)。

消费 context 的组件是如何跳过 PureComponent、shouldComponentUpdate 强制 render?

  • 类组件更新流程中,强制更新会跳过 PureComponentshouldComponentUpdate 等优化策略,在外部代码层面,我们可调用 this.forceUpdate(),就会给类组件打上强制更新的 tag。而在内部实现上, context 的 value 改变时,要想订阅 context 的类组件更新,相应的也得打上强制更新的 tag
  • 当 context 值发生变化时,会调用 propagateContextChange 对 Fiber 子树向下深度优先遍历所有的 fiber 节点,如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,如果 fiber 节点是类组件, 则会创建一个 update 对象,并将 update.tag 标记为 ForceUpdate;而处理 update 时,发现 tag 为 ForceUpdate 的话,会将全局变量 hasForceUpdate 设置为 true, 这决定了类组件会强制更新。

updateClassComponent 中会调用 updateClassInstance 判断类组件是否应该更新。在 updateClassInstance 中会判断全局变量 hasForceUpdate 或者组件的 shouldComponentUpdate 的返回值是否为 true, true 则表示要强制更新。

简述 Context 原理

Context 的实现原理:

  • 创建 Context:createContext 返回一个 context 对象,对象包括 ProviderConsumer 两个组件属性,并创建 _currentValue 属性用来保存 context 的值
  • Provider 负责传递 context 值,并使用栈的特性存储修改 context 值
  • 消费 Context:消费组件节点调用 readContext 读取 context._currentValue 获取最新值
  • Provider 更新 Context:ContextProvider 节点深度优先遍历子代 fiber,消费 context 的 fiber 和父级链都会提升更新优先级;对于类组件的 fiber ,会被 forceUpdate 处理。接下来所有消费的 fiber,都会执行 beginWork

结语

本文对 Context 源码的理解有限,暂未能完全读完,只是过了一遍大致实现,如有错误恳请纠正。

参考文章

本文正在参加「金石计划」