React Hooks 核心精讲:从 useState 到 useEffect,彻底搞懂响应式与副作用

68 阅读12分钟

React Hooks 核心精讲:从 useState 到 useEffect,彻底搞懂响应式与副作用

大家好,今天我们来聊一聊React Hooks,这篇文章基于我实际调试过的代码(包括各种坑),结合官方机制和常见误区,带你从底层逻辑彻底吃透 React Hooks 的两大核心:useStateuseEffect。力求让你看完后不仅会用,还知道为什么这么用。

f7afe1f6c5914b92044e39cfb1e0cf81.jpg

一、useState:组件的“心脏” —— 响应式状态

1. 状态是组件的核心

React 组件本质上是一个函数,输入是 props 和 state,输出是 JSX。

没有状态的组件就像静态页面,点击无反应。而 useState 就是给函数组件注入“心脏”的钩子,让它拥有了响应式能力。

const [num, setNum] = useState(0);
  • num 是当前状态值
  • setNum 是更新状态的唯一合法方式
  • 一旦调用 setNum,React 会重新执行组件函数,生成新的 UI

2. 初始化:为什么推荐用函数形式?

大多数时候我们直接传初始值:

useState(0)

但当初始值需要复杂计算时,官方强烈推荐传一个函数:

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

为什么?

因为 React 在某些情况下(尤其是开发模式的 StrictMode)可能会多次调用初始化逻辑。如果你的初始化有副作用(如 console.log、fetch 请求),就会被执行多次,导致 bug。

而传入函数形式,React 只会在真正需要时调用一次,并且这个函数必须是纯函数

3. 什么是纯函数?(超级重要!)

纯函数是函数式编程的核心概念,在 React 中被反复强调。

定义:相同输入永远返回相同输出,并且没有可观察的副作用。

用大白话讲:

“你给我什么原料,我就老老实实加工成什么产品。只干这一件事,不打印日志、不发请求、不改全局变量、不看心情。”

纯函数示例
const add = (a, b) => a + b;
// add(2, 3) 永远是 5
非纯函数示例

打印日志


const logAdd = (a, b) => {
  console.log(a, b); // 副作用
  return a + b;
};

依赖外部变量

JavaScript

let tax = 0.1;
function calcPrice(price) {
  return price * (1 + tax);  // 依赖外部的 tax
}

如果别人改了 tax,同样的 price 输入会得到不同输出 → 不纯

经典错误案例
const nums = [1, 2];
const add = function(arr) {
  arr.push(3);        // 修改了外部数组!副作用!
  return arr.reduce((a, b) => a + b);
};
add(nums);
console.log(nums.length); // 3,而不是预期的 2
纯函数的核心本质

“你给我什么输入,我就老老实实根据这个输入给你计算出输出。只做这一件事,不多管闲事,也不影响外面任何东西。”

拆开来说:

  1. 只有一个很纯粹的功能 对!它只负责一件事,比如加法、字符串拼接、数据转换等。功能单一、明确。

  2. 你输入什么,他就执行什么(相同输入 → 相同输出) 绝对确定性: 今天调用 add(2, 3) 是 5,明天调用还是 5,一万年后再调用还是 5。 不管外面天气怎么样、全局变量改没改、时间是几点,它都永远给你同一个结果。

  3. 没有附加功能 / 不影响别的 这点最关键,也就是“无副作用”:

    • 不会偷偷打印 console.log
    • 不会修改外部变量
    • 不会发网络请求
    • 不会改 DOM
    • 不会依赖随机数或当前时间 它就像一个“与世隔绝”的计算机器,进来原料,出去产品,不污染环境,也不受环境影响。

React 为什么在意纯函数?

  • 组件的渲染函数(function App() { ... })最好是纯函数:相同 props/state → 相同 JSX
  • useState 的初始化函数最好是纯的:React 可能调用多次,但结果始终一样
  • setNum(prev => prev + 1) 这种更新函数最好纯:只根据 prev 计算新值,不偷偷干别的

