React Hooks 入门与实战:从 useState 到 useEffect 的最佳实践

0 阅读9分钟

一、什么是 React Hooks?

use 开头的函数,都是 React Hooks。它们是 React 官方提供的函数式编程接口,用于在函数组件中使用状态、生命周期、上下文等特性,而无需编写 class 组件。

特点

  • 更贴近原生 JavaScript 风格
  • 逻辑复用更简单(通过自定义 Hooks)
  • 代码更简洁、可读性更强

二、useState:管理组件状态

基本用法

jsx
编辑
import { useState } from 'react';

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

  return (
    <div onClick={() => setCount(count + 1)}>
      点击次数:{count}
    </div>
  );
}
  • useState 返回一个数组:[当前状态值, 更新状态的函数]
  • 初始值可以是任意类型(数字、对象、数组等)

初始化支持函数(惰性初始化)

如果初始状态需要复杂计算,可传入一个函数:

jsx
编辑
const [num, setNum] = useState(() => {
  const a = 1 + 2;
  const b = 2 + 3;
  return a + b; // 必须返回值
});

⚠️ 注意:这个函数只在首次渲染时执行一次,且必须是同步纯函数(不能包含异步操作)。

❓问题:如何在初始化时发起异步请求?

答案:不能直接在 useState 中做异步初始化!

因为状态必须是确定的、同步的。正确的做法是:

jsx
编辑
import { useState, useEffect } from 'react';

async function fetchData() {
  const res = await fetch('/api/data');
  return res.json();
}

export default function App() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchData().then(result => {
      setData(result);
      setLoading(false);
    });
  }, []); // 依赖项为空数组 → 相当于 componentDidMount

  if (loading) return <div>加载中...</div>;
  return <div>{data?.message}</div>;
}

关键点:异步数据获取属于“副作用”,应放在 useEffect 中处理。


三、useEffect:处理副作用

什么是副作用?

在函数式组件中,纯函数指:输入 props → 输出 JSX。
而以下行为属于副作用(Side Effect):

  • 发起网络请求
  • 订阅事件(如定时器、WebSocket)
  • 手动操作 DOM
  • 修改外部变量

这些操作都应通过 useEffect 来管理。

三种依赖模式的核心区别

依赖模式主逻辑执行时机清理函数执行时机
空数组 []仅组件挂载时执行 1 次仅组件卸载时执行 1 次
非空数组 [num]挂载时执行 + 依赖变化时执行依赖变化前执行 + 卸载时执行
无依赖数组(省略)组件每次重渲染都执行组件下一次重渲染前执行 + 卸载时执行

四、深度解析三种依赖模式

一、空数组 []:模拟 componentDidMount / onMounted

jsx
编辑
useEffect(() => {
  console.log('仅挂载时执行');
  const timer = setInterval(() => console.log('运行'), 1000);
  return () => console.log('仅卸载时清理');
}, []);
  • 主逻辑:组件首次挂载时执行一次
  • 清理函数:组件卸载时执行一次
  • 典型用途:一次性数据请求、全局监听器注册

📌 注意:若内部使用了组件状态(如 num),该状态值将被闭包锁定为初始值,不会随后续更新而变化。


二、非空数组 [num]:响应依赖变化

jsx
编辑
useEffect(() => {
  console.log(`effect: num=${num}`);
  const timer = setInterval(() => console.log(`timer: num=${num}`), 1000);
  return () => console.log(`remove: 清除 num=${num} 的定时器`);
}, [num]);

完整执行流程(关键!)

假设初始 num=0,点击按钮依次变为 1 → 2:

阶段执行顺序控制台输出
挂载(num=0)执行主逻辑effect: num=0 → 创建打印 0 的定时器
num → 11. 执行上一轮清理 2. 执行本轮主逻辑remove: 清除 num=0 的定时器 effect: num=1 → 创建打印 1 的定时器
num → 21. 执行上一轮清理 2. 执行本轮主逻辑remove: 清除 num=1 的定时器 effect: num=2 → 创建打印 2 的定时器
卸载执行最后一轮清理remove: 清除 num=2 的定时器

优势

  • 始终只有一个定时器在运行
  • 定时器内打印的是最新 num 值
  • 自动清理旧资源,避免内存泄漏

⚠️ 避坑:若忘记写清理函数,每次 num 变化都会新增一个定时器,导致多个定时器同时运行(旧值 + 新值混杂)。


三、无依赖数组(省略第二个参数):高频重执行模式

jsx
编辑
useEffect(() => {
  console.log(`effect: num=${num}`);
  const timer = setInterval(() => console.log(`timer: num=${num}`), 1000);
  return () => console.log(`remove: 清除 num=${num} 的定时器`);
}); // 无依赖!

核心行为

  • 主逻辑:组件首次挂载执行 1 次,之后每一次重渲染(state 变化、props 变化、父组件重渲染等)都会重新执行;
  • 清理函数每一次新主逻辑执行前(包括重渲染、卸载),都会先执行上一轮的清理函数;
  • 最终效果:只要组件还在页面上,就会 “清旧的 → 建新的” 无限循环。

