「前端何去何从」一直写 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 官方文档 "添加交互" 章节。

相关推荐
醉城夜风~1 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华2 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子4 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅4 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学6 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
先吃饱再说6 小时前
编写一个颜色选择器之后,我理解了 React 工程化的三层架构
react.js·前端框架·前端工程化
先吃饱再说6 小时前
别说你懂 useEffect:从底层机制到生命周期管理,这篇全讲透了
react.js·前端框架
赵庆明老师6 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim