useEffect 和 useLayoutEffect 的区别

在 React 中,我们经常需要在组件更新后执行一些副作用操作,比如发送网络请求,或者操作 DOM。

React 提供了两个 Hook 来实现这个功能,分别是 useEffectuseLayoutEffect,但是在使用上它们是有一些区别的,下面简单介绍一下 useEffectuseLayoutEffect 这两个 hook 之间的一些差别。

useEffect 与 useLayoutEffect 的区别

useEffect 会在浏览器渲染新内容后异步执行,也就是说当组件更新后,它并不会立即执行。 而 useLayoutEffect 会在 DOM 更新之后,浏览器绘制之前同步执行。

所以说,区别是一个为同步,一个为异步,你可以理解为 useLayoutEffectuseEffect 的执行时机都是在“DOM 更新之后和浏览器绘制之前”(它们会在 React 更新 DOM 完成之后立即执行,但在浏览器进行 DOM 重排和绘制之前)。这样可以确保在 React 更新 DOM 之后立即执行副作用代码,而不需要等到下一个渲染周期。

不过,虽然 useLayoutEffect 是同步执行的,但它也是在 React 更新 DOM 完成之后才执行的。因此,在 useLayoutEffect 中访问 DOM 元素时,可以确保它们已经被更新。而在 useEffect 中访问 DOM 元素时,则需要注意它们可能还没有被更新,因为 useEffect 是异步执行的。如果需要在更新 DOM 后立即执行一些操作,应该使用 useLayoutEffect。否则,应该使用 useEffect

为什么在 useEffect 中会出现 DOM 没有被更新的情况?

主要有如下几个原因:

  1. 如果在 useEffect 中访问了 DOM 元素,但是在组件更新之后没有重新渲染,那么 useEffect 中的回调函数就不会被执行。这种情况下,useEffect 中的回调函数就不会访问到最新的 DOM 元素。
  2. 在某些情况下,React 可能会进行优化,跳过对某些 DOM 元素的更新。这可能会导致在 useEffect 中访问这些元素时,它们仍然处于旧的状态。

结合以下示例可以更好地理解上述这个问题。

import { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('count:', count);
  });

  const handleIncrement = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleIncrement}>+1</button>
    </div>
  );
}

当点击 “+1” 按钮时,计数器的值会加一,并且 useEffect 的回调函数会被执行。然而,你可能会发现,当连续点击 “+1” 按钮时,输出的计数器值不是我们期望的递增顺序,而是乱序的,如下所示:

count: 0
count: 2
count: 3
count: 1

这是因为在更新状态时,React 会将多个 setStateuseReducer 的调用合并成一个更新操作,以提高性能。因此,useEffect 的回调函数可能会在多个状态更新之后才被执行,导致访问到的 count 值是旧的。

批量更新和异步执行

那么,react 是如何做到批量更新和 useEffect 的异步执行呢?

批量更新

首先来说说批量更新,当调用某个更新函数(例如 setState)时,React 会将这个更新操作添加到一个更新队列中。当队列中存在更新操作时,React 会通过 batchedUpdates 函数来将所有的更新操作合并为一个,然后异步执行。

在合并更新操作时,React 会尽可能地将相同的更新操作进行合并,以避免重复的更新。例如,如果连续调用两次 setState 来更新同一个状态变量,React 会将这两次更新合并为一次,以减少 DOM 操作的次数。

另外,React 会对 batchedUpdates 函数进行优化,以尽可能地减少更新操作的执行次数。具体来说,React 会将更新操作分为两类:DOM 操作和非 DOM 操作。在执行 batchedUpdates 函数时,React 会先执行所有的非 DOM 操作,然后再执行所有的 DOM 操作。这样可以最大程度地减少 DOM 操作的次数,提高性能。

上面提到了更新队列,React 中的更新队列是一个双缓存队列,分为 current 和 work-in-progress 两个阶段,其中 current 表示当前渲染的状态,而 work-in-progress 则表示正在进行更新的状态。

当调用 setState 或者 forceUpdate 时,React 会将更新操作添加到 work-in-progress 阶段的队列中,然后开始执行更新。在更新过程中,React 会对 work-in-progress 阶段的队列进行修改,最终得到一个新的状态。如果更新过程中出现错误,React 会将 work-in-progress 阶段的队列丢弃,然后回退到 current 阶段的状态。

