WinddSnow

React-State-and-useState-Batching-Immutability-Functional-Updates

字数统计: 3.8k阅读时长: 15 min
2026/07/31

第89课:State 与 useState——状态更新、批处理、不可变性、函数式更新

State(状态)是 React 组件内部可变的私有数据。与只读的 Props 不同,State 属于组件自身,可以随时间变化,驱动 UI 的重新渲染。在函数组件中,useState 是管理 State 的入口 Hook——它返回一个状态变量和一个更新函数,让你在保持 React 的渲染规则(不可变性、异步批处理)的同时,以声明式的方式更新 UI。理解 State 的不可变性异步批处理机制以及函数式更新的正确使用场景,是写出无 Bug、高性能 React 组件的关键。


1. useState 的基本用法

1.1 声明 State 变量

useState 是一个 Hook(钩子函数),在函数组件的顶层调用。它接收一个参数——State 的初始值,返回一个包含两个元素的数组:当前状态值更新该状态的函数

1
2
3
4
5
6
7
8
9
10
11
12
import { useState } from 'react';

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

return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

useState(0) 声明了一个初始值为 0 的 State 变量 countsetCount 是更新函数,调用它并传入新值后,React 会重新渲染组件,count 将变为传入的新值。

1.2 State 与普通变量的区别

