react useEffect

在 React 中,数据获取、设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。在 React 16.8 新增了 Hook,这些副作用都可以通过 useEffect 或者 useLayoutEffect 来调用。前阵子抽空学了下 useState 的源码和做了总结,今天也稍微总结了下 use(Layout)Effect。

写在前面

不熟悉 Fiber 架构的话可以参考 https://blog.zhouweibin.top/detail/60aa954b6da89c0011cca404

useState 原理感兴趣的可以参考我之前写的博客 https://blog.zhouweibin.top/detail/60a16c423ba50e0011ffbc3b

涉及到上面知识的本文可能不会赘述,下面主要重点谈谈 use(Layout)Effect 这两个 Hooks 在 React 挂载和更新过程中做的事情

effectList

这是一个副作用链表,会连接具有副作用 effect 的 fiber 节点,副作用的数据格式如下

{
  create: Function, // 传入use(Layout)Effect函数的第一个参数,即回调函数
  destroy: Function, // 回调函数return的函数,在组件销毁的时候执行
  deps: any[], // 依赖项
  next: Effect, // 指向下一个effect
  tag: string, // effect的类型,区分是useEffect还是useLayoutEffect
}

副作用在函数组件中体现为 use(Layout)Effect 的回调和销毁函数(即回调的返回值)(统称为副作用)

收集

从单个 fiber 节点看,fiber.memorizedState 挂载着 Hooks 链表,Hooks 链表会按顺序连接该节点上的 Hook,其中 use(Layout)Effect 上的 memorizedState 会存放副作用,待执行的副作用会串联成一个循环链表存储在updateQueue上,如下图所示:

从整个 Fiber Tree 来看,render 阶段会自下而上创建 effectList,主要是通过completeUnitOfWork这个方法创建,只有依赖项有变化才会将当前的 fiber 节点追加到 effectList。如下图所示:

最终的 effectList 如下所示:

首次挂载组件(mount),执行 useEffect 时,会调用mountEffectImpl,简化后的源码如下:

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
  const hook = mountWorkInProgressHook(); // 创建hook对象
  const nextDeps = deps === undefined ? null : deps; // 依赖

  // NOTE 给 fiber 节点打上副作用的 effectTag
  currentlyRenderingFiber.flags = fiberFlags;

  // 创建 effect 对象,挂载到 hook 的 memoizedState 上
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps
  );
}

更新时(update),会调用 updateEffectImpl

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;
    // 获取上一次 effect 的 destroy 函数
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // NOTE 如果前后依赖相同
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  // NOTE flags的作用
  currentlyRenderingFiber.flags |= fiberFlags;
  // 如果前后依赖有变,在 effect 的 tag 中加入 HookHasEffect
  // 并将新的 effect 挂载到 hook.memoizedState 上
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps
  );
}

组件 mount 和 update 时收集 effect 的区别在于,前者无须调用前一次的销毁函数,后者在创建 effect 前会判断依赖项,主要依赖项变化才会创建 effect 并挂载到 hook 上

不管组件处于什么状态,都会调用到pushEffect这个函数它的作用其实就是创建 effect 对象,将 effect 对象追加到 fiber 的 updateQueue

function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    next,
  };

  // NOTE 从 workInProgress 上取到 updateQueue(和 effectList 的区别)
  let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
  if (componentUpdateQueue === null) {
    // 如果 updateQueue 为空,把 effect 放到链表中,和它自己形成闭环
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = componentUpdateQueue;
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // updateQueue 不为空,将 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;
}

在执行完 fiber 节点时,如果有更新 effect,会将该 fiber 节点追加到父节点的 effectList

