react源码分析:深度理解React.Context
创始人
2024-04-02 06:39:30
0

开篇

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

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

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

2.2、Context.Provider

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

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

/* 某个值,一般会传递对象 */}>

2.3、React.useContext

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

const value = useContext(Context);

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 (
theme: {value.theme}
) }const App = () => {const [count, setCount] = React.useState(0);return ({ theme: 'light' }}>
() => setCount(count + 1)}>触发更新
) }ReactDOM.render(, document.getElementById('root'));

示例中,在 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(defaultValue: T): ReactContext {const context: ReactContext = {$$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;
}

尽管在这里我们只看到要返回一个对象,却看不出别的名堂,只需记住它返回的对象结构信息即可,我们接着往下看。

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,
};

有了对象描述结构,接下来进入渲染流程并在 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;
}

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

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

  1. 组件上的 value 值何时更新到 context._currentValue
  2. Provider.value 值发生更新后,如果能够让消费组件进行重渲染 ?

这两点都会在这里找到答案。

在 example 中,点击「触发更新」div 后,React 会进入调度更新阶段。我们通过断点定位到 Context.Provider Fiber 节点的 Reconciler/beginWork 之中。

截屏2022-08-30 下午9.15.10.png

Provider Fiber 类型为 ContextProvider,因此进入 tag switch case 中的 updateContextProvider

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

首先,更新 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
}

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; // 拿出第一个 contextwhile (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}}}}
}

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 ({something: 'something'}}>);}
}

为了防止这种情况,可以将 value 状态提升到父节点的 state 里:

class App extends React.Component {constructor(props) {super(props);this.state = {value: { something: 'something' },};}render() {return (this.state.value}>);}
}

五、对比 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 发生更新的动作,然后通知组件「按需」执行重渲染。

最后

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

相关内容

热门资讯

监控摄像头接入GB28181平... 流程简介将监控摄像头的视频在网站和APP中直播,要解决的几个问题是:1&...
Windows10添加群晖磁盘... 在使用群晖NAS时,我们需要通过本地映射的方式把NAS映射成本地的一块磁盘使用。 通过...
protocol buffer... 目录 目录 什么是protocol buffer 1.protobuf 1.1安装  1.2使用...
在Word、WPS中插入AxM... 引言 我最近需要写一些文章,在排版时发现AxMath插入的公式竟然会导致行间距异常&#...
Fluent中创建监测点 1 概述某些仿真问题,需要创建监测点,用于获取空间定点的数据࿰...
educoder数据结构与算法...                                                   ...
MySQL下载和安装(Wind... 前言:刚换了一台电脑,里面所有东西都需要重新配置,习惯了所...
MFC文件操作  MFC提供了一个文件操作的基类CFile,这个类提供了一个没有缓存的二进制格式的磁盘...
有效的括号 一、题目 给定一个只包括 '(',')','{','}'...
【PdgCntEditor】解... 一、问题背景 大部分的图书对应的PDF,目录中的页码并非PDF中直接索引的页码...