React 交互性:事件和状态
在确定了组件计划后,现在是时候开始将我们的应用程序从一个完全静态的 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 属性。该属性的值是一个触发 alert 的函数。这可能看起来与关于不在 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}>{/* … */}</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 的参数
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() 回调函数有效,但是如果我们可以让 alert 显示我们在输入字段中输入的内容,那就更好了!这就是我们接下来要做的。
旁注:关于命名约定
我们将 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 名称,请务必在继续之前将它们改回来!
使用状态持久化和更改数据
到目前为止,我们已经使用 prop 在组件之间传递数据,这已经很好地满足了我们的需求。然而,现在我们正在处理交互性,我们需要创建新数据、保留它并在以后更新它的能力。Prop 不是这项工作的正确工具,因为它们是不可变的——组件无法更改或创建自己的 prop。
这就是状态的用武之地。如果我们将 prop 视为组件之间通信的一种方式,我们可以将状态视为赋予组件“记忆”的一种方式——它们可以保存和根据需要更新的信息。
React 提供了一个特殊的函数,用于向组件引入状态,恰当地命名为 useState()。
注意: useState() 属于一类特殊的函数,称为 hook,每个 hook 都可以用于向组件添加新功能。我们将在后面学习其他 hook。
要使用 useState(),我们需要从 React 模块导入它。将以下行添加到 Form.jsx 文件的顶部,在 Form() 函数定义之上
import { useState } from "react";
useState() 接受一个参数,该参数确定状态的初始值。此参数可以是字符串、数字、数组、对象或任何其他 JavaScript 数据类型。useState() 返回一个包含两个项的数组。第一项是状态的当前值;第二项是可以用于更新状态的函数。
让我们创建一个 name 状态。在 Form() 内部的 handleSubmit() 函数上方编写以下内容
const [name, setName] = useState("Learn React");
这行代码中发生了几件事
- 我们正在定义一个值为
"Learn React"的name常量。 - 我们正在定义一个名为
setName()的函数,其作用是修改name。 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("");
}
最后,你可以在浏览器中的输入字段中输入一些内容,然后点击“添加”——你输入的内容将出现在一个 alert 对话框中。
你的 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 按钮而不输入任务名称即可提交空任务。你能想到一种方法来防止这种情况吗?作为提示,你可能需要在 handleSubmit() 函数中添加某种检查。
整合所有内容:添加任务
现在我们已经练习了事件、回调 prop 和 hook,我们已经准备好编写功能,允许用户从浏览器添加新任务。
作为状态的任务
我们需要将 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 控制台中发出警告——“Warning: Encountered two children with the same key…”
我们需要解决这个问题。创建唯一标识符是一个难题——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 tasks remaining”!我们可以通过计算 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 /> 组件的 prop 中;将其更新如下
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 /> 现在应该如下所示
<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() 期望一个数组作为参数,我们应该提供一个新数组,该数组复制现有任务,排除与传递给 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) {
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 渲染。