开篇
在 React 中提供了一种「数据管理」机制:React.context
,大家可能对它比较陌生,日常开发直接使用它的场景也并不多。
但提起 react-redux
通过 Provider
将 store
中的全局状态在顶层组件向下传递,大家都不陌生,它就是基于 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 一个新的对象引用地址,直接修改对象属性不会触发消费组件的重渲染。
<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 在线平台 转换查看。
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
发生更新的动作,然后通知组件「按需」执行重渲染。
最后
感谢阅读,如有不足之处,欢迎指出讨论。