React 副作用链表(Effect List):在 v16/v17 源码中如何通过 firstEffect 指针追踪变更

各位好,欢迎来到“React 源码深潜室”。

今天我们不讲组件怎么写,也不讲 Hooks 的最佳实践。我们要聊的是 React 脂肪层下的一块硬骨头——副作用链表

如果你觉得 React 的渲染过程像是在盖房子,那么“副作用”就是那个负责通下水道、装窗帘、把家具搬进去的人。在 React 16 和 17 的源码中,React 并没有在渲染阶段(Render Phase)直接去触碰这些“脏活累活”,而是采用了一种非常精妙的数据结构——链表,来记录谁需要干什么活。

今天,我们就来扒开 firstEffect 指针,看看这个链表是如何像一条贪吃蛇一样,在渲染和提交之间穿梭的。

第一部分:渲染阶段——副作用的“潜伏”

首先,我们要搞清楚一个核心概念:渲染阶段(Render Phase)

在这个阶段,React 正在构建 Fiber 树。这时候,React 的心情是“思考中”。它知道你在组件里写了 useEffect(() => {}, []),也知道你写了 useLayoutEffect,但 React 现在还不能去执行这些回调函数里面的逻辑。为什么?因为这时候还没有 DOM 节点,或者正在构建的 DOM 节点还没定稿。如果在渲染阶段直接操作 DOM,那不就成了同步阻塞的噩梦了吗?

所以,React 16/17 的策略是:在渲染阶段,让副作用回调函数先“跑”一遍,但只跑不干实事,而是把“干活”的任务记下来。

这时候,firstEffect 就登场了。

1. Fiber 节点上的 Effect 指针

每个 Fiber 节点(也就是每个组件的实例)都有一个属性叫 firstEffect。它的类型是 Effect 对象的引用。

// 源码简化版
interface FiberNode {
  // ... 其他属性
  firstEffect: Effect | null;
  lastEffect: Effect | null;
}

interface Effect {
  tag: number; // 标记是哪种副作用
  nextEffect: Effect | null; // 指向下一个 Effect
  callback: Function; // 实际要执行的函数
  deps: any[]; // 依赖数组
}

你可以把 firstEffect 想象成这个 Fiber 节点的“购物清单”。每当组件渲染时,如果有副作用需要处理,React 就会把一个 Effect 对象加到这个清单的末尾。

2. 代码演示:副作用是如何被“塞”进链表的?

假设我们在组件中写了一个 useEffect

function MyComponent() {
  useEffect(() => {
    console.log("我要去提交阶段执行了");
    // 这里返回 null,表示没有清理函数
  }, []);

  return <div>Hello</div>;
}

当 React 处理 MyComponent 这个 Fiber 节点时,会发生什么呢?源码中的逻辑大概是这样的(为了方便理解,我们做了大幅简化):

// 假设这是 Fiber 树构建过程中的一个函数
function commitWork(fiber) {
  // 1. 执行副作用回调函数(为了检查依赖项,但不执行副作用逻辑)
  // 在 React 16/17 中,useEffect 的回调函数在渲染阶段会被调用
  const effectCallback = fiber.updateQueue?.effects?.[0]; // 这里简化了获取逻辑

  if (effectCallback) {
    // 2. 创建一个 Effect 对象
    const effect = {
      tag: 0, // 默认是 0,代表普通 useEffect
      nextEffect: null,
      callback: effectCallback, // 把刚才跑完的函数存进去
      deps: [], // 依赖数组
    };

    // 3. 把这个 Effect 对象挂载到当前 Fiber 节点的 firstEffect 链表上
    if (!fiber.firstEffect) {
      // 如果当前节点还没有清单,那我就是第一条
      fiber.firstEffect = effect;
      fiber.lastEffect = effect;
    } else {
      // 如果已经有清单了,我就排在最后面
      fiber.lastEffect!.nextEffect = effect;
      fiber.lastEffect = effect;
    }
  }
}

注意看上面的代码,fiber.firstEffect 就像是一条链子的头。React 通过 nextEffect 指针,把一个个 Effect 对象串了起来。

这时候,渲染阶段结束了,Fiber 树构建完成了,DOM 节点也挂好了。React 闭上了眼睛,深吸一口气,准备进入提交阶段(Commit Phase)

第二部分:提交阶段——链表的“收割”时刻

提交阶段,React 拿着渲染阶段准备好的 DOM 树,开始真刀真枪地干活了。这时候,它需要找到刚才记录下来的副作用。