这样 React 才能放心地优化、缓存、预测组件行为,不会出奇怪的 bug。

4. setNum 的两种用法:函数式更新(推荐!)

// 普通方式
setNum(num + 1);

// 函数式(推荐,尤其是连续更新)
setNum(prev => prev + 1);

为什么推荐函数式?

因为 React 会把多次 setState 合并(batching)。如果你连续调用三次普通方式:

setNum(num + 1);
setNum(num + 1);
setNum(num + 1);

实际只会 +1(因为都基于旧的 num)。

而函数式每次都拿最新值:

setNum(prev => prev + 1);
setNum(prev => prev + 1);
setNum(prev => prev + 1);
// 真正 +3

注意:即使你写了 console.log(prevNum),这也算轻微副作用,生产环境建议去掉。

二、useEffect:处理“副作用”

1. 什么是副作用?

副作用就是“除了返回 JSX 之外的其他操作”。

纯函数的对立面就是副作用。

常见副作用:

  • 发请求
  • 手动操作 DOM
  • 设置定时器
  • 添加事件监听
  • 订阅事件
useEffect(() => {
  // 这里就是副作用区域
}, []);

2. useEffect 的三种执行时机

依赖数组决定了 useEffect 什么时候执行:

情况1:[] —— 只在挂载时执行(onMounted)
useEffect(() => {
  queryData().then(data => setNum(data));
}, []);

最常见:发请求获取初始数据。

情况2:[dep1, dep2] —— 挂载 + 依赖变化时执行
useEffect(() => {
  console.log('num 变了');
}, [num]);
情况3:不传依赖 —— 每次渲染都执行(慎用!)
useEffect(() => {
  console.log('每次渲染都执行');
});

3. return 清理函数:防止内存泄漏的救命稻草

这是最容易被忽略但最关键的点!

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

  return () => {
    console.log('remove');
    clearInterval(timer);
  };
}, []);

执行顺序

  • 组件挂载 → 执行 effect → 设置定时器
  • 组件卸载 → 先执行 return 清理函数 → 清除定时器
  • 如果依赖变化 → 先执行上一次的清理 → 再执行新的 effect

不写清理会怎样?

定时器、事件监听、订阅会一直存在,导致:

  • 内存泄漏
  • 控制台疯狂打印
  • 甚至访问已卸载组件的 state(报错)

金句:凡是在 useEffect 里启动的“长期运行操作”,必须在 return 里停止!

4. 经典坑:条件渲染导致组件反复挂载/卸载

{num % 2 === 0 && <Demo />}

点击改变 num,Demo 组件会:

  • 偶数 → 挂载(执行 effect)
  • 奇数 → 卸载(执行清理)
  • 再偶数 → 重新挂载

控制台会看到: 123123 → timer → remove → 123123 → timer...

很多人以为是 useEffect([]) 执行多次,其实是组件本身反复 mount/unmount。

解决方案

  • 用 display: none 隐藏而不是条件渲染
  • 或把定时器逻辑移到父组件

三、真实案例剖析:一步步看执行顺序

假设代码如下(无 StrictMode):

function App() {
  const [num, setNum] = useState(0);
  console.log('yyy');

  useEffect(() => {
    console.log('xxx');
    queryData().then(data => setNum(data)); // 2秒后返回666
  }, []);

  useEffect(() => {
    console.log(num, 'zzz');
  }, [1,2,3,new Date()]); // 坑:每次都变化!

  useEffect(() => {
    console.log('ddd');
  });

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

执行顺序

  1. 初次渲染:

    • yyy
    • xxx(发起请求)
    • 0 zzz(依赖变化)
    • ddd
  2. 2秒后 setNum(666) → 重新渲染:

    • yyy(第2次)
    • 666 zzz(依赖又变化)
    • ddd(第2次)

最终控制台:

  • yyy ×2
  • xxx ×1(因为依赖 [])
  • zzz ×2
  • ddd ×2

常见误区:有人把请求写成依赖 [num],会导致 num 变后又发一次请求(多余!)

四、开发模式下的“诡异”重复执行

很多同学会发现:开发时日志打印了两次甚至三次!

原因:React 18+ 默认开启 StrictMode

在开发环境,React 会故意:

  • mount → unmount → remount 一次
  • 帮助你发现副作用不纯的问题

解决方案

  • 接受它(它只在开发环境)
  • 或临时注释掉 main.jsx 的 <StrictMode>

生产环境自动消失。

五、几个细节知识点

fc962ce0cd306c49bc54248e80437e81.jpg

1、一个经典的“定时器地狱”(timer hell)

看这个 useEffect:

jsx

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

}, [num])

