【译】useEffect究竟何时执行?

3,040 阅读7分钟

前言:这篇文章解释了为何 useEffect 会在 paint 之前执行这一不符合普遍认知的现象。更多地,对译者这类 React 初学者而言,文章形象生动阐释了 useEffectuseLayoutEffect 的执行时机以及设计缘由,同时给出了 useEffectuseLayoutEffect 最佳实践的建议,可以帮助你在开发中正确地使用useEffectuseLayoutEffect

原文标题:useEffect sometimes fires before paint

useEffect 不寻常的执行时机?

在我们的认知中, useEffect 为了防止卡顿应该在浏览器重绘之后执行,实际上,它并不能完全保证在重绘之后执行:如果在某个render 的 useLayoutEffect中更新了 state,那么来自这个 render 的所有useEffect 会在重绘之前执行,再将 useLayoutEffect 中更新的状态有效地转换到屏幕上(注:即 useEffect 后开启一次 re-render 再重绘)。令人疑惑?由我一一道来。

一般的渲染流程:

  1. React 前面的步骤:渲染虚拟 Dom,调度 effects,更新真实DOM
  2. 调用 useLayoutEffect
  3. React 让权给浏览器重绘最新的 DOM
  4. 调用 useEffect

React 文档 没有准确地说明 useEffect 何时执行 ———— 一般来说, **在浏览器重绘之后延迟执行。**我以为它是通过 setTimeout(effect,3) 实现的, 但好像是使用 MessageChannel 的技巧,更加优雅。

然而,文档中出现了一个耐人寻味的段落:

虽然 useEffect 会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行。在开始新的更新前,React 总会先清除上一轮渲染的 effect。

React给出了保证 ———— 每次更新都不会被错过,但这也暗示着有时候 effect 可能在重绘之前执行。如果 a 轮渲染 的 effects 在新的 updates 前生效,那么 b 轮渲染会在重绘之前就更新。例如:触发自useLayoutEffect(注:即上文说到在 useLayoutEffect 中更新数据),然后本轮渲染的 useEffect 会在重绘前的 update 前生效。

时间线如下:

  1. 第一轮 Update 开始,前面的步骤:渲染虚拟 Dom,调度 effects,更新真实DOM
  2. 调用 useLayoutEffect
  3. useLayoutEffect中更新状态, 需要调度 re-render
  4. 调用 useEffect
  5. 第二轮 Update 开始
  6. 调用 useLayoutEffect
  7. React 让权给浏览器重绘最新的 DOM
  8. 调用 useEffect

举个例子

考虑一个不罕见的场景 ———— 你不应该在 useEffect 中更新状态,因为更新状态就会更新 Dom,如果这样做的话,就会留给用户旧状态渲染的一帧,最终导致引起注意的闪烁。

让我们一起编写一个响应式的 input 组件(类似于 CSS 容器查询)为例吧,它只在 input 组件 width 大于 200px 时才渲染一个清空按钮。我们需要真实的 DOM 去测量 input 的宽度,所以我们需要一些 effect。同时,我们不希望图像会在一帧之后闪烁,所以,初次测量在 useLayoutEffect 中进行:

