【react】useEffect VS useLayoutEffect | 您所在的位置:网站首页 › useeffect和render执行顺序 › 【react】useEffect VS useLayoutEffect |
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我们不妨把上面的话摘抄下来: 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我们不妨从上面的简单一句话里面去摘抄一些关键词或者子句: 「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 ( { 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} ); }不出意外,结果是这样的: 从打印顺序来看,我们的认知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} ); }从录屏结果可以看出,我点击按钮,界面没有马上得到更新,而是等了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} ); }从录屏结果,我们可以看出,同样的阻塞代码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} ); }从屏幕录制结果,我们可以看到,我们点击界面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流程之后。 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)。看不到有什么区别,那你可得仔细看了: 也许具体业务开发中,还有用到 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 流程之后。 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 实验室设备网 版权所有 |