它首先会找到根节点(RootFiber),然后检查根节点的 firstEffect

1. 遍历链表:从 Head 到 Tail

React 16/17 的提交阶段入口在 commitRoot 函数中。这里有一个非常经典的遍历循环,就像是一个尽职尽责的快递员,按照清单一个个送件。

// 源码简化版
function commitRoot(root) {
  // ... 前面的 DOM 插入、删除逻辑省略 ...

  // 1. 获取根节点的副作用链表头
  let firstEffect = root.firstEffect;

  // 2. 开始遍历链表
  while (firstEffect !== null) {
    const effect = firstEffect;

    // 3. 根据不同的 tag(标签),执行不同的逻辑
    switch (effect.tag) {
      case EffectTag.Placement: // 插入
      case EffectTag.Update: // 更新
        commitPlacement(effect); // 插入 DOM
        break;
      case EffectTag.Deletion: // 删除
        commitDeletion(effect); // 删除 DOM
        break;
      case EffectTag.Callback: // 副作用回调
        commitCallback(effect); // 执行 useEffect
        break;
      // ... 还有 useLayoutEffect, useInsertionEffect 等等
    }

    // 4. 移动指针,去下一个节点
    firstEffect = effect.nextEffect;
  }

  // 5. 链表遍历完毕,提交阶段结束
}

这里有个非常有趣的点:nextEffect 指针在遍历过程中会被修改。

在 React 源码中,为了防止在遍历过程中链表被修改(比如 React 在遍历过程中突然又发现了一个新副作用——虽然这很少见,但在并发模式下有这种可能性),React 通常会在遍历前备份一个指针,或者利用 lastEffect 倒序遍历。

但在经典的 16/17 模型中,通常是这样工作的:

  1. React 拿到 root.firstEffect
  2. 处理当前节点。
  3. firstEffect 指向 nextEffect,继续下一轮循环。

2. 代码演示:执行回调

那么,commitCallback 做了什么呢?它会把之前存好的函数拿出来执行。

function commitCallback(effect) {
  const { callback } = effect;

  // 检查依赖项是否变化(React 会做这个检查)
  // 这里简化了依赖检查逻辑
  if (shouldRunEffect(callback)) {
    // 执行副作用
    callback();
  }
}

对于 useEffect 来说,这里的 callback 就是你写在 useEffect 里面的那个箭头函数。

第三部分:16 vs 17 —— 严格模式下的“双胞胎”难题

现在,我们知道了链表是怎么连的,以及怎么遍历的。但是,React 16 和 React 17 在处理这个链表时,有一个巨大的区别,那就是严格模式(Strict Mode)

在 React 16 中,如果你开启了严格模式,组件会被渲染两次。

function MyComponent() {
  useEffect(() => {
    console.log("我执行了");
    return () => {
      console.log("我清理了");
    };
  }, []);
  return <div />;
}

在 React 16 下,严格模式会让 MyComponent 渲染两次。这意味着什么?意味着 useEffect 的回调会被执行两次。console.log("我执行了") 会打印两次。

这会导致什么问题?如果你的 useEffect 里去操作 DOM,或者发送网络请求,你会得到两个结果,或者两个请求。这显然是个大坑。

为什么 React 16 会这样?

因为在 React 16 的渲染阶段,副作用回调函数是同步调用的。当 React 构建完第一遍 Fiber 树,执行完副作用,然后又构建第二遍 Fiber 树,再次执行副作用。这时候,链表是动态变化的。

React 16 的代码逻辑大概是这样的(伪代码):

// React 16 逻辑(简化)
function renderWithHooks(fiber) {
  // ... 构建 Fiber ...

  // 执行 useEffect
  const effectCallback = () => { /* ... */ };
  const effect = { tag: 0, callback: effectCallback, ... };

  // 此时链表是: [Effect1]
  fiber.firstEffect = effect;

  // 严格模式要求再次渲染
  // renderWithHooks(fiber) 被再次调用
  // 此时链表变成了: [Effect2, Effect1]  <-- 注意顺序变了!
  fiber.firstEffect = effect; 

  // 提交阶段遍历
  let current = fiber.firstEffect;
  while(current) {
    // 先执行 Effect2,再执行 Effect1
    current.callback();
    current = current.nextEffect;
  }
}

所以在 React 16 中,严格模式下的副作用是顺序执行的。这导致了双重挂载和双重请求的问题。

