React 交互性:事件和状态
在我们的组件计划制定完成后,现在是时候开始将我们的应用程序从完全静态的 UI 更新为一个允许我们交互和更改内容的 UI 了。在本文中,我们将完成此操作,在此过程中深入探讨事件和状态,并最终得到一个应用程序,在这个应用程序中,我们可以成功地添加和删除任务,并将任务切换为已完成状态。
先决条件 |
熟悉核心 HTML、CSS 和 JavaScript 语言,了解 终端/命令行。 |
---|---|
目标 | 学习如何在 React 中处理事件和状态,并使用它们开始使案例研究应用程序具有交互性。 |
处理事件
如果您之前只编写过原生 JavaScript,您可能习惯于拥有一个单独的 JavaScript 文件,在其中查询一些 DOM 节点并向其附加监听器。例如,一个 HTML 文件可能包含一个按钮,如下所示
<button type="button">Say hi!</button>
而一个 JavaScript 文件可能包含如下代码
const btn = document.querySelector("button");
btn.addEventListener("click", () => {
alert("hi!");
});
在 JSX 中,描述 UI 的代码与我们的事件监听器位于同一位置
<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()
,可以显示任何您想要的内容。它最终应该看起来像这样
function handleSubmit(event) {
event.preventDefault();
alert("Hello, world!");
}
要使用此函数,请向 <form>
元素添加一个 onSubmit
属性,并将其值设置为 handleSubmit
函数
<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
的参数
function addTask(name) {
alert(name);
}
接下来,将 addTask()
作为 prop 传递到 <Form />
中。此 prop 可以具有任何您想要的名称,但选择一个您以后可以理解的名称。像 addTask
这样的名称可以,因为它与函数的名称以及函数的作用相匹配。您的 <Form />
组件调用应更新如下
<Form addTask={addTask} />
要使用此 prop,我们必须更改 Form.jsx
中 Form()
函数的签名,以便它接受 props
作为参数
function Form(props) {
// ...
}
最后,我们可以在 <Form />
组件中的 handleSubmit()
函数内部使用此 prop!更新如下
function handleSubmit(event) {
event.preventDefault();
props.addTask("Say hello!");
}
点击浏览器中的“添加”按钮将证明 addTask()
回调函数有效,但如果我们可以让警报显示我们在输入字段中键入的内容,那就更好了!这是我们接下来要做的。
旁注:关于命名约定的说明
我们将 addTask()
函数作为 prop addTask
传递到 <Form />
组件中,以便 addTask()
函数和 addTask
prop 之间的关系尽可能清晰。但是请记住,prop 名称不 *需要* 是任何特定内容。我们可以将 addTask()
作为任何其他名称传递到 <Form />
中,例如
- <Form addTask={addTask} />
+ <Form onSubmit={addTask} />
这将使 addTask()
函数作为 prop onSubmit
可用于 <Form />
组件。此 prop 可以在 Form.jsx
中使用,如下所示
function handleSubmit(event) {
event.preventDefault();
- props.addTask("Say hello!");
+ props.onSubmit("Say hello!");
}
这里,on
前缀告诉我们 prop 是一个回调函数;Submit
是我们提交事件将触发此函数的线索。
虽然回调 prop 通常与熟悉事件处理程序的名称匹配,如 onSubmit
或 onClick
,但它们可以被命名为几乎任何有助于阐明其含义的内容。假设的 <Menu />
组件可能包含一个在菜单打开时运行的回调函数,以及一个在菜单关闭时运行的单独回调函数
<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()
函数定义之上添加以下行
import { useState } from "react";
useState()
接受一个参数,该参数确定状态的初始值。此参数可以是字符串、数字、数组、对象或任何其他 JavaScript 数据类型。useState()
返回一个包含两个项目的数组。第一个项目是状态的当前值;第二个项目是一个可用于更新状态的函数。
让我们创建一个 name
状态。在 Form()
内,在您的 handleSubmit()
函数上方编写以下内容
const [name, setName] = useState("Learn React");
这一行代码中发生了几件事
- 我们正在使用值
"Learn React"
定义一个名为name
的常量。 - 我们正在定义一个函数,其作用是修改
name
,称为setName()
。 useState()
在数组中返回这两样东西,因此我们正在使用 数组解构 将它们捕获到单独的变量中。
读取状态
您可以立即看到 name
状态的实际效果。向表单的输入添加一个 value
属性,并将其值设置为 name
。您的浏览器将在输入中呈现“Learn React”。
<input
type="text"
id="new-todo-input"
className="input input__lg"
name="text"
autoComplete="off"
value={name}
/>
完成后,将“Learn React”更改为空字符串;这是我们初始状态所需的。
const [name, setName] = useState("");
读取用户输入
在我们更改 name
的值之前,我们需要在用户键入时捕获其输入。为此,我们可以监听 onChange
事件。让我们编写一个 handleChange()
函数,并在 <input />
元素上监听它。
// 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()
函数,并在输入中键入以查看控制台中的结果
function handleChange(event) {
console.log(event.target.value);
}
更新状态
记录是不够的——我们希望实际存储用户键入的内容并在输入中呈现它!将您的 console.log()
调用更改为 setName()
,如下所示
function handleChange(event) {
setName(event.target.value);
}
现在,当您在输入中键入时,您的按键将填充输入,正如您预期的那样。
我们还有最后一步:我们需要更改我们的 handleSubmit()
函数,以便它使用 name
作为参数调用 props.addTask
。还记得我们的回调 prop 吗?这将用于将任务发送回 App
组件,以便我们稍后将其添加到任务列表中。作为良好实践的一部分,您应该在提交表单后清除输入,因此我们将再次使用空字符串调用 setName()
来执行此操作
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;
注意:您会注意到,只需点击添加
按钮而不输入任务名称,就可以提交空任务。您能想到阻止这种情况的方法吗?提示:您可能需要在handleSubmit()
函数中添加某种检查。
综合应用:添加任务
现在我们已经练习了事件、回调 props 和 hooks,我们准备编写允许用户从浏览器添加新任务的功能。
任务作为状态
我们需要将useState
导入到App.jsx
中,以便我们可以将任务存储在状态中。将以下内容添加到App.jsx
文件的顶部
import { useState } from "react";
我们希望将props.tasks
传递到useState()
hook 中——这将保留其初始状态。将以下内容添加到App()
函数定义的顶部
const [tasks, setTasks] = useState(props.tasks);
现在,我们可以更改我们的taskList
映射,使其成为tasks
而不是props.tasks
的映射结果。您的taskList
常量声明现在应该如下所示
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()
函数应该如下所示
function addTask(name) {
const newTask = { id: "id", name, completed: false };
setTasks([...tasks, newTask]);
}
现在您可以使用浏览器将任务添加到我们的数据中!在表单中键入任何内容并点击“添加”(或按Enter键),您将看到您的新待办事项出现在 UI 中!
但是,我们还有另一个问题:我们的addTask()
函数为每个任务赋予了相同的id
。这对可访问性不利,并且使 React 无法使用key
prop 区分未来的任务。事实上,React 会在您的 DevTools 控制台中向您发出警告——“警告:遇到两个具有相同键的子节点……”
我们需要解决这个问题。生成唯一标识符是一个难题——JavaScript 社区为此编写了一些有用的库。我们将使用nanoid,因为它很小而且有效。
确保您位于应用程序的根目录中,并运行以下终端命令
npm install nanoid
注意:如果您使用的是 yarn,则需要以下内容:yarn add nanoid
。
现在我们可以使用nanoid
为我们的新任务创建唯一的 ID。首先,通过在App.jsx
的顶部包含以下行来导入它
import { nanoid } from "nanoid";
现在让我们更新addTask()
,以便每个任务 ID 成为前缀todo-
加上 nanoid 生成的唯一字符串。将您的newTask
常量声明更新为此
const newTask = { id: `todo-${nanoid()}`, name, completed: false };
保存所有内容,然后再次尝试您的应用程序——现在您可以添加任务,而不会收到有关重复 ID 的警告。
岔路:统计任务
现在我们可以添加新任务了,您可能会注意到一个问题:无论我们有多少任务,我们的标题都显示为“剩余 3 个任务”!我们可以通过计算taskList
的长度并相应地更改标题的文本来解决此问题。
在您的App()
定义中添加此内容,在 return 语句之前
const headingText = `${taskList.length} tasks remaining`;
这几乎是正确的,除了如果我们的列表曾经包含单个任务,标题仍将使用“tasks”一词。我们也可以将其设为变量。按如下所示更新您刚刚添加的代码
const tasksNoun = taskList.length !== 1 ? "tasks" : "task";
const headingText = `${taskList.length} ${tasksNoun} remaining`;
现在您可以用headingText
变量替换列表标题的文本内容。按如下所示更新您的<h2>
<h2 id="list-heading">{headingText}</h2>
保存文件,返回浏览器,然后尝试添加一些任务:计数现在应该按预期更新。
完成任务
您可能会注意到,当您单击复选框时,它会适当地选中和取消选中。作为 HTML 的一项功能,浏览器知道如何在没有我们帮助的情况下记住哪些复选框输入被选中或未选中。但是,此功能隐藏了一个问题:切换复选框不会更改我们 React 应用程序中的状态。这意味着浏览器和我们的应用程序现在不同步了。我们必须编写自己的代码以使浏览器与我们的应用程序同步。
证明 bug
在修复问题之前,让我们观察它发生的现象。
我们将从在我们的App()
组件中编写一个toggleTaskCompleted()
函数开始。此函数将具有一个id
参数,但我们现在还不会使用它。现在,我们将数组中的第一个任务记录到控制台——我们将检查在浏览器中选中或取消选中它时会发生什么
将此内容添加到您的taskList
常量声明的正上方
function toggleTaskCompleted(id) {
console.log(tasks[0]);
}
接下来,我们将toggleTaskCompleted
添加到在我们的taskList
内部渲染的每个<Todo />
组件的 props 中;按如下所示更新它
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 />
现在应该如下所示
<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()
函数更新为以下内容
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()
下方
function deleteTask(id) {
console.log(id);
}
接下来,向我们的<Todo />
组件数组添加另一个回调 prop
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
中的“删除”按钮
<button
type="button"
className="btn btn__danger"
onClick={() => props.deleteTask(props.id)}>
Delete <span className="visually-hidden">{props.name}</span>
</button>
现在,当您单击应用程序中的任何“删除”按钮时,您的浏览器控制台都应记录相关任务的 ID。
此时,您的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;
从状态和 UI 中删除任务
现在我们知道deleteTask()
已正确调用,我们可以在deleteTask()
中调用我们的setTasks()
hook 以实际从应用程序的状态以及应用程序 UI 中的视觉上删除该任务。由于setTasks()
期望一个数组作为参数,因此我们应该为它提供一个新的数组,该数组复制现有的任务,排除其 ID 与传递到deleteTask()
中的 ID 匹配的任务。
这是一个使用Array.prototype.filter()
的绝佳机会。我们可以测试每个任务,如果任务的id
prop 与传递到deleteTask()
中的id
参数匹配,则从新数组中排除该任务。
按如下所示更新App.jsx
文件中的deleteTask()
函数
function deleteTask(id) {
const remainingTasks = tasks.filter((task) => id !== task.id);
setTasks(remainingTasks);
}
再次尝试您的应用程序。现在您应该能够从您的应用程序中删除任务了!
此时,您的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) {
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;