当你点击num几次后,你会看到控制台开始疯狂快速打印数字(0、1、2、3、0、1、2、3...),而且速度越来越快!

发生了什么?

  1. 初始 num = 0,组件挂载 → useEffect 执行一次 → 打印 'effct' → 创建一个定时器,每秒打印一次当前 num(现在是 0)

  2. 你点击 div → setNum(1) → 组件重新渲染 → num 变成 1 → 因为依赖是 [num],num 变了 → useEffect 重新执行 → 又打印 'effct' → 又创建一个新的定时器,每秒打印当前 num → 但上一个定时器没有被清除! (因为你注释了 return cleanup)

  3. 现在你有两个定时器在同时运行:

    • 第一个还在每秒打印旧的 num
    • 第二个每秒打印新的 num
  4. 再点击 → num = 2 → useEffect 又执行 → 创建第三个定时器…… 以此类推,每次点击 num 增加,useEffect 就再跑一次,再加一个没清理的定时器。

  5. 定时器越来越多(点击 n 次就有 n+1 个定时器),它们都在疯狂地每秒执行 console.log,所以你看到数字刷屏得越来越快!

所以问题是:每次状态更新都创建一个新定时器,但从不清理旧的,导致定时器数量指数级增长,日志刷屏。

正确修复方式

有两种方案,任选其一:

方案1:修复 cleanup(推荐,最标准)

jsx

useEffect(() => {
    console.log('effct');

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

    // 一定要加返回清理函数!
    return () => {
        console.log('remove timer');
        clearInterval(timer);
    };
}, [num]); // 依赖 num 是可以的,只要有 cleanup 就不会泄漏

这样每次 num 变化时:

  • 先执行上一次的 cleanup(清除旧定时器)
  • 再创建新定时器 → 永远只会有一个定时器在运行,不会爆炸。
方案2:如果不想让 num 变化时重启定时器,把依赖改成空数组

jsx

useEffect(() => {
    console.log('effct');

    const timer = setInterval(() => {
        // 注意:这里拿到的 num 是挂载时的值,不会更新!
        // 如果想打印最新值,需要用 useRef 存最新 num
        console.log(num);
    }, 1000);

    return () => {
        clearInterval(timer);
    };
}, []); // 只在挂载时执行一次

2、空更新(保活渲染)

来看这张图,这是延续了上面的“执行顺序相关知识点” image.png

