React useEffect 副作用链表存储模型

欢迎来到 React 内部世界的“幕后黑手”讲座

各位编程界的同仁、未来的架构师、以及那些被 useEffect 弄得头秃的同学们,大家晚上好(或者早上好,取决于你的时区)。

今天我们不聊那些花里胡哨的 UI 库,不聊 Next.js 的 SSR 优化,也不聊 TypeScript 的类型体操。今天,我们要把手伸进 React 的“裤兜”里,去掏那个最核心、最神秘、也是最迷人的脏活累活——useEffect 副作用链表存储模型

如果你觉得 React 只是一个写组件的库,那你就像以为《黑客帝国》里的人只是嚼口香糖一样天真。React 的渲染循环是纯净的数学逻辑,而 useEffect 就是那个负责把数学逻辑变成现实(DOM 操作)的翻译官。而这个翻译官,靠的不是魔法,而是一根根看不见的“链表”。

准备好了吗?让我们撕开 React 的伪装,来一场深度解剖。


第一部分:为什么我们需要一个“链表”?

在深入代码之前,我们先来聊聊哲学。或者说,聊聊 React 的设计哲学。

React 的核心理念是“声明式编程”。你告诉 React“状态是什么”,React 告诉你“UI 应该是什么”。这很美,很优雅。但是,浏览器不懂优雅。浏览器只懂命令:“嘿,DOM,把这个元素删了,把那个元素移过去,把颜色改成红色。”

这就产生了冲突。React 的渲染循环是同步的,它只负责计算,不负责修改。修改 DOM 属于“副作用”。为了不让副作用污染渲染循环的纯净性,React 把副作用抽离了出来,交给了 useEffect

但是,问题来了:一个组件里可能有十个 useEffect,父组件里有五个,子组件里有三个。当组件卸载或者更新时,React 怎么知道该执行哪个?该先执行哪个?该先清理哪个?

React 不能用数组索引,因为索引会变(比如你删了第 2 个 useEffect)。React 也不能用对象哈希,因为哈希计算太慢了。

于是,React 想到了链表。是的,就是那个让你大一数据结构课头疼的链表。

React 在每个 Fiber 节点(React 的虚拟 DOM 节点,也就是组件的化身)里,维护了两个指针:firstEffectlastEffect。这就像是在每个组件门口挂了一串钥匙。

firstEffect 是这串钥匙的第一把,lastEffect 是最后一把。 所有的 Effect 节点通过 next 指针连在一起,形成了一条长长的队伍。这就是所谓的副作用链表


第二部分:EffectNode 的“三六九等”

链表上的每一个节点,不仅仅是一个函数,它是一个信息包,一个包含了任务描述、依赖检查和清理逻辑的“三明治”。

让我们来定义这个核心数据结构。在 React 源码中,它通常被称为 Effect 节点。

// 这是一个高度简化的概念模型,为了方便理解
class EffectNode {
  constructor(tag, create, deps) {
    // 1. tag: 标签,决定了这个 Effect 是做什么的
    // React 使用位掩码来存储这些信息
    this.tag = tag; 

    // 2. create: 副作用函数本身 (就是 useEffect 里传的那个函数)
    this.create = create;

    // 3. deps: 依赖数组
    this.deps = deps;

    // 4. next: 链表指针
    this.next = null;
  }
}

那么,tag 到底是个什么神仙玩意儿?React 为了节省内存和计算,把 Effect 的生命周期浓缩成了三种状态:

  1. 插入: 组件刚刚挂载。
    • 含义: useEffect 第一次执行,没有清理函数。
    • Tag值: Placement (0x0001)
  2. 更新: 组件重新渲染,且依赖变了。
    • 含义: useEffect 再次执行。如果有旧的清理函数,先执行旧的;再执行新的。
    • Tag值: Update (0x0002)
  3. 删除: 组件卸载。
    • 含义: 组件销毁,必须执行清理函数。
    • Tag值: Deletion (0x0004)

这三种状态就像是一个人的三种人生阶段:出生(插入)、成长(更新)、死亡(删除)。React 在构建 Fiber 树的时候,就把这些状态打上标签,挂到链表上。


第三部分:渲染循环中的“挂链子”过程

