跟着 MDN 学 React 框架 Day 6:React 交互性——事件与状态管理

前言

在现代前端开发中,交互性是衡量应用质量的重要标准之一。一个静态的页面无论设计得多么精美,如果无法与用户进行有效互动,其价值都会大打折扣。React 作为当下最流行的前端框架之一,提供了一套优雅而高效的事件处理与状态管理机制。本文将继续基于 MDN 的 React 教程,深入探讨如何在 React 应用中实现交互性,包括事件处理、回调 props、以及 useState 钩子的使用,最终构建一个功能完整的任务管理应用。

从静态 UI 到交互式应用的转变

在之前的开发阶段,我们已经完成了组件的拆分与规划,构建了一个静态的任务管理界面。这个界面虽然展示了基本的结构和样式,但无法响应用户的任何操作。现在我们需要将这个静态界面转变为可交互的应用,实现三个核心功能:添加新任务、删除现有任务、以及切换任务的完成状态。

这一转变涉及到 React 中两个最核心的概念:事件处理和状态管理。事件处理负责捕获用户的操作行为,而状态管理则确保应用能够记住这些操作带来的变化并相应地更新 UI。

事件处理的基本原理

如果你之前只使用过原生 JavaScript 进行开发,可能习惯于在独立的 JavaScript 文件中通过 DOM 查询获取节点,然后手动绑定事件监听器。在传统的开发模式中,HTML 文件会包含一个按钮元素,而 JavaScript 文件则会通过 querySelector 获取该按钮的 DOM 节点,然后调用 addEventListener 方法绑定点击事件。

在 React 的 JSX 中,事件监听器直接写在描述 UI 的代码旁边,形成了一种更加内聚的编程模式。React 为所有浏览器事件提供了统一的处理方式,属性名采用驼峰命名法,以 on 开头后接事件名称。例如,点击事件写作 onClick,提交事件写作 onSubmit,变化事件写作 onChange。这种命名规范是强制性的,JSX 不会识别全小写的 onclick 或其他变体。

将事件处理函数直接写在 JSX 中可能会让熟悉传统最佳实践的人感到困惑,因为过去我们一直被告知不要在 HTML 中编写事件监听器。但这里需要明确一个重要概念:JSX 不是 HTML。JSX 是一种 JavaScript 的语法扩展,它最终会被编译为普通的 JavaScript 函数调用,因此直接在 JSX 中绑定事件处理函数并不会带来传统意义上内联事件处理器的那些问题。

表单提交事件的实际应用

在任务管理应用中,表单提交是最重要的用户交互之一。我们需要在 Form 组件中处理用户的输入和提交行为。处理表单提交的第一步是创建一个 handleSubmit 函数,这个函数接收事件对象作为参数,并调用 preventDefault 方法来阻止浏览器的默认表单提交行为,这样页面就不会刷新,我们可以完全用 JavaScript 来控制后续的逻辑。

下面是 handleSubmit 函数的实现代码:

jsx 复制代码
function handleSubmit(event) {
  event.preventDefault();
  alert("Hello, world!");
}

在 Form 组件内部的 return 语句中,我们将 handleSubmit 函数绑定到 form 元素的 onSubmit 属性上:

jsx 复制代码
<form onSubmit={handleSubmit}>{/* ... */}</form>

当用户点击 "Add" 按钮或者按下回车键时,handleSubmit 函数就会被调用。这个设计确保了用户可以通过多种方式触发表单提交,提升了用户体验。在浏览器中点击按钮后,会弹出一个显示 "Hello, world!" 的提示框,证明事件绑定成功。

回调 Props:实现组件间通信的桥梁

React 应用中的交互性很少局限于单个组件。一个组件中发生的事件往往会影响应用的其他部分。以我们的任务管理应用为例,用户在 Form 组件中添加新任务的操作,需要影响到 App 组件中渲染的任务列表。这就引出了一个关键问题:如何将数据从子组件传递给父组件。

在 React 中,数据流通常是单向的,从父组件流向子组件。标准 props 只能用于父组件向子组件传递数据,无法直接实现反向传递。为了解决这个问题,React 引入了回调 props 的概念。具体做法是:在父组件中定义一个函数,然后将这个函数作为 props 传递给子组件。子组件在适当的时机调用这个函数,并将需要传递的数据作为参数传入,从而实现子组件向父组件的数据传递。

