陌上人如玉
公子世无双

12.3 useEffect 与 useLayoutEffect

12.3 useEffect 与 useLayoutEffect

一句话:两者唯一的区别是执行时机——useLayoutEffect 在「DOM 更新后、浏览器绘制前」同步执行(会阻塞绘制),useEffect 在「绘制后」异步执行;需要「测量 DOM 并立即修正以防闪烁」时用前者。

一、结论先行

维度 useLayoutEffect useEffect
执行时机 DOM 更新后、绘制前 绘制后(异步)
是否阻塞绘制 ✅ 阻塞 ❌ 不阻塞
执行方式 同步 异步(调度)
用户是否可能看到中间态 ❌ 不会(绘制前已修正) ⚠️ 可能(闪一下)
默认选择 ❌ ✅
SSR ⚠️ 服务端不执行(会警告) ✅ 不执行但无警告
典型用途 测量布局、避免闪烁 请求、订阅、日志
⭐ 一句话回答:
「它们的区别只在时机——useLayoutEffect 在『DOM 已更新但还没绘制』时同步执行,
  useEffect 在『绘制之后』异步执行;所以需要『读取布局并立即修正』时用前者,
  其它情况一律用后者。」

二、执行时机详解

一次更新的完整时序:

① render 阶段(执行组件函数,算出需要哪些 DOM 变更)—— 可中断
② commit 阶段:
     a. before mutation
     b. mutation(把 DOM 变更应用到真实 DOM)
        · 执行 useLayoutEffect 的【清理函数】
     c. layout(DOM 已更新、绘制前)
        · 赋值 ref
        · ⭐ 执行 useLayoutEffect 的【effect 函数】(同步,阻塞绘制)
③ 【浏览器绘制 paint】← 用户在这一刻看到界面
④ passive effects(异步)
   · 执行 useEffect 的【清理函数】
   · ⭐ 执行 useEffect 的【effect 函数】
// ✅ 用日志验证(强烈建议自己跑一遍)
function Demo({ id }: { id: number }) {
  console.log('① render', id);

  useLayoutEffect(() => {
    console.log('② layoutEffect', id);
    return () => console.log('② cleanup layout', id);
  }, [id]);

  useEffect(() => {
    console.log('③ effect', id);
    return () => console.log('③ cleanup effect', id);
  }, [id]);

  return <div>{id}</div>;
}

// 首次挂载:① render 1 → ② layoutEffect 1 → ③ effect 1
// id 从 1 变 2:
//   ① render 2
//   ② cleanup layout 1 → ② layoutEffect 2
//   ③ cleanup effect 1 → ③ effect 2

三、为什么 useLayoutEffect 能避免闪烁

