前言:这篇文章解释了为何
useEffect
会在paint
之前执行这一不符合普遍认知的现象。更多地,对译者这类 React 初学者而言,文章形象生动阐释了useEffect
和useLayoutEffect
的执行时机以及设计缘由,同时给出了useEffect
和useLayoutEffect
最佳实践的建议,可以帮助你在开发中正确地使用useEffect
和useLayoutEffect
useEffect 不寻常的执行时机?
在我们的认知中, useEffect
为了防止卡顿应该在浏览器重绘之后执行,实际上,它并不能完全保证在重绘之后执行:如果在某个render 的 useLayoutEffect
中更新了 state,那么来自这个 render 的所有useEffect
会在重绘之前执行,再将 useLayoutEffect
中更新的状态有效地转换到屏幕上(注:即 useEffect 后开启一次 re-render 再重绘)。令人疑惑?由我一一道来。
一般的渲染流程:
- React 前面的步骤:渲染虚拟 Dom,调度 effects,更新真实DOM
- 调用
useLayoutEffect
- React 让权给浏览器重绘最新的 DOM
- 调用
useEffect
React 文档 没有准确地说明 useEffect
何时执行 ———— 一般来说, **在浏览器重绘之后延迟执行。**我以为它是通过 setTimeout(effect,3)
实现的, 但好像是使用 MessageChannel
的技巧,更加优雅。
然而,文档中出现了一个耐人寻味的段落:
虽然
useEffect
会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行。在开始新的更新前,React 总会先清除上一轮渲染的 effect。
React给出了保证 ———— 每次更新都不会被错过,但这也暗示着有时候 effect 可能在重绘之前执行。如果 a 轮渲染 的 effects 在新的 updates 前生效,那么 b 轮渲染会在重绘之前就更新。例如:触发自useLayoutEffect
(注:即上文说到在 useLayoutEffect
中更新数据),然后本轮渲染的 useEffect
会在重绘前的 update 前生效。
时间线如下:
- 第一轮 Update 开始,前面的步骤:渲染虚拟 Dom,调度 effects,更新真实DOM
- 调用
useLayoutEffect
- 在
useLayoutEffect
中更新状态, 需要调度 re-render - 调用
useEffect
- 第二轮 Update 开始
- 调用
useLayoutEffect
- React 让权给浏览器重绘最新的 DOM
- 调用
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
中的状态更新影响到
-
我的组件保证没有用
useLayoutEffect
。但你能保证自定义 hooks (e.g.
usePopper
)中没有用吗 ? -
我的组件只用 React 内部的 hooks。
但是一个
useLayoutEffect
中状态的更新会通过useContext
和 一个祖先组件的 re-render 而泄出 -
我的组件只有
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或微任务更新状态时也会发生这种情况。
这对我们意味着什么:
- 在
useLayoutEffect
中更新状态不利于应用的性能。尽量不要这样做,除非没有更好的选择了。 - 不要信赖 useEffect 一定在 update 后执行。
- 在
useEffect
更新DOM会导致可见的闪烁,也许你看不到,是因为一个 layout effect 中的状态更新。 - 如果你在layout effect部分设置了状态,那么将
useLayoutEffect
的部分逻辑提取到useEffect
中以提高性能是没有意义的。 - 在性能关键的场景下在 useLayoutEffect 中手动更改 Dom 又多了一个原因 。