React 交互性:编辑、过滤、条件渲染

随着我们的 React 之旅接近尾声(至少目前是这样),我们将为待办事项列表应用的主要功能区域添加点睛之笔。这包括允许您编辑现有任务,以及在所有任务、已完成任务和未完成任务之间过滤任务列表。在此过程中,我们将探讨条件 UI 渲染。

预备知识 熟悉核心 HTMLCSSJavaScript 语言,以及 终端/命令行
学习成果 React 中的条件渲染,以及在我们的应用程序中实现列表过滤和编辑 UI。

编辑任务名称

我们还没有一个用于编辑任务名称的用户界面。我们稍后会讲到。首先,我们至少可以在 App.jsx 中实现一个 editTask() 函数。它会类似于 deleteTask(),因为它会接受一个 id 来查找其目标对象,但它还会接受一个 newName 属性,其中包含要更新为的任务名称。我们将使用 Array.prototype.map() 而不是 Array.prototype.filter(),因为我们想要返回一个包含一些更改的新数组,而不是从数组中删除某些内容。

在您的 <App /> 组件内部,与其他函数相同的位置添加 editTask() 函数。

jsx
function editTask(id, newName) {
  const editedTaskList = tasks.map((task) => {
    // if this task has the same ID as the edited task
    if (id === task.id) {
      // Copy the task and update its name
      return { ...task, name: newName };
    }
    // Return the original task if it's not the edited task
    return task;
  });
  setTasks(editedTaskList);
}

以与 deleteTask 相同的方式,将 editTask 作为 prop 传递到我们的 <Todo /> 组件中。

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

现在打开 Todo.jsx。我们将进行一些重构。

编辑 UI

为了允许用户编辑任务,我们必须为他们提供一个用户界面来执行此操作。首先,像我们之前在 <App /> 组件中那样,将 useState 导入到 <Todo /> 组件中。

jsx
import { useState } from "react";

我们将用它来设置一个 isEditing 状态,默认值为 false。在您的 <Todo /> 组件定义顶部内部添加以下行。

jsx
const [isEditing, setEditing] = useState(false);

接下来,我们将重新考虑 <Todo /> 组件。从现在开始,我们希望它显示两个可能的“模板”之一,而不是到目前为止使用的单个模板。

  • “查看”模板,当我们只是查看待办事项时;这是我们到目前为止在教程中使用的。
  • “编辑”模板,当我们正在编辑待办事项时。我们即将创建它。

将此代码块复制到 Todo() 函数中,放在 useState() 钩子下方但 return 语句上方。

jsx
const editingTemplate = (
  <form className="stack-small">
    <div className="form-group">
      <label className="todo-label" htmlFor={props.id}>
        New name for {props.name}
      </label>
      <input id={props.id} className="todo-text" type="text" />
    </div>
    <div className="btn-group">
      <button type="button" className="btn todo-cancel">
        Cancel
        <span className="visually-hidden">renaming {props.name}</span>
      </button>
      <button type="submit" className="btn btn__primary todo-edit">
        Save
        <span className="visually-hidden">new name for {props.name}</span>
      </button>
    </div>
  </form>
);
const viewTemplate = (
  <div className="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>
  </div>
);

我们现在已经将两种不同的模板结构(“编辑”和“视图”)定义在两个单独的常量中。这意味着 <Todo />return 语句现在是重复的——它也包含“视图”模板的定义。我们可以通过使用条件渲染来清理这一点,以确定组件返回哪个模板,从而在 UI 中渲染。

条件渲染

在 JSX 中,我们可以使用条件来更改浏览器渲染的内容。要在 JSX 中编写条件,我们可以使用三元运算符

在我们的 <Todo /> 组件的情况下,我们的条件是“此任务是否正在被编辑?”更改 Todo() 中的 return 语句,使其如下所示:

jsx
return <li className="todo">{isEditing ? editingTemplate : viewTemplate}</li>;

您的浏览器应该像以前一样渲染所有任务。要查看编辑模板,您现在必须在代码中将默认的 isEditing 状态从 false 更改为 true;我们将在下一节中探讨如何使编辑按钮切换此状态!

切换 <Todo /> 模板

终于,我们准备好让我们的最后一个核心功能具有交互性。首先,当用户按下我们 viewTemplate 中的“编辑”按钮时,我们希望调用 setEditing() 并将其值设置为 true,以便我们可以切换模板。

像这样更新 viewTemplate 中的“编辑”按钮

jsx
<button type="button" className="btn" onClick={() => setEditing(true)}>
  Edit <span className="visually-hidden">{props.name}</span>
</button>

现在我们将向 editingTemplate 中的“取消”按钮添加相同的 onClick 处理程序,但这次我们将 isEditing 设置为 false,以便它将我们切换回视图模板。

像这样更新 editingTemplate 中的“取消”按钮