function completeUnitOfWork(unitOfWork: Fiber): void {
  let completedWork = unitOfWork;
  do {
    const current = completedWork.alternate;
    const returnFiber = completedWork.return;

    let next = completeWork(current, completedWork, subtreeRenderLanes);

    // effect list构建
    if (returnFiber !== null && (returnFiber.flags & Incomplete) === NoFlags) {
      // 层层拷贝
      if (returnFiber.firstEffect === null) {
        returnFiber.firstEffect = completedWork.firstEffect;
      }
      if (completedWork.lastEffect !== null) {
        // 说明当前节点是兄弟节点,子节点有effect,已经给returnFiber.lastEffect赋值过了
        if (returnFiber.lastEffect !== null) {
          // 连接兄弟节点的effect
          returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
        }
        returnFiber.lastEffect = completedWork.lastEffect;
      }

      const flags = completedWork.flags;

      // NOTE: 该 fiber 节点有 effect
      if (flags > PerformedWork) {
        // 当前节点有effect连接上effect list
        if (returnFiber.lastEffect !== null) {
          returnFiber.lastEffect.nextEffect = completedWork;
        } else {
          // returnFiber没有firstEffect的情况是第一次遇见有effect的节点
          returnFiber.firstEffect = completedWork;
        }
        returnFiber.lastEffect = completedWork;
      }
    }

    // 兄弟元素遍历再到返返回父级
    const siblingFiber = completedWork.sibling;
    if (siblingFiber !== null) {
      workInProgress = siblingFiber;
      return;
    }
    completedWork = returnFiber;
    workInProgress = completedWork;
  } while (completedWork !== null);
}

执行

render 阶段 useEffect 和 useLayoutEffect 没什么区别,在 commit 阶段会有比较明显的区别。先看下 useEffect,主要有三个地方会执行

useEffect

  1. 刚进入 commit 阶段
function commitRootImpl(root, renderPriorityLevel) {
  // 进入commit阶段,先执行一次之前未执行的 useEffect
  do {
    flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);
  // ...
  do {
    try {
      // beforeMutation阶段的处理函数:commitBeforeMutationEffects内部,
      // 异步调度 useEffect
      commitBeforeMutationEffects();
    } catch (error) {
      ...
    }
  } while (nextEffect !== null);
  // ...
  const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;

  if (rootDoesHavePassiveEffects) {
    // NOTE 记录有副作用的 effect
    rootWithPendingPassiveEffects = root;
  }
}
  1. beforeMutation,该阶段会异步调度 useEffect
