React 用 flushSync 强制同步刷新 DOM:自动滚到底部、读取最新布局与它的性能代价

📅 2026/8/11 23:04:43
React 用 flushSync 强制同步刷新 DOM:自动滚到底部、读取最新布局与它的性能代价
React 用 flushSync 强制同步刷新 DOM:自动滚到底部、读取最新布局与它的性能代价聊天窗口来了新消息,你想让它自动滚到底部;或者点一下「展开」,你想马上量一下展开后的高度做动画。你写了setState之后紧接着操作 DOM,结果发现——量到的是旧的DOM,滚动也差了一屏。这篇讲清楚为什么会这样,以及flushSync什么时候是解药、什么时候是毒药。先复现这个坑一个最常见的场景:聊天列表,发消息后要滚到底。function Chat() { const [messages, setMessages] useState([]); const listRef useRef(null); function send(text) { setMessages((prev) [...prev, text]); // 想在新消息渲染后滚到底 —— 但这里 DOM 还没更新! const el listRef.current; el.scrollTop el.scrollHeight; // scrollHeight 还是加新消息之前的高度 } return ( div ref{listRef} style{{ height: 300, overflow: auto }} {messages.map((m, i) ( p key{i}{m}/p ))} /div ); }现象:每次发消息,列表滚到的是上一条消息的位置,永远差最后一条那么高。原因是 React 18 的setState是异步批处理的。调用setMessages只是排了个更新任务,函数继续往下执行时,组件还没重渲染,DOM 里还没有新的p,所以scrollHeight读到的是旧值。朴素修法:useEffect,但它有闪烁标准答案是把「读 DOM / 操作 DOM」放到useEffect里,等 React 提交完再执行:useEffect(() { const el listRef.current; el.scrollTop el.scrollHeight; }, [messages]); // messages 变了、DOM 更新后才跑大多数场景这就够了,应该优先用它。但useEffect在浏览器绘制之后才执行,如果你的操作会改变布局(比如滚动位置、插入元素撑高),用户可能会看到「先渲染在错误位置、再跳一下」的闪烁。想消灭闪烁可以换useLayoutEffect(绘制前同步执行)。绝大多数「渲染后滚动/量尺寸」用它就对了。那flushSync是干嘛的?flushSync 登场:强制立即提交flushSync来自react-dom,它包住一段setState,强制 React 同步地、立刻完成这次更新并提交到 DOM,函数返回后 DOM 已是最新:import { flushSync } from react-dom; function send(text) { flushSync(() { setMessages((prev) [...prev, text]); }); // 到这行时,新的 p 已经在 DOM 里了 const el listRef.current; el.scrollTop el.scrollHeight; // 现在 scrollHeight 是最新高度,滚动正确 }关键点:flushSync(fn)里的fn执行完,React 会立即冲刷这批更新、跑完 DOM diff 和提交,然后flushSync才返回。所以紧跟其后的命令式 DOM 读写拿到的都是最新状态。真正非它不可的场景:一次操作里要「读旧 写新 读新」有个场景useEffect很难优雅处理:在同一个事件里,既要拿更新前的布局,又要拿更新后的布局。比如往列表顶部插入内容,想保持用户当前看到的位置不跳(锚定滚动):function prepend(item) { const el listRef.current; const prevHeight el.scrollHeight; // 1. 先记住旧高度 flushSync(() { setMessages((prev) [item, ...prev]); // 2. 同步插入并提交 }); // 3. 新内容撑高了顶部,把 scrollTop 补上高度差,视口锚定不动 el.scrollTop el.scrollHeight - prevHeight; }这种「测量 → 同步改 DOM → 再测量 → 补偿」的流程,用useEffect要靠 ref 在多次渲染间传值,又绕又容易错;flushSync把它压进一个同步函数里,直白得多。性能代价:为什么官方叫你「少用」flushSync是把 React 18 辛苦攒起来的批处理优化直接关掉。正常情况下,一个事件里的多次setState会被合并成一次渲染;而每个flushSync都会强制单独渲染并提交一次。function bad() { // 三次 flushSync 三次完整的渲染 提交,浏览器被迫同步布局三遍 flushSync(() setA(1)); flushSync(() setB(2)); flushSync(() setC(3)); } function ok() { // 需要同步的话,合并成一次 flushSync(() { setA(1); setB(2); setC(3); }); }代价具体是:打断批处理:本可合并的更新被拆成多次渲染,组件树越大越明显。强制同步布局(layout thrashing):提交后浏览器要立刻重算布局,循环里连续调用会造成布局抖动。让并发特性失效:它是同步的,和useTransition、Suspense的可中断渲染理念相悖,包在里面的更新无法被打断或降级。React 官方文档对它的原话是「uncommon(不常见)」「can hurt performance(会损害性能)」。默认不要用。决策清单:到底该用哪个渲染后要滚动/量尺寸,允许一帧闪烁 →useEffect。渲染后要滚动/量尺寸,不能闪烁(最常见) →useLayoutEffect。必须在同一个同步函数里「改 state 后立刻读到最新 DOM」,尤其要做「旧布局 → 新布局」的补偿计算 →flushSync,且尽量只调一次、把多个setState合并进去。整页/大列表更新且想保持响应 → 用useTransition,别用flushSync。还有个隐藏坑:别在渲染过程中或useEffect内部调flushSync,React 会警告 “flushSync was called from inside a lifecycle method”。它只该出现在事件处理器这类命令式代码里。小结setState是异步批处理的,调用后紧接着读 DOM 拿到的是旧DOM,这是「滚动差一屏」的根因。优先用useLayoutEffect(绘制前、无闪烁)或useEffect来做「渲染后操作 DOM」。flushSync强制同步提交,让你在一个函数内立刻读到最新 DOM,专治「读旧布局 → 改 → 读新布局 → 补偿」这类命令式流程。它的代价是关掉批处理、强制同步布局、让并发特性失效,所以要少用、合并用、只在事件里用。一句话记忆:flushSync是「立刻兑现」的按钮,方便但贵——非要在同一函数里读到刚改完的 DOM 才按它,其余时候交给 Effect。