jsx
<button
  type="button"
  className="btn todo-cancel"
  onClick={() => setEditing(false)}>
  Cancel
  <span className="visually-hidden">renaming {props.name}</span>
</button>

有了这段代码,您应该能够点击待办事项中的“编辑”和“取消”按钮来切换模板。

The eat todo item showing the view template, with edit and delete buttons available

The eat todo item showing the edit template, with an input field to enter a new name, and cancel and save buttons available

下一步是实际让编辑功能生效。

从 UI 中编辑

我们接下来要做的很多事情都将与我们在 Form.jsx 中所做的工作类似:当用户在我们的新输入字段中输入时,我们需要跟踪他们输入的文本;一旦他们提交表单,我们需要使用回调 prop 来使用任务的新名称更新我们的状态。

我们将首先创建一个新的 hook 来存储和设置新名称。仍在 Todo.jsx 中,将以下内容放在现有 hook 下面。

jsx
const [newName, setNewName] = useState("");

接下来,创建一个 handleChange() 函数来设置新名称;将其放在 hook 下方但在模板上方。

jsx
function handleChange(e) {
  setNewName(e.target.value);
}

现在我们将更新 editingTemplate<input /> 字段,设置 newNamevalue 属性,并将我们的 handleChange() 函数绑定到其 onChange 事件。如下更新它:

jsx
<input
  id={props.id}
  className="todo-text"
  type="text"
  value={newName}
  onChange={handleChange}
/>

最后,我们需要创建一个函数来处理编辑表单的 onSubmit 事件。将以下内容添加到 handleChange() 正下方。

jsx
function handleSubmit(e) {
  e.preventDefault();
  props.editTask(props.id, newName);
  setNewName("");
  setEditing(false);
}

请记住,我们的 editTask() 回调 prop 需要我们正在编辑的任务的 ID 以及其新名称。

通过向 editingTemplate<form> 添加以下 onSubmit 处理程序,将此函数绑定到表单的 submit 事件。

jsx
<form className="stack-small" onSubmit={handleSubmit}>
  {/* … */}
</form>

您现在应该能够在浏览器中编辑任务了。此时,您的 Todo.jsx 文件应该如下所示:

jsx
function Todo(props) {
  const [isEditing, setEditing] = useState(false);
  const [newName, setNewName] = useState("");

  function handleChange(e) {
    setNewName(e.target.value);
  }

  function handleSubmit(e) {
    e.preventDefault();
    props.editTask(props.id, newName);
    setNewName("");
    setEditing(false);
  }

  const editingTemplate = (
    <form className="stack-small" onSubmit={handleSubmit}>
      <div className="form-group">
        <label className="todo-label" htmlFor={props.id}>
          New name for {props.name}
        </label>
        <input
          id={props.id}
          className="todo-text"
          type="text"
          value={newName}
          onChange={handleChange}
        />
      </div>
      <div className="btn-group">
        <button
          type="button"
          className="btn todo-cancel"
          onClick={() => setEditing(false)}>
          Cancel
          <span className="visually-hidden">renaming {props.name}</span>
        </button>
        <button type="submit" className="btn btn__primary todo-edit">
          Save
          <span className="visually-hidden">new name for {props.name}</span>
        </button>
      </div>
    </form>
  );

  const viewTemplate = (
    <div className="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"
          onClick={() => {
            setEditing(true);
          }}>
          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>
    </div>
  );

  return <li className="todo">{isEditing ? editingTemplate : viewTemplate}</li>;
}

export default Todo;

返回到过滤按钮

现在我们的主要功能已经完成,我们可以考虑我们的过滤按钮了。目前,它们重复了“全部”标签,并且没有任何功能!我们将重新运用我们在 <Todo /> 组件中使用的某些技能来

  • 创建一个用于存储活动过滤器的 hook。
  • 渲染一个 <FilterButton /> 元素数组,允许用户在全部、已完成和未完成任务之间更改活动过滤器。

添加过滤钩子

向您的 App() 函数添加一个新的 hook,用于读取和设置过滤器。我们希望默认过滤器是 All,因为所有任务都应该在初始时显示。

jsx
const [filter, setFilter] = useState("All");

定义我们的过滤器

我们目前的目标是双重的

  • 每个过滤器都应该有一个唯一的名称。
  • 每个过滤器都应该有一个独特的行为。

JavaScript 对象是关联名称和行为的好方法:每个键都是过滤器的名称;每个属性是与该名称关联的行为。

App.jsx 的顶部,在我们的导入下方但在 App() 函数上方,让我们添加一个名为 FILTER_MAP 的对象。

jsx
const FILTER_MAP = {
  All: () => true,
  Active: (task) => !task.completed,
  Completed: (task) => task.completed,
};

FILTER_MAP 的值是我们将用于过滤 tasks 数据数组的函数。

  • All 过滤器显示所有任务,因此我们对所有任务返回 true
  • Active 过滤器显示 completed 属性为 false 的任务。
  • Completed 过滤器显示 completed 属性为 true 的任务。

