跟着 MDN 学 React 框架 Day 7:编辑任务、过滤列表与条件渲染

前言

在上一篇文章中,我们成功实现了任务管理应用的核心功能,包括添加任务、删除任务和切换任务的完成状态。然而一个完整的任务管理工具还需要更多功能来提升用户体验。本文将基于 MDN 的 React 教程,继续完善我们的 Todo 应用,重点实现三个关键特性:编辑现有任务、根据完成状态过滤任务列表,以及使用条件渲染来管理不同的 UI 状态。通过本文的学习,你将掌握 React 中条件渲染的多种实现方式,以及如何将数据过滤逻辑与 UI 渲染有机结合。

编辑任务功能的实现思路

编辑功能是任务管理应用的重要组成部分。用户创建任务后,难免需要修改任务名称。从技术实现的角度来看,编辑任务需要解决两个核心问题:首先是在状态层面更新特定任务的数据,其次是提供一套完整的用户界面来支持编辑操作。

在状态管理层面,编辑任务与删除任务有相似之处,都需要通过任务的 ID 来定位目标对象。但编辑操作还需要额外的信息,即修改后的新名称。与删除操作使用 filter 方法过滤掉目标元素不同,编辑操作更适合使用 map 方法,因为我们希望返回一个包含修改内容的新数组,而不是删除某个元素。

在 App 组件中,我们创建 editTask 函数,它接收两个参数:id 用于定位需要编辑的任务,newName 用于提供更新后的名称。函数内部使用 map 遍历整个任务数组,当找到匹配 ID 的任务时,使用对象扩展运算符创建一个新对象,并将 name 属性更新为 newName。对于不匹配的任务,原样返回。最后将生成的新数组通过 setTasks 更新到状态中。

editTask 函数的具体实现代码如下:

jsx 复制代码
function editTask(id, newName) {
  const editedTaskList = tasks.map((task) => {
    if (id === task.id) {
      return { ...task, name: newName };
    }
    return task;
  });
  setTasks(editedTaskList);
}

这个函数定义完成后,需要像之前的 deleteTasktoggleTaskCompleted 一样,通过回调 props 传递给 Todo 组件。在 taskListmap 渲染中,为每个 Todo 组件添加 editTask 属性:

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}
  />
));

这样每个任务项就能在需要时调用 editTask 来更新自己的名称。

设计编辑模式的用户界面

为了让用户能够编辑任务,我们需要设计一套编辑模式的界面。在此之前,Todo 组件一直使用单一的视图模板来展示任务。现在我们需要引入一个全新的编辑模板,让组件能够在查看模式和编辑模式之间切换。

首先在 Todo 组件中导入 useState 钩子,用于管理当前是否处于编辑状态。创建一个名为 isEditing 的布尔类型状态,默认值为 false,表示初始状态下组件处于查看模式。

jsx 复制代码
import { useState } from "react";

// 在Todo组件内部
const [isEditing, setEditing] = useState(false);

接下来定义两个模板常量。首先是编辑模板 editingTemplate,它包含一个表单,表单中有文本输入框用于输入新名称,以及取消按钮和保存按钮:

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>
);

然后是查看模板 viewTemplate,这正是我们之前一直在使用的界面,包含复选框、任务名称标签、编辑按钮和删除按钮:

jsx 复制代码
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 语句中直接返回了完整的视图模板,现在我们需要根据 isEditing 状态来决定渲染哪个模板。这就引出了 React 中一个重要的概念:条件渲染。

条件渲染的核心概念

条件渲染是 React 开发中的基础技能,它允许组件根据不同的状态或属性来显示不同的 UI 内容。在 JSX 中,实现条件渲染最常用的方式是使用三元运算符。三元运算符的语法简洁明了,适合在 JSX 表达式中进行简单的条件判断。

在 Todo 组件中,条件渲染的逻辑非常简单:如果 isEditingtrue,就渲染 editingTemplate;否则渲染 viewTemplate

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

通过修改 isEditing 的默认值可以验证条件渲染是否正常工作。将初始状态改为 true 时,所有任务项都会以编辑模式显示。

除了三元运算符,React 中还有其他条件渲染的方式,例如使用逻辑与运算符进行短路渲染,当条件为 true 时显示某个元素,为 false 时不渲染任何内容。还有一种方式是将条件判断逻辑提取到组件外部,通过 if 语句提前决定渲染内容。不同的场景适合不同的实现方式,三元运算符因其简洁性在内联条件渲染中最为常用。

在查看与编辑模板之间切换

实现模板切换的核心在于通过事件处理来更新 isEditing 状态。在 viewTemplate 中,编辑按钮需要绑定点击事件,调用 setEditing 并传入 true,将组件切换到编辑模式。

更新 viewTemplate 中的编辑按钮,为其添加 onClick 事件处理:

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

