【react】useEffect VS useLayoutEffect

2,838 阅读12分钟

TLDR;

useLayoutEffectuseEffect 的相同点是:

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

useLayoutEffectuseEffect 的不同点是:

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

前言

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

重温官方文档

useEffect

image.png

image.png

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

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

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

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

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

image.png

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

image.png

得到认知

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

image.png

在官方文档中,除了说到了useLayoutEffectuseEffect执行时机之外,还提到了“useLayoutEffect 是同步执行的”。同步一般意味着“阻塞”。所以,这里的“同步”可以理解为是“阻塞浏览器的paint流程”。按照这个参照物,我们就可以说“useLayoutEffect 是异步执行的”。

综上所述,useEffect 跟 useLayoutEffect有什么区别呢?我们目前的认知就是:

  • 从是否阻塞paint流程的角度来看,useLayoutEffect 是同步执行的,useEffect 是异步执行的。
  • 从执行的时间节点来看,useLayoutEffect 是在 「paint 之前」被调用的,而useEffect是在 「paint 之后」执行的。

验证认知

以上仅仅是从官方文档和第三方技术文章综合得到的认知而已,下面我们运行真正的代码去验证一下这些认知的正确性。

以上的认知又可以划分为以下的认证点:

  1. useLayoutEffectuseEffect 先执行
  2. 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>
  );
}

验证 「useLayoutEffectuseEffect 先执行」

要验证这一点就很简单了,只要打印一下就行:

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>
  );
}

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

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 (
    <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>
  );
}

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 (
    <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>
  );
}

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」被动词确实表露了useEffectuseLayoutEffect第三个差异点:

  • 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>
  );
}

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分别是在不同的三帧里面完成的。更具体点来说:

  1. 用户点击按钮, 后0 -> 1 是发生在第一帧;
  2. 阻塞3s1 -> 2 是发生在第二帧;
  3. 阻塞3s2 -> 3 是发生在第三帧;

这与我们对 useEffect 的异步执行机制的认知是一致的。不过,我们这里是想突出“异步渲染”的另外一面:“非批量,分帧次”的渲染。

综上所述,useLayoutEffect 里面的状态更新是「批量」的,而useEffect里面的状态更新是 「非批量」。

最终结论

经过上面的信息收集和试验,“useEffectuseLayoutEffect 有什么区别呢?”这个问题的答案是:

useEffectuseLayoutEffect 有三个不同点:

  • 执行时机是不同的。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 (
    <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)。看不到有什么区别,那你可得仔细看了:

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

也许具体业务开发中,还有用到 useLayoutEffect 的业务场景,无法一一穷举。上面的例子只是为了抛砖引玉地阐述一个观点:“当你在使用 useEffect的时候遇到了棘手的问题,那么不妨联想到它跟 useLayoutEffect 的不同,然后尝试用它来解决那个棘手的问题。”

特别发现

在上述试验的过程中,我发现了一个有趣的结果:上面用于验证「useLayoutEffect 里面的状态更新是「批量的」,而useEffect却不是」的示例中,虽然 useLayoutEffectuseEffect 所触发的渲染帧数不同,但是两者所触发的 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 的时候,你会得到同样的打印结果。

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

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

总结

useLayoutEffectuseEffect 的相同点是:

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

useLayoutEffectuseEffect 的不同点是:

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

参考资料