写点什么

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

作者:flyzz177
  • 2022-10-31
    浙江
  • 本文字数:5333 字

    阅读完需:约 17 分钟

开篇

在 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 一个新的对象引用地址,直接修改对象属性不会触发消费组件的重渲染。


<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 组件内使用 Providervalue 值向子树传递,Child 组件通过 useContext 读取 value,从而成为 Consumer 消费组件。

三、原理分析

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


  1. 创建 Context:React.createContext() 方法;

  2. Provider 组件:<Context.Provider value={value}>

  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;}
复制代码


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

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 消费组件的分析,我们需要思考两点:


  1. <Provider> 组件上的 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);  }}
复制代码


首先,更新 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 前后是否发生变化)。


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

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

最后

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


用户头像

flyzz177

关注

还未添加个人签名 2021-12-07 加入

还未添加个人简介

评论

发布
暂无评论
react源码分析:深度理解React.Context_React_flyzz177_InfoQ写作社区