editingTemplate 中,取消按钮同样绑定点击事件,调用 setEditing 并传入 false,将组件切换回查看模式。

更新 editingTemplate 中的取消按钮,为其添加 onClick 事件处理:

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

这个设计让用户可以通过点击编辑按钮进入编辑状态,修改任务名称后可以选择保存或取消。取消操作会丢弃当前的编辑内容并返回查看模式,保存操作则需要将新名称传递给父组件进行状态更新。

编辑状态的数据管理

在编辑模板中,输入框的值需要与 React 状态进行绑定。我们创建一个名为 newName 的字符串状态,初始值为空字符串。

jsx 复制代码
const [newName, setNewName] = useState("");

接下来创建 handleChange 函数来响应用户的输入,将输入框的当前值更新到 newName 状态中:

jsx 复制代码
function handleChange(e) {
  setNewName(e.target.value);
}

然后更新 editingTemplate 中的 input 元素,设置 value 属性绑定到 newName,并绑定 handleChange 函数到 onChange 事件:

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

这种受控组件的模式确保了 React 始终掌握输入框的当前值。当用户进入编辑模式时,输入框初始为空,用户需要手动输入新的任务名称。

编辑流程的最后一步是处理表单提交。创建 handleSubmit 函数,它阻止表单的默认提交行为,调用 props.editTask 并传入任务的 ID 和新名称,然后清空 newName 状态并将 isEditing 设置为 false,使组件返回查看模式:

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

将这个函数绑定到 editingTemplateform 元素的 onSubmit 事件上:

jsx 复制代码
<form className="stack-small" onSubmit={handleSubmit}>

整个过程形成了一个完整的编辑循环:用户点击编辑按钮进入编辑模式,修改名称后点击保存,组件切换回查看模式并显示更新后的名称,或者点击取消直接返回查看模式而不做任何修改。

完整 Todo 组件代码解析

经过上述所有功能的实现,Todo 组件的完整代码如下:

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 组件现在包含两个状态变量:isEditing 控制显示模板,newName 存储编辑时的新名称。组件内部定义了两个事件处理函数:handleChange 响应用户输入,handleSubmit 处理编辑表单的提交。editingTemplateviewTemplate 作为两个独立的常量,各自包含完整的 JSX 结构。组件最终的返回语句使用三元运算符根据 isEditing 的值选择渲染对应的模板。

过滤按钮的功能需求分析

目前应用中的三个过滤按钮还只是静态的占位符,都显示相同的标签文字且没有任何功能。我们需要让这些按钮具备实际的过滤能力,允许用户在全部任务、未完成任务和已完成任务之间切换。

过滤功能的设计涉及两个层面:UI 层面需要按钮能够显示当前激活状态并允许用户切换,数据层面需要根据选中的过滤条件对任务列表进行筛选。这两个层面通过 React 的状态管理机制有机结合。

首先在 App 组件中创建一个 filter 状态,用于存储当前激活的过滤器名称。默认值设为 "All",表示初始状态下显示所有任务。

jsx 复制代码
const [filter, setFilter] = useState("All");

setFilter 函数用于更新过滤条件,这样我们就有了一个可以响应变化的过滤器状态。

定义过滤器映射关系

过滤器需要三个要素:名称、显示标签和过滤逻辑。使用 JavaScript 对象可以很好地将这些要素关联起来。在 App 组件外部定义 FILTER_MAP 常量对象,它的键是过滤器名称,值是对应的过滤函数。

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

All 过滤器的过滤函数始终返回 true,表示不过滤任何任务。Active 过滤器的过滤函数检查任务的 completed 属性是否为 false,只保留未完成的任务。Completed 过滤器的过滤函数检查 completed 属性是否为 true,只保留已完成的任务。

这些过滤函数的共同特点是接收一个任务对象作为参数,返回一个布尔值表示该任务是否应该被显示。这种设计模式使得过滤逻辑清晰且易于扩展,如果将来需要添加新的过滤条件,只需在 FILTER_MAP 中添加新的键值对即可。

通过 Object.keys 方法可以从 FILTER_MAP 中提取所有过滤器名称,生成 FILTER_NAMES 数组。这个数组将用于动态渲染过滤按钮。

jsx 复制代码
const FILTER_NAMES = Object.keys(FILTER_MAP);

FILTER_MAPFILTER_NAMES 定义在 App 组件外部是一个重要的设计决策,因为它们的内容不会随应用状态改变而变化,放在组件外部可以避免每次重新渲染时重复创建这些常量。

动态渲染过滤按钮

有了 FILTER_NAMES 数组后,我们可以使用 map 方法动态生成 FilterButton 组件列表,而不是手动编写三个重复的按钮标签。在 App 组件中创建 filterList 常量:

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

然后用 filterList 替换原有的三个重复的 FilterButton 组件:

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

每个 FilterButton 组件需要接收几个关键的 props。更新 filterList,为每个 FilterButton 添加 isPressedsetFilter 属性:

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