// ❌ 用 useEffect:用户可能看到「先渲染错误位置,再跳到正确位置」
function Tooltip({ target, children }: { target: HTMLElement; children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState({ top: 0, left: 0 });

  useEffect(() => {
    // ⚠️ 此时浏览器【已经绘制过一帧】(pos 还是初始值 0,0)
    const rect = ref.current!.getBoundingClientRect();
    const targetRect = target.getBoundingClientRect();
    setPos({ top: targetRect.bottom, left: targetRect.left - rect.width / 2 });
  }, [target]);

  return <div ref={ref} style={{ position: 'fixed', ...pos }}>{children}</div>;
}
// 现象:tooltip 先在左上角闪一下,然后跳到目标位置

// ✅ 用 useLayoutEffect:在绘制前完成测量与更新
useLayoutEffect(() => {
  const rect = ref.current!.getBoundingClientRect();
  const targetRect = target.getBoundingClientRect();
  setPos({ top: targetRect.bottom, left: targetRect.left - rect.width / 2 });
}, [target]);
// → 浏览器只绘制最终结果(用户看不到中间态)
⭐ 原理:
· useLayoutEffect 里的 setState 会「同步触发一次重渲染」,
  且这次重渲染【在浏览器绘制之前】完成 → 用户看不到「初值那一帧」
· useEffect 里的 setState 发生在「绘制之后」→
  用户已经看到了「初值那一帧」→ 会有「跳动/闪烁」

四、什么时候必须用 useLayoutEffect

// ① 测量元素尺寸/位置并据此设置样式(上面 Tooltip 的例子)
useLayoutEffect(() => {
  const { height } = ref.current!.getBoundingClientRect();
  setHeight(height);
}, [content]);

// ② 根据内容高度做「展开动画」的初始定位
useLayoutEffect(() => {
  const el = panelRef.current!;
  el.style.maxHeight = open ? `${el.scrollHeight}px` : '0px';
}, [open]);

// ③ 自动聚焦 + 设置光标位置(避免「先失焦再聚焦」的视觉跳动)
useLayoutEffect(() => {
  inputRef.current?.focus();
  inputRef.current?.setSelectionRange(value.length, value.length);
}, []);

// ④ 切换标签/路由时「同步恢复滚动位置」
useLayoutEffect(() => {
  containerRef.current!.scrollTop = savedScrollTop.current;
}, [activeTab]);
⭐ 共同特征:「**读取 DOM 布局信息(尺寸/位置)并立即据此更新 UI**」。
   判断标准:「**如果晚一帧修正,用户会看到闪烁吗?**」
   · 会 → useLayoutEffect
   · 不会 → useEffect(更安全、不阻塞绘制)

五、useLayoutEffect 的代价

⚠️ 它【阻塞浏览器绘制】:
① effect 的执行时间
② 其中触发的「同步重渲染」
都要在「用户看到下一帧之前」完成。

后果:
· 逻辑重(大量 DOM 读写、复杂计算)→ 首屏可见时间变长(白屏更久)
· 在「大列表 / 复杂组件」里滥用 → 明显拖慢

所以规则是:
① 只在「必须绘制前完成」时用
② 里面的逻辑要「轻」(测量 + 一次 setState,不要做重计算)
// ❌ 在 useLayoutEffect 里做重计算(阻塞绘制)
useLayoutEffect(() => {
  const result = heavyCompute(data);        // ⚠️ 阻塞首屏
  setResult(result);
}, [data]);

// ✅ 放到 useEffect(不阻塞绘制),或用 useMemo(在渲染期算但不阻塞绘制)
const result = useMemo(() => heavyCompute(data), [data]);
// ❌ 在 useLayoutEffect 里做「数据请求」(时机没意义,反而阻塞)
useLayoutEffect(() => {
  fetchData().then(setData);                // ⚠️ 请求是异步的,布局时机没意义
}, []);

// ✅ 请求放 useEffect
useEffect(() => {
  const ac = new AbortController();
  fetchData({ signal: ac.signal }).then(setData);
  return () => ac.abort();
}, []);

六、SSR 中的警告与同构 Hook

⚠️ 服务端没有 DOM,useLayoutEffect 不会执行,React 会发出警告:
   "useLayoutEffect does nothing on the server"

三种解法:
① 改用 useEffect(如果不需要「绘制前」的时机)
② 让组件只在客户端渲染(Next.js:dynamic(..., { ssr: false }))
③ 用「同构 Hook」:服务端用 useEffect、客户端用 useLayoutEffect
// ✅ 同构 Hook(社区标准做法,很多 UI 库都这么做)
import { useEffect, useLayoutEffect } from 'react';

export const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

// 使用
function Tooltip() {
  const ref = useRef<HTMLDivElement>(null);

  useIsomorphicLayoutEffect(() => {
    // 客户端:同步执行(避免闪烁)
    // 服务端:降级为 useEffect(不执行、不警告)
  }, []);

  return <div ref={ref} />;
}

七、常见追问

Q: 它们的「清理函数」时机也不同吗?
A: 规则相同(依赖变化后 + 卸载时),只是「执行的阶段不同」:
   layout 的清理在「mutation 阶段」执行,
   passive 的清理在「绘制后」执行。

Q: 都能读到最新 DOM 吗?
A: 都能。区别是「useLayoutEffect 在绘制前读取」(可以用来修正 UI 而不闪),
   「useEffect 在绘制后读取」(读得到,但改样式会闪)。

Q: 都能读 ref 吗?
A: 都能(ref 在 commit 的 mutation/layout 阶段赋值)。
   但 useLayoutEffect 里 ref 一定已就绪(因为它就在 layout 阶段)。

Q: 在 useLayoutEffect 里 setState 会「多一次渲染」吗?
A: 会(同步重渲染),但它发生在「绘制前」 →
   用户只看到最终结果;代价是「这一帧的计算量变大」。

Q: 用 useEffect 也看不到闪烁,是不是就不用 useLayoutEffect 了?
A: 只要「effect 里 setState 影响了可见样式」就可能闪。
   在现代浏览器里「有时看起来不闪」(因为绘制可能被推迟),
   但这【不可靠】——应该在「需要保证不闪」时用 useLayoutEffect。

Q: 三个 Effect 的顺序是怎样的(还有一个 useInsertionEffect)?
A: useInsertionEffect(给 CSS-in-JS 用,在 mutation 前/布局前)
   → useLayoutEffect(绘制前)→ useEffect(绘制后)。

Q: 为什么 React 18 的 StrictMode 会让它们都执行两次?
A: 它故意模拟「挂载 → 卸载 → 再挂载」以暴露「清理缺失」。

Q: 在 SSR 里用 useLayoutEffect 会「报错」吗?
A: 只是【警告】(且服务端不执行它);用同构 Hook 可消除警告。

八、常见坑

// ① 用 useEffect 做「测量后立即设置样式」(会闪)
useEffect(() => { setHeight(ref.current!.offsetHeight); }, []);   // ⚠️ 可能闪

// ② 用 useLayoutEffect 做「请求/重计算」(阻塞绘制)
useLayoutEffect(() => { fetch(...).then(setData); }, []);          // ❌

// ③ 在 SSR 里直接用 useLayoutEffect(会警告)
// ✅ 用同构 Hook 或客户端渲染

// ④ 认为「useLayoutEffect 更早执行所以更好」(它会阻塞绘制)
// ⑤ 认为「两者清理时机不同」(规则相同,只是阶段不同)
// ⑥ 在 useLayoutEffect 里「读 ref.current 时忘了判空」
useLayoutEffect(() => { ref.current.focus(); }, []);              // ⚠️
// ⑦ 用 useLayoutEffect 做「需要在绘制后才做」的事(如统计上报)
// ⑧ 在「已经挂起(Suspense)的组件」里用布局 effect(时机语义会变)
// ⑨ 忘了「依赖数组」(默认每次渲染都执行 → 阻塞绘制)
useLayoutEffect(() => { /* 没有依赖 */ });                         // ⚠️
// ⑩ 认为「useLayoutEffect 能替代 useEffect」——
//    技术上可以(更早),但会「无谓阻塞绘制」且 SSR 会警告
// ⑪ 在「布局 effect 里改样式」导致「强制同步布局」(layout thrashing)
useLayoutEffect(() => {
  const h = el.offsetHeight;            // 读 → 触发 layout
  el.style.height = `${h}px`;           // 写 → 又要 layout
  const h2 = el.offsetHeight;           // 再读 → 又触发
}, []);
// ⑫ 依赖「useLayoutEffect 一定在绘制前」但在「并发渲染」下有更细的语义

九、面试延伸

  1. 「useEffect 和 useLayoutEffect 有什么区别?」
    唯一的区别是执行时机:useLayoutEffect 在「DOM 已更新、浏览器还没绘制」时同步执行(会阻塞绘制);useEffect 在「绘制之后」异步执行(不阻塞)。两者的清理函数规则相同(依赖变化后 + 卸载时执行),并且都能读到最新的 DOM。所以选择的唯一标准是「是否需要在用户看到之前修正 UI」。

  2. 「什么时候必须用 useLayoutEffect?」
    当「读取 DOM 布局信息并据此立即更新 UI」时,具体有四个典型场景:① 测量元素尺寸/位置后设置样式(Tooltip 定位、自适应宽度);② 根据内容高度做展开动画的初始定位(maxHeight);③ 自动聚焦并设置光标位置;④ 切换标签/路由时同步恢复滚动位置。判断标准是「如果晚一帧修正,用户会看到闪烁吗」——会就用它,不会就用 useEffect。

  3. 「为什么 useEffect 会导致闪烁?」
    因为它在「浏览器绘制之后」执行:此时用户已经看到了一帧「初始状态」(如 pos 还是 0,0 的 Tooltip),然后你在 effect 里 setState 触发重渲染,用户又看到「正确位置」——两帧之间产生了肉眼可见的跳动。而 useLayoutEffect 在「绘制前」完成「测量 + setState + 同步重渲染」,浏览器只绘制最终结果,因此看不到中间态。

  4. 「useLayoutEffect 有什么代价?」
    它阻塞浏览器绘制——effect 的执行、其中的 DOM 操作、以及由此触发的「同步重渲染」都必须在「用户看到下一帧之前」完成。所以如果逻辑重(大量 DOM 读写、复杂计算),用户的首屏可见时间会被延长(表现为白屏更久)。因此它应该「只在必要时使用」,并且内部的逻辑要「轻」(测量 + 一次 setState,不要做重计算或请求)。

  5. 「SSR 中用 useLayoutEffect 会怎样?」
    服务端没有 DOM,所以它不会执行,React 会发出警告(useLayoutEffect does nothing on the server)。三种解法:① 改用 useEffect(如果不需要「绘制前」的时机);② 让组件只在客户端渲染(Next.js 用 dynamic(..., { ssr: false }));③ 用同构 Hook——const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;。社区库里普遍用第 ③ 种(既避免闪烁又不产生警告)。

  6. 「React 的内部执行顺序是怎样的?」
    一次更新:① render 阶段(可中断,执行组件函数算出 DOM 变更)→ ② commit 阶段:mutation(应用 DOM 变更,并执行 layout effect 的清理)→ layout(赋 ref、同步执行 useLayoutEffect)→ ③ 浏览器绘制 → ④ passive effects(异步执行 useEffect 的清理与 effect)。这个划分解释了「为什么 useLayoutEffect 能避免闪烁」——它处在「DOM 已更新但还没绘制」的窗口里。

  7. 「useInsertionEffect 是什么?」
    它是 React 18 引入的第三个 effect,专为 CSS-in-JS 库设计——它在「DOM 变更前、布局 effect 之前」同步执行,用于「插入 <style> 标签」(这样样式在布局计算前就位,避免「样式晚到导致的一次额外布局」)。业务代码几乎不需要它,但要知道「存在三种 effect,时机从早到晚是:useInsertionEffect → useLayoutEffect → useEffect」。

  8. 「如果 useEffect 里 setState 也不闪,还需要 useLayoutEffect 吗?」
    需要(在「必须保证不闪」的场景)。因为「useEffect 里 setState 有时看起来不闪」是巧合——它取决于「浏览器是否在 effect 之前绘制了那一帧」(受「JS 执行是否超出一帧」等因素影响,不可靠)。所以在「测量后立即改样式」这类场景应当明确用 useLayoutEffect(或让「初值就是合理值」,从而根本上不需要那次修正)。工程上更稳的做法是「避免需要二次修正」(如用 CSS 直接算出位置、或用 ResizeObserver 提前拿到尺寸)。

一句话速记

唯一的区别是时机:useLayoutEffect 在「DOM 更新后、绘制前」同步执行(阻塞绘制),useEffect 在「绘制后」异步执行(清理规则相同、都能读最新 DOM);需要「测量 DOM 并立即修正以防闪烁」时用前者(Tooltip 定位、自适应宽度、展开动画初始高度、自动聚焦、滚动恢复),其它一律用后者;代价是阻塞绘制(逻辑要轻,别放重计算或请求);SSR 会警告(服务端不执行)→ 用同构 Hook(typeof window !== 'undefined' ? useLayoutEffect : useEffect);三种 effect 的时机从早到晚是「useInsertionEffect(CSS-in-JS 插样式)→ useLayoutEffect → useEffect」;「useEffect 里 setState 有时不闪」是巧合、不可靠。

赞(0) 打赏
未经允许不得转载:陌上寒 » 12.3 useEffect 与 useLayoutEffect

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