好,现在我们知道 Effect 是链表结构,节点有三种状态。那么,这些节点是怎么产生的?

这要回到 React 的渲染阶段。当你写了一个组件:

function App() {
  useEffect(() => {
    console.log("我是副作用");
  }, []); // 空依赖,意味着我只会执行一次
}

React 在渲染这个组件时,会创建一个 Fiber 节点。在这个 Fiber 节点的 updateQueue(更新队列)里,React 会创建一个 EffectNode 对象。

这里有一个非常关键的细节:依赖数组的比较

React 不会傻傻地每次渲染都创建一个新的 EffectNode。它会对比当前的依赖数组([])和上一次的依赖数组。

  • 如果依赖没变,React 会复用旧的 EffectNode,把它标记为 Update
  • 如果依赖变了(比如从 [] 变成了 [count]),或者组件是第一次渲染,React 会创建一个新的 EffectNode。

这个过程就像是你在点外卖。每次你刷新页面,React 都要检查你的“购物车”(EffectNode 队列)里有什么。如果只是把水换成可乐,那就是 Update;如果是把水换成牛肉面,这就是一个新的订单。


第四部分:提交阶段——链表的“大阅兵”

渲染阶段结束了,React 已经算出了新的 DOM。接下来,就是最激动人心的提交阶段

在这个阶段,React 会遍历 Fiber 树,执行所有的 Effect。这就是那个“副作用链表”真正发挥作用的时候。

注意,这里有两个非常重要的队列,它们决定了执行的顺序和时机:

  1. Layout Effects (同步): useLayoutEffect
  2. Passive Effects (异步): useEffect

4.1 useLayoutEffect: 也就是“布局后,绘制前”

这是 React 最早引入的副作用钩子。因为它在绘制之前执行,所以它是同步的。

想象一下,你正在画一幅画(DOM 渲染)。
useLayoutEffect 就像是你在画笔落下去之前,先用尺子量一下画布的大小,调整一下画框的边框。因为是在绘制前,所以用户看不到这个调整的过程,但浏览器必须立刻执行完这个调整,才能开始画。

在链表遍历中,React 会优先处理 firstEffect 链表中的 Layout 标签节点。

4.2 useEffect: 也就是“绘制后,事件循环前”

这是后来引入的,也就是我们最常用的那个。

它就像是画完画之后,你在画框后面挂了一块牌子,写上“请勿触摸”。
因为是在绘制之后,所以它是异步的。浏览器先把画展示给用户看,然后才去处理这块牌子。

在链表遍历中,React 会把所有 Passive 标签的节点推入一个任务队列,然后交给浏览器的 requestIdleCallback 或者 setTimeout 去执行。这样就不会阻塞用户的视觉体验了。


第五部分:深度代码模拟——构建一个微型 React 引擎

为了让大家彻底明白,我不讲虚的,直接上代码。我们来手写一个简化的 React,模拟 useEffect 的链表存储和执行逻辑。

5.1 定义核心结构

首先,我们需要一个 FiberNode 来代表组件实例,以及一个 EffectNode 来代表副作用。

// EffectNode: 副作用节点
const EffectTag = {
  Placement: 0x0001, // 插入
  Update: 0x0002,    // 更新
  Deletion: 0x0004,  // 删除
  Passive: 0x0008    // 被动(useEffect)
};

class EffectNode {
  constructor(tag, create, deps) {
    this.tag = tag;
    this.create = create;
    this.deps = deps;
    this.next = null;
    this.cleanup = null; // 清理函数
  }
}

// FiberNode: 组件节点
class FiberNode {
  constructor(tag) {
    this.tag = tag; // 组件类型
    this.stateNode = null; // DOM 实例或组件实例
    this.firstEffect = null; // 链表头
    this.lastEffect = null;  // 链表尾
  }
}

5.2 模拟 useEffect 的挂载

当组件首次渲染时,React 会创建 EffectNode 并插入到链表中。

function mountEffect(create, deps) {
  // 获取当前正在渲染的 Fiber 节点(这里简化处理,假设有个全局 currentFiber)
  const fiber = currentFiber; 

  const effectNode = new EffectNode(EffectTag.Placement, create, deps);

  // 将 EffectNode 插入到 Fiber 的 Effect 链表尾部
  if (fiber.lastEffect === null) {
    fiber.firstEffect = fiber.lastEffect = effectNode;
  } else {
    fiber.lastEffect.next = effectNode;
    fiber.lastEffect = effectNode;
  }
}

