好,各位开发者,把你们的键盘握紧一点,今天我们要聊的是 React 里的“幽灵”——useLayoutEffect。别以为它只是 useEffect 的一个兄弟,这家伙可是个披着羊皮的狼,是个披着 React 外衣的“同步阻塞怪兽”。
为什么它更容易导致页面白屏?为什么当你不小心在它里面写了一行稍微重一点的代码,整个页面就像死机了一样?别急,咱们把这事儿掰开揉碎了,像看一部惊悚片一样,顺着浏览器渲染的流水线,一步步把它扒个精光。
第一幕:浏览器的“装修流水线”
要理解 useLayoutEffect 为什么会搞破坏,你首先得明白,当你点击一个按钮,或者输入一个字符时,浏览器到底在干嘛。别告诉我它在“渲染”,那是外行话。它是在执行任务。
想象一下,浏览器就是一个正在装修的毛坯房,而 React 就是那个装修队队长。当你更新状态时,React 的工作流程是这样的,这一步一步,环环相扣,就像多米诺骨牌:
-
阶段一:虚拟 DOM 的碰撞(React 层面)
React 在内存里算了一笔账,发现:“哎呀,这个div的宽度变了,那个span的颜色也变了。” 它把这一堆变化整理成一份“装修计划书”(虚拟 DOM Diff)。 -
阶段二:真实 DOM 的施工(浏览器层面 – 布局计算)
React 把计划书扔给浏览器,浏览器说:“好嘞,先把旧墙拆了,量量新墙要多宽。” 这一步叫布局,或者更专业的说法叫回流。浏览器计算每个元素的位置、大小。这时候,屏幕上还是一片空白(或者是旧的画面)。 -
阶段三:上色(浏览器层面 – 绘制)
量完尺寸,浏览器开始刷漆了。它把计算好的样式画在 Canvas 或者 DOM 节点上。现在,你看到房子的轮廓了,但是可能还没上色。 -
阶段四:组装(浏览器层面 – 合成)
最后,浏览器把所有的图层拼起来,加上阴影、加上过渡动画,最终变成你眼睛里看到的那个高清大图。
这就是经典的渲染流水线。
第二幕:两个性格迥异的“监工”
现在,我们要在这个流水线上安插两个监工,一个叫 useEffect,一个叫 useLayoutEffect。它们都在等 React 干完活,但它们进场的时机完全不同。
1. useEffect:那个“事后诸葛亮”的懒汉
useEffect 的进场时机非常从容,甚至有点懒。它是在阶段四(合成)之后才被调用的。
流程是这样的:
- React 更新状态。
- 浏览器计算布局。
- 浏览器绘制画面。
- 浏览器合成画面(此时你的眼睛已经看到了屏幕上的内容)。
- 突然! React 唤醒了
useEffect,说:“嘿,兄弟,刚才画完了,你看看能不能改改?”
因为 useEffect 发生在视觉更新之后,所以它对用户的体验影响极小。你看到的是最终的画面,如果它在 useEffect 里改了点什么(比如修改了 URL),你不会感觉到任何卡顿或闪烁。它就像一个下班后才发现工地上有个螺丝松了的保安,虽然晚,但没事。
2. useLayoutEffect:那个“同步暴君”
而 useLayoutEffect 是个急性子,也是个独裁者。它的进场时机非常霸道,直接插队到了阶段二和阶段三之间。
流程是这样的:
- React 更新状态。
- 浏览器开始计算布局(刚刚量完尺寸)。
- 突然! React 唤醒了
useLayoutEffect,说:“快!给我干活!” useLayoutEffect立即开始执行代码。因为它是在浏览器开始绘制之前执行的,所以它是同步的。这意味着,浏览器根本没法画画,必须停下来,死死地盯着useLayoutEffect,直到它把活干完。useLayoutEffect执行完毕(可能修改了 DOM,可能计算了尺寸)。- 浏览器才敢继续进行绘制和合成。
这就好比你正准备在墙上挂画,结果装修队长突然冲进来,抢过你的锤子,把墙砸了个洞,非要自己重新量一遍尺寸。你只能在旁边干瞪眼,画是绝对画不上去的。
第三幕:白屏的恐怖故事
现在,我们要搞清楚为什么 useLayoutEffect 更容易导致白屏。这得从JS 主线程的独占性说起。
浏览器是单线程的,JS 代码执行、DOM 操作、样式计算,全部都在这唯一的主线程上排队。而 useLayoutEffect 是同步执行的,它就像一条拦路虎,挡在了浏览器渲染流水线的咽喉要道上。
场景模拟:用户点击了一个按钮,触发数据加载。
我们写了一段代码,看起来很正常,但里面藏着一个 useLayoutEffect:
function MyComponent() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 模拟异步数据获取
fetch('/api/data').then(res => res.json()).then(json => {
setData(json);
setIsLoading(false);
});
}, []);
// 危险区域:同步布局计算
useLayoutEffect(() => {
// 假设我们想在渲染前计算一下宽度,或者做点复杂的 DOM 操作
if (data) {
const element = document.getElementById('my-content');
if (element) {
// 这里可能发生重排
element.style.width = Math.random() * 500 + 'px';
}
}
}, [data]);
return (
<div id="my-content">
{data ? <div>{data.text}</div> : <div>Loading...</div>}
</div>
);
}
让我们看看时间线发生了什么:
- T0 (用户点击): 用户点击按钮。
- T1 (React 更新): React 更新
data为null,isLoading为true。 - T2 (浏览器布局阶段): 浏览器看到
isLoading变了,开始计算布局。此时,屏幕上显示的是“Loading…”。 - T3 (useLayoutEffect 执行 – 阻塞点):
- React 调用
useLayoutEffect。 - 这段代码开始同步执行。
- 如果你的
useLayoutEffect里面包含复杂的计算(比如上面的Math.random(),或者更糟糕的,一个巨大的循环计算),或者包含一些阻塞主线程的操作(比如同步的alert,或者一个非常重的 DOM 操作)。 - 关键点来了: 浏览器此时正准备开始绘制下一帧。但是,它被
useLayoutEffect锁住了。它不能画,因为 React 还没说画呢,React 在等useLayoutEffect结束。
- React 调用
- T4 (useLayoutEffect 结束): 经过 100ms(或者 500ms,取决于你的代码多烂)的计算,
useLayoutEffect终于跑完了。 - T5 (浏览器绘制): 浏览器终于拿到了最新的 DOM 状态(此时
data已经是真实数据了),开始绘制。
结果是什么?
-
情况 A:白屏(闪烁)
用户在 T2-T4 这段时间里,屏幕上显示的是上一帧的画面(可能是空的,也可能是“Loading…”)。然后到了 T5,画面突然跳变成了新数据。对于用户来说,屏幕上闪过一瞬间的空白或者旧内容,然后才显示新内容。这就是白屏闪烁。虽然不是完全的白屏,但体验极差。 -
情况 B:完全白屏(如果 JS 执行时间过长)
如果你的useLayoutEffect里面写了一个死循环,或者一个极其耗时的计算(比如递归遍历巨大的树结构),而浏览器为了等待这个同步函数完成,根本来不及进入绘制阶段。
那么用户看到的屏幕,会长时间停留在上一帧的状态,甚至如果是首次加载,用户可能根本看不到任何内容,只能看到浏览器原本的背景色。这就叫白屏。
第四幕:隐藏元素的“测量陷阱”
除了计算密集型任务,还有一个非常经典的导致白屏的原因:在 useLayoutEffect 里测量隐藏元素。
这是一个非常常见的 React 技巧:先创建一个隐藏的 div,往里面塞入一段 HTML 字符串,然后通过 offsetWidth 等属性去获取它的宽度,计算好之后再把 div 删掉,最后在屏幕上渲染真正的内容。
看起来很聪明,对吧?但这在 useLayoutEffect 里就是一颗定时炸弹。
function MeasureText() {
const [dimensions, setDimensions] = useState({ width: 0 });
useLayoutEffect(() => {
const hiddenDiv = document.createElement('div');
hiddenDiv.style.visibility = 'hidden';
hiddenDiv.style.position = 'absolute';
hiddenDiv.style.whiteSpace = 'nowrap';
hiddenDiv.innerText = '这是一段很长的文本';
document.body.appendChild(hiddenDiv);
// 获取宽度
const width = hiddenDiv.offsetWidth;
// 清理工作
document.body.removeChild(hiddenDiv);
setDimensions({ width });
}, []);
return <div style={{ width: dimensions.width }}>真实内容</div>;
}
让我们看看为什么这会白屏:
- T0: 组件首次挂载。
- T1: React 渲染组件。此时
dimensions是{ width: 0 }。屏幕上显示一个宽度为 0 的div。 - T2: 浏览器开始布局计算。它看到宽度为 0,没问题。
- T3: 浏览器准备绘制。此时,
useLayoutEffect被调用。 - T4 (阻塞):
useLayoutEffect创建了hiddenDiv并添加到document.body。- 注意! 此时 DOM 结构变了!
- 浏览器原本准备画那个宽度为 0 的
div的计划被打乱了。 - 浏览器必须重新计算布局,因为
document.body里突然多了一个节点。 useLayoutEffect读取offsetWidth。useLayoutEffect删除hiddenDiv。DOM 结构又变了!- 浏览器再次不得不重新计算布局。
- T5:
useLayoutEffect结束。 - T6: 浏览器终于可以绘制了。它根据最新的 DOM(那个宽度为 0 的
div)进行绘制。
发生了什么?
在 T2 到 T6 之间,屏幕上实际上一直显示的是上一帧的画面。由于 useLayoutEffect 的同步特性,浏览器被迫在同一个渲染帧内反复计算布局。如果这个计算过程稍微慢一点(比如你的机器性能不好,或者浏览器开销大),用户就会看到屏幕上一瞬间的空白,或者看到“真实内容”的宽度为 0 的情况,然后突然弹跳到正确宽度。
这就是典型的布局抖动,也是白屏的前兆。
第五幕:为什么 useEffect 就没事?
回到 useEffect。如果上面的 MeasureText 代码里的 useLayoutEffect 换成 useEffect,会发生什么?
流程变成:
- T0: 组件挂载。
- T1: React 渲染。
dimensions为 0。 - T2: 浏览器布局计算。
- T3: 浏览器绘制。屏幕上显示宽度为 0 的
div。 - T4: 浏览器合成。
- T5:
useEffect终于执行了!(此时用户已经看到宽度为 0 的画面了)。 - T6:
useEffect计算、创建、删除hiddenDiv。 - T7:
useEffect更新dimensions为真实宽度。 - T8: React 触发下一次渲染。
结果:
用户在第一眼看到的是宽度为 0 的 div(因为那是浏览器绘制的),然后过了一小会儿(通常只有几毫秒到几十毫秒),React 重新渲染,div 变成了正确宽度。
这叫闪烁,虽然不好看,但不是白屏。因为 useEffect 没有阻塞浏览器的绘制流程。浏览器已经把像素画到屏幕上了,useEffect 只是事后修改了数据,让下一帧画得更准一点。虽然用户感觉到了“跳变”,但屏幕不会出现长时间的空白。
第六幕:实战演练——如何避坑
既然知道了 useLayoutEffect 是个同步阻塞怪兽,我们在写代码时就得像防贼一样防着它。
1. 检查你的代码:有循环吗?
如果你的 useLayoutEffect 里面有一个 while(true),或者一个递归调用,恭喜你,你的页面直接死机。白屏是轻的,浏览器可能会直接提示“页面无响应”。
// 绝对禁止的写法!
useLayoutEffect(() => {
while (true) {
console.log('我在死循环');
}
}, []);
2. 检查你的代码:有同步 alert 吗?
alert 是同步阻塞的,它会暂停整个 JS 主线程。如果在 useLayoutEffect 里弹出一个 alert,浏览器会死死等用户点掉 alert 才能继续渲染。这绝对会白屏。
// 绝对禁止的写法!
useLayoutEffect(() => {
alert('请先点击确定,我才能渲染页面');
}, []);
3. 检查你的代码:有隐藏元素测量吗?
尽量把隐藏元素的操作移到 useEffect 里。虽然 useEffect 会导致初始渲染时的尺寸跳动,但它不会阻塞浏览器,不会导致白屏。
// 推荐的写法
useEffect(() => {
const hiddenDiv = document.createElement('div');
// ... 测量逻辑
setDimensions({ width: hiddenDiv.offsetWidth });
// ... 清理逻辑
}, []);
4. 尽量减少 DOM 操作
useLayoutEffect 的核心问题是同步。一旦你动了 DOM,浏览器就必须重新计算布局。如果你在 useLayoutEffect 里疯狂地操作 DOM(比如批量插入 1000 个节点),浏览器会累得喘不过气来。
5. 使用 requestAnimationFrame 替代(如果可能)
有时候我们想在绘制前做点事,但不想阻塞。虽然 React 的生命周期里没有直接对应 requestAnimationFrame 的钩子,但我们可以利用 requestAnimationFrame 在 useLayoutEffect 的末尾执行一些非关键任务,或者使用 useEffect 来处理视觉反馈。
总结
所以,为什么 useLayoutEffect 更容易导致白屏?
因为它是一个同步的、阻塞式的、插队到浏览器绘制前一刻的监工。
它强迫浏览器在每一帧渲染前,必须先完成它的计算任务。如果它的任务太重,或者触发了额外的 DOM 变化导致浏览器反复计算布局,那么浏览器就会卡在“计算”这一步,而无法进入“绘制”这一步。屏幕上留存的,就是上一帧的画面,或者原本的空白。
相比之下,useEffect 是个“事后诸葛亮”,它不抢戏,不阻塞,哪怕它把房子装修完了再告诉你,房子也已经盖好了,你只是看不到装修的过程而已。
记住这个原则:在 useLayoutEffect 里,每一毫秒都至关重要。如果你在写代码时感觉到手指头停顿了一下,或者代码跑得有点慢,赶紧检查一下是不是你的 useLayoutEffect 又在搞破坏了。
好了,今天的讲座就到这里。现在,去检查你的代码,看看有没有哪个 useLayoutEffect 正在屏幕后面搞鬼吧!