function App() {
  const [num, setNum] = useState(0);
  console.log('yyy');

  useEffect(() => {
    console.log('xxx');
    queryData().then(data => setNum(data)); // 2秒后返回666
  }, []);

  useEffect(() => {
    console.log(num, 'zzz');
  }, [1,2,3,new Date()]); // 坑:每次都变化!

  useEffect(() => {
    console.log('ddd');
  });

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

如果把依赖[] 改成了依赖[num],就会发生图片中的情况————'yyy'多打印了一次,这就显得有点诡异了,而且我们已经明确把严格模式给注释掉了,那这是怎么回事呢?

我们来按照代码走一遍执行流程:

严格的执行顺序和打印次数
  1. 初次渲染(num = 0)

    • yyy ×1
    • xxx ×1(effect 执行)
    • 0 zzz ×1
    • ddd ×1
    • 启动第1个 queryData(2秒后 resolve 666)
  2. 约2秒后,第1个 queryData 完成 → setNum(666)

    • num 0 → 666(值真的变了)→ 触发第2次渲染
    • yyy ×1(第2次)
    • xxx ×1(第2次!因为 num 变了,effect 重新执行)
    • 666 zzz ×1(第2次)
    • ddd ×1(第2次)
    • 启动第2个 queryData(又一个2秒延迟)
  3. 又约2秒后,第2个 queryData 完成 → setNum(666)

    • 当前 num 已经是 666

    • setNum(666) 设置完全相同的值(666 === 666)

    • React 优化:如果 useState 的新值与当前值严格相等(===),直接跳过更新

      • 不调度渲染
      • 不执行组件函数体
      • 不检查任何 useEffect 的依赖
      • 不执行任何 effect 回调
    • 没有第3次 yyy、xxx、zzz、ddd

完了,更晕了,明明按道理来说只打印两次'yyy'啊

那你为什么看到 yyy 打印了 3 次?

只有一种常见情况会导致 只有 yyy 多打印一次,而其他 effect 日志没多

第2个 queryData 的 setNum(666) 虽然值相同,但由于某些原因,React 还是调度了一次“空更新”渲染(但不运行 effect)

这种情况在以下场景会出现:

  • 你在项目中还有其他状态更新(比如父组件 props 变、Context 变、另一个 setState)
  • Vite 热刷新正好在第2个 setNum 时触发了一次保活渲染
  • React 18 的 automatic batching 在异步链中偶尔没完全 bail out
  • 浏览器 DevTools 开启了 “Paint flashing” 或其他调试选项干扰渲染计数

最常见的真实原因:Vite 的 Fast Refresh 在你等待的这几秒内,检测到文件没变但保活状态,导致额外触发一次轻量渲染

这种渲染的特点是:

  • 组件函数体执行了(yyy 打印)
  • 但因为 state 没实际变化,React 跳过所有 useEffect 依赖检查和执行
  • 所以你只会看到额外一次 'yyy',而 'xxx'、'zzz'、'ddd' 依然只有 2 次

我们再在函数体添加一行打印日志来验证

console.log('多执行了吗?');

image.png

最终结论

  • 正常情况下,依赖 [num] 时所有日志都是 2 次(因为第二次 setNum 同值被 React 完全跳过)

  • 你看到 只有 yyy 是 3 次,其他仍是 2 次 → 是 Vite 热更新或异步批处理导致的一次“空渲染”

    • 函数体执行了(yyy +3)
    • 但 React 认为 state 没变,跳过 effect 阶段(xxx、zzz、ddd 不执行第3次)

这正是 React 优化的结果:即使有 setState 调用,只要值没变,就尽量少做事儿

所以不是 effect “不执行”,而是被 React 聪明地阻止了不必要的执行。

六、总结:React Hooks 使用心法

  1. useState

    • 复杂初始化用函数,且保持纯函数
    • 更新优先用函数式(prev => ...)
    • 状态是组件的核心,改变状态 = 重新渲染
  2. useEffect

    • 数据请求依赖写 []
    • 定时器/订阅必须写清理函数
    • 依赖数组要精确,别放 new Date()、{} 等不稳定值
    • 条件渲染会触发子组件反复挂载
  3. 纯函数思维

    • 组件函数、初始化函数、更新函数尽量保持纯
    • 副作用都放 useEffect 里处理

掌握了这些,你就真正理解了 React 函数组件的底层逻辑:状态驱动渲染,副作用隔离处理

写 React 代码时,多问自己两个问题:

  • 这个操作是纯计算吗?
  • 这个副作用有没有清理?

坚持这样做,你的代码会越来越稳定、可预测、可维护。

最后,附上一个调试时的金句自勉:

“状态纯洁,副作用隔离,清理不忘,渲染无忧。”