React 交互性:事件和状态

在确定了组件计划后,现在是时候开始将我们的应用程序从一个完全静态的 UI 更新为一个实际允许我们进行交互和更改的应用程序了。在本文中,我们将这样做,在此过程中深入研究事件和状态,最终得到一个我们可以成功添加和删除任务、并切换任务完成状态的应用程序。

预备知识 熟悉核心 HTMLCSSJavaScript 语言,以及 终端/命令行
学习成果 在 React 中处理事件和状态,并利用它们使案例研究应用程序具有交互性。

处理事件

如果你以前只编写过原生 JavaScript,你可能习惯于有一个单独的 JavaScript 文件,在该文件中查询一些 DOM 节点并向它们附加监听器。例如,一个 HTML 文件可能包含一个按钮,如下所示

html
<button type="button">Say hi!</button>

而一个 JavaScript 文件可能包含一些如下所示的代码

js
const btn = document.querySelector("button");

btn.addEventListener("click", () => {
  alert("hi!");
});

在 JSX 中,描述 UI 的代码与事件监听器并存

jsx
<button type="button" onClick={() => alert("hi!")}>
  Say hi!
</button>

在这个例子中,我们向 <button> 元素添加了一个 onClick 属性。该属性的值是一个触发 alert 的函数。这可能看起来与关于不在 HTML 中编写事件监听器的最佳实践建议相悖,但请记住:JSX 不是 HTML。

onClick 属性在此处具有特殊含义:它告诉 React 在用户点击按钮时运行给定函数。还有其他几点需要注意

  • onClick驼峰命名法很重要——JSX 不会识别 onclick(同样,它已在 JavaScript 中用于特定目的,相关但不同——标准 onclick 处理程序属性)。
  • 所有浏览器事件在 JSX 中都遵循这种格式——on,后跟事件的名称。

让我们将此应用于我们的应用程序,从 Form.jsx 组件开始。

处理表单提交