React 17 是怎么解决的?

React 17 引入了“副作用隔离”的概念。在 React 17 中,即使在严格模式下,React 也会保证副作用回调只执行一次。

它怎么做到的?它利用了闭包队列的机制。React 16 和 17 在收集 Effect 对象的方式上有细微差别。

在 React 17 中,commitBeforeMutationEffectscommitLayoutEffects 这两个阶段被明确区分开来,并且对于副作用回调的执行做了隔离。

虽然底层链表结构(firstEffect)没有变,但 React 17 在 render 阶段处理 useEffect 回调时,增加了一层“防抖”或者“去重”的逻辑(具体源码在 useEffect 的初始化阶段)。

简单来说,React 17 知道:“嘿,我在严格模式下,这个回调我已经执行过了,别再跑一遍了。”

这就像是你去餐厅点菜(渲染),服务员(React)拿着菜单(链表)去厨房(提交阶段)做菜。在 React 16 里,如果老板让你重做一遍菜单,服务员可能会傻乎乎地真的去厨房做两遍。而在 React 17 里,服务员会聪明地说:“老板,这道菜我刚做完,不用再做了。”

第四部分:深入剖析 Effect 对象的 Tag 标签

光有链表还不够,React 怎么知道这个 Effect 对象是 useEffect,还是 useLayoutEffect,或者是 useInsertionEffect

这就全靠 effect.tag 了。这是一个数字,每一位代表一个含义。

在源码中,你会看到这样的常量定义:

// 源码中的常量
const NoFlags = 0b00000000;
const PerformedWork = 0b00000001; // 已经做过的标记
const Placement = 0b00000010; // 插入
const Update = 0b00000100; // 更新
const Deletion = 0b00001000; // 删除

// Effect 特有的标记
const PassiveEffect = 0b00010000; // 对应 useEffect (异步)
const LayoutEffect = 0b00100000; // 对应 useLayoutEffect (同步)
const PassiveMount = 0b01000000; // mount 时的 passive
const PassiveUnmount = 0b10000000; // unmount 时的 passive

当你在提交阶段遍历链表时,React 会用位运算来检查:

