从react源码分析useEffect与useLayoutEff

作者: 竹叶寨少主 | 来源:发表于2022-02-20 20:57 被阅读0次

    本文将从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。

    相关文章

      网友评论

        本文标题:从react源码分析useEffect与useLayoutEff

        本文链接:https://www.haomeiwen.com/subject/rnsulrtx.html