在组件内部声明的普通变量(如 let total = 0不会触发 React 重新渲染。每次组件重新渲染时,普通变量都会被重新赋值为初始值,之前的修改全部丢失。State 则不同——React 会在多次渲染之间“记住”State 的值,更新 State 会调度一次新的渲染。

1
2
3
4
5
6
7
8
function BrokenCounter() {
let count = 0; // 每次渲染都重置为 0
return (
<button onClick={() => { count++; console.log(count); }}>
Count: {count} {/* 界面上永远是 0 */}
</button>
);
}

1.3 State 的初始值

初始值可以是任何 JavaScript 类型——数字、字符串、布尔值、对象、数组,甚至是通过函数计算出的值(惰性初始化,见第 5 节)。

1
2
3
4
const [name, setName] = useState('');
const [items, setItems] = useState([]);
const [user, setUser] = useState({ id: null, name: '' });
const [isLoading, setIsLoading] = useState(true);

命名约定:社区约定 State 更新函数的命名遵循 set + State 名(camelCase),如 setNamesetItems。这既是约定也是提高可读性的方式。


2. State 的不可变性

2.1 为什么 State 必须被视为不可变?

在 React 中,你绝不能直接修改 State 变量,而必须通过更新函数(如 setState)提供一个新的值。对于对象和数组类型的 State,需要创建新的副本来替换旧值,而非修改原对象。

1
2
3
4
5
6
7
// ❌ 错误:直接修改 State
const [user, setUser] = useState({ name: 'Alice', age: 25 });
user.name = 'Bob'; // 直接修改了原对象
setUser(user); // React 可能不会重新渲染

// ✅ 正确:创建新对象替换
setUser({ ...user, name: 'Bob' });

原理:React 使用 Object.is 进行浅比较来判断 State 是否发生变化。如果直接修改对象内部的属性,对象的引用不变,React 认为 State 没有变化,会跳过重新渲染。创建新对象则确保引用改变,React 能检测到变化并更新 UI。

2.2 数组的不可变更新

修改数组类型的 State 时,需要使用返回新数组的方法,如 mapfilterconcat、扩展运算符,而不能使用 pushpopsplice 等会修改原数组的方法。

1
2
3
4
5
6
7
8
9
10
const [items, setItems] = useState([1, 2, 3]);

// ❌ 错误:直接修改原数组
items.push(4);
setItems(items);

// ✅ 正确:创建新数组
setItems([...items, 4]); // 末尾追加
setItems(items.filter(i => i !== 2)); // 删除元素
setItems(items.map(i => i * 2)); // 更新元素

2.3 嵌套对象的不可变更新

对于深层嵌套的 State,需要逐层拷贝所有被修改属性的祖先对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
const [config, setConfig] = useState({
theme: { color: 'blue', fontSize: 14 },
layout: { sidebar: true }
});

// 更新 theme.color
setConfig({
...config,
theme: {
...config.theme,
color: 'red'
}
});

手动处理深层不可变更新较为繁琐。社区流行的解决方案包括:

  • Immer:通过 produce 函数以“可变”语法编写代码,自动生成不可变更新。
  • Immutable.js:提供不可变数据结构,但学习成本较高。

3. State 的异步批处理(Batching)

3.1 什么是批处理?

React 会将同一事件处理函数中的多次 setState 调用合并为一次更新,仅触发一次重新渲染。这被称为批处理(Batching),是 React 优化性能的关键机制。

1
2
3
4
5
6
7
8
9
10
11
12
function Counter() {
const [count, setCount] = useState(0);

function handleClick() {
setCount(count + 1); // 这里 count 是 0
setCount(count + 1); // 这里 count 仍然是 0(批处理期间不会变化)
setCount(count + 1); // 这里 count 仍然是 0
// 最终 count 变为 1,而非 3
}

return <button onClick={handleClick}>Count: {count}</button>;
}

原因:在当前渲染中,count 是一个常量(它被闭包捕获)。三次 setCount(count + 1) 都等价于 setCount(0 + 1),React 将它们合并为一次更新,最终 count 变为 1。这是初学者最常遇到的陷阱之一。

3.2 批处理的范围

在 React 18 之前,批处理仅在 React 事件处理函数中生效,setTimeoutPromise 等异步回调中的更新不会被批处理。React 18 引入了自动批处理(Automatic Batching),将批处理扩展到所有更新——包括 setTimeout、原生事件和异步操作。这进一步减少了不必要的渲染次数。

3.3 函数式更新:解决依赖旧值的问题

当新的 State 依赖于上一次渲染的 State 值时,不能直接使用 State 变量(因为它是旧值),而应使用函数式更新——向更新函数传递一个回调函数,该回调接收上一次的 State 值,返回新的 State。

1
2
3
4
5
6
function handleClick() {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
// 最终 count 会增加 3
}

React 会将所有函数式更新加入队列,依次执行:0 → 1 → 2 → 3。无论批处理如何合并调用,函数式更新始终基于最新的中间值进行计算。

何时使用函数式更新

  • 新 State 依赖于旧 State(如计数器递增、数组追加元素)。
  • 在短时间内连续调用 setState 时。
  • useCallbackuseEffect 中引用 State 而希望避免将其加入依赖数组时(使用函数式更新可让回调不依赖外部 State 变量)。

何时使用直接值更新

  • 新 State 不依赖旧 State(如 setName('Bob'))。
  • 直接替换整个对象或数组。

4. State 的惰性初始化

4.1 避免每次渲染都计算初始值

useState 的初始值仅在组件首次渲染时使用。但如果初始值的计算开销很大(例如读取 localStorage、进行复杂计算),在每次渲染时都传入该表达式仍然会导致不必要的计算(即使 React 会忽略后续渲染的初始值,但表达式本身仍会被求值)。

1
2
3
4
5
// ❌ 每次渲染都调用 expensiveComputation(浪费)
const [value, setValue] = useState(expensiveComputation());

// ✅ 惰性初始化:传入函数,仅在首次渲染时调用
const [value, setValue] = useState(() => expensiveComputation());

惰性初始化:将初始值作为一个函数传递(useState(() => initialValue)),React 仅在首次渲染时调用该函数获取初始值,后续渲染中完全忽略。

4.2 典型场景:从 localStorage 读取状态

1
2
3
4
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem('theme');
return saved !== null ? saved : 'light';
});