在 App 组件中,我们定义了 addTask 函数,它接收一个 name 参数并弹出提示框显示该名称:

jsx 复制代码
function addTask(name) {
  alert(name);
}

然后将 addTask 通过名为 addTask 的 prop 传递给 Form 组件:

jsx 复制代码
<Form addTask={addTask} />

在 Form 组件中,我们需要修改函数签名为接受 props 参数,然后在 handleSubmit 中调用 props.addTask

jsx 复制代码
function Form(props) {
  function handleSubmit(event) {
    event.preventDefault();
    props.addTask("Say hello!");
  }
  // ...
}

关于回调 props 的命名,虽然没有严格的规则限制,但 React 社区普遍采用 on 开头的命名约定,例如 onSubmitonClickonChange 等。这种命名方式能让开发者一眼识别出某个 prop 是一个回调函数,以及它会在什么时机被触发。不过在本教程中为了保持与函数名的一致性,我们使用了 addTask 这样的命名方式。无论选择哪种命名风格,最重要的是保持代码库内部的一致性。

状态管理:赋予组件记忆能力

到目前为止,props 已经能够满足组件间数据传递的需求。但当涉及到交互性时,应用需要能够创建新数据、保留数据并在后续更新数据。Props 是不可变的,组件不能修改自己的 props,因此我们需要一种新的机制来管理会随时间变化的数据。

这就是状态的作用。如果我们将 props 比作组件之间通信的管道,那么状态就是组件的记忆系统,组件可以保存信息并在需要时进行更新。React 提供了 useState 这个特殊的函数来为组件引入状态管理能力。

useState 属于 React 中一类特殊的函数,称为钩子。钩子函数能够为函数组件添加各种额外的功能,例如状态管理、副作用处理等。要使用 useState,首先需要从 React 模块中导入它:

jsx 复制代码
import { useState } from "react";

useState 接收一个参数作为状态的初始值,这个值可以是字符串、数字、数组、对象或任何 JavaScript 数据类型。useState 的返回值是一个包含两个元素的数组:第一个元素是当前的状态值,第二个元素是用于更新该状态的函数。通常我们会使用数组解构赋值来同时获取这两个值,并为它们赋予有意义的名称。

在 Form 组件中创建一个 name 状态,初始值设置为空字符串:

jsx 复制代码
const [name, setName] = useState("");

命名时遵循的惯例是:状态变量使用描述性的名词,更新函数则使用 set 加上状态变量名的驼峰形式。name 对应 setName,这种命名方式清晰明了,能够让代码的意图一目了然。

读取状态与响应用户输入

创建状态后,我们可以通过状态变量直接读取其当前值。在 Form 组件中,我们将 input 元素的 value 属性绑定到 name 状态变量上,这样输入框就会显示 name 的当前值:

jsx 复制代码
<input
  type="text"
  id="new-todo-input"
  className="input input__lg"
  name="text"
  autoComplete="off"
  value={name}
/>

初始状态下,name 被设置为空字符串,输入框即为空。当用户尝试在输入框中键入文字时,我们会发现输入框的内容并没有发生变化。这是因为我们还没有实现用户输入的捕获和状态更新机制。浏览器在按下键盘时会触发 onChange 事件,我们需要监听这个事件来获取用户的输入内容。

首先编写 handleChange 函数来监听变化事件,并添加 onChange 属性到 input 元素上:

jsx 复制代码
function handleChange(event) {
  console.log(event.target.value);
}

// 在input元素上添加
<input
  // ...其他属性
  value={name}
  onChange={handleChange}
/>;

handleChange 事件处理函数中,我们通过事件对象的 target.value 属性获取到输入框的当前文本内容。事件对象的 target 属性指向触发事件的 DOM 元素,对于 input 元素来说,value 属性就是用户输入的内容。此时在控制台中可以看到用户每次按键时输出的值。

获取到用户输入后,接下来就是调用 setName 函数来更新 name 状态。将 console.log 替换为 setName 调用:

jsx 复制代码
function handleChange(event) {
  setName(event.target.value);
}

setName 被调用时,React 会重新渲染组件,将新的状态值反映到 UI 上,这就是所谓的响应式更新。用户每输入一个字符,组件就会重新渲染一次,输入框中始终显示最新的状态值。