isPressed 的判断逻辑是将按钮的 name 与当前的 filter 状态进行比较,如果两者相等则返回 true。这样当用户点击某个过滤按钮时,setFilter 更新 filter 状态,导致 isPressed 重新计算,所有按钮的激活状态随之更新。React 会自动重新渲染受影响的组件,实现按钮高亮状态的切换。

在 FilterButton 组件中接收和处理 Props

FilterButton 组件之前只是一个静态的按钮骨架,现在需要根据接收到的 props 进行动态渲染。更新 FilterButton 组件的代码如下:

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;

按钮的显示文字从 props.name 获取,替换之前硬编码的文字。aria-pressed 属性绑定到 props.isPressed,这个无障碍属性帮助屏幕阅读器用户了解当前哪个过滤选项处于激活状态。按钮的点击事件调用一个箭头函数,在函数内部调用 props.setFilter 并传入 props.name 作为参数。这样当用户点击按钮时,父组件中的 filter 状态就会更新为该按钮对应的过滤器名称。

访问浏览器中的页面时,可以看到三个过滤按钮分别显示 All、Active 和 Completed 标签。点击不同的按钮时,按钮的视觉样式会发生改变,通过浏览器开发者工具可以看到 aria-pressed 属性值在 truefalse 之间切换。但目前任务列表还没有根据过滤器进行筛选,这是最后需要实现的功能。

实现任务列表的过滤逻辑

之前 taskList 常量直接对 tasks 状态进行 map 操作,渲染所有任务。现在需要在 map 之前添加一个 filter 步骤,根据当前激活的过滤条件筛选出应该显示的任务。

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}
    />
  ));

实现方式是通过 FILTER_MAP[filter] 获取当前过滤条件对应的过滤函数,然后将这个函数传递给数组的 filter 方法。当 filter 状态为 "All" 时,FILTER_MAP[filter] 返回始终为 true 的函数,所有任务都会通过筛选。当 filter"Active" 时,只有未完成的任务通过筛选。当 filter"Completed" 时,只有已完成的任务通过筛选。

过滤后的数组再通过 map 方法转换为 Todo 组件列表。这种链式调用的写法清晰地表达了数据处理的流程:先筛选,再映射。任务计数的标题也会自动反映过滤后的数量,因为 taskList 的长度会随着过滤条件的变化而变化。

在浏览器中选择不同的过滤按钮,任务列表会实时更新,只显示符合当前过滤条件的任务。同时标题中的任务数量也会相应变化。这标志着过滤功能的完整实现。

编辑与过滤功能的协同工作

编辑功能和过滤功能虽然在代码上是独立实现的,但它们在实际使用中需要协同工作。当用户在某个过滤视图下编辑任务时,如果编辑导致任务不再满足当前过滤条件,任务应该从当前视图中消失。

例如在 Active 过滤视图下,用户将某个未完成任务的名称修改后点击保存,由于该任务仍然是未完成状态,它依然符合 Active 过滤条件,会继续显示在列表中。如果用户在 Completed 过滤视图下将某个已完成任务标记为未完成,该任务将不再满足 Completed 过滤条件,从而从列表中消失。

这种协同工作是由 React 的响应式更新机制自动保证的。当任务状态发生变化时,tasks 状态更新触发重新渲染,taskList 重新计算过滤和映射,最终 UI 自动反映最新的数据状态。开发者不需要手动同步各个功能模块,这正是 React 状态管理模型的强大之处。

总结

通过本文的学习,我们为任务管理应用添加了编辑任务和过滤列表两个重要功能。编辑功能的实现涉及条件渲染的核心概念,通过 isEditing 状态控制组件在查看模板和编辑模板之间切换。受控组件模式确保了编辑输入框的值与 React 状态保持同步,回调 props 机制则负责将编辑结果传递给父组件进行状态更新。

过滤功能展示了数据驱动 UI 渲染的典型模式。通过定义 FILTER_MAP 映射关系,将过滤器名称与过滤逻辑关联起来。使用 useState 管理当前激活的过滤器,通过动态生成的 props 控制 FilterButton 组件的显示状态。在渲染任务列表时,先使用 filter 方法根据当前过滤条件筛选数据,再进行 map 映射生成 UI 组件。

条件渲染是 React 开发中的基础技能,本文展示了使用三元运算符在 JSX 中进行条件判断的标准方式。数据过滤与 UI 渲染的结合则体现了 React 应用中状态流转的完整链路:用户操作触发状态更新,状态更新引发数据重新计算,计算结果的改变最终反映到 UI 上。

目前应用的功能已经基本完整,用户可以添加任务、编辑任务、删除任务、切换完成状态以及按条件过滤任务。在下一篇文章中,我们将关注焦点管理和可访问性方面的优化,确保应用能够为更广泛的用户群体提供良好的使用体验。