5. 综合实战:购物车组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function ShoppingCart() {
const [items, setItems] = useState([]);
const [nextId, setNextId] = useState(1);

function addItem(name, price) {
setItems(prevItems => [
...prevItems,
{ id: nextId, name, price, quantity: 1 }
]);
setNextId(prevId => prevId + 1);
}

function updateQuantity(id, quantity) {
if (quantity <= 0) {
setItems(prev => prev.filter(item => item.id !== id));
} else {
setItems(prev =>
prev.map(item => item.id === id ? { ...item, quantity } : item)
);
}
}

const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

return (
<div>
<h2>购物车</h2>
{items.length === 0 ? (
<p>购物车是空的</p>
) : (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} - ¥{item.price} × {item.quantity}
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
</li>
))}
</ul>
)}
<p>合计:¥{total}</p>
<button onClick={() => addItem('商品' + nextId, 100)}>添加商品</button>
</div>
);
}

设计要点

  • items 是对象数组,更新时使用 mapfilter 返回新数组,使用扩展运算符创建新对象。
  • updateQuantityquantity <= 0 时从数组中移除该商品。
  • 添加新商品时使用函数式更新(prevItems => [...prevItems, newItem]),确保并发安全。
  • total 是派生值,直接通过 reduce 计算,无需单独 State。

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) 在 React 中,以下哪种方式是正确更新对象类型 State 的?
    A. state.name = 'Bob'; setState(state);
    B. setState({ name: 'Bob' });
    C. setState(prev => ({ ...prev, name: 'Bob' }));
    D. setState(Object.assign(state, { name: 'Bob' }));

  2. (单选) 函数式更新(setCount(c => c + 1))的主要用途是?
    A. 提高代码的可读性。
    B. 当新 State 依赖于旧 State 时,确保基于最新的中间值计算。
    C. 减少组件渲染次数。
    D. 在异步操作中强制同步更新。

  3. (填空) 使用 useState 时,如果初始值需要经过复杂计算获取,应使用 ______ 模式,将初始值包裹在一个函数中,避免每次渲染都执行计算。

  4. (多选) 以下哪些操作会触发 React 组件的重新渲染?
    A. 调用 useState 返回的更新函数并传入与当前 State 不同的值。
    B. 直接修改 State 变量(如 state.count++)。
    C. 父组件重新渲染,即使子组件的 Props 没有变化。
    D. 在组件内部声明并修改普通变量 let x = 1; x++

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要实现一个待办事项列表(Todo List) 组件。要求如下:

  • 使用 useState 管理 todos 数组,每个 todo 包含 { id, text, completed }
  • 提供输入框和“添加”按钮,点击后将新 todo 加入列表(输入框清空),使用函数式更新。
  • 每个 todo 项左侧有复选框,点击切换 completed 状态(使用函数式更新)。
  • 已完成项的文字显示删除线样式。
  • 每个 todo 项右侧有“删除”按钮,点击后从列表中移除。
  • 底部显示统计:“共 X 项,已完成 Y 项”。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 React 函数组件代码。提示词中需明确指定不可变更新方式、函数式更新的使用场景以及派生状态(统计数字)的计算方式。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个 React Todo List 组件。需要创建文件 src/components/TodoList.jsx

  • 使用 useState 管理 todos(初始空数组)和 inputValue(初始空字符串)。
  • 输入框绑定 inputValue,按回车或点击“添加”按钮调用 addTodo
  • addTodo:若 inputValue.trim() 为空则忽略;否则使用函数式更新向 todos 追加 { id: Date.now(), text: inputValue, completed: false },并清空 inputValue
  • toggleTodo(id):使用函数式更新 setTodos(prev => prev.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo))
  • deleteTodo(id):使用函数式更新 setTodos(prev => prev.filter(todo => todo.id !== id))
  • 渲染:每个 todo 一行,复选框绑定 todo.completed,文字样式 { textDecoration: todo.completed ? 'line-through' : 'none' },右侧删除按钮。
  • 底部:<p>共 {todos.length} 项,已完成 {todos.filter(t => t.completed).length} 项</p>
  • 添加 JSDoc 注释,确保代码整洁可直接运行。完成后输出完整文件内容。

四、面试真题与参考答案

题目(百度前端面试题):

请解释 React 中 useState 的批处理机制(Batching)以及函数式更新的原理。为什么在事件处理函数中多次调用 setCount(count + 1) 实际只增加 1?如何让它增加 3?请结合闭包和更新队列说明。

