跟着 MDN 学 React 框架 Day 5:组件化 React 应用——从单体到模块化

摘要:本文是 React 学习之旅的第五日记录,核心主题是将一个单体式的 React 应用重构为清晰、可复用的组件化架构。文章将引导读者识别并提取应用中的关键组件,从最重要且重复使用的待办事项项入手,创建独立的 Todo 组件文件。我们将深入学习如何通过 Props 机制向组件动态传递数据(如任务名称、完成状态、唯一 ID),从而用同一组件渲染出不同内容,真正体现组件的复用价值。随后,我们会将任务数据抽象为 JavaScript 对象数组,并利用 Array.prototype.map() 方法配合特殊的 key 属性,实现列表的高效渲染。最后,我们将提取 FormFilterButton 组件,并整合所有组件,构建出一个模块化、易维护的组件树结构。

一、定义第一个组件:从识别到创建

在 React 开发的初期,我们的应用通常被写在一个庞大的 App 组件中,形成一个难以维护的"单体"结构。要让应用变得可管理、可扩展,我们必须将其分解为一系列职责单一、可描述的组件。React 本身并不强制规定何为组件、何不为组件,这给予了开发者极大的自由度,但也要求我们具备良好的判断力。一个实用的指导原则是:如果一个 UI 片段在应用中是明显的"块",或者它被频繁地复用,那么它就应该被抽离成一个独立组件。

遵循这个原则,我们审视当前的待办清单应用,最明显、重复度最高的 UI 片段就是每个任务项。应用中有三个几乎一模一样的任务列表项,每个都包含复选框、任务名称、编辑和删除按钮。这正是我们第一个要提取的组件。

在动手编写代码前,我们需要为组件建立合适的文件组织结构。良好的文件组织是项目可维护性的基石。我们将在 src 目录下创建一个专门存放组件的 components 文件夹,并在其中为第一个组件创建文件。请确保终端位于项目根目录,然后执行以下命令:

bash 复制代码
mkdir src/components
touch src/components/Todo.js

第一条命令创建了 components 文件夹;第二条命令在其中创建了一个空的 Todo.js 文件。现在,打开这个新文件,我们将开始编写第一个独立的 React 组件。

二、编写 <Todo /> 组件:从复制到独立

每一个 React 组件文件都需要引入 React 核心库,因为 JSX 最终会被转换为 React.createElement 调用。在 Todo.js 的顶部,我们首先添加导入语句:

javascript 复制代码
import React from "react";

接下来,我们需要定义并导出 Todo 组件。我们使用函数式组件的形式,这也是现代 React 推荐的方式。组件必须是一个首字母大写的函数,并且必须返回有效的 JSX 或者 null。如果组件什么都不返回,React 会在浏览器控制台抛出错误。

javascript 复制代码
export default function Todo() {
  return (
    // 这里将放置我们的JSX
  );
}

现在,我们需要给这个空壳组件填充 UI 内容。回到 src/App.js,找到 <ul> 无序列表中任意一个 <li> 任务项,将其完整的 JSX 代码复制下来,粘贴到 Todo 函数的 return 语句中。此时,Todo.js 的内容如下:

jsx 复制代码
export default function Todo() {
  return (
    <li className="todo stack-small">
      <div className="c-cb">
        <input id="todo-0" type="checkbox" defaultChecked={true} />
        <label className="todo-label" htmlFor="todo-0">
          Eat
        </label>
      </div>
      <div className="btn-group">
        <button type="button" className="btn">
          Edit <span className="visually-hidden">Eat</span>
        </button>
        <button type="button" className="btn btn__danger">
          Delete <span className="visually-hidden">Eat</span>
        </button>
      </div>
    </li>
  );
}

至此,Todo 组件本身已经完成。我们可以回到 App.js 去使用它。首先,在文件顶部导入 Todo 组件:

javascript 复制代码
import Todo from "./components/Todo";