Form() 组件函数的顶部(即,在 function Form() { 行下方),创建一个名为 handleSubmit() 的函数。此函数应阻止 submit 事件的默认行为。之后,它应触发一个 alert(),内容可以是你想要的任何内容。它最终应该看起来像这样

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

要使用此函数,请向 <form> 元素添加一个 onSubmit 属性,并将其值设置为 handleSubmit 函数

jsx
<form onSubmit={handleSubmit}>{/* … */}</form>

现在,如果你回到浏览器并点击“添加”按钮,你的浏览器将显示一个带有“Hello, world!”字样的 alert 对话框——或者你选择在那里写入的任何内容。

回调 prop

在 React 应用程序中,交互性很少局限于一个组件:在一个组件中发生的事件会影响应用程序的其他部分。当我们开始赋予自己创建新任务的能力时,<Form /> 组件中发生的事情会影响在 <App /> 中渲染的列表。

我们希望我们的 handleSubmit() 函数最终帮助我们创建新任务,因此我们需要一种方法将信息从 <Form /> 传递到 <App />。我们不能像使用标准 prop 从父级传递数据到子级一样,以相同的方式从子级传递数据到父级。相反,我们可以在 <App /> 中编写一个函数,该函数将期望我们的表单中的一些数据作为输入,然后将该函数作为 prop 传递给 <Form />。这种作为 prop 的函数称为回调 prop。一旦我们有了回调 prop,我们就可以在 <Form /> 内部调用它,以将正确的数据发送到 <App />

通过回调处理表单提交

App.jsx 中的 App() 函数内部,创建一个名为 addTask() 的函数,它有一个名为 name 的参数

jsx
function addTask(name) {
  alert(name);
}

接下来,将 addTask() 作为 prop 传递给 <Form />。prop 可以有你想要的任何名称,但选择一个你以后能理解的名称。像 addTask 这样的名称可以,因为它与函数的名称以及函数将执行的操作相匹配。你的 <Form /> 组件调用应该更新如下

jsx
<Form addTask={addTask} />

为了使用这个 prop,我们必须更改 Form.jsxForm() 函数的签名,使其接受 props 作为参数

jsx
function Form(props) {
  // …
}

最后,我们可以在 <Form /> 组件的 handleSubmit() 函数中使用这个 prop!更新它如下

jsx
function handleSubmit(event) {
  event.preventDefault();
  props.addTask("Say hello!");
}

点击浏览器中的“添加”按钮将证明 addTask() 回调函数有效,但是如果我们可以让 alert 显示我们在输入字段中输入的内容,那就更好了!这就是我们接下来要做的。

旁注:关于命名约定

我们将 addTask() 函数作为 prop addTask 传递给 <Form /> 组件,以便 addTask() 函数addTask prop 之间的关系尽可能清晰。但是请记住,prop 名称不需要是特定的任何东西。我们可以将 addTask() 以任何其他名称传递给 <Form />,例如这样

diff
- <Form addTask={addTask} />
+ <Form onSubmit={addTask} />

这将使 addTask() 函数作为 prop onSubmit 可用于 <Form /> 组件。该 prop 可以在 Form.jsx 中像这样使用

diff
function handleSubmit(event) {
  event.preventDefault();
- props.addTask("Say hello!");
+ props.onSubmit("Say hello!");
}

在这里,on 前缀告诉我们这个 prop 是一个回调函数;Submit 提示我们提交事件将触发这个函数。

虽然回调 prop 通常与熟悉的事件处理程序名称匹配,例如 onSubmitonClick,但它们几乎可以命名为任何有助于明确其含义的名称。一个假设的 <Menu /> 组件可能包含一个在菜单打开时运行的回调函数,以及一个在菜单关闭时运行的单独回调函数

jsx
<Menu onOpen={() => console.log("Hi!")} onClose={() => console.log("Bye!")} />

这种 on* 命名约定在 React 生态系统中非常常见,因此在你继续学习时请记住它。为了清晰起见,在本教程的其余部分中,我们将坚持使用 addTask 和类似的 prop 名称。如果你在本节阅读时更改了任何 prop 名称,请务必在继续之前将它们改回来!

使用状态持久化和更改数据

到目前为止,我们已经使用 prop 在组件之间传递数据,这已经很好地满足了我们的需求。然而,现在我们正在处理交互性,我们需要创建新数据、保留它并在以后更新它的能力。Prop 不是这项工作的正确工具,因为它们是不可变的——组件无法更改或创建自己的 prop。

这就是状态的用武之地。如果我们将 prop 视为组件之间通信的一种方式,我们可以将状态视为赋予组件“记忆”的一种方式——它们可以保存和根据需要更新的信息。

React 提供了一个特殊的函数,用于向组件引入状态,恰当地命名为 useState()

注意: useState() 属于一类特殊的函数,称为 hook,每个 hook 都可以用于向组件添加新功能。我们将在后面学习其他 hook。

要使用 useState(),我们需要从 React 模块导入它。将以下行添加到 Form.jsx 文件的顶部,在 Form() 函数定义之上

jsx
import { useState } from "react";

useState() 接受一个参数,该参数确定状态的初始值。此参数可以是字符串、数字、数组、对象或任何其他 JavaScript 数据类型。useState() 返回一个包含两个项的数组。第一项是状态的当前值;第二项是可以用于更新状态的函数。

让我们创建一个 name 状态。在 Form() 内部的 handleSubmit() 函数上方编写以下内容

jsx
const [name, setName] = useState("Learn React");

这行代码中发生了几件事

  • 我们正在定义一个值为 "Learn React"name 常量。
  • 我们正在定义一个名为 setName() 的函数,其作用是修改 name
  • useState() 将这两件事以数组形式返回,因此我们使用数组解构将它们都捕获到单独的变量中。

读取状态

你可以立即看到 name 状态的效果。向表单的输入添加一个 value 属性,并将其值设置为 name。你的浏览器将在输入框中渲染“Learn React”。

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

完成后将“Learn React”更改为空字符串;这是我们想要的初始状态

jsx
const [name, setName] = useState("");

读取用户输入

在我们更改 name 的值之前,我们需要捕获用户在输入时的输入。为此,我们可以监听 onChange 事件。让我们编写一个 handleChange() 函数,并在 <input /> 元素上监听它。

jsx
// near the top of the `Form` component
function handleChange() {
  console.log("Typing!");
}

// …

// Down in the return statement
<input
  type="text"
  id="new-todo-input"
  className="input input__lg"
  name="text"
  autoComplete="off"
  value={name}
  onChange={handleChange}
/>;

目前,当你尝试在输入框中输入文本时,输入框的值不会改变,但你的浏览器会将“Typing!”这个词记录到 JavaScript 控制台,因此我们知道我们的事件监听器已附加到输入框。

要读取用户的按键,我们必须访问输入的 value 属性。我们可以通过读取 handleChange() 被调用时接收到的 event 对象来做到这一点。event 又有一个target 属性,它表示触发 change 事件的元素。那就是我们的输入。所以,event.target.value 是输入框中的文本。

你可以 console.log() 这个值以在浏览器的控制台中查看它。尝试按如下方式更新 handleChange() 函数,并在输入框中输入以在控制台中查看结果

jsx
function handleChange(event) {
  console.log(event.target.value);
}

更新状态

仅仅记录是不够的——我们希望实际存储用户输入的内容并将其呈现在输入框中!将你的 console.log() 调用更改为 setName(),如下所示

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

现在,当你在输入框中输入时,你的按键将填充输入框,正如你所期望的那样。

我们还有一步:我们需要更改 handleSubmit() 函数,使其以 name 作为参数调用 props.addTask。还记得我们的回调 prop 吗?这将用于将任务发送回 App 组件,以便我们可以在稍后将其添加到任务列表中。作为一项良好实践,你应在提交表单后清除输入框,因此我们将再次调用 setName() 并传入一个空字符串来完成此操作

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

最后,你可以在浏览器中的输入字段中输入一些内容,然后点击“添加”——你输入的内容将出现在一个 alert 对话框中。

你的 Form.jsx 文件现在应该像这样

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 按钮而不输入任务名称即可提交空任务。你能想到一种方法来防止这种情况吗?作为提示,你可能需要在 handleSubmit() 函数中添加某种检查。

整合所有内容:添加任务

现在我们已经练习了事件、回调 prop 和 hook,我们已经准备好编写功能,允许用户从浏览器添加新任务。

作为状态的任务

我们需要将 useState 导入到 App.jsx 中,以便我们可以将任务存储在状态中。将以下内容添加到 App.jsx 文件的顶部

jsx
import { useState } from "react";

我们希望将 props.tasks 传递给 useState() hook——这将保留其初始状态。将以下内容添加到 App() 函数定义的顶部

jsx
const [tasks, setTasks] = useState(props.tasks);

现在,我们可以更改我们的 taskList 映射,使其成为映射 tasks 的结果,而不是 props.tasks。你的 taskList 常量声明现在应该如下所示

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

添加任务

我们现在有一个 setTasks hook,可以在我们的 addTask() 函数中使用它来更新我们的任务列表。但是有一个问题:我们不能简单地将 addTask()name 参数传递给 setTasks,因为 tasks 是一个对象数组而 name 是一个字符串。如果我们尝试这样做,数组将被字符串替换。

首先,我们需要将 name 放入一个与我们现有任务具有相同结构的对象中。在 addTask() 函数内部,我们将创建一个 newTask 对象以添加到数组中。

然后,我们需要创建一个包含此新任务的新数组,然后将任务数据的状态更新为这个新状态。为此,我们可以使用展开语法来复制现有数组,并将我们的对象添加到末尾。然后我们将此数组传递给 setTasks() 以更新状态。

综合所有这些,你的 addTask() 函数应该如下所示

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

现在你可以使用浏览器向我们的数据添加任务了!在表单中输入任何内容并点击“添加”(或按 Enter 键),你将看到你的新待办事项出现在 UI 中!

然而,我们还有另一个问题:我们的 addTask() 函数给每个任务相同的 id。这不利于可访问性,并使 React 无法使用 key prop 区分未来的任务。实际上,React 会在你的 DevTools 控制台中发出警告——“Warning: Encountered two children with the same key…”

我们需要解决这个问题。创建唯一标识符是一个难题——JavaScript 社区为此编写了一些有用的库。我们将使用 nanoid,因为它小巧且有效。

确保你在应用程序的根目录中,并运行以下终端命令

bash
npm install nanoid

注意:如果你使用的是 yarn,则需要使用 yarn add nanoid

现在我们可以使用 nanoid 为我们的新任务创建唯一的 ID。首先,通过在 App.jsx 的顶部包含以下行来导入它

jsx
import { nanoid } from "nanoid";

现在让我们更新 addTask(),以便每个任务 ID 变为前缀 todo- 加上由 nanoid 生成的唯一字符串。将你的 newTask 常量声明更新为

jsx
const newTask = { id: `todo-${nanoid()}`, name, completed: false };

保存所有内容,然后再次尝试你的应用程序——现在你可以添加任务而不会收到关于重复 ID 的警告了。

岔路:任务计数

现在我们可以添加新任务了,你可能会注意到一个问题:无论我们有多少任务,我们的标题都显示“3 tasks remaining”!我们可以通过计算 taskList 的长度并相应地更改标题文本来解决这个问题。

在你的 App() 定义内部,在 return 语句之前添加此内容

jsx
const headingText = `${taskList.length} tasks remaining`;

这几乎是正确的,只不过如果我们的列表只包含一个任务,标题仍然会使用“tasks”这个词。我们也可以将它变成一个变量。按如下方式更新你刚刚添加的代码

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

现在你可以将列表标题的文本内容替换为 headingText 变量。更新你的 <h2> 如下所示

jsx
<h2 id="list-heading">{headingText}</h2>

保存文件,返回浏览器,然后尝试添加一些任务:计数现在应该按预期更新。

完成任务

你可能会注意到,当你点击复选框时,它会适当地选中和取消选中。作为 HTML 的一个特性,浏览器知道如何记住哪些复选框输入被选中或未选中,而无需我们的帮助。然而,这个特性隐藏了一个问题:切换复选框不会改变我们 React 应用程序中的状态。这意味着浏览器和我们的应用程序现在不同步。我们必须编写自己的代码,使浏览器与我们的应用程序保持同步。

证明 bug

在我们解决问题之前,让我们观察它的发生。

我们将从在 App() 组件中编写一个 toggleTaskCompleted() 函数开始。此函数将有一个 id 参数,但我们暂时不会使用它。目前,我们将数组中的第一个任务记录到控制台——我们将检查当我们在浏览器中选中或取消选中它时会发生什么

将此添加到 taskList 常量声明上方

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

接下来,我们将 toggleTaskCompleted 添加到 taskList 中渲染的每个 <Todo /> 组件的 prop 中;将其更新如下

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

接下来,转到你的 Todo.jsx 组件,并向你的 <input /> 元素添加一个 onChange 处理程序,该处理程序应使用匿名函数调用 props.toggleTaskCompleted(),并传入 props.id 作为参数。<input /> 现在应该如下所示

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

保存所有内容并返回浏览器,注意我们的第一个任务“吃”被选中了。打开你的 JavaScript 控制台,然后点击“吃”旁边的复选框。它取消选中了,正如我们所期望的。然而,你的 JavaScript 控制台将记录如下内容

Object { id: "task-0", name: "Eat", completed: true }

复选框在浏览器中取消选中,但我们的控制台告诉我们“吃”仍然已完成。我们接下来会解决这个问题!

使浏览器与我们的数据同步

让我们重新访问 App.jsx 中的 toggleTaskCompleted() 函数。我们希望它只更改被切换任务的 completed 属性,而其他任务保持不变。为此,我们将遍历任务列表并只更改我们完成的任务。

将你的 toggleTaskCompleted() 函数更新为以下内容

jsx
function toggleTaskCompleted(id) {
  const updatedTasks = tasks.map((task) => {
    // if this task has the same ID as the edited task
    if (id === task.id) {
      // use object spread to make a new object
      // whose `completed` prop has been inverted
      return { ...task, completed: !task.completed };
    }
    return task;
  });
  setTasks(updatedTasks);
}

在这里,我们定义了一个 updatedTasks 常量,它映射原始 tasks 数组。如果任务的 id 属性与提供给函数的 id 匹配,我们使用对象展开语法创建一个新对象,并在返回之前切换该对象的 completed 属性。如果不匹配,我们返回原始对象。

然后我们使用这个新数组调用 setTasks() 来更新我们的状态。

删除任务

删除任务将遵循与切换其完成状态类似的模式:我们需要定义一个用于更新状态的函数,然后将该函数作为 prop 传递给 <Todo /> 并在正确事件发生时调用它。

deleteTask 回调 prop

在这里,我们将首先在你的 App 组件中编写一个 deleteTask() 函数。像 toggleTaskCompleted() 一样,这个函数将接受一个 id 参数,我们将首先将该 id 记录到控制台。在 toggleTaskCompleted() 下方添加以下内容

jsx
function deleteTask(id) {
  console.log(id);
}

接下来,向我们的 <Todo /> 组件数组添加另一个回调 prop

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 中,当“删除”按钮被按下时,我们希望调用 props.deleteTask()deleteTask() 需要知道调用它的任务的 ID,以便它可以从状态中删除正确的任务。

更新 Todo.jsx 中的“删除”按钮,如下所示

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

现在,当你点击应用程序中的任何“删除”按钮时,你的浏览器控制台应该记录相关任务的 ID。

此时,你的 Todo.jsx 文件应该看起来像这样

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;

从状态和 UI 中删除任务

现在我们知道 deleteTask() 被正确调用了,我们可以调用 deleteTask() 中的 setTasks() hook,以实际从应用程序的状态中删除该任务,并在应用程序 UI 中进行视觉删除。由于 setTasks() 期望一个数组作为参数,我们应该提供一个新数组,该数组复制现有任务,排除与传递给 deleteTask() 的 ID 匹配的任务。

这是一个使用 Array.prototype.filter() 的绝佳机会。我们可以测试每个任务,如果任务的 id prop 与传递给 deleteTask()id 参数匹配,则从新数组中排除该任务。

按如下方式更新 App.jsx 文件中的 deleteTask() 函数

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

再次尝试你的应用程序。现在你应该能够从你的应用程序中删除任务了!

此时,你的 App.jsx 文件应该看起来像这样

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 this task has the same ID as the edited task
      if (id === task.id) {
        // use object spread to make a new object
        // whose `completed` prop has been inverted
        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 如何处理事件和管理状态,并实现了添加任务、删除任务以及切换任务完成状态的功能。我们即将完成。在下一篇文章中,我们将实现编辑现有任务和按所有、已完成和未完成任务过滤任务列表的功能。在此过程中,我们将探讨条件 UI 渲染。