从零攻克 useState:React 最核心的 Hook,一篇讲透状态更新和批处理

开篇

学 React,第一个真正容易卡住的点,通常不是 JSX,也不是组件,而是 useState

你可能已经会写:

jsx 复制代码
const [count, setCount] = useState(0);

页面也能正常显示。

但当你连续写三次:

jsx 复制代码
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

你期待数字加 3,结果页面只加了 1。

这时候很多人就开始怀疑人生:

setState 到底是不是异步的?

为什么打印出来还是旧值?

为什么传函数就能加 3?

useState(() => xxx) 又是在干什么?

这篇文章就围绕一个极简 React basic 项目,把 useState 的几个核心点讲清楚:

  • useState 到底是什么
  • 为什么初始值可以传函数
  • 状态更新为什么不会立刻反映到当前变量上
  • React 批处理为什么会让连续三次 setCount(count + 1) 只加 1
  • 函数式更新为什么能解决这个问题
  • 受控组件和数据驱动 UI 是怎么串起来的

一、先用一句话理解 useState

useState 是 React 函数组件里保存状态的 Hook。

它做的事可以简单理解为:

给组件一份"会触发重新渲染的数据"。

普通变量变了,React 不知道。

状态变了,React 知道。

所以:

jsx 复制代码
let count = 0;

这种普通变量,改了不会自动刷新页面。

而:

jsx 复制代码
const [count, setCount] = useState(0);

这里的 count 是状态,调用 setCount 后,React 会安排组件重新渲染,页面也会跟着更新。

useState 的返回值是一个数组:

jsx 复制代码
const [state, setState] = useState(initialState);
  • state:当前这次渲染拿到的状态值
  • setState:告诉 React 状态要更新的函数

为什么叫 Hook?

因为它"钩住"了 React 的内部状态系统。你在函数组件里调用 useState,React 就能把这份状态和当前组件关联起来。

二、Fragment:不要把它和 DocumentFragment 完全画等号

项目里有一个 test.html,演示了浏览器原生的 DocumentFragment

html 复制代码
<ul id="list"></ul>

<script>
  const data = ["任务一", "任务二", "任务三"];
  const oList = document.querySelector("#list");
  const fragment = document.createDocumentFragment();

  for (const task of data) {
    const item = document.createElement("li");
    item.innerText = task;
    fragment.appendChild(item);
  }

  oList.appendChild(fragment);
</script>

这段代码的思路很清楚:

先把多个 li 放进 fragment,最后再一次性挂到真实 DOM 上。

这样可以减少频繁操作真实 DOM 的成本。

React 里也有一个类似的概念:Fragment。

jsx 复制代码
<>
  <h1>标题</h1>
  <p>内容</p>
</>

它的作用是:

包裹多个子元素,但不额外生成一层真实 DOM 节点。

不过这里要说严谨一点:

React Fragment 和浏览器原生的 DocumentFragment 不是一回事,不能简单说 React Fragment 的原生实现就是 createDocumentFragment()

更准确的理解是:

  • DocumentFragment 是浏览器提供的原生 DOM API
  • React Fragment 是 React 提供的组件结构能力
  • 它们的共同点是:都可以避免多出一个无意义的包裹节点

所以在文章里,可以把 DocumentFragment 当作帮助理解 Fragment 的类比,但不要把它说成 React Fragment 的底层实现。

三、useState 懒初始化:为什么要传函数?

项目里有一个耗时计算函数:

jsx 复制代码
function heavyComputation() {
  console.log("开始执行 heavyComputation...");

  const startTime = performance.now();
  const result = [];

  for (let i = 0; i < 1000; i++) {
    result.push({ id: i, name: `用户-${i}` });
  }

  const duration = performance.now() - startTime;
  console.log(`heavyComputation 耗时: ${duration}ms`);

  return result;
}

这里用 performance.now() 记录执行耗时。它返回的是毫秒单位的高精度时间戳,适合做前端性能分析。

接下来重点来了。

下面两种写法,看起来只差一个箭头函数:

jsx 复制代码
// 不推荐:每次组件渲染都会先执行 heavyComputation()
const [users] = useState(heavyComputation());

// 推荐:只在组件首次初始化 state 时执行
const [users] = useState(() => heavyComputation());

为什么第一种不推荐?

因为 JavaScript 会先执行函数调用:

jsx 复制代码
heavyComputation()

然后把执行结果传给 useState

也就是说,只要组件函数重新执行,这个耗时函数就会重新跑一遍。

即使 React 后续渲染时不会再使用这个初始值,你这个函数调用也已经发生了。

第二种写法:

jsx 复制代码
useState(() => heavyComputation())

传给 React 的不是计算结果,而是一个初始化函数。

React 会在组件首次初始化 state 时调用它,拿到初始值。后续组件重新渲染时,这个初始化函数不会再执行。

这就叫 useState 的懒初始化。

用大白话说:

text 复制代码
useState(heavyComputation())