然后,将 <ul> 中原本的三个 <li> 元素全部替换为自闭合的 <Todo /> 标签。修改后的列表部分看起来如下:

jsx 复制代码
<ul
  role="list"
  className="todo-list stack-large stack-exception"
  aria-labelledby="list-heading">
  <Todo />
  <Todo />
  <Todo />
</ul>

然而,刷新浏览器后你会发现一个不幸的结果:三个相同的 "Eat" 任务被重复渲染了三次。这是因为我们直接将硬编码的数据(如 "Eat")写死在了组件内部。为了让组件真正具有复用价值,我们必须让它能接收外部数据并渲染出不同的内容。

三、制作不同的 <Todo />:Props 让组件活起来

组件的强大之处在于,它能让我们重用 UI 的绝大部分结构,同时又允许动态地改变小部分内容。在 React 中,这一机制就是 Props。Props 是父组件向子组件传递数据的桥梁,就像给 HTML 元素设置属性一样。

用 Props 传递任务名称

首先,我们在 App.js 中为每个 <Todo /> 实例传入一个名为 name 的 prop,赋上不同的任务名称:

jsx 复制代码
<Todo name="Eat" />
<Todo name="Sleep" />
<Todo name="Repeat" />

此时刷新浏览器,页面并不会有任何变化,因为 Todo 组件内部还没有使用这个 prop。我们需要回到 Todo.js 进行两项关键修改:

  • 修改函数签名,让它接收一个 props 参数。
  • 在 JSX 中,将所有硬编码的 "Eat" 替换为对 props.name 的引用。在 JSX 中,我们通过大括号 {} 来访问 JavaScript 变量或表达式。

修改后的 Todo 组件如下:

