React 交互性:事件和状态

在我们的组件计划制定完成后,现在是时候开始将我们的应用程序从完全静态的 UI 更新为一个允许我们交互和更改内容的 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 属性。该属性的值是一个触发简单警报的函数。这似乎与关于不要在 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}>

现在,如果您返回浏览器并点击“添加”按钮,您的浏览器将显示一个包含“Hello, world!”的警报对话框——或者您选择写入的内容。

回调 props

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

我们希望我们的 handleSubmit() 函数最终帮助我们创建一个新任务,因此我们需要一种方法将信息从 <Form /> 传递到 <App />。我们不能像使用标准 props 从父组件传递数据到子组件那样,以相同的方式从子组件传递数据到父组件。相反,我们可以在 <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() 回调函数有效,但如果我们可以让警报显示我们在输入字段中键入的内容,那就更好了!这是我们接下来要做的。

旁注:关于命名约定的说明

我们将 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 名称,请务必在继续之前将其更改回原样!

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

到目前为止,我们已使用 props 通过组件传递数据,这为我们服务得很好。但是,现在我们正在处理交互性,我们需要能够创建新数据,保留它并在以后更新它。Props 不适合此任务,因为它们是不可变的——组件无法更改或创建自己的 props。

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

React 提供了一个专门用于向组件引入状态的函数,恰如其分地命名为 useState()

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

要使用 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 的常量。
  • 我们正在定义一个函数,其作用是修改 name,称为 setName()
  • 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("");
}

最后,您可以在浏览器中输入字段中键入内容并点击添加——您键入的内容将显示在警报对话框中。

您的 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;

注意:您会注意到,只需点击添加按钮而不输入任务名称,就可以提交空任务。您能想到阻止这种情况的方法吗?提示:您可能需要在handleSubmit()函数中添加某种检查。

综合应用:添加任务

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

任务作为状态

我们需要将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 控制台中向您发出警告——“警告:遇到两个具有相同键的子节点……”

我们需要解决这个问题。生成唯一标识符是一个难题——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 个任务”!我们可以通过计算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 />组件的 props 中;按如下所示更新它

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.id参数的props.toggleTaskCompleted()<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()期望一个数组作为参数,因此我们应该为它提供一个新的数组,该数组复制现有的任务,排除其 ID 与传递到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) {
  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 [tasks, setTasks] = useState(props.tasks);
  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 渲染。