执行流程(以 num 变化为例)

  1. 挂载(num=0)
    → 执行主逻辑:创建打印 0 的定时器
  2. 点击按钮,num→1(重渲染)
    → 先执行清理:清除 num=0 的定时器
    → 再执行主逻辑:创建打印 1 的定时器
  3. 再点击,num→2
    → 清理 num=1 的定时器 → 创建 num=2 的新定时器
  4. 组件卸载
    → 清理最后一轮(num=2)的定时器

三大坑点(重点!)

  1. 触发频率远高于 [num]

    • [num]:仅 num 变化时触发
    • 无依赖:任何重渲染都触发(包括无关 state、父组件刷新等)
  2. 定时器高频销毁/重建,性能损耗
    即使 num 没变,只要组件被动重渲染,就会:
    → 清掉当前定时器 → 创建新定时器
    虽然功能正确,但带来不必要的开销。

  3. 闭包陷阱(若未清理)
    如果忘记写清理函数

    • 每次重渲染都创建新定时器
    • 旧定时器不清理 → 多个定时器同时运行
    • 旧定时器中的 num 是创建时的旧值(闭包捕获)

好消息:只要写了清理函数,就不会出现“值不更新”的问题——因为每次都是全新创建,用的是当前渲染的最新值。


五、闭包:贯穿 useEffect + 定时器的核心机制

在你的 useEffect + 定时器 代码中,闭包是理解一切的关键。很多人遇到“定时器值不更新”或“内存泄漏”,根源都在于对闭包的理解不足。

一、闭包的核心本质

闭包是指「内部函数可以访问外部函数作用域中的变量 / 函数,即使外部函数已经执行完毕」。

在 React 函数组件中:

  • 每次组件渲染(挂载 / 重渲染)都会生成一个独立的函数执行上下文(可理解为“一次渲染快照”);
  • useEffect 里的主逻辑、清理函数、定时器回调,都是“内部函数”,会闭包捕获当前渲染上下文的变量(如 num);
  • 这些内部函数即使脱离了原渲染上下文(比如定时器每秒执行),依然能访问当时捕获的变量。

二、代码中的闭包具体体现(逐行分析)

我们以这段代码为例,标注所有闭包场景:

jsx
编辑
useEffect(() => { 
  console.log(`effect: num=${num}`); // 主逻辑闭包捕获当前 num
  
  // 定时器回调是内部函数,闭包捕获当前渲染的 num
  const timer = setInterval(() => {
    console.log(`timer: num=${num}`); // 👈 闭包核心位置!
  }, 1000);

  // 清理函数也是内部函数,闭包捕获当前的 timer 和 num
  return () => {
    console.log(`remove: 清除 num=${num} 的定时器`);
    clearInterval(timer); // 👈 闭包捕获当前的 timer 实例
  };
}, [num]);

场景 1:定时器回调闭包捕获「当前渲染的 num」

  • 每次 useEffect 执行(挂载 / num 变化),都会创建一个新的定时器回调函数
  • 这个回调函数会“记住”(闭包)本次渲染上下文的 num 值
  • 即使后续 num 变化、组件重渲染,只要这个定时器没被清除,回调里的 num 永远是捕获时的值

💥 极端错误示例(无清理 + 空依赖)

jsx
编辑
useEffect(() => { 
  setInterval(() => {
    console.log(num); // 闭包捕获“挂载时的 num(比如 0)”
  }, 1000);
}, []); 

后续 num 改为 1、2、3...,定时器永远打印 0
原因:空依赖 → useEffect 仅执行一次 → 定时器回调闭包锁定初始 num

场景 2:清理函数闭包捕获「当前渲染的 timer / num」

  • 每次 useEffect 执行时,返回的清理函数会闭包捕获本次创建的 timer 实例

  • 当后续执行清理(num 变化前 / 卸载),它能精准清除对应定时器

  • 例如 num 从 0 → 1:

    • 上一轮清理函数闭包的是 num=0 时创建的 timer → 清除旧定时器;
    • 本轮主逻辑创建新定时器,回调闭包 num=1 → 打印 1。

场景 3:useEffect 主函数本身也是闭包

  • useEffect 的回调函数是组件渲染时创建的;
  • 它闭包捕获本次渲染的 num,所以 console.log(effect: num=${num}) 能打印当前值。

三、闭包导致的三大常见坑点

坑 1:依赖数组写错 → 闭包捕获旧值

jsx
编辑
// ❌ 错误:想打印最新 num,却用了空依赖
useEffect(() => { 
  const timer = setInterval(() => {
    console.log(num); // 永远打印初始值!
  }, 1000);
  return () => clearInterval(timer);
}, []); // 漏写 num

解决:依赖数组加 [num],让 num 变化时重建闭包,捕获新值。


坑 2:不清理定时器 → 多个闭包堆积(内存泄漏)

jsx
编辑
// ❌ 错误:无清理函数
useEffect(() => { 
  setInterval(() => {
    console.log(num); // 每个定时器闭包不同 num
  }, 1000);
}, [num]);

