Skip to content

7.3 useEffect: 副作用的实现原理

useEffect 是 React Hooks 中用于处理副作用(side effects)的核心。数据获取、DOM 操作、订阅等都属于副作用的范畴。useEffect 的设计精妙之处在于,它将副作用的执行与组件的渲染分离,推迟到浏览器完成绘制之后,从而避免了阻塞渲染。

useState 类似,useEffect 的实现在 Dispatcher 中也分为 mountEffectupdateEffect 两个版本。

1. Effect 对象的创建:pushEffect

无论是 mount 还是 update,useEffect 的核心都是调用 pushEffect 函数。这个函数负责创建一个 Effect 对象,并将其链接到 Fiber 的 updateQueue 上。这个 updateQueue 是一个专门为 effects 准备的环形链表。

Effect 对象的结构如下:

javascript
// In packages/react-reconciler/src/ReactFiberHooks.js

export type Effect = {
  tag: HookFlags,
  inst: EffectInstance, // 用于存储 destroy 函数
  create: () => (() => void) | void, // 副作用函数
  deps: Array<mixed> | void | null, // 依赖项数组
  next: Effect, // 指向下一个 Effect
};

pushEffect 函数将这个 Effect 对象添加到组件 Fiber 的 updateQueue.lastEffect 链表中,并根据 tag 在 Fiber 上设置相应的 flags(例如 PassiveEffect),以通知 commit 阶段需要处理这些副作用。

javascript
// In packages/react-reconciler/src/ReactFiberHooks.js

function pushEffect(
  tag: HookFlags,
  create: () => (() => void) | void,
  inst: EffectInstance,
  deps: Array<mixed> | void | null,
): Effect {
  const effect: Effect = {
    tag,
    create,
    inst,
    deps,
    // Circular
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    // ... 创建 updateQueue
  } else {
    // 将 effect 插入到环形链表的末尾
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

2. 首次渲染:mountEffect

在组件首次渲染时,mountEffect 会被调用。它的逻辑相对简单:

  1. 创建一个新的 Hook,并将其添加到 Hooks 链表中。
  2. 调用 pushEffect,传入 HookPassive | HookHasEffect 作为 tag,这会给当前 Fiber 添加 PassiveEffectUpdateEffect 的 flags。
  3. PassiveEffect flag 告诉 React 的 commit 阶段,在渲染完成后需要异步执行一个副作用。
javascript
// In packages/react-reconciler/src/ReactFiberHooks.js

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  // ... 省略 DEV 下的检查
  return mountEffectImpl(
    PassiveEffect | PassiveStaticEffect, // Fiber Flags
    HookPassive, // Hook Flags
    create,
    deps,
  );
}

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 在 Fiber 上打上副作用的标记
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    createEffectInstance(),
    nextDeps,
  );
}

3. 更新渲染:updateEffect

在更新渲染时,updateEffect 的逻辑会复杂一些,因为它需要处理依赖项的变化:

  1. 获取当前 Hook 对象。
  2. 获取上一次的依赖项 prevDeps
  3. 使用 areHookInputsEqual 函数比较 prevDeps 和本次的 nextDeps
  4. 如果依赖项没有变化,则不执行任何操作。
  5. 如果依赖项发生变化,则调用 pushEffect 创建一个新的 Effect,并标记 Fiber 需要在 commit 阶段执行副作用。
javascript
// In packages/react-reconciler/src/ReactFiberHooks.js

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  // ...
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.inst;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 比较依赖项
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 依赖项相同,标记为不需要执行
        pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  // 依赖项不同或首次渲染,标记 Fiber 需要执行副作用
  currentlyRenderingFiber.flags |= fiberFlags;

  // 标记 Effect 本身需要执行
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

areHookInputsEqual 函数通过 Object.is 逐个比较依赖项数组中的值。这就是为什么 useEffect 的依赖项应该是值类型或者引用地址稳定的对象。

4. 副作用的执行与清理

在 render 阶段,useEffect 只是创建并标记了 Effect。真正的执行发生在 commit 阶段之后。

  1. 执行 create:在 commit 阶段的 commitPassiveEffects 函数中,React 会遍历 updateQueue 中的 Effect 链表,执行那些被标记为 HookHasEffect 的 effect 的 create 函数。create 函数返回的清理函数会被保存在 effect.inst.destroy 上。
  2. 执行 destroy:在下一次依赖项变化导致 effect 重新执行之前,或者在组件卸载时,React 会先执行上一次保存在 effect.inst.destroy 中的清理函数。

通过这种方式,useEffect 巧妙地将副作用的声明(在 render 阶段)与执行(在 commit 阶段后)分离开来,确保了副作用不会阻塞 UI 渲染,并提供了一套完整的生命周期管理机制(创建、销毁、依赖更新)。

Last updated: