深入理解 React Hooks:从 `useState` 到 `useEffect` 的实践与思考

19 阅读8分钟

今天想和大家聊聊我在学习 React 函数式组件中两个核心 Hook —— useStateuseEffect 时的一些体会。不讲花里胡哨的概念,只用最朴实的语言、真实的代码例子,带你一步步看清这两个 API 的本质。


一、为什么需要 useState?

在类组件时代,我们通过 this.state 来管理组件内部的状态变化。而到了函数式组件,它原本是“无状态”的——执行完就销毁,无法记住上一次的数据。

于是 React 引入了 useState,让函数组件也能拥有“记忆”。

1. 基本用法

const [num, setNum] = useState(0);

这行代码的意思是:声明一个叫 num 的状态变量,初始值为 0;同时提供一个更新它的方法 setNum

当你调用 setNum(1),React 会重新渲染组件,并让 num 变成 1。这就是所谓的“响应式状态”。

2. 初始化可以传函数

有时候初始值不是简单的数字或字符串,而是需要经过复杂计算才能得出的结果:

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

这样做有两个好处:

  • 避免每次渲染都执行这些计算(提升性能)
  • 符合“纯函数”原则:输入相同,输出确定

⚠️ 注意:这个初始化函数必须是同步且无副作用的。你不能在这里写 async/await 或发起网络请求,因为状态的初始化过程必须是确定性的。

那如果我真想在初始化时请求数据怎么办?别急,后面 useEffect 会解决这个问题。

3. 更新状态也可以传函数

当我们基于前一个状态来更新当前状态时,推荐使用函数形式:

setNum(prevNum => prevNum + 1);

这样能确保拿到的是最新的状态值,避免闭包带来的“旧状态”问题。

举个例子:

// ❌ 错误示范:可能读取到过期的 num
setTimeout(() => {
  setNum(num + 1);
}, 1000);

// ✅ 正确做法:总是基于最新状态
setTimeout(() => {
  setNum(prev => prev + 1);
}, 1000);

二、useEffect:处理副作用的利器

如果说 useState 是“状态引擎”,那 useEffect 就是“副作用控制器”。

什么是副作用?

先说清楚一个概念:纯函数 vs 副作用

  • 纯函数:给定相同输入,永远返回相同输出,没有外部影响。

    function add(x, y) {
      return x + y;
    }
    
  • 副作用:函数执行过程中对外部产生了不可控的影响,比如:

    • 修改全局变量
    • 发起 AJAX 请求
    • 操作 DOM
    • 设置定时器
    • 订阅事件

在 React 组件中,我们的目标是尽量保持组件函数为“纯”的——即只负责根据 props/state 输出 JSX。但现实开发中,我们不可避免要处理副作用,这就轮到 useEffect 上场了。


useEffect 的三种典型用法

① 模拟 onMounted:组件挂载后执行一次

useEffect(() => {
  console.log('组件已挂载');
}, []);

第二个参数是一个空数组 [],表示该 effect 不依赖任何状态。因此它只会在组件第一次渲染后执行一次,类似于 Vue 的 onMounted

常见用途:

  • 页面加载后请求接口数据
  • 初始化第三方库(如 echarts)
  • 监听全局事件(once)

② 根据依赖项更新:类似 onUpdated

useEffect(() => {
  console.log('num 改变了:', num);
}, [num]);

只要 num 发生变化,这个 effect 就会重新执行。这就是所谓的“监听某个状态的变化”。

⚠️ 注意:如果你漏写了依赖项,可能会导致拿到的是旧值;但如果多写了不必要的依赖,又可能导致频繁执行。所以要精准填写依赖项

③ 不传依赖项:每次渲染都执行

useEffect(() => {
  console.log('每次渲染都会打印');
});

这种写法很少见,因为它会在每次状态更新、props 改变时都触发,容易造成性能问题或无限循环。

一般用于调试或特殊场景。


如何清除副作用?return 清理函数

很多副作用是有“寿命”的,比如定时器、订阅、连接等。如果不及时清理,会造成内存泄漏。

useEffect 允许你在内部 return 一个清理函数:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('每秒打印一次');
  }, 1000);

  // 清理函数
  return () => {
    console.log('清除定时器');
    clearInterval(timer);
  };
}, [num]);

这个 return 的函数会在两种情况下被执行:

  1. 当前 effect 要重新执行前(比如 num 变了)
  2. 组件卸载时

✅ 这是非常重要的模式!尤其是在处理定时器、WebSocket、addEventListener 等资源时,一定要记得清理。


当然可以。下面是一段补充内容,适合作为文章的“反面案例”章节,用来警示开发者如果不正确清理副作用可能带来的严重后果。


三、血的教训:不写 return 清理函数,真的会“内存泄漏”

我们常说“记得清理定时器”“记得取消订阅”,但很多新手甚至老手在实际开发中都会忽略这一点。下面来看一个典型的错误写法:

// ❌ 危险示范:没有清理定时器
useEffect(() => {
  const timer = setInterval(() => {
    console.log('当前计数:', num);
  }, 1000);

  // 没有 return 清理函数!!!
}, [num]);

看起来没什么问题?每秒打印一次 num,当 num 改变时,重新设置定时器?

错!这会导致严重的内存泄漏和逻辑混乱。

会发生什么?

假设你有一个按钮,点击后跳转页面或卸载当前组件(比如从 /home 切换到 /about)。此时组件已经不在界面上了,但这个 setInterval 的回调依然在后台运行!

  • 定时器不会自动停止
  • 每次 num 更新都会注册一个新的定时器(因为 effect 重新执行)
  • 老的定时器还在跑,新的也加上了 → 多个定时器同时工作
  • 最终导致控制台疯狂输出、浏览器卡顿、甚至崩溃

🚨 更可怕的是:这些定时器仍然持有对 num 的引用,JavaScript 引擎无法回收该组件的内存 —— 这就是典型的内存泄漏

再看一个更隐蔽的问题:闭包陷阱

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('count');
    }, 2000);
    // 忘记 return clearInterval(id)
  }, [count]);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      当前是 {count}
    </button>
  );
}

你以为每次 count 变化时,定时器里的 count 也会更新?
不会!

因为每个 useEffect 执行时捕获的是当时的 count 值。而且由于没有清除上一个定时器,结果就是:

  • 第一次点击:启动一个打印 0 的定时器
  • 第二次点击:又启动一个打印 1 的定时器(原来的还在)
  • 第三次点击:再启动一个打印 2 的定时器……

最终你会看到控制台每隔两秒就同时打出多个值,越积越多,完全失控。

sss.jpg

✅ 正确做法:永远记得清理

useEffect(() => {
  const timer = setInterval(() => {
    console.log('安全地打印:', num);
  }, 1000);

  return () => {
    // 🔥 在重新执行前或组件卸载时,清除上一个定时器
    clearInterval(timer);
  };
}, [num]);

这样就能保证:

  • 每次只存在一个定时器
  • 组件销毁时不再有任何后台任务
  • 不会出现内存泄漏
  • 行为可控、可预测

总结一句话:

凡是有“开始”的操作,就必须有对应的“结束”操作。

开启了定时器?→ 清除它
添加了事件监听?→ 移除它
建立了 WebSocket?→ 关闭它
订阅了数据流?→ 取消订阅

否则,你的应用将在用户看不见的地方悄悄“腐烂”。

别让一个小疏忽,成为压垮性能的最后一根稻草。


四、实战案例分析

让我们结合一段完整代码来看这些知识点是如何协同工作的。

示例:点击计数 + 异步加载数据 + 条件渲染子组件

// App.jsx
import { useState, useEffect } from 'react';
import Demo from './components/Demo';


export default function App() {
  const [num, setNum] = useState(0);

  //  定时器副作用,记得清理
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前num:', num);
    }, 1000);

    return () => {
      console.log('清除上一个定时器');
      clearInterval(timer);
    };
  }, [num]);

  return (
    <>
      <div onClick={() => setNum(prev => prev + 1)}>
        {num}
      </div>
      {/* 条件渲染:只有偶数才显示 Demo */}
      {num % 2 === 0 && <Demo />}
    </>
  );
}

再看一下子组件 Demo.jsx 中的副作用清理:

// components/Demo.jsx
import { useEffect } from 'react';

export default function Demo() {
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Demo 内部定时器运行中...');
    }, 1000);

    // 卸载前清除定时器
    return () => {
      clearInterval(timer);
      console.log('Demo 组件即将被卸载,定时器已清除');
    };
  }, []);

  return <div>偶数 Demo</div>;
}

你会发现:

  • 每次 num 是奇数时,<Demo /> 被移除,component unmount → 清理定时器
  • 再变成偶数时,重新挂载 → 新建定时器
  • 完美避免了内存泄漏!

五、总结:Hooks 使用最佳实践

场景推荐写法
初始化复杂状态useState(fn) 函数式初始化
更新状态依赖前值setState(prev => prev + 1)
组件挂载后操作useEffect(fn, [])
监听某状态变化useEffect(fn, [dep])
清理副作用useEffectreturn cleanupFn
避免重复创建把不会变的对象/函数提到外面或用 useMemo/useCallback

六、最后的提醒

  • useState 提供的是“确定性状态”,不要在里面做异步操作。
  • useEffect 是“副作用容器”,适合处理异步请求、DOM 操作、订阅等。
  • 所有副作用都要考虑“如何清除”,否则容易引发 bug 或内存泄漏。
  • React 的设计理念是“UI = f(state)” —— 用户界面是状态的函数。我们要做的,就是合理管理 state 和 effect。

七、结语

Hooks 的出现,让函数式组件变得强大而灵活。但它也带来了新的心智负担:你需要更清楚地知道“什么时候执行”、“依赖谁”、“要不要清理”。

但我相信,只要你坚持写注释、多动手实践、像今天这样一行行去读代码背后的逻辑,一定能掌握好 useStateuseEffect 这对黄金搭档。

共勉!


📌 如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发~
💬 欢迎在评论区分享你的疑问和经验,我们一起进步!

#React #ReactHook #useState #useEffect #前端开发 #JavaScript #掘金原创