填坑 | React Context原理

发布于:2025-07-23 ⋅ 阅读:(18) ⋅ 点赞:(0)

很久没有更新我的 从0带你实现 React 18 专栏了,还剩下两个部分,一个是Context,一个是Suspense&unwind。这次来说说Context原理

Context的使用大家肯定不陌生了,被Context包裹的子节点可以通过 Consumer 或者 useContext获取Context的共享值,那么这是如何实现的呢?

我们简单的画图说明

一段简单的DOM结构: 

<Context_A.Provider value={A1}>
    <FunctionComponet1> // A1
        <Context_A.Provider value={A2}>
            <FunctionComponet2> // A2
            <FunctionComponet3> // A2
        </Context_A.Provider>
    </FunctionComponet1>
    <Context_B.Provider value={B1}> 
      <FunctionComponet4> // B1
    </Context_B.Provider>
</Context_A.Provider>

其对应的Fiber结构如下所示: 

可以看到,最外层有Context_A包裹,内部包含函数组件1 和 Context_B, 而函数组件1再次包含Context_A, Context_A 包含函数组件2 3 。 Context_B 包含函数组件4

在这四个函数组件内,其中 组件1可以拿到 A1的值,组件2 3 可以拿到 A2的值,也就是内层Context_A的值,组件4可以拿到B1, Context是可以层层嵌套的。

你也许会发现,嵌套Context的Value类似于栈,每经过一层嵌套就把最新的值Push到栈中,而回溯的时候每回溯到一层Context就把顶部的值Pop,React中就是这么实现的

React对于不同的Context,比如Context A , B 共用了一个Stack,因为Fiber节点的遍历是深度优先的,递归的顺序是稳定的,也就是说,当我向下递的顺序为 Context A -> Context B -> Context C的时候,向上回溯(归)的顺序一定是 Context C -> Context B -> Context A 天然的保证了某个节点在 递,归的两个阶段,压入栈和弹出栈的值是同一个。

当向下递到第一层 Context_A 时,会将当前Context_A的value A1 压入栈

每个Context内部都保存一个最新的值 _currentValue 当便利到一个Context时,都会将value属性传入的最新值爆存在 _currentValue中,并且推入栈

此时,Context_A下面的子节点,都能通过Context_A._currentValue拿到A1值,在函数组件1内,输出Context Value为栈顶的值 A1

继续向下递,遍历到第二层 Context_A的时候,将新的值A2 PUSH STACK 如图

 

下面的函数组件 2 3 读到的currentValue值就是 A2了,旧的的A1值就无法被 2 3 读取到了,相当于覆盖。

递到叶子结点,开始回溯,经过内层 Context_A 弹出A2,此时把弹出的A1值,赋给 Context_A的_currentValue 

继续向上回归,遍历到组件1的兄弟节点 Context_B 此时把B1 推入栈 并且 B1 赋给 _currentValue

此时 函数组件4 即可拿到 B的值 B1,如果在其中拿Context_A的值取到的也是 Context_A._currentValue =  A1,你可以理解为, _currentValue的值是随着遍历变化的,而组件读取Context都是读取其._currentValue值。

最后,回溯,弹出B1 设置 Context_B.current_value = null 弹出A1 设置Context_A._currentValue = null 如下

如果你创建Context的时候,通过参数传入初始值:

                        const Context = createContext(initialValue) 

那么._currentValue的默认值就是这个初始化值,而不是null

下面我们简单看一下实现,具体实现你可以看我的git仓库 https://github.com/Gravity2333/My-React/tree/learn

createContext & Context对象

我们使用 createContext创建一个Context对象,其结构如下所示

Context对象包含一个 _currentValue属性 记录最新的Context值

一个Provider属性指向Provider对象

一个Consumer属性指向Consumer对象

Provider和Consumer对象中包含内部属性 _context 指回 Context对象

实现代码如下:

// lib/react/context
import { REACT_CONSUMER_TYPE, REACT_CONTEXT_TYPE, REACT_PROVIDER_TYPE } from "../share/ReactSymbols";

export type Context<T> = {
  $$typeof: symbol | number;
  Provider: ContextProviderType<T>;
  Consumer: ContextConsumer<T>;
  _currentValue: T;
};

/** 创建一个Context对象 */
export default function createContext<T>(defaultValue: T): Context<T> {
  const _context: Context<T> = {
    $$typeof: REACT_CONTEXT_TYPE,
    Provider: null,
    Consumer: null,
    _currentValue: defaultValue,
  };

  // add provider
  _context.Provider = {
    $$typeof: REACT_PROVIDER_TYPE,
    _context,
  };

  // add consumer
  _context.Consumer = {
    $$typeof: REACT_CONSUMER_TYPE,
    _context,
  };

  return _context;
}