是:

每次渲染都先算一遍,只是 React 后面不一定用。

而:

text 复制代码
useState(() => heavyComputation())

是:

先把计算方法交给 React,让 React 只在需要初始化时算一次。

什么时候需要懒初始化?

  • 初始数据生成成本比较高
  • 需要读取 localStorage
  • 需要构造比较大的数组或对象
  • 初始值只需要在组件第一次挂载时确定

如果只是:

jsx 复制代码
useState(0)
useState("")
useState(false)

这种简单初始值,就没必要包一层函数。

四、setState 真的"异步"吗?

很多教程会直接说:

setState 是异步的。

这句话能帮助新手理解,但如果面试时只这么说,其实还不够准确。

更严谨的说法是:

调用 setState 不会立刻修改当前这次渲染里的状态变量;它会告诉 React 安排一次状态更新,React 会在合适的时机批量处理并重新渲染组件。

看这个例子:

jsx 复制代码
const addCount = () => {
  setCount(count + 1);
  console.log(count);
};

假设当前页面显示的是 0

点击按钮后,执行 setCount(count + 1)

很多人以为下一行:

jsx 复制代码
console.log(count);

应该打印 1

但实际打印的还是 0

原因是:

当前函数里的 count,来自当前这次渲染。它不会因为你调用了 setCount 就在原地变成新值。

React 的状态更新不是这样工作的:

text 复制代码
setCount → 立刻改 count 变量

而是这样:

text 复制代码
setCount → React 记录更新 → 重新执行组件函数 → 新一轮渲染拿到新 count

所以你要记住:

state 像一张快照。当前渲染里的 state,不会在当前函数执行过程中被原地改掉。

五、为什么连续 setCount 三次只加 1?

来看最经典的问题:

jsx 复制代码
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

如果当前 count0,你以为这是:

text 复制代码
0 → 1 → 2 → 3

但在当前这次渲染里,count 一直都是 0

所以这三行实际更像是:

jsx 复制代码
setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);

也就是:

jsx 复制代码
setCount(1);
setCount(1);
setCount(1);

React 收到的是三次"把 count 设置成 1"的更新。

最后重新渲染后,结果自然就是 1

这背后还有一个重要机制:批处理。

React 会把同一轮事件里的多个状态更新合并处理,避免每调用一次 setState 就重新渲染一次。

这是性能优化,不是 bug。

如果每次点击里有多个状态更新:

jsx 复制代码
setX(newX);
setY(newY);
setVisible(true);

React 不会傻乎乎地渲染三次,而是尽量合并成一次渲染。

这就是批处理的价值。

六、函数式更新:如何正确实现 +3?

如果你真的想让 count 连续加 3,应该这样写:

jsx 复制代码
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);

这叫函数式更新。

setCount 不只可以接收一个新值,也可以接收一个函数:

jsx 复制代码
setCount((prevState) => nextState);

这个函数的参数 prevState,是 React 在处理更新队列时传进来的最新状态。

执行过程可以理解为:

text 复制代码
初始 count = 0

第一次:prevCount = 0 → 返回 1
第二次:prevCount = 1 → 返回 2
第三次:prevCount = 2 → 返回 3

最终 count = 3

所以两种写法的区别是:

jsx 复制代码
setCount(count + 1);

使用的是当前渲染里的 count 快照。

而:

jsx 复制代码
setCount((prev) => prev + 1);

使用的是 React 更新队列里的最新状态。

记住一个原则:

只要新状态依赖旧状态,就优先使用函数式更新。

比如:

jsx 复制代码
setCount((prev) => prev + 1);
setList((prev) => [...prev, newItem]);
setUser((prev) => ({ ...prev, name: "新名字" }));

这类写法都更稳。

七、把概念串起来:用户列表过滤

回到项目里的业务逻辑:

jsx 复制代码
const [users] = useState(() => heavyComputation());
const [filterText, setFilterText] = useState("");

const filteredUsers = users.filter((user) =>
  user.name.includes(filterText)
);

这几行代码把前面的知识点串起来了。

第一行:

jsx 复制代码
const [users] = useState(() => heavyComputation());

用懒初始化生成用户列表。

因为 users 初始值是一个较大的数组,所以用函数包起来,让它只在初始化时执行一次。

第二行:

jsx 复制代码
const [filterText, setFilterText] = useState("");

保存输入框的搜索文本。

第三段:

jsx 复制代码
const filteredUsers = users.filter((user) =>
  user.name.includes(filterText)
);

这里没有再用 useState 保存 filteredUsers

为什么?

因为它可以从已有状态推导出来:

text 复制代码
filteredUsers = users + filterText 计算出来的结果

这种数据通常不需要单独存 state。

否则你还要维护两份状态的一致性,反而更容易出 bug。

JSX 部分:

jsx 复制代码
<input
  type="text"
  placeholder="输入用户名过滤"
  value={filterText}
  onChange={(e) => setFilterText(e.target.value)}