参考答案

React 的批处理机制会将同一事件处理函数中的多次 setState 调用合并为一次更新。在当前渲染中,count 是一个被闭包捕获的常量。三次 setCount(count + 1) 都读取到相同的 count(初始值),因此等价于三次 setCount(0 + 1)。React 将它们放入更新队列,最终仅将 count 更新为 1,触发一次重新渲染。

要让 count 增加 3,需要使用函数式更新setCount(prev => prev + 1)。React 会将函数式更新依次加入队列,每次将前一次计算的结果传入下一个函数,确保每次递增都基于最新的中间值。因此三个 setCount(prev => prev + 1) 会依次执行 0 → 1 → 2 → 3,最终结果正确。

从底层看,React 维护了一个更新队列(Update Queue)。直接值更新(setCount(newValue))会直接替换队列中的待更新值;函数式更新(setCount(fn))会依次入队,在渲染时逐个调用以计算最终状态。批处理结束后,React 使用计算出的最终状态进行一次重新渲染。


课后练习答案

一、概念自测答案

  1. C

    • 解析:A 直接修改了原对象,未创建新引用;B 没有保留原有属性;C 使用扩展运算符创建新对象,保留旧属性并覆盖 name,正确;D 使用 Object.assign 修改了原对象。
  2. B

    • 解析:函数式更新的核心用途是当新状态依赖于旧状态时,确保基于最新值计算,避免闭包捕获旧值导致的 Bug。
  3. 惰性初始化(Lazy Initialization)

    • 解析:useState(() => expensiveComputation()) 仅在首次渲染时执行函数。
  4. A、C

    • 解析:A 正确,State 更新触发重新渲染;C 正确,父组件渲染默认导致子组件也渲染(除非使用 React.memo);B 错误,直接修改 State 不会触发渲染(因为 React 检测不到变化);D 错误,普通变量修改不会触发渲染。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请用 React 实现一个 Todo List 组件。要求:

  • useState 管理 todos 数组和 inputValue
  • 添加功能:输入框回车或按钮点击触发,使用函数式更新 setTodos(prev => [...prev, { id: Date.now(), text: inputValue, completed: false }]),清空输入框。
  • 切换完成状态:使用函数式更新 setTodos(prev => prev.map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo))
  • 删除功能:使用函数式更新 setTodos(prev => prev.filter(todo => todo.id !== id))
  • 底部统计:使用 todos.filter(t => t.completed).length 计算完成数。
  • 完成项文字加删除线。直接输出完整组件代码。”
CATALOG
  1. 1. 第89课:State 与 useState——状态更新、批处理、不可变性、函数式更新
    1. 1.1. 1. useState 的基本用法
      1. 1.1.1. 1.1 声明 State 变量
      2. 1.1.2. 1.2 State 与普通变量的区别
      3. 1.1.3. 1.3 State 的初始值
    2. 1.2. 2. State 的不可变性
      1. 1.2.1. 2.1 为什么 State 必须被视为不可变?
      2. 1.2.2. 2.2 数组的不可变更新
      3. 1.2.3. 2.3 嵌套对象的不可变更新
    3. 1.3. 3. State 的异步批处理(Batching)
      1. 1.3.1. 3.1 什么是批处理?
      2. 1.3.2. 3.2 批处理的范围
      3. 1.3.3. 3.3 函数式更新:解决依赖旧值的问题
    4. 1.4. 4. State 的惰性初始化
      1. 1.4.1. 4.1 避免每次渲染都计算初始值
      2. 1.4.2. 4.2 典型场景:从 localStorage 读取状态
    5. 1.5. 5. 综合实战:购物车组件
    6. 1.6. 课后练习
      1. 1.6.1. 一、概念自测(选择题 / 填空题)
      2. 1.6.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.6.3. 三、Agent 模式下的提示词示例
      4. 1.6.4. 四、面试真题与参考答案
    7. 1.7. 课后练习答案
      1. 1.7.1. 一、概念自测答案
      2. 1.7.2. 二、AI 编程任务参考答案(提示词示例)