「前端何去何从」一直写 Vue ,为何要在 AI 时代去学 React「2」?

React 交互模型:从事件到状态的完整指南

理解 React 的交互模型,是从"会用"到"用好"的关键一步

交互是 React 的灵魂

静态的 UI 只是开始。

真正的应用需要响应用户操作:点击按钮、填写表单、切换选项卡。

这些都需要组件能够"记住"状态,并在状态变化时更新 UI。

React 的交互模型建立在几个核心概念上:事件处理、state、渲染机制

这些概念看起来简单,但背后有很多值得深入理解的细节。

很多 React bug 都源于对这些概念的误解。

本文涵盖的内容

本文对应 React 官方文档"添加交互"章节,涵盖以下主题:

  • 响应事件:如何处理用户操作
  • State:组件如何"记住"数据
  • 渲染与提交:React 如何更新 UI
  • State 快照:为什么 state 的行为有时出乎意料
  • 批量更新:React 如何优化状态更新
  • 更新对象和数组:不可变性原则的实践

Part 1: 响应事件

事件处理函数的写法

在 React 中,事件处理函数通过 props 传递给元素:

jsx 复制代码
function Button() {
  function handleClick() {
    alert('你点击了我!');
  }

  return <button onClick={handleClick}>点击我</button>;
}

注意这里的细节:onClick={handleClick} 传递的是函数本身,而不是函数调用。

jsx 复制代码
// 正确:传递函数引用
<button onClick={handleClick}>

// 错误:立即调用了函数
<button onClick={handleClick()}>

第二种写法会在每次渲染时立即执行 handleClick,而不是等用户点击。这是初学者最常见的错误之一。

如果需要传递参数,可以用箭头函数包裹:

jsx 复制代码
<button onClick={() => handleClick(item.id)}>删除</button>

事件处理函数的命名约定

React 社区有一个约定:事件处理函数以 handle 开头,后跟事件名称:

jsx 复制代码
function Form() {
  function handleSubmit(e) {
    e.preventDefault();
    // 处理提交
  }

  function handleChange(e) {
    // 处理输入变化
  }

  return (
    <form onSubmit={handleSubmit}>
      <input onChange={handleChange} />
    </form>
  );
}

这个约定不是强制的,但遵循它能让代码更易读。

事件传播与阻止

React 的事件会向上传播(冒泡)。点击子元素,父元素的事件处理函数也会触发:

jsx 复制代码
function Toolbar() {
  return (
    <div onClick={() => alert('你点击了工具栏')}>
      <button onClick={() => alert('你点击了按钮')}>
        播放电影
      </button>
    </div>
  );
}

点击按钮时,会先弹出"你点击了按钮",再弹出"你点击了工具栏"。

如果不想让事件继续传播,使用 e.stopPropagation()

jsx 复制代码
function Button({ onClick, children }) {
  return (
    <button onClick={e => {
      e.stopPropagation();
      onClick();
    }}>
      {children}
    </button>
  );
}

还有一个常用的方法是 e.preventDefault(),用于阻止浏览器的默认行为(比如表单提交时的页面刷新):