当更新完成后,React 会将 work-in-progress 阶段的队列和 current 阶段的队列进行交换,这样就完成了一次更新操作。由于 work-in-progress 阶段的队列是一个新的队列,因此可以保证在更新过程中不会影响到 current 阶段的状态,从而避免出现副作用。

batchedUpdates 函数则是用来控制更新的执行时机的。当调用 setState 或者 forceUpdate 时,React 会将更新操作添加到 work-in-progress 阶段的队列中,但并不会立即执行更新操作。相反,React 会等到某个特定的时机(例如浏览器空闲时间)才会开始执行更新操作,以避免在短时间内进行大量的 DOM 操作,从而提高性能。

在实现上,batchedUpdates 函数会使用一个叫做“事务”的机制来控制更新的执行时机。事务的基本思想是将多个操作放在一起执行,然后在操作完成后再触发更新。React 中的事务机制是通过 transaction 模块来实现的,具体来说,batchedUpdates 函数会使用 transaction 模块来注册一个名为 ReactDefaultBatchingStrategy 的事务处理器,然后在该处理器中执行更新操作。这样可以保证在更新过程中,所有的操作都是在同一个事务中进行的,从而避免出现副作用。

如下是一个简单的双缓存队列的实现:

function createDoubleBuffer(initialValue) {
  let buffer1 = initialValue;
  let buffer2 = initialValue;
  // 当前活动的缓存
  let activeBuffer = buffer1;

  function getActiveBuffer() {
    return activeBuffer;
  }

  // 切换活动缓存
  function swapBuffers() {
    activeBuffer = activeBuffer === buffer1 ? buffer2 : buffer1;
  }

  // 更新非活动缓存中的值
  function updateBuffer(newValue) {
    const inactiveBuffer = getInactiveBuffer();
    inactiveBuffer.value = newValue;
  }

  // 获取非活动缓存
  function getInactiveBuffer() {
    return activeBuffer === buffer1 ? buffer2 : buffer1;
  }

  return {
    getActiveBuffer,
    swapBuffers,
    updateBuffer,
    getInactiveBuffer,
  };
}

异步执行

当组件状态发生变化时,React 会将所有的更新操作先保存到更新队列中,然后通过 requestIdleCallbacksetTimeout 等浏览器提供的 API 来等待浏览器空闲时间或一定的延迟后批量执行更新操作。

requestIdleCallback API 允许开发者注册一个回调函数,该函数会在浏览器空闲时间时执行,这样就能最大限度地避免阻塞主线程,提高应用的响应性能。

React 在内部使用了 requestIdleCallback API,将待更新的任务打包成一个个小任务,然后注册到浏览器的空闲时间队列中,等待执行。当浏览器空闲时,React 就会从队列中取出任务,并开始执行更新操作。如果当前帧没有空闲时间,React 就会继续等待下一次空闲时间。

另外,为了更好地支持旧版浏览器,React 还提供了一个 polyfill,该 polyfill 使用了 setTimeout 代替了 requestIdleCallback。当浏览器不支持 requestIdleCallback 时,React 就会使用这个 polyfill,以保证应用的兼容性。

requestIdleCallback 的使用方式可以参考这里。这里说说怎么用 setTimeout 来实现 requestIdleCallback

具体来说,我们可以在 setTimeout 中设置一个足够大的延迟时间(例如 50 毫秒),当任务在这个时间之前完成时,可以在回调函数中再次调用 setTimeout,继续等待下一个空闲时间,并维持任务的持续执行。

下面是一个简单的 requestIdleCallback 的 polyfill 实现:

if (!window.requestIdleCallback) {
  window.requestIdleCallback = function (cb) {
    var start = Date.now();
    return setTimeout(function () {
      cb({
        didTimeout: false,
        timeRemaining: function () {
          return Math.max(0, 50 - (Date.now() - start));
        },
      });
    }, 1);
  };
}

if (!window.cancelIdleCallback) {
  window.cancelIdleCallback = function (id) {
    clearTimeout(id);
  };
}

这只是一个简单的实现,帮助理解 requestIdleCallback 的原理。实际上,React 的 polyfill 实现要复杂得多,有兴趣可以查看 React 的源码。

最后

扯得有点远,本文针对 useEffectuseLayoutEffect 的区别,对它们的执行时机做了一些分析,通过了解它们的执行时机,我们就能更好地理解它们的使用场景,从而更好地使用它们。

如果您觉得本文对您有用,欢迎捐赠或留言~
微信支付
支付宝

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注