本文记录了在使用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>
}
解决问题
能复现的问题基本上都是可以被解决的。
第一反应就是想到了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>
}
通过示例结果, 已经可以看到DidMount的触发与Render之间已经没有很长的延时了。
因此, 解决方法就是灵活使用useLayoutEffect
替代useEffect
, 并且在非必要时, 关闭掉会影响到React渲染的龙骨动画。
知其所以然
下面这部分写的有点绕, 而且也与React源码有关, 不感兴趣的同学可以结束了
虽然上述方案解决了问题, 但仍有两个疑惑
-
为什么
useLayoutEffect
的执行会在绘制之前, 而useEffect
就在绘制之后呢? -
为什么
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的延时, 那这是怎么与useLayoutEffect
和useEffect
关联起来的呢?
首先我们在源码中找到useLayoutEffect
与useEffect
的区别。
// 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, 这里就不贴出来了
区别就在于UpdateEffect
与PassoveEffect
。这两个实际上是不同的二进制数据标示。
而在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-fiber-architecture一文介绍了React Fiber架构
react-scheduler一文介绍了React Scheduler调度算法。