为什么useEffect会被延迟5000ms执行?

3,009 阅读4分钟

本文记录了在使用React Hooks 的过程中,useEffect遇到的问题,解决方案,以及从源码角度对产生的问题进行剖析。

本文假设读者已经对React Hooks有相关了解,也有使用过useEffect的经验。如果读者还未了解过,可以阅读官网文档Hooks at a Glance

场景再现

通常,我们会通过useEffect模拟DidMount的Callback实现。这也是官网文档中推荐模拟componentDidMount的方案。

function App() {
	useEffect(() => {
		console.log(‘DidMount’)
	}, [])
	return <div>Home</div>
}

然而,在一次需求开发中引入了全屏大小的龙骨动画,在三星某款Android机器上发现DidMount的被调用时机比预期延迟了很久。

但龙骨动画本身的渲染并没有卡顿,并且单独循环播放龙骨动画也是没有问题的。 因此推断龙骨动画某些代码的执行引起了useEffect的延时。

几经猜测验证,推测是龙骨动画在RAF的代码中耗时太久导致了useEffect延时。


// 模拟在RAF中执行超过5ms的代码片段
function delay(){
	const t = new Date().getTime();
	while(1) {
		if(new Date().getTime() - t > 60) {
			break;
		}
	}
	window.requestAnimationFrame(delay);
}
// 调用delay
delay();

function App() {
	console.log(‘render’, +new Date());
	useEffect(() => {
		console.log(‘DidMount’, +new Date());
	}, [])
	return <div>Home</div>
}

Edit 16.8.0-useEffectDelay

解决问题

能复现的问题基本上都是可以被解决的。

第一反应就是想到了React Hooks文档中提到的关于useEffect的介绍。

使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。

useEffect的回调函数将会在‘屏幕绘制’后被执行。这也就表明, 回调函数的执行是异步调用的。那如果我们可以在Render后, 同步调用Effect回调函数, 就可以解决我们的问题了。

然后, 就在文档中找到了useLayoutEffect

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

简要说明就是, useLayoutEffect会同步执行。通过代码来看


// 模拟在RAF中执行超过5ms的代码片段
function delay(){
	const t = new Date().getTime();
	while(1) {
		if(new Date().getTime() - t > 60) {
			break;
		}
	}
	window.requestAnimationFrame(delay);
}
// 调用delay
delay();

function App() {
	console.log(‘render’, +new Date());
	// 使用useLayoutEffect
	useLayoutEffect(() => {
		console.log(‘DidMount’, +new Date());
	}, [])
	return <div>Home</div>
}

Edit 16.8.0-uselayoutEffectDelay (forked)

通过示例结果, 已经可以看到DidMount的触发与Render之间已经没有很长的延时了。

因此, 解决方法就是灵活使用useLayoutEffect替代useEffect, 并且在非必要时, 关闭掉会影响到React渲染的龙骨动画。

知其所以然

下面这部分写的有点绕, 而且也与React源码有关, 不感兴趣的同学可以结束了

虽然上述方案解决了问题, 但仍有两个疑惑

  1. 为什么useLayoutEffect的执行会在绘制之前, 而useEffect就在绘制之后呢?

  2. 为什么requestAnimationFrame的执行耗时过长会导致useEffect的延迟触发呢?

以下内容是基于React V16.8.6进行分析, 现在最新版本已经是V16.14.0, 作者简单阅读了新的代码, 大体逻辑没有变化, 但实现方案已有变更。 作者在阅读了源码后, 不求甚解, 只摘取了部分代码验证自己的想法, 如有谬误, 请多多指正。

从现象到本质

在上述Demo中, 从console.log打印出的时间戳差值总是在5000ms左右, 根据表现盲猜useEffect的回调调用有一个5000ms的Timeout。

在React源码中搜索

// https://github.com/facebook/react/blob/v16.8.6/packages/scheduler/src/Scheduler.js#L29

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

因此, 我们可以大致定位到具体逻辑在Scheduler的地方。 我简单写了一份Scheduler的伪代码

var channel = new MessageChannel();
var port = channel.port2;
requestAnimationFrame(animationTick)
var frameDeadline // 渲染每帧的Deadline时间戳
var animationTick = function(rafTime) {
	// rafTime是调用animationTick的时间戳
	// activeFrameTime是每一帧建议执行时间, 可能在8ms - 16ms
	frameDeadline = rafTime + activeFrameTime;
	// 通过channel发送消息, 这样不会被主线程阻塞
	port.postMessage(undefined);
}

channel.port1.onmessage = function(event){
  // 执行onmessage
	if(frameDeadline - currentTime <= 0) {
		// onmessage回调执行时, 已经超时
		didTimeout = true;
		// 如果当前Callback任务还没有timeout, 那就先return
		// 如果当前Callback任务已经timeout了, 则需要立即执行
	}else {
		// 时间还足够, 可以继续执行Callback
		requestAnimationFrame(animationTick)
		return;
	}
}

从伪代码的逻辑中, 可以推断出, 如果业务代码中也使用了requestAnimationFrame, 并且传入的callback是一个比较耗时的任务。那么它会导致channel.port1.onmessage执行的任务都会超过frameDeadline.

如果该ScheduledCallback还没有超时, 就会被推迟, 如果到了超时时间就会立即执行。

因为底层Scheduler的实现, 我们可以反推出useEffect执行时创建了一个Normal Priority的Task, useEffect可能会出现5000ms的延时。

React Fiber Scheduler

我们从上述的结论中知道了React底层Scheduler的实现可能会导致出现5000ms的延时, 那这是怎么与useLayoutEffectuseEffect关联起来的呢?

首先我们在源码中找到useLayoutEffectuseEffect的区别。

// https://github.com/facebook/react/blob/v16.8.6/packages/react-reconciler/src/ReactFiberHooks.js

// useEffect的底层调用
function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(
    UpdateEffect | PassiveEffect,
    UnmountPassive | MountPassive,
    create,
    deps,
  );
}
// useLayoutEffect的底层调用
function updateLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(
    UpdateEffect,
    UnmountMutation | MountLayout,
    create,
    deps,
  );
}
// 还有mount, 这里就不贴出来了

区别就在于UpdateEffectPassoveEffect。这两个实际上是不同的二进制数据标示。

而在ReactFiberScheduler中, 我们可以找到其与Scheduler的关系, 通过伪代码展示

// https://github.com/facebook/react/blob/v16.8.6/packages/react-reconciler/src/ReactFiberScheduler.js#L502

function commitRoot() {
	// 处理
	commitBeforeMutationLifecycles()
	commitAllHostEffects()
	// useLayoutEffect在这里同步执行
	// LifeCycles里会同步执行 Update Tag类型的Effect
	commitAllLifeCycles();

	// 如果有PassiveEffect, 设置NormalPriority, 异步通过event去解决
	// useEffect在这里执行
	runWithPriority(NormalPriority, () => {
      return schedulePassiveEffects(callback);
  });
}

伪代码简单的展示了commitAllLifeCycles是同步执行的, schedulePassiveEffects通过Scheduler的runWithPriority去等待执行。

因此, useLayoutEffect会优先于useEffect执行, 并且是Sync同步处理的。也就是说, 在useLayoutEffect执行耗时过久的代码, 会阻塞UI的渲染。

Reference

react source code

react-fiber-architecture一文介绍了React Fiber架构

react-scheduler一文介绍了React Scheduler调度算法。