React Hooks 不难:状态、副作用与清理,一文彻底搞懂

63 阅读7分钟

React Hooks 入门:从状态管理到副作用处理

如果你刚开始接触 React,可能会被 useStateuseEffect 这些以 use 开头的函数搞得一头雾水。别担心,它们其实就像厨房里的工具——有的负责“记住”当前做了几道菜(状态),有的负责“洗锅刷碗”(清理副作用)。今天我们就用生活化的比喻,一步步揭开 React Hooks 的面纱。


一、状态的起点:useState

React 组件默认是“无记忆”的——每次渲染都像一张白纸。但现实应用中,我们常常需要记住用户点了几次按钮、输入了什么内容。这时就轮到 useState 登场了。

useState

它是 React 内置的一个 Hook(钩子),专门用来给函数组件添加状态(state)能力,创建的是响应式状态

import { useState } from 'react';

export default function App() {
  const [num, setNum] = useState(1);
  return (
    <div onClick={() => setNum(num + 1)}>
      <h1>当前数字:{num}</h1>
    </div>
  );
}

这里 const [num, setNum] = useState(1) 是 JavaScript 的解构赋值语法,把 useState 返回的数组拆成两个变量:

  • num:当前状态值
  • setNum:修改状态的函数

初始值也可以是个函数?

是的!如果你的初始状态需要复杂计算(比如加法、查表),可以传入一个纯函数

const [num, setNum] = useState(() => {
  const num1 = 1 + 2;
  const num2 = 2 + 3;
  return num1 + num2; // 返回8
});

image.png

注意:这个函数必须是同步的、确定的,不能是异步请求(比如 fetchsetTimeout)。为什么?因为 React 需要在组件首次渲染时立刻知道状态是什么,不能等。

React 可能在开发模式下多次调用初始化函数(例如 Strict Mode),如果函数有副作用或非确定性(比如 Math.random()),会导致状态不一致或 bug 难以复现

这就引出了一个关键概念:纯函数


二、什么是纯函数?为什么重要?

纯函数就像一台可靠的咖啡机:你放进去相同的豆子和水量,它永远给你同样的一杯咖啡。在编程中,纯函数满足两个条件

  1. 相同输入 → 相同输出
  2. 没有副作用(不修改外部变量、不发网络请求、不操作 DOM)

反例:

const nums = [1, 2];
function add(nums) {
  nums.push(3); // 修改了外部数组!这是副作用
  return nums.reduce((pre, cur) => pre + cur, 0);
}

调用 add(nums) 后,nums 本身被改变了——这会让程序变得难以预测。

useState 的初始化函数必须是纯函数,就是为了保证组件行为可预测、可重复,确保状态是确定的。


三、那我想加载网络数据怎么办?

既然 useState 不能处理异步,我们就需要另一个工具:useEffect

想象你点了一份外卖(异步请求),不能在“决定吃什么”(初始化状态)的时候就拿到饭,而是要等骑手送来。useEffect 就是那个“等骑手来”的机制。

import { useState, useEffect } from 'react';

async function queryData() {
  const data = await new Promise(resolve => {
    setTimeout(() => resolve(666), 2000);
  });
  return data;
}

export default function App() {
  const [num, setNum] = useState(0);
   console.log(111); // 渲染日志
   
  useEffect(() => {
     console.log('xxx'); // effect 执行日志
    queryData().then(data => {
      setNum(data); // 2秒后设置为666
    });
  }, []); // 注意这个空数组!

  return <div>{num}</div>;
}

这里的 []依赖项数组。空数组表示:“只在组件挂载时执行一次”,相当于 Vue 中的 onMounted

我们打印一下:

未命名的设计 (1).gif 函数组件体中的代码(包括 console.log(111))在每次组件渲染时都会执行,包括首次挂载和状态更新后的重新渲染。而 useEffect 回调中的代码(console.log('xxx'))由于其依赖数组为空 [] ,只在组件挂载后执行一次,不会在后续更新中重新执行。


四、依赖项:让 effect “聪明”起来

useEffect 的依赖项决定了它何时重新运行:

  • [] :只运行一次(挂载时)
  • [num] :当 num 变化时运行
  • 不写依赖项:每次渲染都运行(慎用!)

当依赖项为[num]时:

未命名的设计 (2).gif

该 effect 会在组件挂载时执行,并且在 num 的值发生变化(状态更新)时也会执行

