首页 / React 学习笔记 / 04

REACT · Vol.I · LESSON 04 · 核心原语

State 与 useState:数据驱动的视图更新

入门核心#核心#Hooks

state:组件自己的「实例字段」

上一篇说 Props 是「从外部传入、不可变的参数」。那组件内部的数据呢?答案是 state(状态)

用 Java 类比最直观:

ReactJava 类比说明
props构造器/方法参数外部传入,只读
state实例字段(field)内部持有,可变化
setState字段的 setter改字段,并触发界面更新

看一个具体例子——一个「点赞按钮」:

LikeButton.tsx
import { useState } from "react";

function LikeButton() {
  // 声明一个 state:初始值为 0
  const [likes, setLikes] = useState(0);

  return (
    <button onClick={() => setLikes(likes + 1)}>
      点赞 {likes}
    </button>
  );
}

useState(0) 返回一个长度为 2 的数组,我们用解构语法拆成两个:

  • likes —— 当前状态值(初始 0);
  • setLikes —— 更新状态的函数,调用它 React 就会重新渲染组件。

这里 useState 就是 React 的 Hook(钩子)之一。Hook 是一类特殊的函数,只能在组件或其它 Hook 里调用,它们让函数组件「记住」数据。

等一下:函数哪来的「记忆」?

你可能会疑惑:函数每次执行完局部变量就销毁了,likes 怎么记得住?答案是——数据根本不存放在你的函数里,而是存在 React 内部。React 为每个组件实例维护一份「状态档案」,useState 读的是档案里的值,setLikes 改的也是档案,然后 React 重新调用你的函数,让你用新值再渲染一遍。你的组件函数只是「执行一遍 = 拍一张快照」,真正持有状态的是 React。

组件需要一个以上 state?多调几次就行

多个 state 并列
function RegisterForm() {
  const [name, setName] = useState("");
  const [age, setAge] = useState(0);
  const [agreed, setAgreed] = useState(false);
  // ……每个 useState 都是独立的「字段」
}

每个 useState 声明一个独立字段,就像类里并排写三个字段声明。命名约定:[xxx, setXxx]——setter 永远是 set 加字段名的驼峰形式。

关键:改 state 的「正确姿势」——不可变更新

这是后端转 React 最容易踩的坑。看这段经典错误:

❌ 错误:直接修改(mutate)
function List() {
  const [items, setItems] = useState([1, 2, 3]);

  function addWrong() {
    items.push(4);        // ❌ 直接改了原数组
    setItems(items);      // ❌ 传入的还是同一个引用
  }
}

为什么不行?因为 React 判断「状态有没有变」用的是 引用相等Object.is)。你 push 后数组引用没变,React 会认为「没变」,于是跳过重渲染——界面纹丝不动。

正确做法是不可变更新:生成一个的数组/对象,再传进去:

✅ 正确:不可变更新
function addRight() {
  setItems([...items, 4]);        // 展开旧数组 + 新元素 = 新数组
}

function updateObj() {
  setUser({ ...user, age: 27 });  // 展开旧对象 + 覆盖字段 = 新对象
}

「修改」和「不可变替换」在 JS 里是两套不同的方法族,把这张对照表存下来,日常开发照着抄:

意图❌ 原地修改(mutate)✅ 不可变(返回新引用)
数组追加arr.push(x)[...arr, x]
数组删除arr.splice(i, 1)arr.filter((_, idx) => idx !== i)
按条件删循环 + splicearr.filter(t => t.id !== id)
数组逐项变换for 循环改元素arr.map(t => ({ ...t, done: true }))
数组排序arr.sort()(原地!)[...arr].sort(...)
对象改字段obj.age = 27{ ...obj, age: 27 }

规律就一句话:所有返回 void、悄悄改原数据的方法都不能直接用在 state 上;换成返回新数据的那一个。对应 Java 世界:就像 Stringconcat 返回新串,而不是 StringBuilder.append 改自己。

「不可变」听着反直觉,但换个角度想:Java 里 String 为什么设计成不可变?——因为不可变让多线程共享、缓存、比较都变得安全。React 的不可变 state 带来同样的好处:可预测、易追踪、好优化(能配合 memo 做浅比较)。不可变不是「仪式感」,是实打实的工程决策。

进阶点:批处理与「函数式更新」

两个高频进阶问题,面试常考,工作中也真的会碰到。

问题 1:同一个函数里多次 setState,会渲染几次?

批处理示例
function App() {
  const [count, setCount] = useState(0);

  function handle() {
    setCount(count + 1);   // count 还是 0
    setCount(count + 1);   // count 还是 0!
  }
  // 结果:count 只变成 1,不是 2。两次 set 被「批处理」合并了
}

