一、useEffect 做的事就一件:给组件添加”做事”的能力
useEffect是 React 给组件开的”后门”——让组件能跟外部世界交互。
React 组件是纯函数:输入 props + state,输出 JSX。但真实的应用需要跟外部世界打交道——请求接口、操作 DOM、监听事件、开定时器。这些事不在”输入→输出”的纯函数范围内,React 管它们叫副作用(Side Effect)。
function ChatRoom({ roomId }) {
// 组件的"本职工作":返回 JSX(纯函数部分)
return <div>当前房间:{roomId}</div>;
// 但你还需要:连接聊天服务器、监听消息……
// 这些就是"副作用"——useEffect 的领地
}
类比:组件像一个人,useState 给了他记忆(能记住事情),useEffect 给了他行动力(能做事)。没有 useEffect,组件只会坐在那里回忆,什么也干不了。
1.1 什么是”副作用”?
任何跟组件渲染结果无关、但程序运行需要做的事,都是副作用:
| 副作用类型 | 例子 |
|---|---|
| 数据获取 | fetch('/api/users') |
| DOM 操作 | 修改标题、聚焦输入框、测量元素尺寸 |
| 事件监听 | window.addEventListener('resize', ...) |
| 定时器 | setInterval、setTimeout |
| 订阅 | WebSocket 连接、Observable 订阅 |
| 修改外部变量 | 写 localStorage、改全局状态 |
这些事的共同特点:它们不参与”计算 JSX”的过程,但程序需要它们才能正常工作。
1.2 为什么不能直接写在组件函数体里?
组件函数每次渲染都会执行。直接写副作用 = 每次渲染都重新连接、重新订阅、重新开定时器。副作用需要在正确的时机执行,而不是每次渲染都执行。useEffect 就是 React 提供的”正确时机”机制:你告诉 React “做什么”和”什么时候做”,React 在渲染完成后帮你执行。
二、与 useState 的对比:记忆 vs 行动
| useState | useEffect | |
|---|---|---|
| 给组件什么能力 | 记忆(记住数据) | 行动(执行副作用) |
| 做的事 | 存数据、改数据、触发重渲染 | 跟外部世界交互 |
| 返回值 | [值, 改值函数] | 无(undefined) |
| 触发时机 | 你调 setter 时触发重渲染 | 渲染完成后执行副作用 |
| 纯函数? | 是(setter 触发重渲染,渲染本身是纯的) | 否(副作用就是不纯的) |
两者构成组件的完整能力:useState 管”记住什么”,useEffect 管”做什么”。一个组件 = 纯函数渲染 + 状态记忆 + 副作用行动。
三、基本语法
useEffect(setup, dependencies?)
// ↑ ↑
// 副作用函数 依赖数组(可选)
3.1 setup 函数
setup 是你要执行的副作用逻辑。它可以返回一个清理函数(cleanup),也可以不返回。
useEffect(() => {
// 副作用逻辑写这里
const connection = createConnection(roomId);
connection.connect();
// 可选:返回清理函数
return () => {
connection.disconnect();
};
}, [roomId]);
3.2 依赖数组
依赖数组告诉 React:“什么时候重新执行这个 effect”。三种情况:
// ① 不传依赖数组:每次渲染后都执行
useEffect(() => {
console.log("每次渲染后都执行");
});
// ② 传空数组 []:只在挂载后执行一次
useEffect(() => {
console.log("只在组件第一次渲染后执行");
}, []);
// ③ 传依赖 [a, b]:a 或 b 变化时执行
useEffect(() => {
console.log(`roomId 变了:${roomId}`);
}, [roomId]);
心智模型:依赖数组是你跟 React 签的合同——“我只依赖这些东西,它们不变你就别重新执行”。React 信任你,不会帮你检查你是不是撒谎了(但 ESLint 会,后面讲)。
四、依赖数组三种情况详解
4.1 不传依赖数组:每次渲染后都执行
useEffect(() => {
console.log("渲染完成");
});
组件每渲染一次,这个 effect 就执行一次。几乎不用——因为大多数副作用不需要每次渲染都跑。唯一合理的场景是调试。
4.2 传空数组 []:只在挂载后执行一次
useEffect(() => {
// 组件第一次出现在页面上时执行
document.title = "欢迎来到我的应用";
}, []);
空数组 = “我什么都不依赖,所以永远不需要重新执行”。这是最常见的用法之一——初始化逻辑放这里。
4.3 传依赖 [a, b]:依赖变化时执行
useEffect(() => {
document.title = `当前房间:${roomId}`;
}, [roomId]);
// ↑ roomId 变了 → 重新执行 effect
// roomId 没变 → 跳过
React 用 Object.is() 逐个比较依赖数组中的值。任何一个变化,就重新执行 effect。
useEffect(() => {
fetchTodos(userId);
}, [userId]);
// userId 从 1 变成 2 → 重新 fetch
// userId 还是 1 → 不执行
关键理解:依赖数组不是”我用了哪些变量”的声明,而是”这个 effect 什么时候需要重新执行”的触发条件。但两者在实践中是一回事——effect 里用了什么变量,就应该把什么变量放进依赖数组。
五、清理函数(Cleanup)
5.1 为什么需要清理?
类比:你租了一间房(副作用),退租时必须打扫干净(清理函数)。如果不打扫,下一个租客进来就会看到你的垃圾——上一个 effect 的残留。
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
// 清理函数:退租打扫
return () => {
connection.disconnect();
};
}, [roomId]);
5.2 执行时机
清理函数在两种时机执行:
- 下次 effect 执行前:依赖变化导致 effect 重新执行时,先执行上一次的清理函数,再执行新的 effect
- 组件卸载时:组件从页面消失时,执行最后一次清理函数
挂载 → 执行 effect
roomId 变化 → 执行清理函数(断开旧连接)→ 执行新 effect(连接新房间)
roomId 变化 → 执行清理函数(断开旧连接)→ 执行新 effect(连接新房间)
卸载 → 执行清理函数(断开连接)
5.3 不清理会怎样?
// ❌ 没有 cleanup:每次 roomId 变化都创建新连接,旧连接还在
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
// roomId 从 'A' 变成 'B':
// 连接 A 还在(没人断开)
// 连接 B 建立了
// 结果:同时连着两个房间!
}, [roomId]);
// ✅ 有 cleanup:先断旧连接,再建新连接
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
原则:如果副作用创建了需要”收尾”的资源(连接、监听、定时器),就必须返回清理函数。问自己一个问题:“如果这个 effect 再执行一次,上一次的残留会造成问题吗?“会 → 写 cleanup。
六、常见模式
6.1 数据获取
挂载时获取:
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("/api/users")
.then(res => res.json())
.then(data => setUsers(data));
}, []); // 空数组 = 只在挂载时获取一次
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
依赖变化时获取:
function UserDetail({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // userId 变了就重新获取
if (!user) return <div>加载中...</div>;
return <div>{user.name}</div>;
}
实际项目中,数据获取推荐用 TanStack Query——它帮你处理了加载状态、缓存、重试、去重等
useEffect手写容易遗漏的细节。useEffect+fetch是理解原理的基础,不是生产方案。
6.2 事件监听
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
// 清理:移除监听
return () => {
window.removeEventListener("resize", handleResize);
};
}, []); // 只在挂载时添加,卸载时移除
return <div>窗口宽度:{width}px</div>;
}
模式:
addEventListener和removeEventListener必须成对出现。忘了removeEventListener= 内存泄漏 + 重复执行。
6.3 定时器
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
// 清理:清除定时器
return () => clearInterval(id);
}, []); // 只启动一次定时器
return <div>{time.toLocaleTimeString()}</div>;
}
模式:
setInterval/setTimeout必须用clearInterval/clearTimeout清理。否则组件卸载后定时器还在跑,尝试更新已卸载的组件状态会报警告。
6.4 订阅 / 取消订阅
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
// 订阅消息
connection.onMessage(msg => {
setMessages(prev => [...prev, msg]);
});
// 清理:断开连接
return () => {
connection.disconnect();
};
}, [roomId]); // roomId 变了就重新连接
return (
<ul>
{messages.map((msg, i) => (
<li key={i}>{msg.text}</li>
))}
</ul>
);
}
模式总结:所有副作用的模式都是同一个套路——setup + cleanup。签合同(setup)+ 退租打扫(cleanup)。忘了 cleanup = 资源泄漏。
七、依赖数组的陷阱
这是 useEffect 最容易出错的地方,也是面试高频考点。
7.1 遗漏依赖导致 Stale Closure
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// ❌ count 永远是 0!
// 这个回调函数在 effect 首次执行时创建,
// 闭包里的 count 是第一次渲染时的值(0)
// 后续渲染的 count 变化,这个闭包看不到
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空数组 = 只执行一次 → count 永远是闭包里的 0
return <div>{count}</div>;
}
问题:effect 只在挂载时执行了一次,里面的 count 是第一次渲染时的 0。之后 count 变了,但 effect 不会重新执行,闭包里的 count 还是 0。所以 setCount(count + 1) 永远是 setCount(0 + 1) = setCount(1)。
修复方法一:把 count 加进依赖数组
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ✅ 每次拿到最新的 count
}, 1000);
return () => clearInterval(id);
}, [count]); // count 变了就重新执行 effect
// 但这有个问题:每秒 count 变一次 → effect 重新执行 → 清除旧定时器 → 创建新定时器
// 功能正确,但不够优雅
修复方法二:用函数式更新,不依赖 count
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // ✅ 拿最新值,不需要 count 在闭包里
}, 1000);
return () => clearInterval(id);
}, []); // 空数组就行,定时器只创建一次
规律:当新值依赖旧值时,用
prev => prev + 1的函数形式——和 useState 的闭包陷阱是同一个解法。
7.2 对象/函数作为依赖导致无限循环
function ChatRoom() {
const options = { roomId: "general" }; // ❌ 每次渲染创建新对象
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // 新引用 → effect 每次都执行 → 无限循环
}
原因:{ roomId: 'general' } !== { roomId: 'general' }——对象比较的是引用,不是内容。每次渲染 options 都是新的对象,React 认为”依赖变了”,重新执行 effect。
修复:把对象移到组件外面,或用 useMemo 缓存:
// 方法一:移到组件外面(不需要 props/state 时)
const options = { roomId: "general" }; // ✅ 引用永远不变
// 方法二:useMemo 缓存
function ChatRoom({ roomId }) {
const options = useMemo(() => ({ roomId }), [roomId]); // ✅ roomId 不变则引用不变
// ...
}
函数作为依赖也是同样的问题——每次渲染创建新函数 = 新引用。用 useCallback 缓存:
// ❌ 每次渲染创建新函数
const handleMessage = msg => {
console.log(msg);
};
// ✅ useCallback 缓存
const handleMessage = useCallback(msg => {
console.log(msg);
}, []); // 引用稳定
7.3 为什么 ESLint exhaustive-deps 规则存在
React 官方提供了 eslint-plugin-react-hooks,其中 exhaustive-deps 规则会强制你把 effect 里用到的所有外部变量都放进依赖数组。
// ESLint 会警告:effect 里用了 roomId,但依赖数组里没有
useEffect(() => {
connectToRoom(roomId);
}, []); // ⚠️ 缺少 roomId 依赖
这个规则存在的意义:防止 stale closure。你省略了依赖,React 就不会在依赖变化时重新执行 effect,effect 里的闭包就会读到旧值。
// ✅ ESLint 满意:所有用到的变量都在依赖里
useEffect(() => {
connectToRoom(roomId);
}, [roomId]);
态度:不要把
exhaustive-deps当成烦人的规则去绕过。它是在帮你——如果你发现必须省略某个依赖才能让代码”正常工作”,那大概率是代码本身有问题,应该用函数式更新、useMemo、useCallback等方式修复,而不是欺骗 ESLint。
八、React 18 Strict Mode 下的双重调用
开发环境下,如果用了 <React.StrictMode>,React 会故意挂载→卸载→再挂载组件:
挂载 → 执行 effect
卸载 → 执行 cleanup
挂载 → 执行 effect
这不是 bug,是 React 故意为之——帮你发现 cleanup 写没写对。
8.1 为什么这么做?
很多开发者写 effect 时不写 cleanup,开发时恰好没问题(因为组件不会频繁卸载)。但到了生产环境,组件可能因为路由切换、条件渲染等原因频繁卸载/重新挂载,没有 cleanup 就会出 bug。
Strict Mode 的双重调用让这类问题在开发阶段就暴露。
8.2 怎么应对?
不需要应对——如果你的 cleanup 写对了,双重调用完全无害:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // ✅ cleanup 正确,双重调用没问题
}, [roomId]);
// 双重调用:连接→断开→连接。最终状态和单次调用一样
// ❌ 没有 cleanup:双重调用 = 连接→(没断开)→连接 = 两个连接!
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
}, [roomId]);
注意:双重调用只在开发环境 + Strict Mode 下发生,生产环境不会。
九、useEffect vs useLayoutEffect
useEffect | useLayoutEffect | |
|---|---|---|
| 执行时机 | 渲染提交到 DOM 之后(异步) | 渲染提交到 DOM 之后、浏览器绘制之前(同步) |
| 是否阻塞绘制 | 不阻塞 | 阻塞 |
| 适用场景 | 大多数副作用 | 需要在绘制前读取/修改 DOM 布局 |
9.1 执行顺序
React 计算 Virtual DOM
→ 更新真实 DOM
→ useLayoutEffect 执行(同步,浏览器还没绘制)
→ 浏览器绘制屏幕
→ useEffect 执行(异步,浏览器已经绘制了)
9.2 什么时候用 useLayoutEffect?
需要读取 DOM 布局并同步修改的场景——比如 tooltip 定位、自动测量元素尺寸:
function Tooltip({ children }) {
const ref = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect(); // 读取 DOM 布局
setPosition({ top: rect.bottom + 8, left: rect.left }); // 同步修改
}, []); // 浏览器绘制前就定好位置,不会闪烁
return (
<>
<div ref={ref}>{children}</div>
<div
style={{ position: "absolute", top: position.top, left: position.left }}
>
提示内容
</div>
</>
);
}
用 useEffect 的话,浏览器先绘制位置不对的 tooltip,effect 执行后再重绘到正确位置——用户会看到闪烁。useLayoutEffect 在绘制前修正,没有闪烁。
原则:99% 的情况用
useEffect。只有当你需要”先读 DOM 布局再修改,且不能让用户看到中间状态”时,才用useLayoutEffect。
十、与声明式的关系:useEffect 是”逃生舱”
React 的世界是声明式的——你描述”UI 应该长什么样”,React 帮你实现。但外部世界(网络请求、DOM API、定时器、第三方库)是命令式的——你必须告诉它们”一步步怎么做”。
useEffect 就是这两个世界之间的桥梁:
声明式的 React 世界 命令式的外部世界
───────────────── ────────────────
状态变化 fetch 请求
组件渲染 ──useEffect──→ DOM 操作
JSX 描述 事件监听
定时器
你在声明式的世界里写组件,用 useEffect 打开一扇门,走进命令式的外部世界做事,做完再回来。
10.1 useEffect 不是”生命周期方法”
class 组件有 componentDidMount、componentDidUpdate、componentWillUnmount 三个生命周期方法。很多教程把 useEffect 等价于这三个方法的组合——这个心智模型是错的。
// ❌ 错误心智模型:把 useEffect 当生命周期
useEffect(() => {
// componentDidMount
}, []);
useEffect(() => {
// componentDidUpdate
}, [someValue]);
useEffect(() => {
return () => {
// componentWillUnmount
};
}, []);
// ✅ 正确心智模型:useEffect 是"同步"
// "我的 effect 依赖这些值,当这些值变化时,
// 我需要让外部世界和这些值保持同步"
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
// 不是"挂载时连接、更新时重连、卸载时断开"
// 而是"让连接状态和 roomId 保持同步"
区别在哪?生命周期思维关注”什么时候”(挂载、更新、卸载),同步思维关注”什么关系”(effect 和哪些值保持同步)。同步思维更符合 React 的声明式哲学——你描述”关系”,React 处理”时机”。
详见 声明式与React的实现机制——声明式的本质是”你只管描述结果,有人替你执行过程”。
useEffect是你在声明式世界里唯一需要关心”过程”的地方,所以它是”逃生舱”。
10.2 能不用就不用
useEffect 是”逃生舱”,意味着它应该是最后手段。很多你以为需要 useEffect 的场景,其实不需要:
| 场景 | ❌ 用 useEffect | ✅ 更好的方式 |
|---|---|---|
| 根据 props 计算 derived state | useEffect(() => setX(compute(props.a)), [props.a]) | 直接算:const x = compute(props.a) |
| 重置 state | useEffect(() => setItems([]), [userId]) | const [items, setItems] = useState(() => []) + key 变化 |
| 用户事件处理 | useEffect(() => { if (data) doSomething() }, [data]) | 在事件回调里直接调 doSomething() |
| 数据获取 | useEffect(() => fetch(...), [id]) | TanStack Query |
判断标准:如果一段逻辑是”响应渲染结果”(比如同步 DOM、发请求),用
useEffect;如果是”响应事件”(比如点击按钮),直接在事件处理函数里写,不需要useEffect。
一句话
useEffect给组件添加”做事”的能力,让声明式的 React 能跟命令式的外部世界交互。记住三件事:依赖数组要诚实(别骗 ESLint)、有 setup 就要有 cleanup、能用其他方式解决的别用 useEffect。