从react源码分析useEffect与useLayoutEffect的执行细节

本文将从useEffect的‘闪烁’问题切入,通过devtools并结合源码来分析useEffect与useLayoutEffect的执行细节,最后总结业务开发中二者的适用场景。

闪烁问题

示例demo:https://stackblitz.com/edit/react-tekbkz?file=index.js

当我们点击div时,偶尔会看到视图先变为0再变为随机值的过程,这就是useEffect的闪烁问题,下面通过detools分析上述demo中浏览器的工作流程

image.png

可以看到,在点击事件中setState,react进行一次render流程,视图更新并触发浏览器的布局和绘制。视图变为0。同时触发useEffect的执行再次setState修改视图,又经历一次render流程并触发浏览器布局绘制,视图变为随机值。两次连续的绘制产生闪动问题并增加了性能损耗。 因此我们可以总结此场景的触发条件为:useEffect执行的上一帧中修改了视图,且useEffect中再次修改视图。接下来我们通过源码分析下useEffect的执行细节。

源码分析

react的一次状态更新的流程简单概括就是构造fiber树(render),渲染fiber树(commit),前文已有过介绍,我们暂不关注优先级调度的流程。commit阶段的入口函数是commitRootImpl,不关心其他逻辑,只看effects的相关处理

function commitRootImpl(root, renderPriorityLevel) {
  // 调度useEffect
  scheduleCallback(NormalSchedulerPriority, () => {
    flushPassiveEffects();
  });
  // 处理突变
  // 处理前
  commitBeforeMutationEffects(root, finishedWork);
  // 处理
  commitMutationEffects(root, finishedWork, lanes);
  // 处理后,此时代表当前更新后界面的fiber树已渲染完成
  commitLayoutEffects(finishedWork, root, lanes);
  // 检测并执行同步任务
  flushSyncCallbacks();
}

scheduleCallback 是react调度器(Scheduler)的一个api,它最终会以一个宏任务(MessageChannel)来异步调度传入的回调函数,使得该回调在下一轮事件循环中执行,彼时浏览器已经绘制过一次。

...
const channel = new MessageChannel();
const port = channel.port2;
// performWorkUntilDeadline中将具体执行被调度的任务
channel.port1.onmessage = performWorkUntilDeadline
...
// 触发
port.postMessage(null)

这里调度的函数是flushPassiveEffects,它执行后终会调用如下两个函数:

commitHookEffectListUnmount(HookPassive | HookHasEffect, finishedWork, finishedWork.return);
commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork)

拿其中一个分析:commitHookEffectListMount

function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
  // flags是副作用标识,HookPassive是useEffect的标识
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        const create = effect.create;
        // 调用副作用的create函数,将返回的销毁函数挂到destroy上
        effect.destroy = create();
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

相应的commitHookEffectListUnmount用于执行effect的destroy函数,即flushPassiveEffects的职责是执行useEffect上次调用产生的销毁函数与本次的create函数。因此可以明确useEffect中指定的回调会在dom渲染结束且浏览器绘制后异步执行,先执行上次更新产生的destory函数,再执行本次的create函数。

那闪动问题如何解决呢?我们可以考虑另一个hook:useLayoutEffect。我们关注下layout阶段的主处理函数commitLayoutEffects,他内部会对每个遍历到的fiber执行commitLayoutEffectOnFiber

function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  if ((finishedWork.flags & LayoutMask) !== NoFlags) {
    switch (finishedWork.tag) {
      case FunctionComponent:
      case SimpleMemoComponent: {
        // HookLayout是useLayoutEffect的标识
        commitHookEffectListMount(
          HookLayout | HookHasEffect,
          finishedWork,
        );
        break;
      }
    }
  }
}

我们发现useLayoutEffect的create函数在layout阶段同步执行,我们已经知道commitRootImpl最后阶段会执行flushSyncCallbacks检测并执行同步任务,而useLayoutEffect中触发的调度任务(setState)将是同步的优先级, 因此如果我们在useLayouteffect中setState将会直接重新发起render的流程而不是异步执行,即useLayoutEffect的create函数中触发的任何动作都会在本轮事件循环中同步执行。

下面将demo中的hook改为useLayoutEffect:

https://stackblitz.com/edit/react-qnje3r?file=index.js

image

可以看到视图不会出现0的中间状态,通过devtools发现整个过程中浏览器只绘制了一次。因此可以总结:useLayoutEffect中触发调度会立即进入同步调度逻辑, 相当于放弃本次渲染结果,不产生中间状态,浏览器只进行一次绘制。

使用总结

相比useEffect,useLayoutEffect无论销毁函数和回调函数的执行时机都要更早一些,且会在commit阶段中同步执行。因此useLayoutEffects中适合进行一些可能影响dom的操作,因为在其create中可以获取到最新的dom树且由于此时浏览器未进行绘制(本轮事件循环尚未结束),因此不会有中间状态的产生,可以有效的避免闪动问题。因此当业务中出现需要在effect中修改视图,且执行的上一帧中视图变更,就可以考虑是否将逻辑放入useLayoutEffect中处理。

当然,useLayoutEffect的使用也应当是谨慎的。由于js线程和渲染进程是互斥的,因此useLayoutEffects中不宜加入很耗时的计算,否则会导致浏览器没有时间重绘而阻塞渲染,上述使用useLayoutEffect的demo中加入了200ms延迟,可以明显的感受到每次点击更新的延迟。除此之外的绝大部分场景下二者的行为都是一致的,因此业务开发中的大部分场景应优先使用useEffect。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):