核心思路(一句话)
默认优先使用 useEffect;只有当你需要"读取布局信息并同步修改界面,以避免浏览器绘制出现中间状态或视觉闪烁"时,才使用 useLayoutEffect。
主要矛盾:
useLayoutEffect 在 DOM 变更之后、浏览器绘制之前同步执行,会阻塞绘制,但能保证视觉一致性 ;
useEffect在提交后异步执行,通常不阻塞绘制,但可能出现短暂中间态。
次要矛盾:
服务端渲染兼容、并发模式调度、性能开销、依赖数组与清理函数正确性、第三方库初始化时机。
1. 核心原理流程图(文本版)
text
【一次 React 更新的完整流程】
setState / 首次渲染
│
▼
Render 阶段(计算新的虚拟 DOM,可被打断/并发)
│
▼
Commit 阶段(把 DOM 变更真正写入页面,不可打断)
│
├──────────── 同步 ─────────────┐
▼ │
① useInsertionEffect 执行 │ ← React 18 新增,供 CSS-in-JS 库注入样式
② useLayoutEffect 的 cleanup 执行 │
③ useLayoutEffect 的回调【同步执行】 │
(若其中调用 setState → │
React 立即同步重渲染并重走①②③) │
│ │
▼ │
★ 浏览器进行布局(Layout)+ 绘制(Paint)★ ← 用户此刻才看到画面
│ │
├──────────── 异步(调度器调度) ─┘
▼
④ useEffect 的 cleanup 执行
⑤ useEffect 的回调【异步执行】
(若其中调用 setState → 触发新一轮异步更新)
│
▼
浏览器下一次绘制(用户看到二次更新结果)
闪烁的产生(用 useEffect 改 DOM):
text
DOM 更新 → 浏览器绘制【用户看到未调整的中间态】 → useEffect 执行 → 再次更新 DOM → 再次绘制【用户看到最终态】
└────────── 两帧之间的不一致 = 闪烁 ──────────┘
无闪烁(用 useLayoutEffect 改 DOM):
text
DOM 更新 → useLayoutEffect 同步读取尺寸并修改 DOM(必要时同步重渲染) → 浏览器一次性绘制【用户只看到最终态】
2. 面试题与答案
Q1:useEffect 和 useLayoutEffect 有什么区别?
核心思路: 区别不在"能不能做副作用",而在"执行时机"和"是否阻塞浏览器绘制"。
答案:
-
执行时机不同
useLayoutEffect:在 React 完成 DOM 变更后、浏览器绘制前,同步执行。useEffect:在 React 提交完成后,异步调度执行,通常发生在浏览器绘制后。
-
是否阻塞绘制不同
useLayoutEffect:会阻塞浏览器绘制。如果里面执行耗时任务,用户会感觉页面卡顿。useEffect:不会阻塞浏览器首次绘制。用户通常先看到界面,再执行副作用。
-
使用场景不同
useEffect:数据请求、订阅、事件监听、定时器、日志、滚动高亮、手动聚焦等大多数副作用。useLayoutEffect:读取布局信息并同步修改 DOM 或状态,避免视觉闪烁。例如 Tooltip 定位、测量元素尺寸、第三方库需要 DOM 尺寸初始化。
-
服务端渲染表现不同
useLayoutEffect在服务端渲染时不会执行,并且 React 会给出警告,因为服务端没有真实 DOM。useEffect可以在服务端渲染中安全使用,但也不会在服务端执行,而是在客户端执行。
-
经验法则
- 默认用
useEffect。 - 如果
useEffect导致视觉闪烁、布局跳动、用户看到中间状态,再改用useLayoutEffect。 - 不要滥用
useLayoutEffect,因为它是同步阻塞的。
- 默认用
使用场景:
| Hook | 典型场景 |
|---|---|
useEffect |
请求接口、设置订阅、添加事件监听、清除定时器、日志上报、滚动监听 |
useLayoutEffect |
Tooltip 定位、Popover 定位、测量 DOM 宽高、根据尺寸同步设置样式、避免闪烁 |
useInsertionEffect |
CSS-in-JS 库插入样式,业务代码一般不直接使用 |
边界场景:
- 服务端渲染:
useLayoutEffect会警告。 - 并发模式:
useEffect可能被延迟执行,useLayoutEffect仍然同步执行。 - 性能敏感:
useLayoutEffect中不要做重计算、大量 DOM 读写、频繁setState。 - 依赖数组:空数组、无依赖数组、依赖项变化,行为不同。
Q2:为什么 useLayoutEffect 会阻塞浏览器绘制?
核心思路: 它运行在 Commit 阶段的 Layout 阶段,位于浏览器 Paint 之前,所以浏览器必须等它执行完才能绘制。
答案:
React 的提交阶段大致是:
text
Render 阶段:计算变更
Commit 阶段:
Mutation:更新真实 DOM
Layout:执行 useLayoutEffect
浏览器绘制 Paint
Passive Effects:执行 useEffect
useLayoutEffect 在 Layout 阶段同步执行,因此:
- 它能读取到最新的 DOM 布局。
- 它能在浏览器绘制前修改 DOM 或触发同步重渲染。
- 浏览器会等它完成后才绘制,所以用户不会看到中间状态。
- 代价是:如果它执行时间长,浏览器绘制会被推迟,页面可能卡顿。
Q3:什么时候必须使用 useLayoutEffect?
核心思路: 当副作用需要"读取布局信息,并同步更新界面,避免闪烁"时,使用 useLayoutEffect。
典型场景:
-
Tooltip / Popover 定位
- 需要先渲染出元素,读取它的尺寸和位置,再计算最终坐标。
- 如果用
useEffect,用户可能先看到位置错误的元素,然后它跳到正确位置。 - 用
useLayoutEffect,浏览器绘制前就完成定位,用户只看到最终位置。
-
测量元素宽高并同步设置另一个元素
- 例如左侧栏宽度变化,右侧栏需要同步调整。
- 如果异步执行,可能出现短暂错位。
-
第三方库需要真实 DOM 尺寸初始化
- 某些图表库、拖拽库、虚拟滚动库需要在绘制前拿到尺寸。
- 如果库本身支持异步尺寸更新,也可以考虑
useEffect。
-
避免首屏布局跳动
- 例如根据容器宽度决定显示一行还是两行。
- 如果先显示错误布局再修正,用户会看到闪烁。
边界:
- 不要把所有 DOM 操作都放进
useLayoutEffect。 - 如果只是聚焦输入框、设置标题、添加事件监听,通常
useEffect足够。 - 如果操作耗时长,优先考虑是否能用 CSS、ResizeObserver、requestAnimationFrame 或库自身能力解决。
Q4:数据请求、订阅、事件监听应该用哪个?
核心思路: 这些操作通常不需要在浏览器绘制前完成,优先使用 useEffect。
答案:
应该用 useEffect。
原因:
-
数据请求是异步的
- 请求接口不应该阻塞页面首次绘制。
- 用户可以先看到加载状态,再看到数据。
-
订阅和事件监听不需要同步绘制
- 例如监听 Redux store、窗口滚动、窗口尺寸变化。
- 这些操作晚一点执行不会造成视觉闪烁。
-
清理函数很重要
- 组件卸载或依赖变化时,需要取消请求、清除订阅、移除监听、清除定时器。
- 否则会造成内存泄漏或状态更新到已卸载组件。
示例场景:
- 获取用户信息:
useEffect - 监听滚动实现高亮:
useEffect - 监听窗口大小变化:
useEffect - 设置定时器:
useEffect - 手动聚焦输入框:通常
useEffect足够,除非必须绘制前完成
Q5:动态尺寸图表应该用哪个?
核心思路: 如果图表库对初始尺寸敏感,且错误尺寸会导致明显错位,用 useLayoutEffect;否则优先 useEffect 配合 ResizeObserver。
答案:
分情况:
-
图表库必须在首次绘制前拿到正确尺寸
- 使用
useLayoutEffect。 - 读取容器宽高,同步设置图表尺寸。
- 避免用户先看到错误尺寸的图表,然后图表跳变。
- 使用
-
图表库支持响应式更新,轻微延迟可接受
- 使用
useEffect。 - 配合
ResizeObserver监听容器尺寸变化。 - 这样不会阻塞首次绘制,性能更好。
- 使用
-
更好的方案
- 优先使用图表库自带的响应式能力。
- 使用 CSS 容器查询、
ResizeObserver、requestAnimationFrame。 - 避免在
useLayoutEffect中频繁同步读写布局,导致布局抖动。
Q6:滚动到某个元素附近并添加高亮类名,应该用哪个?
核心思路: 滚动事件本身就是异步交互,轻微延迟可接受,用 useEffect。
答案:
使用 useEffect 配合事件监听。
原因:
- 滚动过程中,用户不会要求高亮必须和滚动像素级同步。
useEffect不阻塞滚动渲染,体验更流畅。- 如果使用
useLayoutEffect,每次滚动相关更新都可能阻塞绘制,容易卡顿。
示例:
- 监听
scroll和resize。 - 使用
getBoundingClientRect判断元素是否接近视口。 - 更新高亮状态。
- 组件卸载时移除监听。
Q7:服务端渲染下 useLayoutEffect 有什么问题?
核心思路: 服务端没有真实 DOM,useLayoutEffect 不会执行,并且 React 会警告。
答案:
在服务端渲染中:
useLayoutEffect不能在服务端执行。- React 会输出警告,提示
useLayoutEffect does nothing on the server。 - 因为它依赖真实 DOM 布局,而服务端没有浏览器环境。
解决方案:
- 如果不需要同步布局,改用
useEffect。 - 如果必须同时兼容客户端和服务端,封装同构 Hook:
jsx
import { useEffect, useLayoutEffect } from 'react';
// 服务端没有 window,因此退化为 useEffect
// 客户端使用 useLayoutEffect,保证绘制前同步执行
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
边界:
- 服务端渲染时不要读取
window、document。 - 如果第三方库依赖 DOM,确保只在客户端执行。
Q8:useLayoutEffect 中 setState 会发生什么?
核心思路: 会触发同步重渲染,React 会在浏览器绘制前完成这次更新,因此能避免闪烁,但可能带来性能问题。
答案:
在 useLayoutEffect 中调用 setState:
- React 会同步重新渲染组件。
- 重新执行 Render 和 Commit。
- 浏览器仍然没有绘制。
- 最终用户只看到更新后的界面。
好处:
- 避免用户看到中间状态。
- 适合测量后立即修正布局。
风险:
- 如果频繁
setState,可能导致多次同步渲染。 - 如果逻辑复杂,会阻塞浏览器绘制,造成卡顿。
- 必须注意依赖数组,避免无限循环。
更好方案:
- 能在 CSS 层解决的,不要用 JavaScript 测量。
- 能用
ResizeObserver异步更新的,优先异步。 - 只有在确实需要绘制前同步修正时,才在
useLayoutEffect中setState。
Q9:useEffect 和 useLayoutEffect 的执行顺序与清理顺序?
核心思路: 同一组件内按声明顺序执行;子组件 Effect 通常先于父组件执行;清理函数先于下一次 Effect 执行。
答案:
-
同一组件内
- 多个
useLayoutEffect按声明顺序执行。 - 多个
useEffect按声明顺序执行。 - 所有
useLayoutEffect先于所有useEffect。
- 多个
-
父子组件
- 通常子组件的 Effect 先于父组件执行。
- 因为提交阶段是深度优先。
-
清理函数
- 依赖变化或组件卸载时,先执行上一次的清理函数。
- 然后执行新的 Effect。
- 卸载时,清理函数用于取消请求、移除监听、清除定时器。
注意:
- React 严格模式开发环境下,Effect 可能被额外执行一次,用于检测副作用是否纯。
- 生产环境不会因为严格模式而双调用。
Q10:useInsertionEffect 是什么?和 useLayoutEffect 有什么关系?
核心思路: useInsertionEffect 是给 CSS-in-JS 库使用的,在 useLayoutEffect 之前执行,用于插入样式,避免重新计算布局。
答案:
React 提供了三个与副作用相关的 Hook:
| Hook | 执行时机 | 主要用途 |
|---|---|---|
useInsertionEffect |
在 useLayoutEffect 之前 |
CSS-in-JS 插入样式 |
useLayoutEffect |
DOM 变更后、绘制前,同步 | 读取布局并同步修改,避免闪烁 |
useEffect |
提交后异步,通常绘制后 | 大多数副作用 |
业务代码通常不需要直接使用 useInsertionEffect。它主要供 styled-components、emotion 等库使用。
3. 补充说明
-
"useEffect 是在浏览器完成布局和绘制之后异步执行"是简化说法
- 更准确:
useEffect在 React 提交后异步调度执行,通常发生在浏览器绘制后。 - React 不保证它严格在某一帧绘制后立即执行,但它不会像
useLayoutEffect那样阻塞绘制。
- 更准确:
-
遗漏知识点:服务端渲染
useLayoutEffect在服务端渲染会警告。- 可用
useEffect或同构 Hook 替代。
-
遗漏知识点:并发模式
- React 18 并发模式下,
useEffect可能被延迟。 useLayoutEffect仍然同步执行,可能阻塞。
- React 18 并发模式下,
-
遗漏知识点:清理函数
- 请求、订阅、事件监听、定时器都必须清理。
- 否则会内存泄漏或更新已卸载组件。
-
遗漏知识点:依赖数组
- 空数组:挂载和卸载时执行。
- 无数组:每次渲染后都执行。
- 有依赖:依赖变化时执行。
-
遗漏知识点:StrictMode
- 开发环境下 Effect 可能双调用,用于发现副作用问题。
- 不要依赖 Effect 只执行一次来做不可重复的操作。
-
遗漏知识点:useInsertionEffect
- 用于 CSS-in-JS,在
useLayoutEffect之前执行。
- 用于 CSS-in-JS,在
4. 示例代码
示例 1:useLayoutEffect 测量宽度并同步更新,避免闪烁
jsx
import React, { useLayoutEffect, useRef, useState } from 'react';
function AutoWidthBox() {
// 源元素:用于测量宽度
const sourceRef = useRef(null);
// 目标元素宽度:根据源元素宽度同步更新
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const element = sourceRef.current;
// 防御:元素可能尚未挂载
if (!element) {
return;
}
// 读取布局信息
const updateWidth = () => {
const rect = element.getBoundingClientRect();
setWidth(rect.width);
};
// 首次同步测量:发生在浏览器绘制前,避免目标元素先显示错误宽度
updateWidth();
// 监听后续尺寸变化:ResizeObserver 是异步回调,但初始测量仍是同步的
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => {
observer.disconnect();
};
}, []);
return (
<div>
<div
ref={sourceRef}
style={{
width: '50%',
background: '#dddddd',
padding: '8px',
}}
>
源元素:宽度为父容器的 50%
</div>
<div
style={{
width: width,
background: '#ff9999',
padding: '8px',
}}
>
目标元素:宽度与源元素同步
</div>
</div>
);
}
export default AutoWidthBox;
示例 2:useEffect 请求数据,并处理取消和清理
jsx
import React, { useEffect, useState } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// 标记是否已取消,防止组件卸载后继续 setState
let cancelled = false;
// AbortController 用于取消 fetch 请求
const controller = new AbortController();
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error('请求用户信息失败');
}
const data = await response.json();
// 只有未取消时才更新状态
if (!cancelled) {
setUser(data);
setError(null);
}
} catch (err) {
// AbortError 是主动取消导致的,不需要展示错误
if (!cancelled && err.name !== 'AbortError') {
setError(err);
}
}
}
fetchUser();
// 清理函数:依赖变化或组件卸载时执行
return () => {
cancelled = true;
controller.abort();
};
}, [userId]);
if (error) {
return <div>错误:{error.message}</div>;
}
if (!user) {
return <div>加载中...</div>;
}
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
示例 3:useEffect 实现滚动高亮
jsx
import React, { useEffect, useState } from 'react';
function ScrollHighlight({ targetId }) {
const [highlighted, setHighlighted] = useState(false);
useEffect(() => {
const target = document.getElementById(targetId);
if (!target) {
return;
}
const handleScroll = () => {
const rect = target.getBoundingClientRect();
const viewportHeight =
window.innerHeight || document.documentElement.clientHeight;
// 元素进入视口下方 80% 区域时高亮
const isNear =
rect.top < viewportHeight * 0.8 && rect.bottom > 0;
setHighlighted(isNear);
};
// 首次执行一次,避免初始状态错误
handleScroll();
// 滚动和尺寸变化时重新计算
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
};
}, [targetId]);
return (
<div>
<div style={{ height: '150vh' }}>滚动区域</div>
<div
id={targetId}
style={{
padding: '20px',
background: highlighted ? 'yellow' : 'transparent',
transition: 'background 0.2s',
}}
>
目标元素
</div>
</div>
);
}
export default ScrollHighlight;
示例 4:服务端渲染下的同构 useLayoutEffect
jsx
import { useEffect, useLayoutEffect } from 'react';
// 客户端存在 window,可以使用 useLayoutEffect
// 服务端不存在 window,退化为 useEffect,避免警告
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
使用:
jsx
import { useRef } from 'react';
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
function MeasureBox() {
const ref = useRef(null);
useIsomorphicLayoutEffect(() => {
if (!ref.current) {
return;
}
// 客户端会同步执行,服务端不会执行
const rect = ref.current.getBoundingClientRect();
console.log(rect.width, rect.height);
}, []);
return <div ref={ref}>测量我</div>;
}
5. 满分答案
useEffect 和 useLayoutEffect 的核心区别是执行时机。useLayoutEffect 在 React 完成 DOM 变更后、浏览器绘制前同步执行,会阻塞浏览器绘制;因此它适合读取布局信息并同步修改界面,避免用户看到闪烁或中间状态。useEffect 在提交后异步执行,通常发生在浏览器绘制后,不阻塞首次绘制;因此它适合数据请求、订阅、事件监听、定时器、日志、滚动高亮等大多数副作用。
主要矛盾是:useLayoutEffect 用同步阻塞换取视觉一致性,useEffect 用异步执行换取流畅性。次要矛盾包括服务端渲染兼容、并发模式调度、性能开销、依赖数组和清理函数。
经验法则是:默认使用 useEffect;只有当 useEffect 导致视觉闪烁、布局跳动、用户看到中间状态,或者必须绘制前同步读取布局并更新时,才使用 useLayoutEffect。典型必须使用 useLayoutEffect 的场景是 Tooltip 定位、Popover 定位、测量 DOM 尺寸并同步设置样式、第三方库需要绘制前初始化。服务端渲染下 useLayoutEffect 会警告,应改用 useEffect 或同构 Hook。React 18 并发模式下,useEffect 可能延迟,useLayoutEffect 仍然同步执行。不要滥用 useLayoutEffect,否则会阻塞绘制,造成性能问题。