// 伪代码:处理 PassiveEffect (useEffect)
function commitWork(fiber) {
  const nextEffect = fiber.firstEffect;
  while (nextEffect) {
    if ((nextEffect.tag & PassiveEffect) !== 0) {
      // 这是一个 useEffect
      // 将其加入异步任务队列
      schedulePassiveEffects(nextEffect);
    } else if ((nextEffect.tag & LayoutEffect) !== 0) {
      // 这是一个 useLayoutEffect
      // 立即执行
      flushLayoutEffects(nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

这里有一个非常关键的性能优化点:

React 在提交阶段会分两波遍历链表:

  1. Before Mutation(变异前): 处理 useLayoutEffect。因为 useLayoutEffect 是同步的,必须在 DOM 变更后、浏览器绘制前完成。这时候 React 还没把 DOM 真的画出来(Mutation),所以它在这里执行逻辑,然后统一修改 DOM。
  2. Layout(布局): 真正的 DOM 变更发生在这里。
  3. Mutation(变异后): 处理 useEffect。这时候 DOM 已经画完了,React 可以放心大胆地去执行异步回调,因为浏览器不会因为 JS 执行而卡顿绘制。

第五部分:为什么是链表?为什么不是数组?

你可能会问,为什么 React 不用 Array 来存副作用,而要用链表?

这是个好问题!作为资深专家,我必须给你分析一下其中的“弯弯绕”。

1. 内存局部性

React 的 Fiber 树本身就是一棵树。每个节点都是一个对象。如果用数组,数组是连续内存,但副作用是分散在各个 Fiber 节点上的(有的在 Root,有的在子节点)。
用链表,React 只需要顺着 fiber.firstEffect 往下走就行了。它不需要去遍历整棵树,也不需要去遍历一个巨大的数组。它只需要遍历“真正发生了副作用”的那些节点。

2. 动态追加的便利性

在渲染阶段,React 是从上往下遍历 Fiber 树的。
当它走到节点 A,发现 A 有个 useEffect,它就创建一个 Effect 对象,挂到 A 的 firstEffect 上。
当它走到节点 B,发现 B 也有个 useEffect,它就创建另一个,挂到 B 上。
这种“走到哪儿,记到哪儿”的模式,用链表是再自然不过的了。

3. 顺序的保证

在 React 16/17 中,副作用链表通常是按照渲染顺序构建的。父组件的副作用在链表的前面,子组件的在后面。这意味着 useLayoutEffect 的执行顺序和组件的渲染顺序是一致的。这对于调试和保证状态更新的顺序非常重要。

第六部分:实战演练——追踪一次完整的渲染

让我们把所有东西串起来。假设我们有这样一个组件树:

function Parent() {
  useEffect(() => console.log("Parent Effect"));
  return <Child />;
}

function Child() {
  useEffect(() => console.log("Child Effect"));
  return <div>Hi</div>;
}

function App() {
  return <Parent />;
}

渲染阶段:

  1. React 开始构建 App 的 Fiber。
  2. 遇到 Parent,执行 Parent 的渲染逻辑。
  3. 调用 useEffect 回调。
  4. React 创建 Effect 对象 P_Effect,挂载到 ParentfirstEffect 链表上。
  5. 递归构建 Child 的 Fiber。
  6. 遇到 Child,执行渲染逻辑。
  7. 调用 useEffect 回调。
  8. React 创建 Effect 对象 C_Effect
  9. 因为 Parent 已经有了 P_Effect,React 把 C_Effect 挂到 Parent.lastEffect.nextEffect 上。
  10. 最终,Parent.firstEffect 指向 P_EffectP_Effect.nextEffect 指向 C_Effect

提交阶段:

  1. commitRoot 开始工作。
  2. 拿到 App.firstEffect(也就是 P_Effect)。
  3. 进入 while 循环。
  4. 处理 P_Effect
    • 如果是 useLayoutEffect,立即执行。
    • 如果是 useEffect,加入异步队列。
  5. 指针后移,指向 C_Effect
  6. 处理 C_Effect
    • 如果是 useLayoutEffect,立即执行。
    • 如果是 useEffect,加入异步队列。
  7. 指针后移,变成 null,循环结束。

输出结果:
在控制台里,你会看到 Parent EffectChild Effect 的打印顺序。这完全依赖于 firstEffect 链表的构建顺序。

第七部分:源码中的细节彩蛋

既然是源码深潜,我们得聊聊源码里那些让人头疼又让人兴奋的细节。

1. lastEffect 的存在

你可能会问,既然有 firstEffect,为什么还要 lastEffect

因为在渲染过程中,React 可能会动态地插入新的副作用(虽然少见,但在某些复杂的并发场景下会发生)。有了 lastEffect,React 可以快速地在链表末尾追加新节点,而不需要从头遍历到尾去寻找尾巴。

2. Effect 对象的复用

在 React 源码中,Effect 对象并不是每次都 new Effect() 的。React 会有一个全局的池子(Pool)来复用这些对象。当一个 Effect 被处理完后,它会回到池子里,等待下一次被取用。这进一步减少了垃圾回收(GC)的压力。

3. useInsertionEffect

React 18 引入了 useInsertionEffect,它的优先级在 useLayoutEffectuseEffect 之间。在 React 17 的源码中,你可以在 commitBeforeMutationEffects 阶段看到它的处理逻辑。它的出现是为了解决 Tailwind CSS 等库在 SSR 和 hydration 时的闪烁问题。

总结

好了,各位,今天我们像剥洋葱一样,一层层剥开了 React 副作用链表的内核。

我们看到了:

  1. 渲染阶段是“潜伏期”,副作用函数跑起来,把自己塞进 fiber.firstEffect 的链表里。
  2. 提交阶段是“收割期”,React 拿着这个链表,根据 tag 标记,分别处理 useLayoutEffect(同步,同步)和 useEffect(异步,异步)。
  3. React 16 vs 17,主要区别在于对严格模式下副作用重复执行的隔离处理。
  4. 链表结构的高效性,让 React 能够以极低的成本追踪每一个组件的副作用。

记住,firstEffect 不是一个简单的指针,它是 React 在“思考”和“行动”之间架起的一座桥梁。它保证了 React 可以在渲染阶段专注于构建虚拟 DOM,而在提交阶段专注于真实 DOM 的更新和副作用回调的执行。

这就是 React 内部世界的一个小小角落,但正是这些微小的机制,支撑起了现代前端框架的基石。下次当你写 useEffect 时,希望你能想起那个在链表中游走的 nextEffect 指针,它会告诉你,你的代码到底经历了怎样的旅程才最终跑到了屏幕上。

祝大家编码愉快,源码愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注