updateContextProvider

这个阶段在Beginwork阶段完成,也就是递的阶段。

这里需要注意,Context对象是不直接生成Fiber对象的,我们平时使用Context的时候也是使用 

Context.Provider / Context.Consumer 

// react-reconsiler/beginwork.ts
/** 更新ContextProvider */
function updateContextProvider(wip: FiberNode, renderLane: Lane) {
  const context = wip.type._context;
  const pendingProps = wip.pendingProps;
  // 获取最新值
  const newValue = pendingProps?.value;

  // 最新值推入 推入Context Stack
  pushContext(context, newValue);

  // reconcile child
  reconcileChildren(wip, pendingProps.children);
  return wip.child;
}

逻辑很简单,拿到最新的值,推入上下文栈,并且协调子节点,和其他类型的节点没什么区别

需要注意,整个Context对象是从 wip.type._context中取得,因为我们在使用 Context.Provider时,通常

<Context.Provider value=""> ... </Context.Provider value="">
等价于
createElement(Context.Provider, {value} ,...)

可以看到,整个Provider对象是作为type保存到Element元素中,生成Fiber的时候也会将其爆存在Fiber.type中。我们通过Provider对象._context属性即可获得Context对象!

 PushContext & PopContext 

简单说一下上下文栈,实现也很简单,如下:

// fiberContext.ts

/**
 * 用来记录上一个context的值
 */
let prevContextValue: any = null;

/**
 * 用来记录prevContextValue的栈
 * 当Context嵌套的时候,会把上一个prevContextValue push到stack中
 *
 */
const prevContextValueStack: any[] = [];

/** 推入context beginwork调用 */
export function pushContext(context: Context<any>, newValue: any) {
  // 保存prevContextValue
  prevContextValueStack.push(prevContextValue);

  // 保存currentValue
  prevContextValue = context._currentValue;

  // 给context设置newValue
  context._currentValue = newValue;
}

/** 弹出context completetwork调用 */
export function popContext(context: Context<any>) {
  /** 从prevContextValue恢复context的值 */
  context._currentValue = prevContextValue;

  /** 从prevContextValueStack恢复prevContextValue的值 */
  prevContextValue = prevContextValueStack.pop();
}

可以看到,设置了一个prevContextValue作为中间变量,你可以理解为一个类似缓存的设计,每次Push的时候,都会先将 prevContextValue的值推入栈,并且将最新的值爆存在这个变量中,Pop相反。这个过程也会将最新的值爆存在 Context._currentValue中。

 CompleteWork阶段

归的阶段,就是将Context从上下文栈中弹出,如下:

// completeWork.ts 
... 
case ContextProvider:
      // pop Context
      popContext(wip.type._context);
      bubbleProperties(wip);
      return null;

获取Context的值

看了上面的Context对象的结构你就能猜出来,可以通过 Context._currentValue的方式获取Contetx的值,但是一般不希望使用者直接操作内置对象,所以React暴露了两种操作方式

  • Context.Consumer
  • useContext hook 
Consumer

Consumer的使用方式很简单,就是传入一个函数作为children,Consumer会调用这个函数并且传入当前上下文的值,如下

<Context.Consumer>{
    contextValue => {
        ...
    }
}</Context.Consumer>

在BeginWork阶段,我们需要对Consumer类型的Fiber单独处理,这个由UpdateContextConsumer实现,如下

/** 更新Consumer */
function updateContextConsumer(wip: FiberNode, renderLane: Lane) {
  const context = wip.type?._context;
  const pendingProps = wip.pendingProps || {};
  const consumerFn = pendingProps.children;

  if (typeof consumerFn === "function") {
    const children = consumerFn(context?._currentValue);
    reconcileChildren(wip, children);
    return wip.child;
  }

  return null;
}

原理很简单,就是把children取出来,作为一个函数运行,并且传入当前Context._currentValue

 useContext

useContext作为一个hooks函数,在mount和update阶段,都调用readContext函数。

readContext函数调用readContextImpl函数

readContextImpl用来实现读取._currentValue 实现如下, 很简单,就不赘述了

// fiberContext.ts
/** 获取context内容
 *  还需要生成新的dependencies
 */
export function readContextImpl<T>(consumer: FiberNode, context: Context<T>) {
  if (!consumer) {
    throw new Error("useContext Error: 请不要在函数组件外部调用hooks");
  }

  return context._currentValue;
}

通过上面的逻辑,我们就完成了Context的最基本功能,但是下面还要解决一个性能优化的问题