5.3 模拟 useEffect 的更新

当组件重新渲染时,React 会比较依赖数组。

function updateEffect(create, deps) {
  const fiber = currentFiber;

  // 1. 获取上一个 EffectNode
  const prevEffect = fiber.firstEffect;

  let nextEffect = prevEffect;
  let hasChanged = false;

  // 2. 遍历旧链表,查找匹配的 Effect
  while (nextEffect !== null) {
    // 简单的依赖比较(实际上 React 会做更复杂的浅比较)
    const depsEqual = 
      (nextEffect.deps === null && deps === null) || 
      (nextEffect.deps !== null && deps !== null && nextEffect.deps.length === deps.length && nextEffect.deps.every((d, i) => d === deps[i]));

    if (depsEqual) {
      // 依赖没变,复用这个节点,标记为 Update
      nextEffect.tag |= EffectTag.Update;
      nextEffect.create = create; // 更新函数
      nextEffect.deps = deps;
      break; // 找到一个匹配的就行了
    }
    nextEffect = nextEffect.next;
  }

  if (nextEffect === null) {
    // 依赖变了,或者第一次渲染没有匹配项,创建新节点
    const effectNode = new EffectNode(EffectTag.Placement, create, deps);
    fiber.lastEffect.next = effectNode;
    fiber.lastEffect = effectNode;
  }
}

5.4 模拟提交阶段——执行链表

这是最精彩的部分。在提交阶段,React 会遍历 firstEffect 链表。

function commitPassiveEffects(fiber) {
  let firstEffect = fiber.firstEffect;

  while (firstEffect !== null) {
    const nextEffect = firstEffect.next;

    // 如果是 Passive 标签 (useEffect)
    if ((firstEffect.tag & EffectTag.Passive) !== 0) {
      // 执行副作用函数
      const create = firstEffect.create;
      create(); 
    }

    // 清理函数的执行逻辑比较复杂(需要处理 Deletion 和 Update),这里简化演示
    // 在真实 React 中,如果 tag 是 Deletion,必须执行 cleanup
    if ((firstEffect.tag & EffectTag.Deletion) !== 0) {
       // 执行清理
       console.log("组件卸载,执行清理函数");
    }

    firstEffect = nextEffect;
  }
}

注意代码中的细节:
我们看到 nextEffect 在循环中被重新赋值为 firstEffect.next。这就是链表的精髓——单向遍历。React 就像走迷宫一样,顺着指针一个个找,直到 null 为止。


第六部分:清理函数的“生死时速”

很多同学对 useEffect 的清理函数(返回的那个函数)感到困惑。它什么时候执行?为什么有时候不执行?

这完全取决于 EffectNodetag

  1. 组件卸载时 (Deletion):

    • 必须执行。这是 React 的保底机制。不管你的依赖数组是什么,只要组件没了,React 就会遍历链表,找到所有 Deletion 标签的节点,强制执行它们的清理函数。
    • 场景: 组件销毁,取消网络请求,移除事件监听。
  2. 依赖变化时 (Update):

    • 必须执行旧的,再执行新的
    • React 在处理 Update 标签时,会先执行该节点上一次渲染时的 create 函数(也就是清理函数)。
    • 然后,再执行新的 create 函数。
    • 场景: 比如你监听了一个窗口大小变化。第一次渲染监听 window,第二次渲染监听 document。React 会先帮你把 window 的监听器拔掉,再给 document 挂上新的监听器。虽然这听起来有点傻(通常建议只挂一个监听器),但这是 React 保证状态一致性的原则。
  3. 依赖不变时:

    • 不执行。因为逻辑没变,没必要重置。

第七部分:执行顺序的“潜规则”

既然是链表,那链表是有顺序的。组件的 Effect 链表是按照子组件 -> 父组件的顺序构建的吗?不,恰恰相反。

在 React 的构建过程中,子组件的 Fiber 节点会被插入到父组件的 child 指针下。因此,当遍历 firstEffect 链表时,子组件的 Effect 会先于父组件执行