结果num 从 0 → 1 → 2,会同时存在 3 个定时器:

  • 定时器1:闭包 num=0,每秒打印 0;
  • 定时器2:闭包 num=1,每秒打印 1;
  • 定时器3:闭包 num=2,每秒打印 2;

这就是内存泄漏:多个闭包占据内存,无法释放。


坑 3:无依赖数组 → 高频闭包重建(性能损耗)

jsx
编辑
// ⚠️ 无依赖数组(慎用)
useEffect(() => { 
  const timer = setInterval(() => {
    console.log(num); // 每次重渲染都创建新闭包
  }, 1000);
  return () => clearInterval(timer);
}); // 无依赖

结果:哪怕 num 没变,只要父组件重渲染 → 当前组件重渲染 →
→ 清旧定时器 → 建新定时器 → 高频闭包重建,造成性能损耗。


四、闭包的“好”与“坏”总结

闭包的作用(好)闭包的坑(坏)
清理函数能记住对应的 timer,精准清除依赖写错 → 闭包旧值,定时器打印错误 num
定时器回调能访问组件内的变量(否则无法打印 num不清理定时器 → 多个闭包堆积,内存泄漏
保证每次渲染的副作用独立(互不干扰)无依赖数组 → 高频闭包重建,性能损耗

五、核心结论:闭包是理解 useEffect + 定时器的钥匙

你这段代码的核心逻辑,从头到尾都是闭包在起作用

  • ✅ 定时器能打印 num → 闭包捕获了组件内的 num
  • ✅ 清理函数能清除对应定时器 → 闭包捕获了当时创建的 timer 实例
  • ✅ 依赖 [num] 能更新定时器值 → 让每次 num 变化都创建新闭包(捕获新 num
  • ❌ 所有“值不更新”“内存泄漏”问题 → 本质是闭包捕获的变量/实例未被正确管理

🔑 一句话总结
理解了这段代码里的闭包,就理解了 useEffect + 定时器 的所有关键逻辑;反之,不理解闭包,就会反复踩坑。


六、实战案例:条件渲染下的组件挂载/卸载

jsx
编辑
// Parent.jsx
import { useState } from "react";
import Demo from "./Demo";

export default function Parent() {
  const [num, setNum] = useState(0);
  return (
    <>
      <button onClick={() => setNum(prev => prev + 1)}>num+1</button>
      <p>当前 num:{num}</p>
      {/* 仅当 num 为偶数时挂载 Demo */}
      {num % 2 === 0 && <Demo />}
    </>
  );
}
jsx
编辑
// Demo.jsx
import { useEffect } from "react";

export default function Demo() {
  useEffect(() => {
    console.log("123123"); // 挂载日志
    const timer = setInterval(() => console.log("timer"), 1000);
    return () => {
      console.log("remove"); // 卸载日志
      clearInterval(timer);
    };
  }, []); // 空依赖

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

执行流程

操作组件状态控制台输出
初始 num=0(偶数)Demo 挂载123123 → 定时器启动
num=1(奇数)Demo 卸载remove → 定时器停止
num=2(偶数)Demo 重新挂载123123 → 定时器启动
num=3(奇数)Demo 卸载remove → 新定时器停止

关键结论

  • 每次“偶数”都会重新挂载 → 创建全新定时器实例(全新闭包)
  • 每次“奇数”都会卸载 → 触发清理函数 → 定时器被清除
  • 不会内存泄漏(前提是写了清理函数)

⚠️ 重要区分
若使用 style={{ display: num%2===0 ? 'block' : 'none' }} 控制显隐(DOM 未卸载):

  • useEffect 不会重新执行
  • 定时器只创建一次,且永远不会被清理(除非组件真正卸载)

七、常见误区与注意事项

误区正确做法
在 useState 中使用 async/await异步逻辑移到 useEffect
忘记清理定时器/订阅总是在 useEffect 中返回清理函数
依赖项遗漏导致 stale closure使用 ESLint 插件 eslint-plugin-react-hooks 自动检查
在条件语句中调用 HooksHooks 必须在顶层调用(React 依赖调用顺序)
用样式控制显隐代替条件渲染若需重置状态/副作用,必须用条件渲染(卸载/挂载)
空依赖中使用状态却不更新若需最新值,必须将状态加入依赖数组

八、总结要点

useState

  • 用于声明响应式状态
  • 初始值可为函数(惰性初始化)
  • setState 可接收新值或更新函数(推荐后者避免闭包问题)

useEffect 三种模式

模式适用场景注意事项
[]一次性初始化(如请求、监听)内部状态会被闭包锁定
[deps]响应特定状态变化必须清理资源,避免残留
无依赖极少使用高频触发,性能敏感

闭包是核心

  • 定时器能访问 num → 闭包
  • 清理函数能清除对应定时器 → 闭包
  • 依赖数组决定是否重建闭包 → 控制值是否更新

核心原则

  • 清理函数是防止内存泄漏的生命线
  • 依赖数组决定执行频率,务必精确
  • 条件渲染 = 卸载/挂载;样式显隐 ≠ 卸载