React useEffect :副作用是什么、为什么、怎么用

5 阅读3分钟

新手看"副作用"这三个字总觉得是坏事。这篇文章把这个概念彻底讲清楚。


一、先搞清楚:什么叫"副作用"?

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,示例代码来自本人的学习项目。