从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。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容