如果没有依赖项:

该 effect 会在每次组件渲染后都执行,无论组件状态或 props 是否发生变化。这通常会导致性能问题。

正确依赖项机制确保副作用在正确时机触发,避免无限循环,同时优化性能。

五、副作用的“善后”:定时器与内存泄漏

有时候副作用会留下“烂摊子”。比如你开了个定时器:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);
}, [num]);
return (
        <>
        <div onClick={() => setNum(prevNum => prevNum + 1)}>{num}</div>
        </>
    )

当我们多次触发点击事件让num变化,状态持续更新时:

未命名的设计 (3).gif 问题来了:每次 num 变化,都会启动新的定时器,但旧的没关!结果就是多个定时器同时跑,疯狂打印,甚至导致内存泄漏——就像你开了10个水龙头却忘了关,最后家里淹了。

解决方法?返回一个清理函数

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);

  return () => {
    console.log('清除定时器');
    clearInterval(timer); // 关掉水龙头!
  };
}, [num]);
return (
        <>
        <div onClick={() => setNum(prevNum => prevNum + 1)}>{num}</div>
        </>
    )

我们重新执行一下程序:

未命名的设计 (4).gif

这个 return 函数会在下一次 effect 执行前组件卸载时自动调用,确保资源被回收。

再看一个例子:

// App.jsx
  return (
        <>
        <div onClick={() => setNum(prevNum => prevNum + 1)}>{num}</div>
            {num % 2 === 0 && <Demo />} 
        </>
    )
   
  // Demo.jsx
export default function Demo() {
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('timer');
    }, 1000);
    return () => clearInterval(timer); // 卸载时清理
  }, []);
  return <div>偶数Demo</div>;
}
{num % 2 === 0 && <Demo />}

这意味着:

  • 当 num 是偶数时,<Demo /> 被渲染(挂载)
  • 当 num 变成奇数时,<Demo /> 被移除(卸载)

当父组件不再渲染 <Demo />(比如 num 变成奇数),React 会自动调用 return 里的清理函数,避免“幽灵定时器”。


六、useEffect 的三种典型执行模式小结

通过前面的例子,我们可以把 useEffect 的行为归纳为以下三种典型模式,这也是理解其工作机制的关键:

1. 挂载时执行一次(模拟 onMounted

useEffect(() => {
  // 初始化操作:请求数据、绑定事件等
}, []);
  • 依赖项为 空数组 []
  • 效果:仅在组件首次挂载后执行一次,后续更新不再触发
  • 用途:替代 Vue 中的 onMounted,适合一次性初始化逻辑

2. 依赖变化时执行(响应式更新)

useEffect(() => {
  // 当 userId 或 theme 变化时重新执行
}, [userId, theme]);
  • 依赖项为 状态或 props 的数组 [a, b]
  • 效果:组件挂载时执行一次,之后每当任一依赖项发生变化,effect 就重新运行
  • 用途:监听特定数据变化并作出响应,比如根据搜索关键词重新获取列表

3. 清理副作用(防止内存泄漏)

useEffect(() => {
  const timer = setInterval(() => { /* ... */ }, 1000);
  
  return () => {
    // 清理上一次 effect 留下的副作用
    clearInterval(timer);
  };
}, [deps]);
  • 如果 effect 返回一个函数,React 会将其作为清理函数(cleanup function)

  • 调用时机:

    • 下一次该 effect 重新执行前(用于清除旧资源)
    • 组件卸载时(确保彻底释放)
  • 用途:清除定时器、取消网络请求、解绑事件监听器等,避免“幽灵副作用”

💡 这三个特性共同构成了 useEffect 的完整生命周期管理能力:初始化 → 响应更新 → 清理收尾。它不是简单的“副作用钩子”,而是一个可控制、可清理、可依赖追踪的副作用管理系统


总结:Hooks 的协作逻辑

  • useState:管理确定的状态,初始值必须来自纯函数。
  • 纯函数 vs 副作用:状态初始化要“干净”,异步、定时器、DOM 操作都是“脏活”。
  • useEffect:专门处理副作用,通过依赖项控制执行时机。
  • 清理函数:负责“擦屁股”,防止内存泄漏,确保应用健壮。

React Hooks 就像一套精密的厨房系统:useState 是你的食材清单,useEffect 是你的灶台和洗碗池。只有每一步都规范操作,才能做出既美味又安全的“前端大餐”。