本文将从useEffect的‘闪烁’问题切入,通过devtools并结合源码来分析useEffect与useLayoutEffect的执行细节,最后总结业务开发中二者的适用场景。
闪烁问题
示例demo:https://stackblitz.com/edit/react-tekbkz?file=index.js
当我们点击div时,偶尔会看到视图先变为0再变为随机值的过程,这就是useEffect的闪烁问题,下面通过detools分析上述demo中浏览器的工作流程
可以看到,在点击事件中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
可以看到视图不会出现0的中间状态,通过devtools发现整个过程中浏览器只绘制了一次。因此可以总结:useLayoutEffect中触发调度会立即进入同步调度逻辑, 相当于放弃本次渲染结果,不产生中间状态,浏览器只进行一次绘制。
使用总结
相比useEffect,useLayoutEffect无论销毁函数和回调函数的执行时机都要更早一些,且会在commit阶段中同步执行。因此useLayoutEffects中适合进行一些可能影响dom的操作,因为在其create中可以获取到最新的dom树且由于此时浏览器未进行绘制(本轮事件循环尚未结束),因此不会有中间状态的产生,可以有效的避免闪动问题。因此当业务中出现需要在effect中修改视图,且执行的上一帧中视图变更,就可以考虑是否将逻辑放入useLayoutEffect中处理。
当然,useLayoutEffect的使用也应当是谨慎的。由于js线程和渲染进程是互斥的,因此useLayoutEffects中不宜加入很耗时的计算,否则会导致浏览器没有时间重绘而阻塞渲染,上述使用useLayoutEffect的demo中加入了200ms延迟,可以明显的感受到每次点击更新的延迟。除此之外的绝大部分场景下二者的行为都是一致的,因此业务开发中的大部分场景应优先使用useEffect。