各位前端界的同仁们,大家好!
欢迎来到今天的“React 内部机制深度解剖”特别讲座。我是你们的老朋友,那个总是喜欢在深夜盯着浏览器渲染流水线发呆的资深工程师。
今天,我们要聊一个稍微有点“硬核”,但绝对能让你在面试场上(或者和同事吹牛时)闪闪发光的话题:React 提交阶段的副作用同步:useLayoutEffect 对浏览器绘制流水线(Painting)的阻塞分析。
别被这堆术语吓到了。咱们把这玩意儿拆开了揉碎了,就像咱们在菜市场挑西红柿一样,把它看个透透的。
第一部分:浏览器渲染流水线——那个忙碌的工厂
在讲 React 之前,咱们得先搞清楚浏览器到底是怎么把 HTML 变成屏幕上那个花花绿绿的画面儿的。这过程啊,就像是一个巨大的、精密的食品加工工厂。
- 解析与构建树:浏览器拿到了你的 HTML 和 CSS。它开始干活,把 HTML 变成 DOM 树,把 CSS 变成 CSSOM 树。这是“备菜”阶段。
- 布局:这是“切菜”阶段。浏览器要算出每个元素的位置、大小。比如,“这个
div放在左边 10px,高度 100px”,“那个img放在右边”。这一步叫 Layout,或者 Reflow。注意,这是同步的。 - 绘制:这是“摆盘”阶段。浏览器拿到了布局信息,开始往屏幕上涂颜色、画边框、填背景。这就是 Painting。这也是同步的。
- 合成:这是“打包发货”阶段。浏览器把图层合并起来,交给 GPU 去处理动画和滚动。
在这个流水线里,有一个核心原则:主线程是独占的。一旦开始干活,谁也别想插队。如果你在“切菜”(计算布局)的时候突然喊一声“我要喝咖啡”,整个工厂就得停下来等你喝完,然后再接着切。
第二部分:React 的提交阶段——那个混乱的“交货口”
React 的渲染流程,说白了就是 Fiber 架构在调度。当 React 决定要更新界面时,它会在渲染阶段(Render Phase)算出新的状态,生成新的 Fiber 树。
一旦渲染阶段结束,React 就进入了一个至关重要的阶段,咱们叫它提交阶段。
在这个阶段,React 干了两件大事:
- 更新 DOM:把新的 DOM 节点插进去,或者修改旧的。
- 执行副作用:调用你写的
useEffect和useLayoutEffect。
这里就是今天的主角登场的地方。
第三部分:useEffect 与 useLayoutEffect 的双面人生
React 提供了两个钩子来处理副作用,它们的性格截然不同,就像一个是“慢递小哥”,一个是“送货卡车”。
1. useEffect:异步的“慢递小哥”
当你写下 useEffect(() => { ... }, []) 时,React 告诉你:“嘿,兄弟,DOM 更新完了,我也把你的代码放进去了。不过呢,我现在要去忙着处理其他事情了,等你空下来,或者浏览器绘制完之后,你再跑这段代码吧。”
- 时机:绘制完成之后。
- 特点:异步,不阻塞绘制。
- 比喻:你把快递扔进邮筒了。邮局会晚点去取,但你不用担心快递员堵在门口。
2. useLayoutEffect:同步的“送货卡车”
当你写下 useLayoutEffect(() => { ... }, []) 时,React 的态度就变了:“兄弟,DOM 更新完了!别急着画图!先把我的代码跑完!必须同步跑完!跑完了才能画图!”
- 时机:绘制之前。
- 特点:同步,阻塞绘制。
- 比喻:送货卡车直接堵在了工厂门口。工人们必须把车上的货卸下来,清空路障,卡车才能开走。如果卡车司机是个磨磨唧唧的,整个工厂都得陪着干瞪眼。
第四部分:useLayoutEffect 的阻塞机制深度剖析
好,咱们来重点聊聊这个“阻塞”。
useLayoutEffect 是在什么时候运行的?
它运行在提交阶段,在浏览器执行绘制(Painting)之前。
这就意味着,浏览器刚刚把 DOM 树更新了,正准备拿起画笔开始给屏幕上色,结果 React 说:“停一下!先别画!我有段代码要跑!”
这时候,浏览器的主线程就被 useLayoutEffect 拦截了。
它是怎么阻塞的?
- DOM 更新完成:React 已经把真实的 DOM 节点修改好了。
useLayoutEffect执行:你的代码开始跑。比如,你获取了一个ref,计算了高度,然后修改了另一个元素的样式。- 等待完成:浏览器在等。它不能画,也不能合成,只能干等。
- 绘制开始:只有当你的代码执行完毕,浏览器才能继续它的 Painting 流程。
这就引出了一个著名的现象:布局抖动。
如果你在 useLayoutEffect 里做了很多计算,或者触发了大量的 DOM 读取(比如读取 offsetHeight),那么浏览器在屏幕上显示内容之前,就会有一段空白或者旧内容的闪烁期。
这就好比你在餐厅点菜,服务员(浏览器)刚把菜单递给你,你突然掏出一把尺子量菜单上的字(计算布局),然后又掏出计算器算账(复杂计算)。这时候,其他客人(用户的视觉体验)就得干等,直到你算完账,服务员才能开始上菜。
第五部分:为什么我们需要“阻塞”?—— 解决 FOUC
既然 useLayoutEffect 会阻塞绘制,听起来挺糟糕的,那 React 为什么要设计这么个东西?
答案很简单:为了视觉的稳定性。
假设我们有一个组件,它的高度是动态计算的。比如,一个卡片的内容变了,我需要先读取内容的高度,然后把这个高度设置给卡片的父容器,让父容器自动撑开。
如果我们用 useEffect:
import { useEffect, useRef, useState } from 'react';
function DynamicCard() {
const contentRef = useRef(null);
const [width, setWidth] = useState('100%');
useEffect(() => {
// 等浏览器绘制完之后才跑
if (contentRef.current) {
const height = contentRef.current.offsetHeight;
console.log('计算高度:', height);
// 修改父容器样式
// ...
}
}, []);
return (
<div style={{ width: '300px', border: '1px solid red' }}>
{/* 父容器在这里突然跳动一下 */}
<div style={{ width: width, height: '100%' }}>
<div ref={contentRef}>这里是动态内容</div>
</div>
</div>
);
}
效果预演:
- React 更新 DOM,内容显示出来。
- 浏览器开始绘制。
- FOUC (Flash of Unstyled Content / 样式闪烁):你看到内容突然弹出来了,然后父容器突然变宽/变高了。这就像你刚打开门,里面的人突然换了一套衣服,非常不雅观。
如果我们用 useLayoutEffect:
import { useLayoutEffect, useRef } from 'react';
function DynamicCardFixed() {
const contentRef = useRef(null);
const containerRef = useRef(null);
useLayoutEffect(() => {
// 在绘制之前跑,浏览器还在等呢
if (contentRef.current && containerRef.current) {
const height = contentRef.current.offsetHeight;
// 修改父容器样式
containerRef.current.style.height = `${height}px`;
}
}, []);
return (
<div style={{ width: '300px', border: '1px solid blue' }}>
{/* 父容器高度是提前算好的,不会跳动 */}
<div ref={containerRef}>
<div ref={contentRef}>这里是动态内容</div>
</div>
</div>
);
}
效果预演:
- React 更新 DOM。
- 浏览器准备绘制。
useLayoutEffect阻塞:浏览器暂停,React 跑代码,算出高度,修改 DOM。- 绘制开始:浏览器直接画出一个已经调整好大小的盒子。用户看到的是最终结果,没有闪烁。
这就是 useLayoutEffect 的核心价值:在用户看到屏幕之前,完成所有的布局计算和 DOM 读取,确保“所见即所得”。
第六部分:性能陷阱——别让“送货卡车”堵死了工厂
虽然 useLayoutEffect 很有用,但它是一把双刃剑。因为它会阻塞绘制,如果你的代码写得太慢,用户体验会非常差。
想象一下,如果你的 useLayoutEffect 里写了一个复杂的循环,或者进行了一次网络请求(虽然不推荐,但确实有人这么干),那么:
- 浏览器更新了 DOM。
- 屏幕应该亮起来了。
- 但是! 你的代码在跑,浏览器在等。
- 屏幕卡住了 100 毫秒。 用户会觉得网页“死”了。
这就是为什么 React 官方文档里会反复强调:useLayoutEffect 应该尽可能快地执行完毕。
1. 禁止在 useLayoutEffect 中做繁重的计算
// ❌ 绝对禁止的写法
useLayoutEffect(() => {
// 这是一个耗时 500ms 的数学计算
const result = heavyMathFunction();
// ...
}, []);
// ✅ 推荐写法:在渲染阶段计算,或者在 Worker 中计算
// 或者:确保你的计算非常轻量
2. 避免在 useLayoutEffect 中读取 DOM
读取 DOM(比如 getBoundingClientRect, offsetHeight, scrollWidth)是昂贵的操作,因为它会触发浏览器的重排。而 useLayoutEffect 本身就在重排前运行,如果你在里面又读又改,就等于是在“重排”的前一秒又引发了一次“重排”。
// ❌ 坏例子
useLayoutEffect(() => {
const width = elementRef.current.offsetWidth; // 触发回流
elementRef.current.style.width = `${width}px`; // 触发回流
}, []);
// ✅ 好例子:批量操作,或者使用 transform
// 使用 transform: translate() 或 scale() 不会触发回流,只会触发合成,性能好得多
3. 注意内存泄漏
因为 useLayoutEffect 是同步的,而且是在组件挂载后立即执行,如果你在里面设置了定时器或订阅,一定要在 useLayoutEffect 里清理吗?
不,通常不需要在 useLayoutEffect 里清理,因为组件卸载时,React 会自动清理 useLayoutEffect。但是,如果你在 useLayoutEffect 里使用了 ref 来保存 DOM 引用,要注意不要在组件卸载后访问这个引用(虽然 ref 通常会在卸载前被清理)。
第七部分:实战演练——那些年我们踩过的坑
为了让大家更深刻地理解,我们来分析几个典型的场景。
场景一:滚动条宽度的动态计算
这是一个经典场景。有些 UI 设计要求隐藏滚动条,但保留滚动功能。这通常需要计算滚动条的宽度,然后用一个 div 把它盖住。
import { useEffect, useRef, useState } from 'react';
function ScrollbarHider({ children }) {
const containerRef = useRef(null);
const scrollbarWidthRef = useRef(0);
const [style, setStyle] = useState({ width: '100%' });
// 使用 useEffect,因为我们需要先看到滚动条出现,然后再计算宽度
// 如果用 useLayoutEffect,可能会因为滚动条还没完全渲染出来而算错
useEffect(() => {
if (containerRef.current) {
// 这里是异步的,不会阻塞绘制,用户体验更好
const el = containerRef.current;
scrollbarWidthRef.current = el.offsetWidth - el.scrollWidth;
setStyle({
paddingRight: scrollbarWidthRef.current
});
}
}, [children]); // 依赖 children 变化
return (
<div
ref={containerRef}
style={{ width: '100%', height: '100%', overflow: 'auto', position: 'relative' }}
>
<div style={{ paddingRight: scrollbarWidthRef.current, boxSizing: 'border-box' }}>
{children}
</div>
</div>
);
}
分析:这个场景用 useEffect 是正确的。因为我们需要先渲染出滚动条,浏览器才能计算出滚动条的宽度。如果用 useLayoutEffect,我们可能还没来得及读取滚动条宽度,或者读取到的宽度是 0(因为滚动条还没画上去)。
场景二:获取动态高度的文本框
假设我们有一个输入框,输入文字后,我们需要根据文字的行数动态调整输入框的高度。
import { useLayoutEffect, useRef } from 'react';
function AutoHeightInput() {
const textareaRef = useRef(null);
const containerRef = useRef(null);
const handleInput = () => {
const el = textareaRef.current;
el.style.height = 'auto'; // 重置高度
el.style.height = `${el.scrollHeight}px`; // 设置为新高度
};
useLayoutEffect(() => {
// 在绘制前调整高度,防止输入时高度跳动
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, []);
return (
<div ref={containerRef} style={{ width: 300 }}>
<textarea
ref={textareaRef}
onInput={handleInput}
style={{ width: '100%', minHeight: 50, overflow: 'hidden' }}
/>
</div>
);
}
分析:这个场景必须用 useLayoutEffect。如果你用 useEffect,用户每打一个字,输入框都会先弹一下,然后变高。这种体验非常糟糕。useLayoutEffect 确保了在用户看到输入框之前,高度已经调整好了。
第八部分:useLayoutEffect 在 SSR(服务端渲染)中的特殊情况
讲到这里,咱们得提一下服务端渲染(SSR)。
在 SSR 环境下,浏览器没有 window,没有 document,也没有 DOM。所以,在服务端,useLayoutEffect 是不会执行的。
这会导致一个潜在的问题:在服务端渲染的 HTML 中,useLayoutEffect 里修改的样式可能不存在。而在客户端,useLayoutEffect 执行后,样式才会生效。
这可能导致服务端渲染的 HTML 和客户端最终渲染的 HTML 不一致(Hydration mismatch)。
解决方案:
通常我们会检查环境:
useLayoutEffect(() => {
if (typeof window !== 'undefined') {
// 这里执行 DOM 操作
console.log('我在浏览器里运行');
}
}, []);
或者,对于 SSR 应用,我们可以使用 useEffect 来处理 DOM 操作,确保它在客户端 hydration 之后执行,避免 hydration mismatch。
第九部分:总结——如何优雅地使用 useLayoutEffect
好了,各位同学,今天的讲座接近尾声。让我们来总结一下如何在这个“阻塞”与“流畅”之间找到平衡。
-
黄金法则:同步即阻塞。
记住,useLayoutEffect是同步的,它会阻塞浏览器的绘制。它不是免费的午餐,它消耗的是用户宝贵的视觉等待时间。 -
何时使用?
- 你需要根据 DOM 的当前尺寸来设置样式,并且你绝对不能让用户看到尺寸调整的过程(布局抖动)。
- 你需要确保在用户看到屏幕之前,布局已经就绪。
-
何时不用?
- 如果你的计算很复杂,或者涉及网络请求,千万别用。
- 如果你的操作只是辅助性的,比如添加一个 class,或者触发一个非关键的动画,用
useEffect吧,让浏览器先画出来,别让它干等着你。
-
性能优化技巧:
- 尽量减少
useLayoutEffect中的 DOM 读取。 - 使用 CSS
transform和opacity来做动画,因为它们不触发重排,不会阻塞绘制。 - 避免在
useLayoutEffect中进行递归计算。
- 尽量减少
最后的思考
React 的设计哲学一直是“声明式”和“高效”。useLayoutEffect 的存在,是为了在声明式更新和底层浏览器同步机制之间搭建一座桥梁。
它就像是一个守门人,在浏览器开始画画之前,先帮你把画布铺平、把颜料调好。虽然它会挡住画家的视线(阻塞绘制),但只有这样,画出来的画才是完美的。
作为开发者,我们的任务就是了解这个守门人的脾气,知道什么时候该让他进来干活,什么时候该让他靠边站。不要滥用它,也不要无视它。
希望今天的讲座能让你对 React 的提交阶段和绘制流水线有一个全新的认识。下次当你看到屏幕闪烁或者布局跳动时,不要只是抱怨 CSS,而是要拿起你的 useLayoutEffect,去守护那完美的第一眼视觉体验!
谢谢大家!下课!