从源码角度理解 React.Context
开篇
在 React 中提供了一种「数据管理」机制:React.context
,大家可能对它比较陌生,日常开发直接使用它的场景也并不多。
但提起 react-redux
通过 Provider
将 store
中的全局状态在顶层组件向下传递,大家都不陌生,它就是基于 React 所提供的 context 特性实现。
本文,将从概念、使用,再到原理分析,来理解 Context 在多级组件之间进行数据传递的机制。
有关组件的重渲染条件机制,可以参考上篇文章「浅谈 React 函数组件性能优化手段」
与之相关的两篇文章可以翻阅这里:
一、概念
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 一个新的对象引用地址,直接修改对象属性不会触发消费组件的重渲染。
<Context.Provider value={/* 某个值,一般会传递对象 */}>
2.3、React.useContext
Context Provider
组件提供了向下传递的 value
数据,对于函数组件,可通过 useContext
API 拿到 Context value
。
const value = useContext(Context);
useContext
接收一个 context 对象(React.createContext 的返回值),返回该 context 的当前值。
当组件上层最近的 <Context.Provider> 更新时,当前组件会触发重渲染,并读取最新传递给 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'));
示例中,在 App 组件内使用 Provider
将 value
值向子树传递,Child 组件通过 useContext 读取 value,从而成为 Consumer
消费组件。
三、原理分析
从上面「使用」我们了解到:Context 的实现由三部分组成:
- 创建 Context:
React.createContext()
方法; - Provider 组件:
<Context.Provider value={value}>
; - 消费 value:
React.useContext(Context)
方法。
原理分析脱离不了源码,下面我们挑选出核心代码来看看它们的实现。
3.1、createContext 函数实现
createContext 源码定义在 react/src/ReactContext.js
位置。它返回一个 context
对象,提供了 Provider
和 Consumer
两个组件属性,_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;
}
尽管在这里我们只看到要返回一个对象,却看不出别的名堂,只需记住它返回的对象结构信息即可,我们接着往下看。
3.2、 JSX 编译
我们所编写的 JSX 语法在进入 render 时会被 babel
编译成 ReactElement
对象。我们可以在 babel repl 在线平台 转换查看。
对于我们的 example 示例,经过转换后的格式如下:
JSX 语法最终会被转换成 React.createElement
方法,我们在 example 环境下执行方法,返回的结果是一个 ReactElement
元素对象。
对象的 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
消费组件的分析,我们需要思考两点:
<Provider>
组件上的 value 值何时更新到context._currentValue
?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);
}
}
首先,更新 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; // 拿出第一个 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
}
}
}
}
}
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>
);
}
}
为了防止这种情况,可以将 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>
);
}
}
五、对比 useSelector
从「注意事项」可以考虑:要想使消费组件进行重渲染,context value
必须返回一个全新对象,这将导致所有消费组件都进行重渲染,这个开销是非常大的,因为有一些组件所依赖的值可能并未发生变化。
当然有一种直观做法是将「状态」分离在不同 Context
之中。
react-redux useSelector
则是采用订阅 redux store.state
更新,去通知消费组件「按需」进行重渲染(比较所依赖的 state 前后是否发生变化)。
-
提供给
Context.Provider
的 value 对象地址不会发生变化,这使得子组件中使用了useSelector -> useContext
,但不会因顶层数据而进行重渲染。 -
store.state
数据变化组件如何更新呢?react-redux
订阅了redux store.state
发生更新的动作,然后通知组件「按需」执行重渲染。
react-redux
的原理分析可以移步到 React-Redux 技术分享 查看。
最后
感谢阅读,如有不足之处,欢迎指出讨论。
转载自:https://juejin.cn/post/7138744777641574407