/>

<p>当前显示 {filteredUsers.length} 个用户</p>

<ul>
  {filteredUsers.map((user) => (
    <li key={user.id}>{user.name}</li>
  ))}
</ul>

这里是典型的受控组件:

  • value 由 React 状态 filterText 控制
  • 用户输入时触发 onChange
  • onChange 调用 setFilterText
  • 状态变化后组件重新渲染
  • filteredUsers 重新计算
  • 页面自动更新

完整数据流是:

text 复制代码
用户输入
  ↓
onChange 触发
  ↓
setFilterText 更新状态
  ↓
React 重新执行组件函数
  ↓
filteredUsers 重新计算
  ↓
列表和数量自动更新

整个过程没有手动操作 DOM。

你只是在描述:

数据是什么,界面应该长什么样。

数据变了,React 负责更新界面。

这就是 React 的核心思维。

八、常见误区

误区一:setState 会立刻修改变量

不会。

当前渲染里的 state 是快照。

调用 setState 只是安排下一次渲染,不会把当前函数作用域里的变量原地改掉。

误区二:useState 传函数等于把函数作为状态值

不一定。

如果你写:

jsx 复制代码
useState(() => heavyComputation())

React 会把这个函数当作初始化函数,并调用它得到初始 state。

如果你真的想把函数本身作为 state,需要再包一层:

jsx 复制代码
const [fn] = useState(() => {
  return () => console.log("hello");
});

误区三:所有计算结果都要放进 state

不需要。

filteredUsers 这种能从 usersfilterText 推导出来的数据,直接计算就行。

不要为了"看起来更 React"而滥用 state。

误区四:Fragment 就是 DocumentFragment

不准确。

它们都能避免多余包裹节点,但一个是 React 的语法能力,一个是浏览器 DOM API。

可以类比理解,但不要直接画等号。

九、面试怎么答?

如果面试官问:

useState 是什么?

可以这样答:

useState 是 React 函数组件中保存状态的 Hook。它返回一个状态值和一个更新函数。调用更新函数后,React 会安排组件重新渲染,并在下一次渲染中返回新的状态值。

如果面试官问:

为什么连续三次 setCount(count + 1) 只加 1?

可以这样答:

因为当前渲染里的 count 是一个快照,三次 setCount(count + 1) 使用的都是同一个旧值。假设旧值是 0,那么三次传入的都是 1。React 会批量处理这些更新,最终结果就是 1。如果要基于上一次更新结果继续计算,应该使用函数式更新:setCount(prev => prev + 1)

如果面试官问:

useState(() => initialValue) 有什么用?

可以这样答:

这是懒初始化写法。传入的函数只会在组件初始化 state 时执行一次,适合初始值计算成本较高的场景。它可以避免组件每次重新渲染时都重复执行昂贵计算。

总结

知识点 结论
useState 给函数组件保存会触发重新渲染的状态
返回值 [state, setState]
state 特点 当前渲染里的 state 是快照
setState 不会立刻修改当前变量,而是安排更新
批处理 多次状态更新会尽量合并,减少渲染次数
函数式更新 新状态依赖旧状态时使用,更稳定
懒初始化 useState(() => expensive()) 只在初始化时执行
Fragment 不额外生成 DOM 节点,但不等同于 DocumentFragment

useState 看起来只是一个 API,但它背后其实包含了 React 最核心的思维:

状态是一张快照,更新是一次调度,渲染是状态变化后的结果。

理解了这句话,再看批处理、函数式更新、受控组件、数据驱动 UI,就不会觉得它们是零散知识点了。

它们讲的都是同一件事:

不要直接改界面,先改状态;状态变了,界面自然会跟着变。

相关推荐
狂师3 小时前
从 LLM 评测到 AI Agent 评测,我的一些思考!
人工智能·程序员·aigc
AI大模型-小雄17 小时前
Codex 长任务总要重新开始?买 Credits 还是升级 ChatGPT Pro
人工智能·chatgpt·程序员·ai编程·codex·ai办公·chatgpt pro
Java爱好狂.19 小时前
Java就业需要学习哪些内容?
spring·程序员·springboot·架构师·java面试·java面试题·java八股文
阿里嘎多学长1 天前
2026-07-22 GitHub 热点项目精选
开发语言·程序员·github·代码托管
程序员cxuan2 天前
Grok Build 被众人唾骂,结果老马把它开源了
人工智能·后端·程序员
爱勇宝2 天前
《道德经》第 7 章:真正厉害的领导者,不抢主角
前端·后端·程序员
Hilaku2 天前
2026 年前端大洗牌:React 服务端到 Vite 的底层重构
前端·javascript·程序员
程序员cxuan2 天前
离谱,GPT-5.6 竟然在后台执行了 rm -rf
人工智能·后端·程序员
林小卫很行2 天前
WorkBuddy 入门:为什么推荐腾讯出的这个 AI 助手
人工智能·程序员