function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    // ...
    if ((flags & Passive) !== NoFlags) {
      // 如果fiber节点上的flags存在Passive调度useEffect
      if (!rootDoesHavePassiveEffects) {
        // 锁住调度状态,防止多次调度
        rootDoesHavePassiveEffects = true;
        // NOTE NormalSchedulerPriority 一般优先级
        scheduleCallback(NormalSchedulerPriority, () => {
          flushPassiveEffects();
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}
  1. layout 阶段填充 effect 执行数组,真正执行 useEffect 的时候,实际上是先执行上一次 effect 的销毁函数,再执行本次 effect 的创建函数
function commitLifeCycles(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
    case Block: {
      // ...
      // layout阶段填充effect执行数组
      schedulePassiveEffects(finishedWork);
      return;
    }
}

在调用 schedulePassiveEffects 填充 effect 执行数组时,有一个重要的地方就是只在包含 HasEffect 的 effectTag 的时候,才将 effect 放到数组内,这一点保证了依赖项有变化再去处理 effect。也就是:如果前后依赖未变,则 effect 的 tag 就赋值为传入的 hookFlags,否则,在 tag 中加入 HookHasEffect 标志位。正是因为这样,在处理 effect 链表时才可以只处理依赖变化的 effect,use(Layout)Effect 才可以根据它的依赖变化情况来决定是否执行回调。

function schedulePassiveEffects(finishedWork: Fiber) {
  // 获取到函数组件的 updateQueue
  const updateQueue = finishedWork.updateQueue;
  // 获取 effect 链表
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    // 循环 effect 链表
    do {
      const { next, tag } = effect;
      if (
        (tag & HookPassive) !== NoHookEffect &&
        (tag & HookHasEffect) !== NoHookEffect
      ) {
        // 当 effect 的 tag 含有 HookPassive 和 HookHasEffect 时
        // 分别插入销毁函数数组和创建函数数组
        enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
        enqueuePendingPassiveHookEffectMount(finishedWork, effect);
      }
      effect = next;
    } while (effect !== firstEffect);
  }
}

上边已经填充好 effect 执行数组了,接下来就是执行这个数组。执行过程是先循环待销毁的 effect 数组,再循环待创建的 effect 数组,这一过程发生在flushPassiveEffectsImpl函数中

function flushPassiveEffectsImpl() {
  // 先校验,如果root上没有 Passive effectTag 的节点,则直接return
  if (rootWithPendingPassiveEffects === null) {
    return false;
  }
  // ...
  // 执行effect的销毁
  const unmountEffects = pendingPassiveHookEffectsUnmount;
  pendingPassiveHookEffectsUnmount = [];
  for (let i = 0; i < unmountEffects.length; i += 2) {
    const effect = ((unmountEffects[i]: any): HookEffect);
    const fiber = ((unmountEffects[i + 1]: any): Fiber);
    const destroy = effect.destroy;
    effect.destroy = undefined;

    if (typeof destroy === "function") {
      try {
        destroy();
      } catch (error) {
        captureCommitPhaseError(fiber, error);
      }
    }
  }

  // 再执行effect的创建
  const mountEffects = pendingPassiveHookEffectsMount;
  pendingPassiveHookEffectsMount = [];
  for (let i = 0; i < mountEffects.length; i += 2) {
    const effect = ((mountEffects[i]: any): HookEffect);
    const fiber = ((mountEffects[i + 1]: any): Fiber);
    try {
      const create = effect.create;
      effect.destroy = create();
    } catch (error) {
      captureCommitPhaseError(fiber, error);
    }
  }
  // ...
  return true;
}

useLayoutEffect

useLayoutEffect 在执行的时候,也是先执行上一次的销毁函数再执行创建函数。和 useEffect 不同的是这两者都是同步执行的,前者在 mutation 阶段执行,后者在 layout 阶段执行。

与 useEffect 不同的是,它不用数组去存储销毁函数和创建函数,而是直接操作当前 fiber 上的 updateQueue

  1. 卸载上一次的 effect,发生在 mutation 阶段
// 调用卸载layout effect的函数,传入layout有关的effectTag和说明effect有变化的effectTag:HookLayout | HookHasEffect
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
  const updateQueue = finishedWork.updateQueue;
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  // 循环updateQueue上的effect链表
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & tag) === tag) {
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}
  1. 执行本次的 effect 创建,发生在 layout 阶段
// 调用创建layout effect的函数
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
  const updateQueue = finishedWork.updateQueue;
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & tag) === tag) {
        // 创建
        const create = effect.create;
        effect.destroy = create();
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

一些问题

  • useEffectuseLayoutEffect 的使用和区别

useEffect在实现上利用 scheduler(关于 scheduler 后续会写篇文章介绍) 的异步调度函数:scheduleCallback,这函数会将执行 useEffect 的动作作为一个任务去调度,这个任务会异步调用,发生在 DOM 渲染之后

useLayoutEffect发生在 DOM 渲染之前,执行时机和componentDidMountcomponentDidUpdate是一致的,也就是说如果在useEffect中触发状态更新或者 DOM 渲染,会额外追加一次 DOM 渲染

一般情况下建议使用 useEffect,因为它 不会阻塞渲染,useLayoutEffect 会阻塞渲染,只有在涉及到修改 DOM、动画等场景下考虑使用 useLayoutEffect

  • 不同 useEffect 回调触发了多次 setState,会触发多次 render 吗?

不会。setState 最后其实都会调用 scheduleUpdateOnFiber

// scheduleUpdateOnFiber
// ...
if (executionContext === NoContext) {
  // Flush the synchronous work now, unless we're already working or inside
  // a batch. This is intentionally inside scheduleUpdateOnFiber instead of
  // scheduleCallbackForFiber to preserve the ability to schedule a callback
  // without immediately flushing it. We only do this for user-initiated
  // updates, to preserve historical behavior of legacy mode.
  flushSyncCallbackQueue();
}
// ...

只有当 executionContextNoContext 才会触发同步更新,其余情况都是批量更新。在 commit 的 beforeMutation 阶段,会赋值 executionContextCommitContext,代表进入 commit 阶段

// beforeMutation
// 将当前上下文标记为CommitContext,作为commit阶段的标志
// ...
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// ...