在表单提交时,我们还需要将 name 状态清空,以便为下一次输入做准备。在 handleSubmit 函数中,调用 props.addTask 传递任务名称后,再次调用 setName 并传入空字符串,就能将输入框恢复到初始状态:

jsx 复制代码
function handleSubmit(event) {
  event.preventDefault();
  props.addTask(name);
  setName("");
}

至此,Form 组件的完整代码如下:

jsx 复制代码
import { useState } from "react";

function Form(props) {
  const [name, setName] = useState("");

  function handleChange(event) {
    setName(event.target.value);
  }

  function handleSubmit(event) {
    event.preventDefault();
    props.addTask(name);
    setName("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2 className="label-wrapper">
        <label htmlFor="new-todo-input" className="label__lg">
          What needs to be done?
        </label>
      </h2>
      <input
        type="text"
        id="new-todo-input"
        className="input input__lg"
        name="text"
        autoComplete="off"
        value={name}
        onChange={handleChange}
      />
      <button type="submit" className="btn btn__primary btn__lg">
        Add
      </button>
    </form>
  );
}

export default Form;

现在在浏览器中输入文字并点击 Add 按钮,就会弹出包含所输入文字的提示框,随后输入框自动清空。

将新任务添加到状态中

实现添加任务功能的核心是在 App 组件中管理一个任务数组状态。首先需要将 useState 导入 App.jsx

jsx 复制代码
import { useState } from "react";

然后将初始任务列表作为 useState 的参数,创建 tasks 状态和对应的 setTasks 更新函数:

jsx 复制代码
const [tasks, setTasks] = useState(props.tasks);

接着将 taskList 的渲染数据源从 props.tasks 改为 tasks 状态:

jsx 复制代码
const taskList = tasks?.map((task) => (
  <Todo
    id={task.id}
    name={task.name}
    completed={task.completed}
    key={task.id}
  />
));

addTask 函数中,我们需要将新任务添加到任务数组中。因为任务数组中的每个元素都是一个包含 idnamecompleted 属性的对象,我们不能直接将任务名称字符串传入 setTasks,否则数组会被替换为字符串。正确的做法是创建一个与现有任务结构一致的新任务对象,然后使用扩展运算符复制现有数组,并在末尾添加新对象,最后将完整的新数组传递给 setTasks

jsx 复制代码
function addTask(name) {
  const newTask = { id: "id", name, completed: false };
  setTasks([...tasks, newTask]);
}

任务 ID 的唯一性是一个需要注意的重要问题。每个任务都需要一个独一无二的标识符,这不仅关系到 React 的 key 机制和渲染性能,也影响可访问性和后续的编辑删除操作。手动生成唯一 ID 是一个复杂的问题,幸运的是 JavaScript 社区提供了许多优秀的解决方案。nanoid 是一个轻量级的库,能够生成简短且唯一的字符串 ID,非常适合这个场景。

安装 nanoid:

text 复制代码
npm install nanoid

App.jsx 顶部导入 nanoid:

jsx 复制代码
import { nanoid } from "nanoid";

更新 addTask 函数,使用 nanoid 生成唯一 ID:

jsx 复制代码
function addTask(name) {
  const newTask = { id: `todo-${nanoid()}`, name, completed: false };
  setTasks([...tasks, newTask]);
}

现在在浏览器中添加任务时,每个新任务都会有唯一的 ID,不会再出现重复 key 的警告。

完善任务计数功能

添加任务功能实现后,一个细节问题浮现出来:页面标题始终显示固定数量的任务,没有随任务列表的实际变化而更新。解决这个问题的方法是根据 taskList 的长度动态计算显示文本。

在 App 组件中,在 return 语句之前添加以下代码来定义动态标题文本:

jsx 复制代码
const tasksNoun = taskList.length !== 1 ? "tasks" : "task";
const headingText = `${taskList.length} ${tasksNoun} remaining`;

这里定义了两个变量:tasksNoun 根据任务数量判断使用单数还是复数形式,headingText 则拼接数量和文字组成完整的标题。然后将 h2 元素的内容替换为 headingText

jsx 复制代码
<h2 id="list-heading">{headingText}</h2>

这个看似简单的功能实际上体现了 React 数据驱动的核心理念:UI 始终是状态的忠实反映,状态变化会自动触发 UI 更新,开发者不需要手动操作 DOM 来更新界面。无论添加还是删除任务,标题中的任务数量都会自动同步更新。

完成任务的切换功能

在 HTML 中,复选框有自己的内部状态,浏览器会自动记录哪些复选框被选中。然而在 React 应用中,这种浏览器自动管理的行为会导致 UI 与 React 状态不同步的问题。表面上用户看到复选框被勾选或取消,但 React 状态中的数据并没有相应更新,这会给后续的数据处理和持久化带来严重问题。

为了验证这个问题的存在,首先在 App 组件中创建 toggleTaskCompleted 函数,暂时在其中打印第一个任务对象到控制台:

jsx 复制代码
function toggleTaskCompleted(id) {
  console.log(tasks[0]);
}

然后将这个函数作为回调 prop 传递给每个 Todo 组件:

jsx 复制代码
const taskList = tasks.map((task) => (
  <Todo
    id={task.id}
    name={task.name}
    completed={task.completed}
    key={task.id}
    toggleTaskCompleted={toggleTaskCompleted}
  />
));

Todo.jsx 组件中,为复选框添加 onChange 事件处理,调用 toggleTaskCompleted 并传入任务 ID:

jsx 复制代码
<input
  id={props.id}
  type="checkbox"
  defaultChecked={props.completed}
  onChange={() => props.toggleTaskCompleted(props.id)}
/>

此时在浏览器中点击复选框,控制台输出的任务对象 completed 属性始终为 true,而界面上的复选框却可以自由切换。这清楚地展示了 UI 与状态的脱节问题。

修复这个问题的方法是使用不可变数据更新模式。更新 toggleTaskCompleted 函数,使用 map 遍历任务数组并对匹配 ID 的任务取反 completed 属性:

jsx 复制代码
function toggleTaskCompleted(id) {
  const updatedTasks = tasks.map((task) => {
    if (id === task.id) {
      return { ...task, completed: !task.completed };
    }
    return task;
  });
  setTasks(updatedTasks);
}

这里使用对象扩展运算符创建新任务对象,确保不直接修改原对象,符合 React 的不可变数据原则。只有 ID 匹配的任务会更新 completed 属性,其余任务原样返回。最后通过 setTasks 更新状态,React 会自动重新渲染受影响的组件。这种创建新数组和新对象的方式确保了 React 能够正确检测到状态变化并进行重新渲染。

删除任务的实现

删除任务遵循与切换完成状态相似的模式:在 App 组件中定义 deleteTask 函数,通过回调 props 传递给 Todo 组件,然后在删除按钮的点击事件中调用。

在 App 组件中定义 deleteTask 函数,暂时用 console.log 验证调用:

jsx 复制代码
function deleteTask(id) {
  console.log(id);
}

deleteTask 传递给 Todo 组件:

jsx 复制代码
const taskList = tasks.map((task) => (
  <Todo
    id={task.id}
    name={task.name}
    completed={task.completed}
    key={task.id}
    toggleTaskCompleted={toggleTaskCompleted}
    deleteTask={deleteTask}
  />
));

Todo.jsx 中,为删除按钮绑定点击事件:

jsx 复制代码
<button
  type="button"
  className="btn btn__danger"
  onClick={() => props.deleteTask(props.id)}>
  Delete <span className="visually-hidden">{props.name}</span>
</button>

此时完整的 Todo 组件代码如下:

jsx 复制代码
function Todo(props) {
  return (
    <li className="todo stack-small">
      <div className="c-cb">
        <input
          id={props.id}
          type="checkbox"
          defaultChecked={props.completed}
          onChange={() => props.toggleTaskCompleted(props.id)}
        />
        <label className="todo-label" htmlFor={props.id}>
          {props.name}
        </label>
      </div>
      <div className="btn-group">
        <button type="button" className="btn">
          Edit <span className="visually-hidden">{props.name}</span>
        </button>
        <button
          type="button"
          className="btn btn__danger"
          onClick={() => props.deleteTask(props.id)}>
          Delete <span className="visually-hidden">{props.name}</span>
        </button>
      </div>
    </li>
  );
}

export default Todo;

点击删除按钮时,控制台会输出对应任务的 ID。

删除操作天然适合使用数组的 filter 方法。更新 deleteTask 函数,使用 filter 过滤掉指定 ID 的任务:

jsx 复制代码
function deleteTask(id) {
  const remainingTasks = tasks.filter((task) => id !== task.id);
  setTasks(remainingTasks);
}

filter 会创建一个新数组,只包含通过测试条件的元素。在这里我们测试每个任务的 ID 是否与要删除的 ID 不同,这样就能过滤掉需要删除的那个任务,保留所有其他任务。将过滤后的数组传递给 setTasks,React 就会从 UI 中移除对应的任务项。

删除按钮的实现细节也值得关注。为了提升可访问性,按钮内包含了一个 visually-hidden 类的 span 元素,用于向屏幕阅读器提供任务的名称信息,这样视障用户就能清楚地知道将要删除的是哪个任务。

完整的 App 组件代码

经过上述所有功能的实现,App 组件的完整代码如下:

jsx 复制代码
import { useState } from "react";
import { nanoid } from "nanoid";
import Todo from "./components/Todo";
import Form from "./components/Form";
import FilterButton from "./components/FilterButton";

function App(props) {
  const [tasks, setTasks] = useState(props.tasks);

  function addTask(name) {
    const newTask = { id: `todo-${nanoid()}`, name, completed: false };
    setTasks([...tasks, newTask]);
  }

  function toggleTaskCompleted(id) {
    const updatedTasks = tasks.map((task) => {
      if (id === task.id) {
        return { ...task, completed: !task.completed };
      }
      return task;
    });
    setTasks(updatedTasks);
  }

  function deleteTask(id) {
    const remainingTasks = tasks.filter((task) => id !== task.id);
    setTasks(remainingTasks);
  }

  const taskList = tasks?.map((task) => (
    <Todo
      id={task.id}
      name={task.name}
      completed={task.completed}
      key={task.id}
      toggleTaskCompleted={toggleTaskCompleted}
      deleteTask={deleteTask}
    />
  ));

  const tasksNoun = taskList.length !== 1 ? "tasks" : "task";
  const headingText = `${taskList.length} ${tasksNoun} remaining`;

  return (
    <div className="todoapp stack-large">
      <h1>TodoMatic</h1>
      <Form addTask={addTask} />
      <div className="filters btn-group stack-exception">
        <FilterButton />
        <FilterButton />
        <FilterButton />
      </div>
      <h2 id="list-heading">{headingText}</h2>
      <ul
        role="list"
        className="todo-list stack-large stack-exception"
        aria-labelledby="list-heading">
        {taskList}
      </ul>
    </div>
  );
}

export default App;

事件与状态协作的完整流程

回顾整个交互功能的实现过程,我们可以总结出 React 中事件与状态协作的标准流程:用户操作触发事件,事件处理函数被调用,在函数中根据事件信息更新状态,React 自动重新渲染组件以反映新的状态。这个单向数据流模式清晰可控,降低了复杂应用的维护难度。

在整个过程中,状态提升是一个重要的设计决策。我们将任务列表的状态放在 App 组件中管理,因为它是最顶层的组件,能够将状态通过 props 分发给所有需要它的子组件。addTasktoggleTaskCompleteddeleteTask 等操作状态的方法也定义在 App 中,确保了状态修改逻辑的集中管理。这种模式避免了状态分散在多个组件中导致的同步问题,使应用的行为更加可预测。

总结

通过本文的学习,我们掌握了 React 交互性开发的核心技术。事件处理让组件能够响应用户的操作,包括点击按钮、提交表单和输入文字等。回调 props 打破了数据只能从父到子单向流动的限制,使子组件能够将数据传递回父组件。useState 钩子为组件提供了持久化和更新数据的能力,是 React 函数组件管理状态的基础。

这些技术的组合使用,使我们成功地将一个静态界面转变为了功能完备的交互式任务管理应用。目前应用已经支持添加任务、删除任务和切换任务完成状态,任务计数能够实时更新,所有操作都保持 UI 与状态的同步。

在下一篇文章中,我们将继续扩展应用的功能,实现任务的编辑操作和基于完成状态的任务过滤功能,并深入学习条件渲染的相关知识。随着功能的逐步完善,我们将越来越深刻地体会到 React 组件化架构和状态管理机制带来的开发效率和代码可维护性提升。