TLDR;
useLayoutEffect 和 useEffect 的相同点是:
- 函数签名是一样的;
- clean up 机制是一样的;
- 提交 DOM mutation 次数是一样的。
useLayoutEffect 和 useEffect 的不同点是:
- 执行时机是不同的。
useLayoutEffect在当前帧paint流程之前,useEffect在当前帧paint流程之后; useEffectcallback 的执行是异步的,而useLayoutEffectcallback 的执行是同步的;useEffectcallback 里面的「状态更新是非批量的」(也就是说,会分配到不同的渲染帧里面),而useLayoutEffectcallback 里面的「状态更新是批量」。
前言
“useEffect 跟 useLayoutEffect 有什么区别呢?” 这是一个相对高频的 react 面试题。那么这篇文章我们就来探寻一下这个问题的答案。首先,我们可以从官方文档入手,看看它在这方面能给到我们什么帮助。
重温官方文档
useEffect
我们不妨把上面的话摘抄下来:
- The function passed to
useEffectwill run after the render is committed to the screen. - The function passed to
useEffectfires after layout and paint, during a deferred event. - Although
useEffectis deferred until after the browser has painted, it is guaranteed to fiel before any new renders. React will always flush a previous render’s efffect before starging a new update.
上面的几句话里面关于 useEffect 的几个表述:
- 「after the render is committed to the screen」
- 「after layout and paint」
- 「after the browser has painted」
其实都是代表同一个意思即,useEffect 会在浏览器渲染引擎完成每一帧的页面绘制(paint)之后被调用。
useLayoutEffect
我们不妨从上面的简单一句话里面去摘抄一些关键词或者子句:
- 「synchronously」
- 「after all DOM mutations」
- 「before the browser has a chance to paint」
废话不多说,意思就是:useLayoutEffect 会在浏览器完成每一帧的布局(layout)之后,页面绘制之前被调用。
建立正确的心智模型
要想理解上面总结的两句话,那么我们必须抛弃“useEffect 会在组件更新之后被调用”的这种心智模型。因为“组件更新之后”这个概念太含糊了,业内的诸多文章常常在不同的语境中去使用这种表述方式,去讨论它的确切定义只会徒增烦恼。
我们需要建立正确,更具备细节的心智模型。这一切得从FPS(frame per second)说起。人类视觉神经能感受到动画效果差别的极限帧率是 60 FPS, 也就是说能让人类觉得当前动画效果是流畅的,那么一秒钟切换60帧就足够了,高于这个帧率所带来的动画效果上的细微差别,人类就无法捕获到了,因而是没有太大意义的。基于这个认知,首先我们对界面的更新得有“帧”的概念,就像电影的黑白胶片一样,我们不妨手动画一个:
在浏览器实现里面,每一帧又可以简单理解为是由两部分所组成的:
- js的解析执行
- 渲染引擎的屏幕绘制(paint)
于是乎,这张图可画成这样:
在 react 的最新架构实现上,react提出了两个概念:“render阶段”和“commit阶段”。js的解析执行当然囊括了这两个阶段,再考虑上 DOM mutation 触发的浏览器 layout/reflow 阶段(也就是中文翻译过来,所谓的“回流”或者“重排”),把它们画进图里面是这样的:
得到认知
基于官方文档的介绍和上面所建立的心智模型,关于useEffect和useLayoutEffect在执行时机上的差异,我们可以通过一张图来表达:
在官方文档中,除了说到了useLayoutEffect 和 useEffect执行时机之外,还提到了“useLayoutEffect 是同步执行的”。同步一般意味着“阻塞”。所以,这里的“同步”可以理解为是“阻塞浏览器的paint流程”。按照这个参照物,我们就可以说“useLayoutEffect 是异步执行的”。
综上所述,useEffect 跟 useLayoutEffect有什么区别呢?我们目前的认知就是:
- 从是否阻塞paint流程的角度来看,
useLayoutEffect是同步执行的,useEffect是异步执行的。 - 从执行的时间节点来看,
useLayoutEffect是在 「paint 之前」被调用的,而useEffect是在 「paint 之后」执行的。
验证认知
以上仅仅是从官方文档和第三方技术文章综合得到的认知而已,下面我们运行真正的代码去验证一下这些认知的正确性。
以上的认知又可以划分为以下的认证点:
useLayoutEffect比useEffect先执行useLayoutEffect是同步执行,而useEffect是异步执行的。换句话说,就是useLayoutEffect会阻塞paint流程,而useEffect不会阻塞paint流程。
基于count的示例进行验证,基本代码如下:
import React, { useEffect, useLayoutEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
return (
<button
id="button"
onClick={() => {
setCount(count + 1);
}}
style={{
width:"200px",
height:"200px",
display:"flex",
alignItems:"center",
justifyContent:"center",
fontSize:"100px",
margin: "100px auto"
}}
>
{count}
</button>
);
}
验证 「useLayoutEffect 比 useEffect 先执行」
要验证这一点就很简单了,只要打印一下就行:
import React, { useEffect, useLayoutEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
useEffect(()=> {
console.log('run useEffect')
})
useLayoutEffect(()=> {
console.log('run useLayoutEffect')
})
return (
<button
id="button"
onClick={() => {
setCount(count + 1);
}}
style={{
width:"200px",
height:"200px",
display:"flex",
alignItems:"center",
justifyContent:"center",
fontSize:"100px",
margin: "100px auto"
}}
>
{count}
</button>
);
}
不出意外,结果是这样的:
从打印顺序来看,我们的认知1是正确的。
验证「useLayoutEffect 是同步执行,而useEffect是异步执行的」
上面已经解释了不少,验证认知2的逻辑是:同步执行则意味着会阻塞浏览器的paint流程,异步则相反。而阻塞浏览器的paint流程的结果就是浏览器界面不会得到更新,处于假死状态。假如我们看到的是这样结果,我们就可以反推当前代码的执行是阻塞了paint流程。
在验证之前,我们要先制造一些会造成 block 的代码,比如实现一个 sleep 函数:
function sleep(duration) {
const current = Date.now();
while (Date.now() - current < duration) {}
}
下面先来试验一下 useLayoutEffect 是否会阻塞 paint 流程:
import React, { useLayoutEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
useLayoutEffect(()=> {
sleep(5000)
},[count])
return (
<button
id="button"
onClick={() => {
setCount(count + 1);
}}
style={{
width:"200px",
height:"200px",
display:"flex",
alignItems:"center",
justifyContent:"center",
fontSize:"100px",
margin: "100px auto"
}}
>
{count}
</button>
);
}
从录屏结果可以看出,我点击按钮,界面没有马上得到更新,而是等了5s才更新。而这个5s正是用sleep所用的时间。这证明了,useLayoutEffect的callback函数的执行是同步的。一旦这个callback函数里面包含了一些阻塞主线程的代码,那么就需要等到这些代码执行完毕,react才会将 DOM mutation commit到浏览器,浏览器才会去更新界面。
所以说,「useLayoutEffect 是同步执行的」这个认知是正确的。
验证「useEffect是异步执行的」
import React, { useEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
useEffect(()=> {
sleep(5000)
},[count])
return (
<button
id="button"
onClick={() => {
setCount(count + 1);
}}
style={{
width:"200px",
height:"200px",
display:"flex",
alignItems:"center",
justifyContent:"center",
fontSize:"100px",
margin: "100px auto"
}}
>
{count}
</button>
);
}
从录屏结果,我们可以看出,同样的阻塞代码sleep(5000)放在 useEffect的callback函数里面,它并没有阻塞 paint 流程(准确点来说,没有阻塞当前帧的 paint 流程)。因为,我们一点击按钮,按钮里面的数字就马上从0变为1,没有丝毫的犹豫。
所以说,「useEffect 是异步执行的」这个认知是正确的。
如果细心的人会注意到,关于useLayoutEffect, 官网文档里面有一句一带而过的话:
Updates scheduled inside useLayoutEffect will be flushed synchronously, before the browser has a chance to paint.
其实,这个不起眼的「be flushed」被动词确实表露了useEffect和useLayoutEffect第三个差异点:
useLayoutEffect里面的状态更新是「批量的」,而useEffect却不是。
上面的这句话展开来具体说就是,如果useLayoutEffect的callback函数里面对状态请求了多次更新,那么这些更新请求会合并成一个 paint 请求,浏览器更新一次 UI 界面;同样的情况如果发生在useEffect的callback函数里面,那么更新请求不会被合并,有多少次状态更新请求,就会有多少次 paint 请求, 浏览器就会更新多少次 UI 界面。下面我们用下面的示例代码来测试一下 useLayoutEffect :
import React, { useEffect, useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
useLayoutEffect(()=> {
if(count === 1){
sleep(3000)
setCount(2)
}else if(count === 2){
sleep(3000)
setCount(3)
}
},[count])
return (
<button
id="button"
onClick={() => {
setCount(count + 1);
}}
style={{
width:"200px",
height:"200px",
display:"flex",
alignItems:"center",
justifyContent:"center",
fontSize:"100px",
margin: "100px auto"
}}
>
{count}
</button>
);
}
从屏幕录制结果,我们可以看到,我们点击界面6s之后,界面才会得到一次更新。状态变化:0 -> 1,1 -> 2, 2-> 3都是在一帧内发生的。从而证明了: useLayoutEffect 里面的状态更新是「批量的」。
下面,我们来看看同样的代码,在useEffect里面会有什么表现(相同的代码罗列了,只给出不同的那部分):
//......
useEffect(()=> {
if(count === 1){
sleep(3000)
setCount(2)
}else if(count === 2){
sleep(3000)
setCount(3)
}
},[count])
//.....
从录屏结果来看,当我们点击按钮之后,按钮的数字马上变成1,然后隔了3s之后变成2,再隔了三秒之后变成了3。界面更新了3次,说明1,2,3分别是在不同的三帧里面完成的。更具体点来说:
- 先
用户点击按钮, 后0 -> 1是发生在第一帧; - 先
阻塞3s,1 -> 2是发生在第二帧; - 先
阻塞3s,2 -> 3是发生在第三帧;
这与我们对 useEffect 的异步执行机制的认知是一致的。不过,我们这里是想突出“异步渲染”的另外一面:“非批量,分帧次”的渲染。
综上所述,useLayoutEffect 里面的状态更新是「批量」的,而useEffect里面的状态更新是 「非批量」。
最终结论
经过上面的信息收集和试验,“useEffect 跟 useLayoutEffect 有什么区别呢?”这个问题的答案是:
useEffect 跟 useLayoutEffect 有三个不同点:
- 执行时机是不同的。
useLayoutEffect在当前帧paint流程之前,useEffect在当前帧paint流程之后。 useEffectcallback 的执行是异步的,而useLayoutEffectcallback 的执行是同步的。useEffectcallback 里面的「状态更新是批量」, 而useLayoutEffectcallback 里面的「状态更新是非批量的」(也就是说,会分配到不同的渲染帧里面)。
实践上的问题
所谓的实践上的问题就是指:在实际开发中,什么时候用 useLayoutEffect, 什么时候用 useEffect 呢?
根据官方文档的说法,99%的情况下,我们都应该使用 useEffect。因为大部分副作用不需要去阻塞界面的更新,比如 DOM mutation,订阅,打log,起定时器啊等等。这么说还剩下 1% 的机会会去使用 useLayoutEffect 。 那这1%的机会会是什么呢?答曰:遇到了一些需要同步界面才能解决问题的业务场景。下面,举个更具体的例子: 页面闪烁问题。假如,我们有以下代码:
import React, {
useState,
useLayoutEffect
} from 'react';
import ReactDOM from 'react-dom';
const BlinkyRender = () => {
const [value, setValue] = useState(0);
useEffect(() => {
if (value === 0) {
setValue(10 + Math.random() * 200);
}
}, [value]);
console.log('render', value);
return (
<div onClick={() => setValue(0)}>
value: {value}
</div>
);
};
ReactDOM.render(
<BlinkyRender />,
document.querySelector('#root')
);
那么这个代码执行起来后,用户点击页面会出现闪烁的现象(在线运行 useEffect 版本的 demo)。原因是用户点击界面之后,界面快速切换了两帧:随机数 -> 0, 0 -> 随机数。这就是页面出现闪烁的原因。那么,如何解决这个问题呢?答案就是:“利用 useLayoutEffect 「同步执行」,「批量更新」的机制,把两帧压缩到一帧来完成 ”。下面,只要简单地用 useEffect 替换为 useLayoutEffect 即可:
// code before....
useLayoutEffect(() => {
if (value === 0) {
setValue(10 + Math.random() * 200);
}
}, [value]);
// code after....
具体查看(在线运行 useLayoutEffect 版本的 demo)。看不到有什么区别,那你可得仔细看了:
也许具体业务开发中,还有用到 useLayoutEffect 的业务场景,无法一一穷举。上面的例子只是为了抛砖引玉地阐述一个观点:“当你在使用 useEffect的时候遇到了棘手的问题,那么不妨联想到它跟 useLayoutEffect 的不同,然后尝试用它来解决那个棘手的问题。”
特别发现
在上述试验的过程中,我发现了一个有趣的结果:上面用于验证「useLayoutEffect 里面的状态更新是「批量的」,而useEffect却不是」的示例中,虽然 useLayoutEffect 和 useEffect 所触发的渲染帧数不同,但是两者所触发的 DOM mutation 次数是一样的 - 都是3次。useEffect 触发三次 DOM mutation 是很好理解的,我们单独把 useLayoutEffect 拎出来看看:
useLayoutEffect(()=> {
console.log("current innter text of DOM: ",btnRef.current.innerText)
if(count === 1){
console.log("current innter text of DOM: ",btnRef.current.innerText)
sleep(3000)
setCount(2)
}else if(count === 2){
console.log("current innter text of DOM: ",btnRef.current.innerText)
sleep(3000)
setCount(3)
}
},[count])
当我们点击按钮之后,录屏结果是这样的:
可以看到,DOM mutation 的次数就是三次。当你把 useLayoutEffect改成 useEffect 的时候,你会得到同样的打印结果。
由此展开,我们不妨总结一下useLayoutEffect 和 useEffect 的相同点是什么:
- 函数签名是一样的
- clean up 机制是一样的
- 提交 DOM mutation 机制是一样的。
总结
useLayoutEffect 和 useEffect 的相同点是:
- 函数签名是一样的;
- clean up 机制是一样的;
- 提交 DOM mutation 次数是一样的。
useLayoutEffect 和 useEffect 的不同点是:
- 执行时机是不同的。
useLayoutEffect在当前帧 paint 流程之前,useEffect在当前帧 paint 流程之后。 useEffectcallback 的执行是异步的,而useLayoutEffectcallback 的执行是同步的。useLayoutEffectcallback 里面的「状态更新是批量」, 而useEffectcallback 里面的「状态更新是非批量的」(也就是说,会分配到不同的渲染帧里面)。