在之前添加的内容下方,添加以下内容——在这里我们使用 Object.keys() 方法来收集一个 FILTER_NAMES 数组。

jsx
const FILTER_NAMES = Object.keys(FILTER_MAP);

注意:我们将这些常量定义在 App() 函数之外,因为如果它们定义在函数内部,每次 <App /> 组件重新渲染时它们都会被重新计算,而我们不希望这样。无论我们的应用程序做什么,这些信息都不会改变。

渲染过滤器

现在我们有了 FILTER_NAMES 数组,我们可以用它来渲染所有三个过滤器。在 App() 函数内部,我们可以创建一个名为 filterList 的常量,我们将用它来遍历名称数组并返回一个 <FilterButton /> 组件。记住,这里也需要 keys。

在您的 taskList 常量声明下方添加以下内容

jsx
const filterList = FILTER_NAMES.map((name) => (
  <FilterButton key={name} name={name} />
));

现在我们将用这个 filterList 替换 App.jsx 中重复的三个 <FilterButton />。替换以下内容:

jsx
<div className="filters btn-group stack-exception">
  <FilterButton />
  <FilterButton />
  <FilterButton />
</div>

使用这个

jsx
<div className="filters btn-group stack-exception">{filterList}</div>

这还行不通。我们还有一些工作要做。

交互式过滤器

为了使我们的过滤按钮具有交互性,我们应该考虑它们需要利用哪些 props。

  • 我们知道 <FilterButton /> 应该报告它当前是否被按下,如果它的名称与我们过滤器状态的当前值匹配,它就应该被按下。
  • 我们知道 <FilterButton /> 需要一个回调来设置活动过滤器。我们可以直接使用我们的 setFilter hook。

如下更新您的 filterList 常量

jsx
const filterList = FILTER_NAMES.map((name) => (
  <FilterButton
    key={name}
    name={name}
    isPressed={name === filter}
    setFilter={setFilter}
  />
));

与我们之前处理 <Todo /> 组件的方式相同,现在我们必须更新 FilterButton.jsx 以利用我们提供的 prop。执行以下各项操作,并记住使用花括号来读取这些变量!

  • all 替换为 {props.name}
  • aria-pressed 的值设置为 {props.isPressed}
  • 添加一个 onClick 处理程序,该处理程序以过滤器的名称调用 props.setFilter()

完成所有这些后,您的 FilterButton.jsx 文件应该如下所示:

jsx
function FilterButton(props) {
  return (
    <button
      type="button"
      className="btn toggle-btn"
      aria-pressed={props.isPressed}
      onClick={() => props.setFilter(props.name)}>
      <span className="visually-hidden">Show </span>
      <span>{props.name}</span>
      <span className="visually-hidden"> tasks</span>
    </button>
  );
}

export default FilterButton;

再次访问您的浏览器。您应该会看到不同的按钮被赋予了各自的名称。当您按下过滤按钮时,您应该会看到其文本呈现新的轮廓——这表明它已被选中。如果您在点击按钮时查看 DevTool 的页面检查器,您会看到 aria-pressed 属性值相应地发生变化。

The three filter buttons of the app - all, active, and completed - with a focus highlight around completed

然而,我们的按钮仍然没有真正过滤 UI 中的待办事项!让我们完成这项工作。

在 UI 中过滤任务

目前,App() 中的 taskList 常量遍历任务状态并为所有任务返回一个新的 <Todo /> 组件。这不是我们想要的!任务只有在包含在应用所选过滤器的结果中时才应该渲染。在我们遍历任务状态之前,我们应该对其进行过滤(使用 Array.prototype.filter())以消除我们不想渲染的对象。

像这样更新您的 taskList

jsx
const taskList = tasks
  .filter(FILTER_MAP[filter])
  .map((task) => (
    <Todo
      id={task.id}
      name={task.name}
      completed={task.completed}
      key={task.id}
      toggleTaskCompleted={toggleTaskCompleted}
      deleteTask={deleteTask}
      editTask={editTask}
    />
  ));

为了决定在 Array.prototype.filter() 中使用哪个回调函数,我们访问 FILTER_MAP 中与我们的过滤器状态键对应的值。例如,当过滤器为 All 时,FILTER_MAP[filter] 将评估为 () => true

现在,在浏览器中选择一个过滤器将删除不符合其条件的任务。列表上方标题中的计数也将更改以反映列表!

The app with the filter buttons in place. Active is highlighted, so only the active todo items are being shown.

总结

至此,我们的应用程序功能已经完整。然而,既然我们已经实现了所有功能,我们可以进行一些改进,以确保更广泛的用户可以使用我们的应用程序。我们的下一篇文章通过研究在 React 中包含焦点管理来结束我们的 React 教程,这可以提高可用性并减少键盘用户和屏幕阅读器用户的困惑。