为什么?

想象一下父组件有个 useEffect 修改了样式,子组件有个 useEffect 修改了内容。如果子组件先执行,它修改内容的时候,父组件还没改样式,可能会造成一次短暂的“闪烁”或者布局错乱。

为了保证布局的稳定性,React 的设计是:

  1. 先执行所有子组件的 Layout Effects(同步)。
  2. 再执行所有父组件的 Layout Effects
  3. 最后把所有 Passive Effects(异步)扔给事件循环。

这就解释了为什么有时候你在 useLayoutEffect 里操作 DOM,看起来是同步的,但如果你在 useEffect 里操作 DOM,浏览器会先渲染出来,然后再变。


第八部分:性能优化与陷阱

理解了链表模型,我们就能更好地避坑。

8.1 频繁创建函数导致链表爆炸

看这段代码:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("Count changed to:", count);
  }, [count]);

  return <button onClick={() => setCount(count + 1)}>Click</button>;
}

每次点击 setCount,组件重渲染。useEffect 的依赖 count 变了。React 会创建一个新的 EffectNode,标记为 Update,挂到链表上。

虽然 React 会复用节点,但如果你的依赖数组是 [](空数组),React 会在渲染阶段就过滤掉所有 Update 标签的节点,只保留 Placement 标签。这大大减少了链表的长度。

陷阱:如果你把依赖数组写错,比如写成了 [count],但你的回调函数里却引用了外部变量 window.innerWidth,那么每次渲染都会创建一个新的 EffectNode,导致不必要的清理和执行,造成性能浪费。

8.2 内存泄漏的根源

为什么 useEffect 里的定时器如果不清理就会内存泄漏?

因为那个 EffectNode 永远挂在那儿。只要组件没卸载(或者没触发 Update),那个节点的 create 函数(定时器)就会一直存在。React 的垃圾回收机制在 JS 环境下无法回收闭包里的定时器引用,除非 React 显式地执行清理函数并置空。


第九部分:进阶视角——Fiber 的工作循环

最后,让我们把视角拉高,看看这个链表在 Fiber 工作循环中是如何被调度的。

React 的调度器(Scheduler)并不直接关心链表。它只关心“什么时候渲染”和“什么时候提交”。

  1. 调度: Scheduler 决定好要渲染哪个组件树了。
  2. 渲染: Reconciler 遍历树,计算差异,创建/更新 Fiber 节点,同时构建 Effect 链表(挂载节点)。
  3. 提交: Committer 获得控制权。它拿到根节点的 firstEffect
    • 它调用 commitBeforeMutationEffects(处理 DOM 删除/插入,主要是 Layout 相关)。
    • 它调用 commitLayoutEffects(处理 useLayoutEffect)。
    • 它调用 commitPassiveEffects(处理 useEffect,推入队列)。

这个流程就像是一个流水线。useEffect 链表只是流水线末端的工位,React 负责把任务(节点)一个个传给工位,工位(EffectNode)执行完任务,然后销毁自己,等待下一个任务。


第十部分:总结——链表的哲学

好了,各位同学,我们的讲座接近尾声。

我们今天并没有讲如何写一个 useEffect,而是讲了 useEffect 如何被写进去

React 的 useEffect 副作用链表存储模型,本质上是一种状态管理与副作用分离的优雅妥协。它用链表这种高效、灵活的数据结构,解决了组件生命周期中“什么时候做什么事”的调度问题。

通过 tag 标签,它区分了生与死;
通过 next 指针,它串联了父子组件;
通过依赖比较,它避免了无意义的重复劳动。

理解了链表,你就理解了 React 的调度逻辑。当你下次看到控制台里那一长串 useEffect 的执行日志时,你不会再觉得那是杂乱无章的噪音,而会看到一副壮丽的画卷:无数个 EffectNode 在 React 的指挥棒下,有序地排队、执行、清理,共同构建出你眼中的 Web 应用。

记住,不要害怕深入底层。最绚丽的魔法,往往只是最朴素的逻辑——比如链表。

谢谢大家!希望你们在 React 的世界里,也能成为那个掌控链表的大师!

发表回复

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