【react】useEffect VS useLayoutEffect 您所在的位置:网站首页 useeffect和render执行顺序 【react】useEffect VS useLayoutEffect

【react】useEffect VS useLayoutEffect

2023-11-15 23:35| 来源: 网络整理| 查看: 265

TLDR;

useLayoutEffect 和 useEffect 的相同点是:

函数签名是一样的; clean up 机制是一样的; 提交 DOM mutation 次数是一样的。

useLayoutEffect 和 useEffect 的不同点是:

执行时机是不同的。useLayoutEffect在当前帧paint流程之前,useEffect在当前帧paint流程之后; useEffect callback 的执行是异步的,而 useLayoutEffect callback 的执行是同步的; useEffect callback 里面的「状态更新是非批量的」(也就是说,会分配到不同的渲染帧里面),而useLayoutEffect callback 里面的「状态更新是批量」。 前言

“useEffect 跟 useLayoutEffect 有什么区别呢?” 这是一个相对高频的 react 面试题。那么这篇文章我们就来探寻一下这个问题的答案。首先,我们可以从官方文档入手,看看它在这方面能给到我们什么帮助。

重温官方文档 useEffect

image.png

image.png

我们不妨把上面的话摘抄下来:

The function passed to useEffect will run after the render is committed to the screen. The function passed to useEffect fires after layout and paint, during a deferred event. Although useEffect is 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

image.png

我们不妨从上面的简单一句话里面去摘抄一些关键词或者子句:

「synchronously」 「after all DOM mutations」 「before the browser has a chance to paint」

废话不多说,意思就是:useLayoutEffect 会在浏览器完成每一帧的布局(layout)之后,页面绘制之前被调用。

建立正确的心智模型

要想理解上面总结的两句话,那么我们必须抛弃“useEffect 会在组件更新之后被调用”的这种心智模型。因为“组件更新之后”这个概念太含糊了,业内的诸多文章常常在不同的语境中去使用这种表述方式,去讨论它的确切定义只会徒增烦恼。

我们需要建立正确,更具备细节的心智模型。这一切得从FPS(frame per second)说起。人类视觉神经能感受到动画效果差别的极限帧率是 60 FPS, 也就是说能让人类觉得当前动画效果是流畅的,那么一秒钟切换60帧就足够了,高于这个帧率所带来的动画效果上的细微差别,人类就无法捕获到了,因而是没有太大意义的。基于这个认知,首先我们对界面的更新得有“帧”的概念,就像电影的黑白胶片一样,我们不妨手动画一个:

image.png

在浏览器实现里面,每一帧又可以简单理解为是由两部分所组成的:

js的解析执行 渲染引擎的屏幕绘制(paint)

于是乎,这张图可画成这样:

image.png

在 react 的最新架构实现上,react提出了两个概念:“render阶段”和“commit阶段”。js的解析执行当然囊括了这两个阶段,再考虑上 DOM mutation 触发的浏览器 layout/reflow 阶段(也就是中文翻译过来,所谓的“回流”或者“重排”),把它们画进图里面是这样的:

image.png

得到认知

基于官方文档的介绍和上面所建立的心智模型,关于useEffect和useLayoutEffect在执行时机上的差异,我们可以通过一张图来表达:

image.png

在官方文档中,除了说到了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 ( { setCount(count + 1); }} style={{ width:"200px", height:"200px", display:"flex", alignItems:"center", justifyContent:"center", fontSize:"100px", margin: "100px auto" }} > {count} ); } 验证 「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 ( { setCount(count + 1); }} style={{ width:"200px", height:"200px", display:"flex", alignItems:"center", justifyContent:"center", fontSize:"100px", margin: "100px auto" }} > {count} ); }

不出意外,结果是这样的:

image.png

从打印顺序来看,我们的认知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 ( { setCount(count + 1); }} style={{ width:"200px", height:"200px", display:"flex", alignItems:"center", justifyContent:"center", fontSize:"100px", margin: "100px auto" }} > {count} ); }

f83a3ca0-d093-4af7-91ca-384ce60adbff.gif

从录屏结果可以看出,我点击按钮,界面没有马上得到更新,而是等了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 ( { setCount(count + 1); }} style={{ width:"200px", height:"200px", display:"flex", alignItems:"center", justifyContent:"center", fontSize:"100px", margin: "100px auto" }} > {count} ); }

e0bbf55f-6764-4721-aa04-45e9d2f4c86e.gif

从录屏结果,我们可以看出,同样的阻塞代码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 ( { setCount(count + 1); }} style={{ width:"200px", height:"200px", display:"flex", alignItems:"center", justifyContent:"center", fontSize:"100px", margin: "100px auto" }} > {count} ); }

2beca2db-7f87-40b2-b9d4-3e93035641e6.gif

从屏幕录制结果,我们可以看到,我们点击界面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]) //.....

06b31dd8-d1b2-4311-bc9e-98b2d4bb5b09.gif

从录屏结果来看,当我们点击按钮之后,按钮的数字马上变成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流程之后。 useEffect callback 的执行是异步的,而 useLayoutEffect callback 的执行是同步的。 useEffect callback 里面的「状态更新是批量」, 而 useLayoutEffect callback 里面的「状态更新是非批量的」(也就是说,会分配到不同的渲染帧里面)。 实践上的问题

所谓的实践上的问题就是指:在实际开发中,什么时候用 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 ( setValue(0)}> value: {value} ); }; ReactDOM.render( , 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)。看不到有什么区别,那你可得仔细看了:

0fe99cb8-7ffa-459d-be18-f14063706e3d.gif

也许具体业务开发中,还有用到 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])

当我们点击按钮之后,录屏结果是这样的:

741239d3-58f7-4048-a58c-286a7ae57a1d.gif

可以看到,DOM mutation 的次数就是三次。当你把 useLayoutEffect改成 useEffect 的时候,你会得到同样的打印结果。

由此展开,我们不妨总结一下useLayoutEffect 和 useEffect 的相同点是什么:

函数签名是一样的 clean up 机制是一样的 提交 DOM mutation 机制是一样的。 总结

useLayoutEffect 和 useEffect 的相同点是:

函数签名是一样的; clean up 机制是一样的; 提交 DOM mutation 次数是一样的。

useLayoutEffect 和 useEffect 的不同点是:

执行时机是不同的。useLayoutEffect在当前帧 paint 流程之前,useEffect在当前帧 paint 流程之后。 useEffect callback 的执行是异步的,而 useLayoutEffect callback 的执行是同步的。 useLayoutEffect callback 里面的「状态更新是批量」, 而 useEffect callback 里面的「状态更新是非批量的」(也就是说,会分配到不同的渲染帧里面)。 参考资料 API - useEffect API - useLayoutEffect When to useLayoutEffect Instead of useEffect (example) useLayoutEffect vs useEffect


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有