2026年6月25日
React useIsomorphicLayoutEffect:修掉 SSR 下的 useLayoutEffect 警告(2026)
你加了一个 useLayoutEffect 来测量一个 tooltip,发版,下一次 Next.js(或 Remix、Gatsby)的开发服务器在服务端渲染这个页面时,控制台就亮了:
Warning: useLayoutEffect does nothing on the server, because its effect cannot
be encoded into the server renderer's output format. This will lead to a
mismatch between the initial, non-hydrated UI and the intended UI. To avoid
this, useLayoutEffect should only be used in components that render exclusively
on the client.
这个警告说得没错,但它给的建议(「只在客户端用」)帮不上忙;而那个最显而易见的绕法——直接换成 useEffect——会悄悄把你当初用 useLayoutEffect 干掉的那个视觉 bug 又请回来。useIsomorphicLayoutEffect 就是化解这个僵局的那个小 hook。本文讲清楚警告到底为什么出现、两种最直觉的修法为什么都不对,以及那个一行的 hook 实际上做了什么。
useLayoutEffect 到底为什么存在
React 给了你两个长得几乎一样的 effect hook:
useEffect在浏览器绘制之后运行。它的回调会被排队,等这一帧上屏之后异步触发。useLayoutEffect在浏览器绘制之前同步运行,就在 React 改完 DOM、但用户还没看到任何东西的那一刻。
这个时序差别就是它存在的全部意义。如果你要读布局——getBoundingClientRect、scrollHeight、某个节点测出来的宽度——然后据此写一个样式,你必须在绘制之前做完。否则用户会先看到一帧错的布局,然后你的 useEffect 纠正过来时会闪一下。最典型的例子就是一个要根据自身尺寸来定位的 tooltip:
function Tooltip({ targetRect, children }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const { height, width } = ref.current!.getBoundingClientRect();
// 放在目标上方、水平居中
setPos({
top: targetRect.top - height - 8,
left: targetRect.left + targetRect.width / 2 - width / 2,
});
}, [targetRect]);
return <div ref={ref} style={{ position: 'fixed', ...pos }}>{children}</div>;
}
用 useLayoutEffect,React 在同一个同步过程里测量并重新定位,所以 tooltip 永远只会在正确的位置被绘制。换成 useEffect,tooltip 会先在 { top: 0, left: 0 } 闪一帧,然后才跳到正确的位置。机器快的时候你可能注意不到;在被降频的手机上你一定会看到。
为什么服务端容不下它
服务端渲染产出的是一段 HTML 字符串。没有浏览器、没有 DOM、没有布局阶段,而且——最关键的——什么都不会绘制。useLayoutEffect 存在的全部理由,就是要在一次绘制之前同步运行,而这次绘制在服务端永远不会到来。
所以 React 做了一个有意的选择:**useLayoutEffect 的回调在服务端渲染期间根本不会运行。**它们没法被有意义地序列化进 HTML,运行它们也产生不了任何有用的东西。React 知道这是个陷阱——你组件的服务端产出不会反映布局 effect 本该算出的结果——于是它抛出那个警告,告诉你服务端 HTML 和你想要的客户端 UI 可能对不上。
这个警告不是你代码的 bug。它是 React 在提醒你:你有一个 hook,它唯一的工作在服务端根本没法完成。
为什么不能直接用 useEffect
第一直觉是把它换成 useEffect 来消掉警告——React 很乐意在服务端跑 useEffect(只是把回调推迟)。警告消失了。闪烁回来了。
记住那个时序:useEffect 在绘制之后触发。所以在客户端水合之后,你那套「先测量、再重定位」的逻辑现在晚了一帧。用户会先看到没定位好的状态,然后才是纠正。你拿一个用户看不见的控制台警告,换来了一个用户看得见的视觉故障——这是严格意义上更差的结果。
第二直觉——让这个组件只在客户端渲染(typeof window !== 'undefined' 守卫、ssr: false 的动态导入、挂载标志位)——能用,但它把整棵子树的服务端渲染都扔掉了。你失去了 SSR 的 HTML,内容在水合之前对爬虫不可见,而且首屏多了一次布局抖动。为了一个「选哪个 hook」的问题,这是大炮打蚊子。
真正的修法:按环境分支
道理其实很简单:你想要 useLayoutEffect 那种「绘制前」的时序——在浏览器里;同时你想要 useEffect 那种「安安静静什么也不做、不报警」的行为——在服务端。这是两个不同的 hook,哪个对取决于代码跑在哪里。
所以在模块加载时,根据是不是浏览器环境来挑:
import { useEffect, useLayoutEffect } from 'react';
const isBrowser = typeof window !== 'undefined';
export const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
整个 hook 就这些。在浏览器里它就是 useLayoutEffect——一模一样的绘制前同步时序、一模一样的签名。在服务端它就是 useEffect,React 从不对它报警,也永远不会跑一次没用的布局过程。「Isomorphic(同构)」是个老词,指那种在服务端和客户端跑法一致的代码;这个 hook 就是为每个环境挑出语义相同的那个 effect。
ReactUse 把它原样做成了 useIsomorphicLayoutEffect,省得你在每个项目里复制粘贴这段:
import { useIsomorphicLayoutEffect } from '@reactuses/core';
function Tooltip({ targetRect, children }) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
// 跟前面一模一样的代码——但没有 SSR 警告,也没有客户端闪烁。
useIsomorphicLayoutEffect(() => {
const { height, width } = ref.current!.getBoundingClientRect();
setPos({
top: targetRect.top - height - 8,
left: targetRect.left + targetRect.width / 2 - width / 2,
});
}, [targetRect]);
return <div ref={ref} style={{ position: 'fixed', ...pos }}>{children}</div>;
}
它是 useLayoutEffect 的无缝替换:一样的回调、一样的可选依赖数组、一样的清理函数。唯一变的是警告没了,而你的客户端行为保持不变。
一个细节:为什么分支放在 render 外面
注意 isBrowser ? useLayoutEffect : useEffect 只在模块求值时跑一次,不在组件里跑。这是故意的。Hook 规则要求你每次渲染都以相同顺序调用相同的 hook。如果你在组件内部写 if (isBrowser) useLayoutEffect(...) else useEffect(...),那严格来说你在服务端和客户端调用了不同的 hook——更糟的是,linter 会(理所应当地)对条件式 hook 调用报警。
把这个选择在模块加载时定成一个稳定的函数引用,组件就只是无条件地调用 useIsomorphicLayoutEffect(...)。isBrowser 在一个进程内永远不变,所以选中的 hook 在整个 bundle 生命周期里都是恒定的。hook 顺序保持稳定,lint 规则也满意。
什么时候用它(什么时候别用)
当下面所有条件都成立时,用 useIsomorphicLayoutEffect:
- 你需要布局阶段的时序——你在测量或改动 DOM,且结果必须出现在第一帧绘制里(tooltip、popover、自动撑高的 textarea、滚动位置恢复、焦点管理,任何「闪一帧就看得见」的场景)。
- 这个组件会被服务端渲染(Next.js、Remix、Astro islands、Gatsby、TanStack Start——任何会调用
renderToString/renderToPipeableStream的东西)。 - 你想消掉 SSR 警告,又不想为这棵子树关掉 SSR。
不要把它当成 useEffect 的无脑替换。如果你的 effect 不碰布局——拉数据、订阅事件、同步到 localStorage、打日志——普通的 useEffect 才是对的,你要的就是它「绘制后、不阻塞」的时序。useLayoutEffect(以及它的同构版本)是同步运行、会阻塞绘制的;滥用它会让你的应用毫无收益地卡顿。经验法则没变:只在不用它就会看到闪烁的时候,才上布局 effect。
而如果一个组件确实只能在客户端跑——它在顶层 import 了 window,或者包了一个只在浏览器里能用的库——那让它客户端渲染(dynamic(() => ..., { ssr: false }))仍然是对的工具。useIsomorphicLayoutEffect 是给那些确实会在服务端渲染、只是内部带了个布局 effect 的组件用的。
布局时序这一族
useIsomorphicLayoutEffect 是 ReactUse 里一小族 effect hook 的基底。一旦你理解了这个 SSR 安全的布局 effect,其余几个就顺理成章了:
useUpdateLayoutEffect—— 一个跳过首次挂载、只在更新时运行的布局 effect。它内部用一个「首次挂载」守卫包住useLayoutEffect,所以它是useUpdateEffect在布局阶段的兄弟。当初始 DOM 已经正确、你只需要对后续 prop 变化做出反应时很好用(把一个值动画到新位置,而不是动画入场)。注意这个直接用了useLayoutEffect,如果你需要它在 SSR 下也静默,把这个模式跟isBrowser分支结合一下即可。useUpdateEffect—— 同样的「跳过首渲染」行为,建立在useEffect之上。日常那个「变化时跑、挂载时不跑」的 hook。useMount—— 在挂载后恰好运行一次回调。当你想表达的只是「挂载时」,它是useEffect(fn, [])的可读别名。
库内部还有一个低调但重要的使用者。useEvent —— ReactUse 那个稳定回调 hook,给你一个身份永久、但闭包始终最新的事件处理函数——就用了 useIsomorphicLayoutEffect,在绘制之前把最新的函数同步进一个 ref:
const handlerRef = useRef(fn);
useIsomorphicLayoutEffect(() => {
handlerRef.current = fn;
}, [fn]);
在布局阶段写这个 ref,保证了如果某个子组件在它自己的布局 effect 里触发这个处理函数,它已经能看到最新的版本——而用同构的方式去做,意味着 useEvent 自己也永远不会踩到 SSR 警告。这很好地说明了为什么一个库 hook 默认就该选同构的版本:你不知道你的使用者跑在哪个环境,所以你挑那个在两边都对的。
要点回顾
- 「useLayoutEffect does nothing on the server」这个警告,是 React 在告诉你:一个「绘制前」的 hook 没法在没有绘制的地方运行。它说得对,不是误报。
- 换成
useEffect能消掉警告,但会在客户端重新引入一帧闪烁,因为useEffect在绘制之后才跑。 useIsomorphicLayoutEffect同时解决两边:它在浏览器里就是useLayoutEffect、在服务端就是useEffect,在模块加载时选定一次,hook 顺序保持稳定。- 在服务端渲染的组件里做布局测量/改动时用它;其余不碰布局的,留给普通
useEffect。 - ReactUse 把它(以及相关的
useUpdateLayoutEffect、useUpdateEffect、useMount)打包好了,省得你重造那一行——并在内部用它来让自家 hook 保持 SSR 安全。
到 reactuse.com 浏览完整的 SSR 安全 effect hook 集合,凡是有 useLayoutEffect 让你的服务端控制台紧张的地方,都把 useIsomorphicLayoutEffect 放进去。