jsx 复制代码
function Form() {
  function handleSubmit(e) {
    e.preventDefault();
    // 自定义提交逻辑
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

实战中的注意事项

React 不要求事件处理函数是纯函数,所以你可以在里面做任何事:发请求、修改 DOM、更新 state。

尽量让事件处理函数保持简洁。如果逻辑复杂,提取成独立的函数,而不是把所有逻辑堆在 onClick 里。


Part 2: State------组件的记忆

为什么普通变量不够用

你可能会想,为什么不直接用普通变量来存储数据?

jsx 复制代码
// 这不会工作
function Counter() {
  let count = 0;

  function handleClick() {
    count = count + 1;
  }

  return <button onClick={handleClick}>点击了 {count} 次</button>;
}

这段代码有两个问题:

  1. 局部变量不会在渲染之间保留 :每次组件重新渲染,count 都会重置为 0
  2. 修改局部变量不会触发渲染 :React 不知道 count 变了,不会更新 UI

这就是 useState 存在的原因。

useState 的工作原理

jsx 复制代码
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 会:

  1. 用新值更新 state
  2. 重新渲染组件
  3. 这次渲染中,count 的值是新的值

一个组件可以有多个 state

jsx 复制代码
function Form() {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  const [isSubmitted, setIsSubmitted] = useState(false);

  // ...
}

每个 useState 调用都是独立的。React 通过调用顺序来区分它们,所以不能在条件语句或循环中调用 Hook

如何设计 State

State 的设计是 React 开发中最需要思考的部分。

一个原则:state 应该存储最小必要信息

jsx 复制代码
// 不好:存储了可以计算出来的值
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 上。只有真正发生变化的部分才会被更新。

jsx 复制代码
// 每次渲染,React 都会重新调用这个函数
function Counter({ count }) {
  return <div>当前计数:{count}</div>;
}

为什么理解渲染很重要

很多开发者以为"调用 setState 就会立即更新 DOM",但实际上不是这样。

React 会先完成当前的渲染,再处理下一次渲染。这种设计让 React 可以批量处理多个 state 更新,提高性能。

把 React 的渲染想象成餐厅点餐:你(state 更新)告诉服务员(React)你想要什么,服务员把所有订单汇总后,再统一送到厨房(DOM 更新)。


Part 4: State 如同一张快照

快照的含义

这是 React 中最容易让人困惑的概念之一。

State 的值在一次渲染中是固定的。

jsx 复制代码
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

jsx 复制代码
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 而改变。这避免了很多竞态条件的问题。

jsx 复制代码
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 更新批量处理,只触发一次重新渲染:

jsx 复制代码
function handleClick() {
  setCount(count + 1);  // 不会立即渲染
  setFlag(true);        // 不会立即渲染
  // React 在这里统一处理,只渲染一次
}

这是一个性能优化,通常你不需要关心它。但理解它能帮你解释一些"奇怪"的行为。

更新函数:解决快照问题

回到之前的问题:如何在一次点击中让 count 增加 3?

使用更新函数

jsx 复制代码
function handleClick() {
  setCount(c => c + 1);  // 基于最新值更新
  setCount(c => c + 1);  // 基于最新值更新
  setCount(c => c + 1);  // 基于最新值更新
}

更新函数接收的参数 c 是队列中最新的 state 值,而不是快照值。

React 会把这三个更新函数排成队列,依次执行:

  • 0 => 0 + 1 = 1
  • 1 => 1 + 1 = 2
  • 2 => 2 + 1 = 3

最终 count 变成 3。

什么时候用更新函数

不是所有情况都需要更新函数。

用直接赋值:当新值不依赖旧值时

jsx 复制代码
setCount(0);        // 重置为 0
setName('Alice');   // 设置为固定值

用更新函数:当新值依赖旧值,且可能在同一事件中多次更新时

jsx 复制代码
setCount(c => c + 1);  // 基于旧值递增

实际项目中,大多数情况用直接赋值就够了。只有在需要连续多次更新同一个 state 时,才需要更新函数。


Part 6: 更新 State 中的对象

不可变性原则

React 的 state 应该被视为不可变的

不要直接修改 state 中的对象:

jsx 复制代码
// 错误:直接修改 state 对象
const [position, setPosition] = useState({ x: 0, y: 0 });

function handleMove(e) {
  position.x = e.clientX;  // 错误!
  position.y = e.clientY;  // 错误!
}

为什么不能直接修改?因为 React 通过比较引用来判断 state 是否变化。直接修改对象不会改变引用,React 不会知道 state 变了,也不会触发重新渲染。

正确的做法:创建新对象

jsx 复制代码
function handleMove(e) {
  setPosition({
    x: e.clientX,
    y: e.clientY,
  });
}

如果只想更新对象的部分属性,使用展开运算符:

jsx 复制代码
const [person, setPerson] = useState({
  name: 'Alice',
  age: 25,
  city: 'Beijing',
});

function handleNameChange(e) {
  setPerson({
    ...person,          // 复制其他属性
    name: e.target.value,  // 只更新 name
  });
}

嵌套对象的更新

嵌套对象需要逐层展开:

jsx 复制代码
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 中的数组也不能直接修改。

常见操作的不可变写法:

添加元素

jsx 复制代码
// 错误:直接 push
items.push(newItem);

// 正确:创建新数组
setItems([...items, newItem]);

// 或者添加到开头
setItems([newItem, ...items]);

删除元素

jsx 复制代码
// 使用 filter 创建不包含目标元素的新数组
setItems(items.filter(item => item.id !== targetId));

更新元素

jsx 复制代码
// 使用 map 创建新数组,只修改目标元素
setItems(items.map(item =>
  item.id === targetId
    ? { ...item, done: true }  // 更新目标元素
    : item                      // 其他元素不变
));

插入元素

jsx 复制代码
// 在指定位置插入
const insertAt = 2;
const newItems = [
  ...items.slice(0, insertAt),
  newItem,
  ...items.slice(insertAt),
];
setItems(newItems);

排序和反转

jsx 复制代码
// 错误:sort 和 reverse 会修改原数组
items.sort();
items.reverse();

// 正确:先复制,再操作
const sorted = [...items].sort();
setItems(sorted);

数组中的对象更新

数组中的对象同样需要不可变更新:

jsx 复制代码
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
  ));
}