jsx 复制代码
export default function Todo(props) {
  return (
    <li className="todo stack-small">
      <div className="c-cb">
        <input id="todo-0" type="checkbox" defaultChecked={true} />
        <label className="todo-label" htmlFor="todo-0">
          {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">
          Delete <span className="visually-hidden">{props.name}</span>
        </button>
      </div>
    </li>
  );
}

刷新浏览器,你会看到三个具有不同名称的任务项。注意,visually-hidden 类包裹的辅助文本中,我们也动态替换了任务名,这对屏幕阅读器用户非常重要。

用 Props 控制完成状态

解决了名称问题,但所有任务的复选框都默认被勾选了。回顾原静态页面,只有 "Eat" 是完成状态。这为我们提供了第二个 Props 用例。在 App.js 中,为每个 <Todo /> 传入一个名为 completed 的 prop,其值为布尔类型 truefalse。注意,在 JSX 中传递布尔值必须使用大括号包裹。

jsx 复制代码
<Todo name="Eat" completed={true} />
<Todo name="Sleep" completed={false} />
<Todo name="Repeat" completed={false} />

然后,回到 Todo.js,将 <input> 元素的 defaultChecked 属性值从硬编码的 {true} 替换为 {props.completed}

jsx 复制代码
<input id="todo-0" type="checkbox" defaultChecked={props.completed} />

现在,刷新浏览器,你将看到只有 "Eat" 一项被勾选,这完全符合我们通过 Props 传入的初始状态。

用 Props 确保唯一 ID

还有一个遗留的 HTML 规范问题:每个 <Todo /> 组件内的 <input> 元素 id 属性都是 "todo-0"id 必须在整个文档中保持唯一,重复的 ID 会导致 CSS 样式错乱、JavaScript 选择器失效等严重问题。因此,我们需要为每个 Todo 组件传入一个唯一的 id prop。

App.js 中:

jsx 复制代码
<Todo name="Eat" completed={true} id="todo-0" />
<Todo name="Sleep" completed={false} id="todo-1" />
<Todo name="Repeat" completed={false} id="todo-2" />

Todo.js 中,更新 <input>id 属性和 <label>htmlFor 属性,使其动态绑定到 props.id

jsx 复制代码
<div className="c-cb">
  <input id={props.id} type="checkbox" defaultChecked={props.completed} />
  <label className="todo-label" htmlFor={props.id}>
    {props.name}
  </label>
</div>

至此,我们通过三个 Props------namecompletedid------让同一个 Todo 组件实例化出了三个外观和初始状态各不相同的任务项。这完美展示了组件的复用能力。

四、任务作为数据:实现数据驱动的渲染

尽管我们成功实现了组件的复用,但在 App.js 中手动编写三行极其相似的 <Todo /> 代码仍然显得重复和低效。随着任务数量增加,这种硬编码方式将完全不可维护。根本问题在于,我们的 UI 渲染逻辑与数据是耦合的。现代前端开发的核心范式是"数据驱动渲染":UI 应该是数据的映射。为此,我们需要将任务信息抽象为一个数据结构,然后通过 JavaScript 的迭代能力动态生成 UI。

定义任务数据结构

审视每个任务的三个核心属性------唯一标识 id、任务名称 name 和完成状态 completed,它们可以完美地用一个 JavaScript 对象来表示。而多个任务则构成了一个对象数组。我们在 src/index.js 中,在 ReactDOM.render() 调用之前,定义一个常量数组 DATA。采用全大写命名是 JavaScript 社区的一种惯例,用于向其他开发者传达"此数据在此定义后将永不改变"的信息。

javascript 复制代码
const DATA = [
  { id: "todo-0", name: "Eat", completed: true },
  { id: "todo-1", name: "Sleep", completed: false },
  { id: "todo-2", name: "Repeat", completed: false },
];

接下来,我们需要将这个数据传递给 App 组件。我们将它作为一个名为 tasks 的 prop 传入:

javascript 复制代码
ReactDOM.render(<App tasks={DATA} />, document.getElementById("root"));

此时,在 App 组件内部,我们就可以通过 props.tasks 访问到这个任务数组了。

使用 map() 方法进行迭代渲染

JavaScript 的 Array.prototype.map() 方法是实现数据驱动渲染的关键。它遍历数组中的每个元素,对每个元素执行一个回调函数,并返回一个由回调函数返回值组成的新数组。

我们在 App 组件的 return 语句之前,创建一个名为 taskList 的常量。我们将使用 map() 方法遍历 props.tasks 数组,并在每次迭代中返回一个配置好的 <Todo /> 组件。

jsx 复制代码
const taskList = props.tasks.map((task) => (
  <Todo
    id={task.id}
    name={task.name}
    completed={task.completed}
    key={task.id}
  />
));

然后在 JSX 的 <ul> 内部,我们只需简单地引用 taskList 这个变量:

jsx 复制代码
<ul
  role="list"
  className="todo-list stack-large stack-exception"
  aria-labelledby="list-heading">
  {taskList}
</ul>

这段代码完美体现了 React 的声明式特性:我们不再关心如何一步步构建 DOM,只需声明"UI 是这个数据数组的映射",React 会高效地执行 DOM 更新。

五、特殊的 key 属性:React 列表渲染的必备品

当 React 渲染一个由数组动态生成的列表时,它需要一种机制来跟踪每个列表项的身份,以便在数据发生变化时(如重新排序、添加、删除)能高效地确定哪些 DOM 节点需要更新,而不是粗暴地销毁并重建整个列表。这就是 key 属性的作用。它是一个由 React 内部使用的、特殊的 prop,你不能在子组件中通过 props.key 来访问它。

每个 key 的值在其兄弟列表中必须是唯一且稳定的。对于我们的任务列表,每个任务对象的 id 天生就是最理想的 key。我们已经在上一节的代码中为每个 <Todo /> 添加了 key={task.id}。如果你在渲染列表时忘记提供 key,或者使用了数组索引(index)作为 key(在列表顺序可能改变时不推荐),React 会在浏览器控制台发出严厉的警告,并且可能导致界面出现难以调试的怪异行为。

六、整合 App 的其他部分:提取剩余的组件

现在,我们已经将最核心、最复杂的 Todo 组件整理完毕。遵循同样的组件化原则,我们可以轻松地将 App 的其余部分也拆分为独立组件。观察可知,顶部的输入表单是一个明显的独立 UI 块,应提取为 <Form /> 组件;而底部的三个筛选按钮功能相似且会重复使用,每个按钮可提取为一个 <FilterButton /> 组件。我们使用命令批量创建它们:

bash 复制代码
touch src/components/Form.js src/components/FilterButton.js

对于 Form.js,我们遵循与 Todo.js 完全相同的模式:导入 React,定义并导出 Form 函数组件,然后将 App.js<form> 及其内部的全部 JSX 剪切过来,粘贴在 return 语句中。最终代码如下:

jsx 复制代码
import React from "react";

function Form(props) {
  return (
    <form>
      <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"
      />
      <button type="submit" className="btn btn__primary btn__lg">
        Add
      </button>
    </form>
  );
}

export default Form;

对于 FilterButton.js,同样导入 React,定义并导出 FilterButton 函数组件,然后复制 App.jsfilters 这个 <div> 内的第一个按钮的 JSX 代码。注意,我们暂时只复制了一个按钮,因为后续我们将利用 Props 让它们产生差异。

jsx 复制代码
import React from "react";

function FilterButton(props) {
  return (
    <button type="button" className="btn toggle-btn" aria-pressed="true">
      <span className="visually-hidden">Show </span>
      <span>all </span>
      <span className="visually-hidden"> tasks</span>
    </button>
  );
}

export default FilterButton;

最后,我们回到 App.js 并完成最终的组装。在文件顶部导入 FormFilterButton,然后更新 return 语句,用自定义组件标签替换原本的原始 HTML 标记。筛选按钮区域我们暂时放置了三个 <FilterButton />,尽管它们现在看起来一样,但我们已经为后续的动态化改造预留了接口。最终整合后的 App.js 内容如下:

jsx 复制代码
import React from "react";
import Form from "./components/Form";
import FilterButton from "./components/FilterButton";
import Todo from "./components/Todo";

function App(props) {
  const taskList = props.tasks.map((task) => (
    <Todo
      id={task.id}
      name={task.name}
      completed={task.completed}
      key={task.id}
    />
  ));
  return (
    <div className="todoapp stack-large">
      <h1>TodoMatic</h1>
      <Form />
      <div className="filters btn-group stack-exception">
        <FilterButton />
        <FilterButton />
        <FilterButton />
      </div>
      <h2 id="list-heading">3 tasks remaining</h2>
      <ul
        role="list"
        className="todo-list stack-large stack-exception"
        aria-labelledby="list-heading">
        {taskList}
      </ul>
    </div>
  );
}

export default App;

总结

本文系统地完成了 React 应用从单体结构到组件化架构的完整重构过程。我们从识别可复用 UI 块出发,创建了第一个独立的 Todo 组件,并深刻理解了组件必须返回有效 JSX 的规则。通过 namecompletedid 三个 Props 的逐步引入,我们彻底掌握了父组件向子组件动态传递数据的模式,让同一组件渲染出各不相同的任务项。之后,我们迈向了数据驱动渲染的关键一步,将任务信息抽象为对象数组,并利用 JavaScript 的 map() 方法配合不可或缺的 key 属性,高效优雅地渲染出整个任务列表。最后,我们将 FormFilterButton 提取为独立组件,并成功整合所有模块,构建了一个结构清晰、职责分明的组件树。

此刻,我们的应用已经具备了坚实的静态结构和数据基础,但所有按钮仍然像道具一样没有反应。在下一篇文章中,我们将进入 React 最激动人心的部分------事件处理与状态管理,为应用注入真正的交互灵魂。