新手看"副作用"这三个字总觉得是坏事。这篇文章把这个概念彻底讲清楚。
一、先搞清楚:什么叫"副作用"?
React 组件的主业只有一件:
state + props → 算出 JSX → 画到屏幕上(渲染)
和这个主业无关的操作,都叫副作用(Side Effect)。
"副"不是"不重要",而是"不在渲染的主干道上"。打个比方——厨师的主业是"把食材做成菜":
- 切菜、炒菜 = 主作用(渲染 UI)
- 顺手接了个电话 = 副作用(和炒菜无关,但必须做)
- 做完菜把灶台擦了 = 副作用(和炒菜无关,但还是必须做)
- 尝了一口咸淡 = 也是副作用,因为这个"尝"的动作会改变菜的状态
哪些代码不是副作用?
初学者容易搞反——以为"除了渲染 UI,其他都是副作用"。其实有些操作是渲染的一部分:
// ✅ 这些不是副作用,可以写在函数体里
const completedCount = todos.filter(t => t.completed).length; // 纯计算
const handleClick = () => { setName('新名字'); }; // 事件处理函数
const [name, setName] = useState(''); // 声明状态
{completed > 0 && <button>清除</button>} // 条件渲染
// ❌ 这些才是副作用,必须放进 useEffect
localStorage.setItem('todos', JSON.stringify(todos)); // 写磁盘
fetch('/api/todos'); // 网络请求
document.title = `你有${count}个待办`; // 改 DOM
const timer = setInterval(() => tick(), 1000); // 定时器
window.addEventListener('resize', handleResize); // 订阅
判断口诀
这行代码执行 100 次,结果一样吗?会改变 React 之外的世界吗?
二、useEffect 的两个参数
useEffect 接受两个参数:
useEffect(
() => { /* 参数一:回调函数 —— 你的副作用逻辑 */ },
[dep1, dep2] // 参数二:依赖数组 —— 管"什么时候重新执行"
);
第二个参数是可选的,它的值决定 effect 的执行时机:
| 你怎么写 | 执行时机 | 场景 |
|---|---|---|
| 不传第二个参数 | 每次渲染后都执行 | 极少使用 |
[] 空数组 | 只挂载后执行一次 | 请求初始数据 |
[count] | 挂载后 + count 变了就执行 | 响应特定状态 |
[a, b] | a 或 b 任意一个变了就执行 | 多个依赖 |
React 在每次渲染后,比较依赖数组里每个值和上次是否一样:
上次:[0] 这次:[0] → 没变,跳过
上次:[0] 这次:[1] → 变了,执行
上次:不传 这次:不传 → 每次都执行(因为没东西可比较)
四种写法演示
// ① 不传依赖 —— 每次渲染后都执行(慎用)
useEffect(() => {
console.log('每次渲染后都跑,累死了');
});
// ② 空数组 —— 只执行一次(最常用)
useEffect(() => {
fetch('/api/user').then(res => setUser(res));
}, []); // "我不依赖任何变量,一辈子跑一次就够了"
// ③ 指定依赖 —— 依赖变了才跑
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]); // "todos 变了叫我,没变别叫我"
// ④ 不写就对了 —— 大多数时候你只需要 ② 和 ③
三、生命周期:一张图看懂组件的一生
组件创建
│
▼
═══ 挂载 (Mount) ═══════════════════════════
│ 组件第一次出现在页面上
│ useEffect(fn, []) 执行 ← 只这一次
│ useEffect(fn, [dep]) 执行 ← 第一次也会跑
│
▼
═══ 更新 (Update) ══════════════════════════
│ state 或 props 变了就触发,可以发生无数次
│ useEffect(fn, [dep]) → 只有 dep 变了才重跑
│ useEffect(fn, []) → 不跑([] 里没值可比)
│
▼
═══ 卸载 (Unmount) ═════════════════════════
│ 组件从页面上移除
│ 清理函数执行
│ 组件从 DOM 中消失
重要:useEffect 没有"挂载前"。useEffect 永远在 DOM 画到屏幕上之后才执行。
四、清理函数:什么时候执行?
useEffect 的回调可以 return 一个函数——这就是清理函数:
useEffect(() => {
// ① 挂载后执行
const timer = setInterval(() => console.log('滴答'), 1000);
return () => {
// ② 清理函数
clearInterval(timer);
};
}, []);
清理函数在两个时间点执行:
| 场景 | 清理函数何时执行 |
|---|---|
依赖是 [] | 只在组件卸载前 |
依赖是 [dep] | 依赖变化后(下次回调前)+ 卸载前 |
| 不传依赖 | 每次重渲染下次回调前 + 卸载前 |
以 WebSocket 连接为例
const ChatRoom = ({ roomId }: { roomId: string }) => {
useEffect(() => {
const ws = new WebSocket(`wss://chat.com/${roomId}`);
console.log(`📡 连接到 ${roomId}`);
return () => {
ws.close();
console.log(`🔌 断开 ${roomId}`);
};
}, [roomId]);
};
// roomId 从 "房间A" 切换到 "房间B":
// 🔌 断开 房间A ← 清理旧的
// 📡 连接到 房间B ← 连接新的
//
// 用户离开页面:
// 🔌 断开 房间B ← 最后一次清理
React 内部怎么实现?
简化版原理:
// 每个 useEffect 对应一个 effect 对象
{
destroy: cleanupFn, // ← 你 return 的函数被存在这里
deps: ['房间A'],
tag: '需要执行'
}
// 渲染完成后,React 遍历所有 effect:
// ① tag === '需要销毁' → 调用 destroy() (先扫上次的)
// ② tag === '需要执行' → 执行回调,return 的函数存在 destroy 里
React 把你 return 的清理函数存起来,下次需要时再调——仅此而已。
五、最实用的场景:数据持久化
Todo App 里最常见的写法——todos 一变就自动存到 localStorage:
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
不用在每个添加、删除、修改 todo 的地方都写一遍存储逻辑——只要盯着 todos 这个状态,它一变自动同步:
添加 todo → setTodos → todos 引用变了 → effect 触发 → 存
删除 todo → setTodos → todos 引用变了 → effect 触发 → 存
勾选完成 → setTodos → todos 引用变了 → effect 触发 → 存
搭配惰性初始化
const [todos, setTodos] = useState(() => {
return JSON.parse(localStorage.getItem('todos')) || [];
// ↑ 只有初次渲染时执行一次,之后重渲染不再执行
});
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
useState 惰性初始化(传函数而不是值)负责读,useEffect 负责写——形成完整的持久化闭环。
六、常见的坑
坑一:把副作用写在函数体里
// ❌ 每次渲染都发请求 → 死循环!
const App = () => {
const [data, setData] = useState(null);
fetch('/api/data').then(res => setData(res)); // 渲染 → 请求 → setData → 渲染 → 无限循环
return <div>{data}</div>;
};
// ✅ 放进 useEffect
useEffect(() => {
fetch('/api/data').then(res => setData(res));
}, []);
坑二:忘记依赖数组
// ❌ count 变化了但 effect 不会重跑
useEffect(() => {
localStorage.setItem('count', count);
}, []); // [] 表示"不依赖任何东西"
// ✅ count 变了就重跑
useEffect(() => {
localStorage.setItem('count', count);
}, [count]);
坑三:忘记清理
// ❌ 每次渲染都加一个新监听,从不移除——内存泄漏
useEffect(() => {
window.addEventListener('resize', handleResize);
});
// ✅ 加上清理
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
七、小结
| 概念 | 一句话 |
|---|---|
| 副作用 | 跟渲染 UI 无关但必须执行的操作 |
依赖数组 [] | 只挂载一次 |
依赖数组 [dep] | dep 变了就重跑 |
| 清理函数 | 卸载前 / 下次执行前打扫卫生 |
| 生命周期 | 挂载 → 更新 → 卸载,useEffect 统一管理 |
把 useEffect 当成 React 给你留的"后门"——渲染是你的主业,渲染之外的事情从这个门进去做。
本文基于 React 19,示例代码来自本人的学习项目。