React 会把同一事件里的多次 setState 合并成一次重渲染,并且每次读取的 count 都是「本次渲染时」的旧值(这叫闭包快照)。这和数据库的「批量提交」一个道理:攒一批一起刷,比逐条刷高效得多。

问题 2:想基于「最新值」连续累加,怎么办?

函数式更新
function handle() {
  setCount(c => c + 1);   // 用「更新函数」接收上一次的值
  setCount(c => c + 1);   // 基于最新值再 +1
}
// 结果:count 变成 2 ✅

当你需要基于上一次 state 计算新值时,把「值」换成「函数」——setCount(c => c + 1)。React 会按顺序把更新函数依次应用。

后端类比:值覆盖 vs CAS 更新

setCount(count + 1) 像「读出来、加一、写回去」的非原子操作——两个并发请求都读到旧值,就丢了一次更新;setCount(c => c + 1) 则像数据库的 UPDATE t SET n = n + 1AtomicInteger.incrementAndGet()——基于最新值原地递增,天然无竞态。只要新值依赖旧值,就用函数式,这条规则可以无脑执行。

核心要点
  • state 是组件的「实例字段」,setState 是触发重渲染的「setter」。
  • 不可变更新:永远生成新对象/新数组,再传给 setState;原地 mutate 的方法一律不用。
  • 基于旧值更新,用函数式 setState(c => c + 1),避免闭包陷阱。

每次渲染都是一张「快照」

批处理那节提到「闭包快照」,这里把它升格为一个正式的心智模型,它能解释日后困扰你的大半「灵异现象」:

快照模型

组件函数的每次执行,都在一份冻结的 state 快照上进行。这次渲染里读到的所有 state/props,都是渲染开始那一刻的值;事件处理器、setTimeout 回调里读到的,也是「它被创建的那次渲染」的快照,而不是「点击发生时」的最新值。

用一道经典面试题检验一下:

点一次按钮,3 秒后弹窗显示几?
function App() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setTimeout(() => {
      alert(count);      // 显示 0,不是 1!
    }, 3000);
  }

  return <button onClick={handleClick}>点我 ({count})</button>;
}

答案:显示 0。因为 setTimeout 里的 count 捕获的是「这次渲染」的快照值 0;setCount 引发的是下一次渲染,和这个已经创建好的回调互不相干。想在 3 秒后拿到最新值?要么用函数式更新的思路,要么等第 12 篇的 useRef——它就是为「跨渲染共享一个可变值」准备的。

初学觉得这违反直觉,但换个角度看:快照让每次渲染都成为一次纯函数调用——输入确定,输出确定,可以放心重放、可以安全丢弃。这正是 React 并发特性(第 50 篇会谈)的地基。后端的你会立刻认出它:这就是「不可变快照 + 写时复制」,和 CopyOnWriteArrayList 是同一种思想。

最后一问:哪些数据配得上 state?

会用 useState 之后,最容易犯的错是「什么都往 state 里塞」。判断标准只有一条——它是「源」,还是「流」?源数据进 state,由源数据推导出来的一律不进:

派生值:算出来,别存
function OrderSummary({ order, user }) {
  // ❌ 冗余:总价可以从明细推出来,存了就多了一个「不同步」的机会
  const [total, setTotal] = useState(0);

  // ✅ 每次渲染直接算:
  const total = order.items.reduce((sum, it) => sum + it.price, 0);
  const paidCount = order.items.filter(it => it.paid).length;
  const fullName = `${user.firstName} ${user.lastName}`;

  return <p>共 {total} 元(已付 {paidCount} 项)</p>;
}

判断流程也简单,对着每个候选数据问三个问题:

  1. 它是从别的 state / props 算出来的吗?——是,就地计算,不进 state。
  2. 它在整个渲染周期内会变吗?——不会(如常量、初始配置),用普通 const 即可。
  3. 它需要跨渲染「记住」吗?——需要,且不直接参与渲染(如定时器 id),那是 useRef 的活儿(第 12 篇)。

三问都过了,才轮到 useStatestate 越少,组件越好维护——这和 Java 里「字段尽量少、能局部变量就别用实例字段」是同一个道理:可变状态是所有复杂度的源头,你在后端为减少可变状态做过的所有斗争(无状态服务、不可变对象),在前端原样适用。

State 与 Props 如何配合

本章回顾

到这一篇,React 数据模型的两块积木就齐了:Props(外部只读参数)+ State(内部可变字段)。记住一条经验法则:

「数据能推出来,就不要放进 state」——state 越少,组件越好维护。

以及本篇的三个关键动作:不可变更新、新值依赖旧值时用函数式更新、把每次渲染当成一张冻结的快照。

下一篇文章我们退后一步,看看 React 到底是怎么在 state 变化时,高效地把新界面「画」出来的——虚拟 DOM 与 diff。理解了它,「为什么必须不可变」「为什么 key 那么重要」这些规矩背后的原因就全通了。

Comments · 评论