Context兼容Bailout

在不引入bailout策略的情况下,父节点的内容变动,整个fiber树都会重新渲染。

这种情况下,某个Context变动,整棵树所有的Fiber节点都会重新渲染,能保证Context的更新被所有子节点感知到。

引入bailout策略之后,react每次更新会跳过那些 props type state 不变化的Fiber,那么设想,如果父Context变动,子节点都设置了 memo包裹,那么这些子节点中,即便有读取Context内容的节点也会被bailout策略跳过,因为其 state props type都没变化。

我们需要一种机制,当上层的Context变化的时候,通知其所有订阅的子节点跳过bailout策略。

我们需要在Fiber节点上设置一个属性,记录当前的Fiber节点(函数节点)订阅了哪些Context

我们在Fiber节点上加入 dependences

/** Fiber节点类 */
export class FiberNode {
  ... 
  /** Fiber依赖的Context */
  dependencies: Dependencies<any>;

/** 记录当前Fiber对象依赖的Context
 *   firstContext 依赖的第一个Context
 *   lanes 所有依赖context对应更新的
 */
export type Dependencies<T> = {
  firstContext: ContextItem<T>;
  lanes: Lanes;
};

/** context在dependencies中以链表的方式连接 其链表项为 ContextItem */
export type ContextItem<T> = {
  context: Context<T>;
  next: ContextItem<T>;
  memorizedState: T;
};

dependencies 是一个对象,其中firstContext维护了一个Context线性链表,以及这些Context在本次更新中的lanes

ContextItem记录了当前的Context对象以及next,memorizedState记录了_currentValue

我们需要在一个地方统一的挂载dependencies,我们发现上面实现的readContextImpl可以用来记录当前fiber有哪些订阅的Context, 修改上述readContextImpl实现

/** 获取context内容
 *  还需要生成新的dependencies
 */
export function readContextImpl<T>(consumer: FiberNode, context: Context<T>) {
  if (!consumer) {
    throw new Error("useContext Error: 请不要在函数组件外部调用hooks");
  }
  // 建立新的dependencies
  const contextItem: ContextItem<T> = {
    context,
    next: null,
    memorizedState: context._currentValue,
  };

  // 绑定到consumer
  if (lastContextDepItem === null) {
    // 第一个contextItem
    consumer.dependencies = {
      firstContext: contextItem,
      lanes: NoLanes,
    };

    lastContextDepItem = contextItem;
  } else {
    // 不是第一个
    lastContextDepItem = lastContextDepItem.next = contextItem;
  }

  return context._currentValue;
}

在每次读取Context值的时候,都会在其Fiber上挂depenencies,在下次更新中,就可以得知Fiber订阅了哪些Context了 

在每次便利到Provider时,我们需要对比新的Value是否有变化,这个对比通过Object.is完成,即对比内存地址。

如果没有变化,就bailout跳出,如果变化了,就需要 “通知” 所有订阅了这个Context的组件,这个通知过程由 propagateContextChange完成

/** 更新ContextProvider */
function updateContextProvider(wip: FiberNode, renderLane: Lane) {
  const context = wip.type._context;
  const memorizedProps = wip.memorizedProps;
  const pendingProps = wip.pendingProps;
  const newValue = pendingProps?.value;
  const oldValue = memorizedProps?.value;
  // 推入Context
  pushContext(context, newValue);

  // TODO bailout逻辑
  if (
    Object.is(oldValue, newValue) &&
    memorizedProps.children === pendingProps.children
  ) {
    /** 两次value相等 并且children不能变化,children变化 哪怕value不变 也要更新下面的Fiber */
    return bailoutOnAlreadyFinishedWork(wip, renderLane);
  } else {
    /** 传播Context变化 */
    propagateContextChange(wip, context, renderLane);
  }

  // reconcile child
  reconcileChildren(wip, pendingProps.children);
  return wip.child;
}

propagateContextChange的逻辑就是,从当前的Provider开始,开启一个子深度优先遍历,遍历其子树,对于每个Fiber子节点都检查其dependcies,如果包含了当前Context就在其Fiber.lane加入当前更新的优先级lane,这样在遍历到这个节点的时候,就会因为其存在状态 state,不会bailout跳过。 同时,这个遍历的过程向下终止到 叶子结点 或者 相同Context的Provider,因为相同Context Provider下的子节点就不由当前的Provider管理了,实现如下:

/** 传播context变化 */
export function propagateContextChange(
  wip: FiberNode,
  context: Context<any>,
  renderLane: Lane
) {
  let nextFiber = wip.child;
  while (nextFiber !== null && nextFiber !== wip) {
    const deps = nextFiber.dependencies;
    if (deps) {
      let contextItem = deps.firstContext;
      while (contextItem !== null) {
        if (contextItem.context === context) {
          // 找到对应的Context 设置lane
          // 设置fiber和alternate的lane
          nextFiber.lanes = mergeLane(nextFiber.lanes, renderLane);
          if (nextFiber.alternate !== null) {
            nextFiber.alternate.lanes = mergeLane(
              nextFiber.alternate.lanes,
              renderLane
            );
          }
          // 从当前Fiber到Provide的Fiber 标记childLanes
          scheduleContextOnParentPath(nextFiber, wip, renderLane);
          // 设置deps.lane
          deps.lanes = mergeLane(deps.lanes, renderLane);
          // break
          break;
        }else{
          contextItem =contextItem.next
        }
      }
    } else if (
      ((nextFiber.tag === ContextProvider && nextFiber.type !== wip.type) ||
        nextFiber.tag !== ContextProvider) &&
      nextFiber.child !== null
    ) {
      nextFiber = nextFiber.child;
      continue;
    }
    // 回溯
    while (nextFiber.sibling === null) {
      if (nextFiber.return === null || nextFiber.return === wip) {
        return // 直接return 跳出所有循环
      }
      nextFiber = nextFiber.return;
    }

    nextFiber = nextFiber.sibling;
  }
}

/** 在parent路径上调度Context
 * 从当前找到context的节点,到provider节点 标记childLanes
 */
function scheduleContextOnParentPath(
  from: FiberNode,
  to: FiberNode,
  renderLane: Lane
) {
  if (from === to) return;
  let parentNode = from.return;
  while (parentNode !== null && from !== to) {
    parentNode.childLanes = mergeLane(parentNode.childLanes, renderLane);
    if (parentNode.alternate !== null) {
      parentNode.alternate.childLanes = mergeLane(
        parentNode.alternate.childLanes,
        renderLane
      );
    }
    parentNode = parentNode.return;
  }
}

我们当前的实现不考虑类组件,所以Context都是在函数组件中完成读取的,这就要我们在开始渲染函数组件之前,先检查这个函数是否包含Context的变化,这个逻辑由 prepareToReadContext完成。

/** 处理函数节点的比较 */
function updateFunctionComponent(
  wip: FiberNode,
  Component: Function,
  renderLane: Lane
): FiberNode {
  /** 重制dependencies信息 */
  prepareToReadContext(wip, renderLane);
  // renderWithHooks 中检查,如果状态改变 则置didReceiveUpdate = true
  const nextChildElement = renderWithHooks(wip, Component, renderLane);
  if (wip.alternate !== null && !didReceiveUpdate) {
    // bailout
    // 重置hook
    bailoutHook(wip, renderLane);
    return bailoutOnAlreadyFinishedWork(wip, renderLane);
  }
  reconcileChildren(wip, nextChildElement);
  return wip.child;
}

这个函数有两个功能,一个是在进入当前函数节点之前判断当前函数节点是否存在因为Context变动而发生的更新 一个是清空dependcies,准备开始下一轮的Context订阅收集

/** readContext之前的准备工作  每次函数调用之前执行 重新设置dependencies*/
export function prepareToReadContext(wip: FiberNode, renderLane: Lane) {
  lastContextDepItem = null; // 置空
  if (wip.dependencies !== null) {
    if (isSubsetOfLanes(wip.dependencies.lanes, renderLane)) {
      // 当前函数组件内的Context 包含当前renderLane更新优先级的更新
      markWipReceiveUpdate(); // 当前函数组件 不能bailout
    }
    wip.dependencies = null; // 置空
  }
}

可以看到,在popagate阶段,会讲所有的Context更新对应的lane合并到 ContextItem的lane上,所以在 prepareToReadContext 中,只需要检查一下当前渲染的lane,是否为ContextItem.lanes的子lane即可判断当前函数组件是否存在因Context变动触发的更新。

最后,对Consumer组件的渲染,我们也进行改造,把通过 context._currentValue读取Context值,统一改成 readContextImpl

/** 更新Consumer */
function updateContextConsumer(wip: FiberNode, renderLane: Lane) {
  const context = wip.type?._context;
  const pendingProps = wip.pendingProps || {};
  const consumerFn = pendingProps.children;

  if (typeof consumerFn === "function") {
    const children = consumerFn(readContextImpl(wip,context));
    reconcileChildren(wip, children);
    return wip.child;
  }

  return null;
}

通过这种方式,我们就可以让Context兼容bailout策略


网站公告

今日签到

点亮在社区的每一天
去签到