React 交互模型:从事件到状态的完整指南
理解 React 的交互模型,是从"会用"到"用好"的关键一步
交互是 React 的灵魂
静态的 UI 只是开始。
真正的应用需要响应用户操作:点击按钮、填写表单、切换选项卡。
这些都需要组件能够"记住"状态,并在状态变化时更新 UI。
React 的交互模型建立在几个核心概念上:事件处理、state、渲染机制。
这些概念看起来简单,但背后有很多值得深入理解的细节。
很多 React bug 都源于对这些概念的误解。
本文涵盖的内容
本文对应 React 官方文档"添加交互"章节,涵盖以下主题:
- 响应事件:如何处理用户操作
- State:组件如何"记住"数据
- 渲染与提交:React 如何更新 UI
- State 快照:为什么 state 的行为有时出乎意料
- 批量更新:React 如何优化状态更新
- 更新对象和数组:不可变性原则的实践
Part 1: 响应事件
事件处理函数的写法
在 React 中,事件处理函数通过 props 传递给元素:
function Button() {
function handleClick() {
alert('你点击了我!');
}
return <button onClick={handleClick}>点击我</button>;
}
注意这里的细节:onClick={handleClick} 传递的是函数本身,而不是函数调用。
// 正确:传递函数引用
<button onClick={handleClick}>
// 错误:立即调用了函数
<button onClick={handleClick()}>
第二种写法会在每次渲染时立即执行 handleClick,而不是等用户点击。这是初学者最常见的错误之一。
如果需要传递参数,可以用箭头函数包裹:
<button onClick={() => handleClick(item.id)}>删除</button>
事件处理函数的命名约定
React 社区有一个约定:事件处理函数以 handle 开头,后跟事件名称:
function Form() {
function handleSubmit(e) {
e.preventDefault();
// 处理提交
}
function handleChange(e) {
// 处理输入变化
}
return (
<form onSubmit={handleSubmit}>
<input onChange={handleChange} />
</form>
);
}
这个约定不是强制的,但遵循它能让代码更易读。
事件传播与阻止
React 的事件会向上传播(冒泡)。点击子元素,父元素的事件处理函数也会触发:
function Toolbar() {
return (
<div onClick={() => alert('你点击了工具栏')}>
<button onClick={() => alert('你点击了按钮')}>
播放电影
</button>
</div>
);
}
点击按钮时,会先弹出"你点击了按钮",再弹出"你点击了工具栏"。
如果不想让事件继续传播,使用 e.stopPropagation():
function Button({ onClick, children }) {
return (
<button onClick={e => {
e.stopPropagation();
onClick();
}}>
{children}
</button>
);
}
还有一个常用的方法是 e.preventDefault(),用于阻止浏览器的默认行为(比如表单提交时的页面刷新):
function Form() {
function handleSubmit(e) {
e.preventDefault();
// 自定义提交逻辑
}
return <form onSubmit={handleSubmit}>...</form>;
}
实战中的注意事项
React 不要求事件处理函数是纯函数,所以你可以在里面做任何事:发请求、修改 DOM、更新 state。
尽量让事件处理函数保持简洁。如果逻辑复杂,提取成独立的函数,而不是把所有逻辑堆在
onClick里。
Part 2: State——组件的记忆
为什么普通变量不够用
你可能会想,为什么不直接用普通变量来存储数据?
// 这不会工作
function Counter() {
let count = 0;
function handleClick() {
count = count + 1;
}
return <button onClick={handleClick}>点击了 {count} 次</button>;
}
这段代码有两个问题:
- 局部变量不会在渲染之间保留:每次组件重新渲染,
count都会重置为 0 - 修改局部变量不会触发渲染:React 不知道
count变了,不会更新 UI
这就是 useState 存在的原因。
useState 的工作原理
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return <button onClick={handleClick}>点击了 {count} 次</button>;
}
useState 返回两个东西:
- 当前 state 值:
count - setter 函数:
setCount,调用它会触发重新渲染
当你调用 setCount(count + 1) 时,React 会:
- 用新值更新 state
- 重新渲染组件
- 这次渲染中,
count的值是新的值
一个组件可以有多个 state
function Form() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [isSubmitted, setIsSubmitted] = useState(false);
// ...
}
每个 useState 调用都是独立的。React 通过调用顺序来区分它们,所以不能在条件语句或循环中调用 Hook。
如何设计 State
State 的设计是 React 开发中最需要思考的部分。
一个原则:state 应该存储最小必要信息。
// 不好:存储了可以计算出来的值
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // 可以从前两个计算出来
// 好:只存储必要的值
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = firstName + ' ' + lastName; // 直接计算
另一个原则:避免 state 之间的矛盾。如果两个 state 可能出现互相矛盾的情况,考虑合并它们。
Part 3: 渲染与提交
React 的渲染流程
理解 React 的渲染流程,能帮你避免很多困惑。
React 的渲染分三个阶段:
1. 触发渲染
有两种情况会触发渲染:
- 组件初次挂载
- 组件或其祖先的 state 发生变化
2. 渲染阶段
React 调用组件函数,计算出新的 JSX。这个过程是纯粹的:React 只是在"计算",不会修改 DOM。
3. 提交阶段
React 将计算结果应用到 DOM 上。只有真正发生变化的部分才会被更新。
// 每次渲染,React 都会重新调用这个函数
function Counter({ count }) {
return <div>当前计数:{count}</div>;
}
为什么理解渲染很重要
很多开发者以为"调用 setState 就会立即更新 DOM",但实际上不是这样。
React 会先完成当前的渲染,再处理下一次渲染。这种设计让 React 可以批量处理多个 state 更新,提高性能。
把 React 的渲染想象成餐厅点餐:你(state 更新)告诉服务员(React)你想要什么,服务员把所有订单汇总后,再统一送到厨房(DOM 更新)。
Part 4: State 如同一张快照
快照的含义
这是 React 中最容易让人困惑的概念之一。
State 的值在一次渲染中是固定的。
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // 仍然是 0,不是 1!
}
return <button onClick={handleClick}>{count}</button>;
}
为什么 console.log(count) 打印的是 0?
因为 count 是这次渲染的快照值。调用 setCount 不会修改当前渲染中的 count,它只是告诉 React "下次渲染时,count 应该是 1"。
快照导致的经典 bug
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
return <button onClick={handleClick}>{count}</button>;
}
你可能期望点击一次后 count 变成 3,但实际上只会变成 1。
原因:这三次 setCount 调用中,count 都是同一个快照值(0)。所以相当于执行了三次 setCount(0 + 1)。
理解快照的实际意义
快照机制让 React 的行为更可预测。
在一次事件处理中,你看到的 state 值始终是一致的,不会因为中途的 setState 而改变。这避免了很多竞态条件的问题。
function sendMessage(message) {
// 假设这是一个异步操作
setTimeout(() => {
alert('发送了:' + message);
}, 5000);
}
function Chat() {
const [message, setMessage] = useState('');
function handleSend() {
sendMessage(message); // 捕获了当前快照中的 message
setMessage('');
}
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSend}>发送</button>
</>
);
}
即使用户在 5 秒内修改了输入框,sendMessage 里的 message 仍然是点击发送时的值。这通常是你想要的行为。
Part 5: 把一系列更新加入队列
批处理机制
React 会把同一个事件处理函数中的所有 state 更新批量处理,只触发一次重新渲染:
function handleClick() {
setCount(count + 1); // 不会立即渲染
setFlag(true); // 不会立即渲染
// React 在这里统一处理,只渲染一次
}
这是一个性能优化,通常你不需要关心它。但理解它能帮你解释一些"奇怪"的行为。
更新函数:解决快照问题
回到之前的问题:如何在一次点击中让 count 增加 3?
使用更新函数:
function handleClick() {
setCount(c => c + 1); // 基于最新值更新
setCount(c => c + 1); // 基于最新值更新
setCount(c => c + 1); // 基于最新值更新
}
更新函数接收的参数 c 是队列中最新的 state 值,而不是快照值。
React 会把这三个更新函数排成队列,依次执行:
0 => 0 + 1 = 11 => 1 + 1 = 22 => 2 + 1 = 3
最终 count 变成 3。
什么时候用更新函数
不是所有情况都需要更新函数。
用直接赋值:当新值不依赖旧值时
setCount(0); // 重置为 0
setName('Alice'); // 设置为固定值
用更新函数:当新值依赖旧值,且可能在同一事件中多次更新时
setCount(c => c + 1); // 基于旧值递增
实际项目中,大多数情况用直接赋值就够了。只有在需要连续多次更新同一个 state 时,才需要更新函数。
Part 6: 更新 State 中的对象
不可变性原则
React 的 state 应该被视为不可变的。
不要直接修改 state 中的对象:
// 错误:直接修改 state 对象
const [position, setPosition] = useState({ x: 0, y: 0 });
function handleMove(e) {
position.x = e.clientX; // 错误!
position.y = e.clientY; // 错误!
}
为什么不能直接修改?因为 React 通过比较引用来判断 state 是否变化。直接修改对象不会改变引用,React 不会知道 state 变了,也不会触发重新渲染。
正确的做法:创建新对象
function handleMove(e) {
setPosition({
x: e.clientX,
y: e.clientY,
});
}
如果只想更新对象的部分属性,使用展开运算符:
const [person, setPerson] = useState({
name: 'Alice',
age: 25,
city: 'Beijing',
});
function handleNameChange(e) {
setPerson({
...person, // 复制其他属性
name: e.target.value, // 只更新 name
});
}
嵌套对象的更新
嵌套对象需要逐层展开:
const [person, setPerson] = useState({
name: 'Alice',
address: {
city: 'Beijing',
street: '长安街',
},
});
function handleCityChange(e) {
setPerson({
...person,
address: {
...person.address, // 复制 address 的其他属性
city: e.target.value, // 只更新 city
},
});
}
嵌套层级深了之后,这种写法会很繁琐。这时可以考虑使用 Immer 库,它让你可以用"直接修改"的语法来更新不可变数据。
Part 7: 更新 State 中的数组
数组的不可变操作
和对象一样,state 中的数组也不能直接修改。
常见操作的不可变写法:
添加元素
// 错误:直接 push
items.push(newItem);
// 正确:创建新数组
setItems([...items, newItem]);
// 或者添加到开头
setItems([newItem, ...items]);
删除元素
// 使用 filter 创建不包含目标元素的新数组
setItems(items.filter(item => item.id !== targetId));
更新元素
// 使用 map 创建新数组,只修改目标元素
setItems(items.map(item =>
item.id === targetId
? { ...item, done: true } // 更新目标元素
: item // 其他元素不变
));
插入元素
// 在指定位置插入
const insertAt = 2;
const newItems = [
...items.slice(0, insertAt),
newItem,
...items.slice(insertAt),
];
setItems(newItems);
排序和反转
// 错误:sort 和 reverse 会修改原数组
items.sort();
items.reverse();
// 正确:先复制,再操作
const sorted = [...items].sort();
setItems(sorted);
数组中的对象更新
数组中的对象同样需要不可变更新:
const [todos, setTodos] = useState([
{ id: 1, text: '买菜', done: false },
{ id: 2, text: '做饭', done: false },
]);
function handleToggle(id) {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, done: !todo.done } // 创建新对象
: todo
));
}
为什么要这么麻烦
不可变性原则看起来很繁琐,但它带来了重要的好处:
- 可预测性:state 的变化是显式的,容易追踪
- 性能优化:React 可以通过比较引用快速判断是否需要重新渲染
- 时间旅行调试:Redux DevTools 等工具依赖不可变性来实现状态回放
刚开始写 React 时,我也觉得这很麻烦。但用了一段时间后,我发现这种方式让 bug 更容易发现和修复。
学习路径与思考
这些概念的内在联系
"添加交互"这一章的概念是层层递进的:
- 事件处理:用户操作的入口
- State:存储需要变化的数据
- 渲染机制:理解 React 如何响应 state 变化
- 快照:解释为什么 state 的行为有时出乎意料
- 批量更新:理解 React 的性能优化策略
- 不可变性:正确更新复杂数据结构的基础
理解了这条链路,很多 React 的"奇怪行为"都能解释清楚。
常见的误解和 bug
误解 1:setState 是同步的
function handleClick() {
setCount(count + 1);
console.log(count); // 仍然是旧值
}
setState 不会立即更新 state,它只是安排了一次重新渲染。
误解 2:可以直接修改 state 对象
// 这不会触发重新渲染
state.value = newValue;
必须通过 setter 函数来更新 state。
误解 3:每次 setState 都会触发一次渲染
React 会批量处理同一事件中的多个 setState,只触发一次渲染。
在 AI 时代的实践建议
AI 工具可以很快生成 state 管理代码,但它经常会:
- 忘记不可变性原则,直接修改 state
- 在不需要的地方使用更新函数
- 设计过于复杂的 state 结构
理解这些概念,能让你快速发现 AI 生成代码中的问题。
总结
本文梳理了 React 交互模型的核心概念:
- 事件处理:传递函数引用,而不是函数调用;注意事件传播
- State:组件的记忆,通过 useState 管理;设计最小必要 state
- 渲染机制:触发 → 渲染 → 提交,理解这个流程避免误解
- State 快照:一次渲染中 state 值固定,这是很多 bug 的根源
- 批量更新:React 优化性能的方式;需要连续更新时用更新函数
- 不可变性:更新对象和数组时,始终创建新的引用
这些概念是 React 状态管理的基础。掌握它们之后,学习 useReducer、Context、以及各种状态管理库都会容易很多。
相关资源
本文基于 React 官方文档 "添加交互" 章节。