const ResponsiveInput = ({ onClear, ...props }) => {
  const el = useRef();
  const [w, setW] = useState(0);
  const measure = () => setW(el.current.offsetWidth);
  useLayoutEffect(() => measure(), []);
  useEffect(() => {
    // don't take this too seriously, say it's a ResizeObserver
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, []);
  return (
    <label>
      <input {...props} ref={el} />
      {w > 200 && <button onClick={onClear}>clear</button>}
    </label>
  );
};

我们试图通过useEffect 延迟 addEventListener 在重绘之后调用,但是在 useLayoutEffect 中更新的状态强制 effect 在重绘之前生效了 (see sandbox):

useLayoutEffect 不是唯一强制使 effect 提前生效的方法 ———— host refs (<div ref={HERE}>), requestAnimationFrame 循环, 以及在 useLayoutEffect 中生成的微任务,有着同样的表现。

最佳实践

好的,以上知识并非全貌 ———— 在某些情况下,你的渲染流程会比它本应有的优化差,Who cares?

尽管这样,知道你使用工具的边界情况还是很有用的,这有四条实践的建议供了解:

不要信赖useEffect 一定在 update 后执行

即使你自己知道注意事项,但还是很难保证某些 useEffect 不会被 useLayoutEffect 中的状态更新影响到

  1. 我的组件保证没有用 useLayoutEffect

    但你能保证自定义 hooks (e.g. usePopper)中没有用吗 ?

  2. 我的组件只用 React 内部的 hooks。

    但是一个 useLayoutEffect 中状态的更新会通过 useContext 和 一个祖先组件的 re-render 而泄出

  3. 我的组件只有useEffect和一个 memo()

    但是一个 update 会导致全局地生效 effects ,所以一个在别的组件产生的 重绘前的 update 仍然会使子组件的 effects 生效。

要遵守如此多准则你不可能写出没有在 useLayoutEffect 中更新状态的代码,除非你是超人。所以,最好的建议是不要信赖useEffect 一定在 update 后执行,就像 React 也不 100% 保证 useMemo 返回相同的引用。如果你想用户看到渲染在一帧的东西,多尝试下requestAnimationFrame 或者自己使用 postMessage 的技巧。

相反地,假设你没有听取 React 团队的建议并在 useEffect 更新 Dom。你尝试了,然后,aha!没有闪烁!但坏消息是 ———— 这可能是一个状态更新的结果。把更新的代码去除,就会闪烁了。

不要浪费时间去分割 layout effects

按照useEffect vs uselayouteeffect的指导原则,我们可以将一个逻辑副作用拆分成一个 layout effect 来更新 DOM 和一个“延迟”的 effect,就像我们在 ResponsiveInput 示例中所做的那样

// DOM update = layout effect
useLayoutEffect(() => setWidth(el.current.offsetWidth), []);
// subscription = lazy logic
useEffect(() => {
  window.addEventListener('resize', measure);
  return () => window.removeEventListener('resize', measure);
}, []);

然而,正如我们现在所知道的,这没有任何作用,这两个 effects 在渲染前生效。此外,分割是草率 ———— 如果我们自以为useEffect在重绘后执行,你能 100% 保证元素不会在 effects 执行期的间隔调整大小吗?我不能保证。在这里,将所有尺寸跟踪逻辑留在一个useLayoutEffect中更安全、更纯粹,在重绘前同样的工作量,并且让React少一个 effect 来管理 ———— 整洁之道获胜

useLayoutEffect(() => {
  setWidth(el.current.offsetWidth);
  window.addEventListener('resize', measure);
  return () => window.removeEventListener('resize', measure);
}, []);

不要在 useLayoutEffect 中更新状态

好的建议,说比做简单得多了 ———— 在 useEffect 中更新状态比想象中更糟,因为闪烁是糟糕的 UX,而UX比性能更重要。在渲染时更新状态看起来很危险。

有时状态可以安全地替换为useRef。更新 ref 不会触发更新,并且可以按预期运行。我恰巧有一篇文章探讨了其中的一些案例

如果可以,请尝试想出一个不依赖于 effect 的状态模型,但我不知道如何根据命令(on commend)发明“好”的状态模型。

绕过状态更新(直接改变DOM)

如果你发现特定的useLayoutEffect引起了问题,请考虑绕过状态更新,直接改变DOM。这样,React 就不需要安排更新,也不需要提前使 effects 生效了。我们可以试一试:

const clearRef = useRef();
const measure = () => {
  // No worries react, I'll handle it:
  clearRef.current.display = el.current.offsetWidth > 200 ? null : none;
};
useLayoutEffect(() => measure(), []);
useEffect(() => {
  window.addEventListener("resize", measure);
  return () => window.removeEventListener("resize", measure);
}, []);
return (
  <label>
    <input {...props} ref={el} />
    <button ref={clearRef} onClick={onClear}>clear</button>
  </label>
);

我在以前关于避免使用useState的文章中已经探讨过这种技术,现在我们又有了一个跳过响应更新的理由。不过,手动管理DOM更新是复杂且容易出错的,所以将此技巧保留到性能关键的情况下 ———— 频繁更新的组件或重度逻辑的 useEffects 才去使用。


小结

今天我们发现 useEffect 有时会在重绘之前执行。一个常见的原因是在useLayoutEffect中更新状态,它请求在重绘之前 re-render,并且 effects 必须在 re-render 之前运行。当从RAFs或微任务更新状态时也会发生这种情况。

这对我们意味着什么:

  1. useLayoutEffect中更新状态不利于应用的性能。尽量不要这样做,除非没有更好的选择了。
  2. 不要信赖 useEffect 一定在 update 后执行。
  3. useEffect更新DOM会导致可见的闪烁,也许你看不到,是因为一个 layout effect 中的状态更新。
  4. 如果你在layout effect部分设置了状态,那么将useLayoutEffect的部分逻辑提取到useEffect中以提高性能是没有意义的。
  5. 在性能关键的场景下在 useLayoutEffect 中手动更改 Dom 又多了一个原因 。