为什么要这么麻烦

不可变性原则看起来很繁琐,但它带来了重要的好处:

  1. 可预测性:state 的变化是显式的,容易追踪
  2. 性能优化:React 可以通过比较引用快速判断是否需要重新渲染
  3. 时间旅行调试:Redux DevTools 等工具依赖不可变性来实现状态回放

刚开始写 React 时,我也觉得这很麻烦。但用了一段时间后,我发现这种方式让 bug 更容易发现和修复。


学习路径与思考

这些概念的内在联系

"添加交互"这一章的概念是层层递进的:

  • 事件处理:用户操作的入口
  • State:存储需要变化的数据
  • 渲染机制:理解 React 如何响应 state 变化
  • 快照:解释为什么 state 的行为有时出乎意料
  • 批量更新:理解 React 的性能优化策略
  • 不可变性:正确更新复杂数据结构的基础

理解了这条链路,很多 React 的"奇怪行为"都能解释清楚。

常见的误解和 bug

误解 1:setState 是同步的

jsx 复制代码
function handleClick() {
  setCount(count + 1);
  console.log(count);  // 仍然是旧值
}

setState 不会立即更新 state,它只是安排了一次重新渲染。

误解 2:可以直接修改 state 对象

jsx 复制代码
// 这不会触发重新渲染
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 官方文档 "添加交互" 章节。

相关推荐
葡萄城技术团队2 小时前
Playwright 官方推荐的 Fixture 模式,为什么大厂架构师却在偷偷弃用?
前端
newbe365242 小时前
ImgBin CLI 工具设计:HagiCode 图片资产管理方案
前端·后端
bluceli2 小时前
CSS Scroll Snap:打造丝滑滚动体验的利器
前端·css
www_stdio2 小时前
深入理解 React Fiber 与浏览器事件循环:从性能瓶颈到调度机制
前端·react.js·面试
工边页字2 小时前
LLM 系统设计核心:为什么必须压缩上下文?有哪些工程策略
前端·人工智能·后端
嚣张丶小麦兜2 小时前
react的理解
前端·react.js·前端框架
重庆穿山甲2 小时前
身份证照片自动裁剪(OpenCV 四边形检测 + 透视矫正)
前端·后端
跟着珅聪学java2 小时前
Electron + Vue 现代化“新品展示“和“快捷下单“菜单
开发语言·前端·javascript
何贤2 小时前
用 Three.js 写了一个《我的世界》,结果老外差点给我众筹做游戏?
前端·开源·three.js