将我们的 React 应用组件化

到目前为止,我们的应用还是一个整体。在让它做任何事情之前,我们需要将其分解成可管理、描述性的组件。React 对于什么是组件,什么不是组件并没有硬性规定——这取决于你!在本文中,我们将向你展示一种将应用分解成组件的合理方式。

预备知识 熟悉核心 HTMLCSSJavaScript 语言,以及 终端/命令行
学习成果 将我们的待办事项列表应用分解成组件的合理方式。

定义我们的第一个组件

定义组件在实践之前可能看起来很棘手,但要点是:

  • 如果它代表你应用中一个明显的“块”,那它可能是一个组件。
  • 如果它经常被重复使用,那它可能是一个组件。

第二个要点尤其有价值:将常见的 UI 元素做成组件,可以让你在一个地方修改代码,并在所有使用该组件的地方看到这些变化。你也不必立即将所有东西都分解成组件。让我们以第二个要点为灵感,将 UI 中最常用、最重要的部分——待办事项列表项——制作成一个组件。

创建一个 <Todo />

在创建组件之前,我们应该为它创建一个新文件。事实上,我们应该为组件创建一个目录。在运行这些命令之前,请确保你位于应用的根目录!

bash
# create a `components` directory
mkdir src/components
# within `components`, create a file called `Todo.jsx`
touch src/components/Todo.jsx

如果你停止了开发服务器来运行之前的命令,别忘了重新启动它!

让我们在 Todo.jsx 中添加一个 Todo() 函数。在这里,我们定义了一个函数并将其导出:

jsx
function Todo() {}

export default Todo;

到目前为止还可以,但我们的组件应该返回一些有用的东西!回到 src/App.jsx,复制无序列表中的第一个 <li>,并将其粘贴到 Todo.jsx 中,使其变为:

jsx
function Todo() {
  return (
    <li className="todo stack-small">
      <div className="c-cb">
        <input id="todo-0" type="checkbox" defaultChecked />
        <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>
  );
}

export default Todo;

现在我们有了一些可以使用的东西。在 App.jsx 中,在文件顶部添加以下行以导入 Todo

jsx
import Todo from "./components/Todo";

导入此组件后,你可以将 App.jsx 中所有的 <li> 元素替换为 <Todo /> 组件调用。你的 <ul> 应该像这样:

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

当你回到你的应用时,你会发现一些不幸的事情:你的列表现在重复了第一个任务三次!

Our todo list app, with todo components repeating because the label is hardcoded into the component

我们不只是想吃饭;我们还有其他事情要做。接下来我们将看看如何让不同的组件调用渲染独特的内容。

创建一个独特的 <Todo />

组件之所以强大,是因为它们允许我们重复使用 UI 的片段,并引用一个地方作为该 UI 的来源。问题是,我们通常不想重复使用每个组件的所有部分;我们想重复使用大部分,并更改小部分。这就是 props 的作用。

name 中有什么?

为了跟踪我们想要完成的任务名称,我们应该确保每个 <Todo /> 组件都渲染一个唯一的名称。

App.jsx 中,给每个 <Todo /> 一个 name prop。让我们使用我们之前的任务名称:

jsx
<ul
  role="list"
  className="todo-list stack-large stack-exception"
  aria-labelledby="list-heading">
  <Todo name="Eat" />
  <Todo name="Sleep" />
  <Todo name="Repeat" />
</ul>

当你的浏览器刷新时,你会看到……和以前一模一样。我们给 <Todo /> 传入了一些 props,但我们还没有使用它们。让我们回到 Todo.jsx 来解决这个问题。

首先修改你的 Todo() 函数定义,使其将 props 作为参数。如果你想检查 props 是否被组件正确接收,可以 console.log() 你的 props。

一旦你确信你的组件正在获取其 props,你就可以通过读取 props.name 来替换所有出现的 Eat 为你的 name prop。记住:props.name 是一个 JSX 表达式,所以你需要将其用花括号包裹起来。

把所有这些放在一起,你的 Todo() 函数应该像这样:

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

export default Todo;

现在你的浏览器应该显示三个独特的任务。然而,还有一个问题:它们默认仍然都是勾选状态。

Our todo list, with different todo labels now they are passed into the components as props

completed 了吗?

在我们最初的静态列表中,只有“Eat”被选中。同样,我们希望重复使用构成 <Todo /> 组件的大部分 UI,但只改变一件事。这正是另一个 prop 的好用之处!给你的第一个 <Todo /> 调用一个布尔 prop completed,并将其余两个保持原样。

jsx
<ul
  role="list"
  className="todo-list stack-large stack-exception"
  aria-labelledby="list-heading">
  <Todo name="Eat" completed />
  <Todo name="Sleep" />
  <Todo name="Repeat" />
</ul>

和以前一样,我们必须回到 Todo.jsx 来实际使用这些 props。更改 <input /> 上的 defaultChecked 属性,使其值等于 completed prop。完成后,Todo 组件的 <input /> 元素将变为:

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

你的浏览器应该会更新,只显示“Eat”被选中。

Our todo list app, now with differing checked states - some checkboxes are checked, others not

如果你改变每个 <Todo /> 组件的 completed prop,你的浏览器将相应地勾选或取消勾选等效渲染的复选框。

请给我一些 id

我们还有另一个问题:我们的 <Todo /> 组件给每个任务都赋予了 todo-0id 属性。这有几个不好的原因:

  • id 属性必须是唯一的(它们被 CSS、JavaScript 等用作文档片段的唯一标识符)。
  • id 不唯一时,label 元素的功能可能会失效。

第二个问题正在影响我们的应用。如果你点击第二个复选框旁边的“Sleep”字样,你会注意到“Eat”复选框被切换了,而不是“Sleep”复选框。这是因为每个复选框的 <label> 元素都有一个 htmlFor 属性,其值为 todo-0<label> 元素只识别具有给定 id 属性的第一个元素,这导致你点击其他标签时看到的问题。

在创建 <Todo /> 组件之前,我们有唯一的 id 属性。让我们按照 todo-i 的格式将它们带回来,其中 i 每次增加一。更新 App.jsxTodo 组件实例以添加 id props,如下所示:

jsx
<ul
  role="list"
  className="todo-list stack-large stack-exception"
  aria-labelledby="list-heading">
  <Todo name="Eat" id="todo-0" completed />
  <Todo name="Sleep" id="todo-1" />
  <Todo name="Repeat" id="todo-2" />
</ul>

注意:这里的 completed prop 排在最后,因为它是一个没有赋值的布尔值。这纯粹是一种风格约定。props 的顺序无关紧要,因为 props 是 JavaScript 对象,而 JavaScript 对象是无序的。

现在回到 Todo.jsx 并使用 id prop。它需要替换 <input /> 元素的 id 属性值以及其 <label>htmlFor 属性值:

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>

有了这些修复,点击每个复选框旁边的标签将如我们预期般操作——勾选和取消勾选这些标签旁边的复选框。

到目前为止一切顺利吗?

到目前为止,我们已经很好地使用了 React,但我们可以做得更好!我们的代码重复性很高。渲染 <Todo /> 组件的三行代码几乎完全相同,只有一个区别:每个 prop 的值。

我们可以利用 JavaScript 的核心能力之一:迭代,来清理我们的代码。要使用迭代,我们首先应该重新思考我们的任务。

任务作为数据

我们每个任务目前包含三条信息:它的名称、它是否已被选中以及它的唯一 ID。这些数据很好地转换为一个对象。由于我们有多个任务,一个对象数组将很好地表示这些数据。

src/main.jsx 中,在最终导入下方但在 ReactDOM.createRoot() 上方声明一个新的 const

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

注意:如果你的文本编辑器有 ESLint 插件,你可能会在这个 DATA 常量上看到一个警告。这个警告来自我们使用的 Vite 模板提供的 ESLint 配置,它不适用于此代码。你可以通过在 DATA 常量上面添加 // eslint-disable-next-line 来安全地抑制警告。

接下来,我们将 DATA 作为名为 tasks 的 prop 传递给 <App />。更新 src/main.jsx 中你的 <App /> 组件调用,使其变为:

jsx
<App tasks={DATA} />

DATA 数组现在可以在 App 组件中作为 props.tasks 使用。如果你想检查,可以 console.log() 它。

注意: ALL_CAPS 常量名称在 JavaScript 中没有特殊含义;它们是一种约定,告诉其他开发者“这些数据在定义后永远不会改变”。

使用迭代进行渲染

要渲染我们的对象数组,我们必须将每个对象转换为一个 <Todo /> 组件。JavaScript 为我们提供了一个将项目转换为其他东西的数组方法:Array.prototype.map()

App.jsx 中,在 App() 函数的 return 语句上方创建一个名为 taskList 的新 const。让我们首先将 props.tasks 数组中的每个任务转换为其 name?. 运算符允许我们执行可选链式操作,以检查 props.tasks 在尝试创建任务名称的新数组之前是否为 undefinednull

jsx
const taskList = props.tasks?.map((task) => task.name);

让我们尝试将 <ul> 的所有子元素替换为 taskList

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

这让我们在重新显示所有组件的路上走了一段,但我们还有更多工作要做:浏览器目前将每个任务的名称渲染为纯文本。我们缺少 HTML 结构——<li> 以及它的复选框和按钮!

Our todo list app with the todo item labels just shown bunched up on one line

为了解决这个问题,我们需要从 map() 函数返回一个 <Todo /> 组件——记住 JSX 是 JavaScript,所以我们可以将其与任何其他更熟悉的 JavaScript 语法一起使用。让我们尝试以下代码,而不是我们已有的代码:

jsx
const taskList = props.tasks?.map((task) => <Todo />);

再看看你的应用程序;现在我们的任务看起来更像以前了,但它们缺少任务本身的名称。请记住,我们映射的每个任务都包含我们想要传递给 <Todo /> 组件的 idnamecompleted 属性。如果我们把这些知识结合起来,我们就会得到这样的代码:

jsx
const taskList = props.tasks?.map((task) => (
  <Todo id={task.id} name={task.name} completed={task.completed} />
));

现在应用看起来和以前一样,并且我们的代码重复性更低。

唯一的键

既然 React 正在从数组中渲染我们的任务,它必须跟踪哪个是哪个以便正确渲染它们。React 会尝试自行猜测来跟踪事物,但我们可以通过将 key prop 传递给我们的 <Todo /> 组件来帮助它。key 是一个由 React 管理的特殊 prop —— 你不能将 key 这个词用于任何其他目的。

因为键必须是唯一的,我们将重用每个任务对象的 id 作为其键。像这样更新你的 taskList 常量:

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

你总是应该为通过迭代渲染的任何内容传递一个唯一的键。你的浏览器中不会发生任何明显的变化,但如果你不使用唯一的键,React 会在控制台中记录警告,并且你的应用可能会出现异常行为!

将应用的其余部分组件化

现在我们已经解决了最重要的组件,我们可以将应用的其余部分转换为组件。记住组件要么是明显的 UI 片段,要么是重用的 UI 片段,或者两者兼有,我们可以再创建两个组件:

  • <Form />
  • <FilterButton />

既然我们知道两者都需要,我们可以在一个终端命令中批量完成一些文件创建工作。在你的终端中运行此命令,注意你处于应用的根目录中:

bash
touch src/components/{Form,FilterButton}.jsx

<Form />

打开 components/Form.jsx 并执行以下操作:

  • 声明一个 Form() 函数并在文件末尾导出它。
  • App.jsx 中复制 <form> 标签及其之间的所有内容,并将其粘贴到 Form()return 语句中。

你的 Form.jsx 文件应该像这样:

jsx
function Form() {
  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 />

FilterButton.jsx 中执行与创建 Form.jsx 相同的操作,但将组件命名为 FilterButton(),并将 App.jsx<div className="filters btn-group stack-exception"> 内部的第一个按钮的 HTML 复制到 return 语句中。

文件应像这样:

jsx
function FilterButton() {
  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;

注意:你可能会注意到我们在这里犯了与我们最初为 <Todo /> 组件犯的相同错误,即每个按钮都将是相同的。没关系!我们将在稍后,在回到过滤按钮中修复此组件。

导入所有组件

让我们利用我们的新组件。在 App.jsx 的顶部添加更多的 import 语句,并引用我们刚刚创建的组件。然后,更新 App()return 语句,使其渲染我们的组件。

完成后,App.jsx 将如下所示:

jsx
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 应用应该会和以前基本相同地渲染,但使用了你闪亮的新组件。

总结

本文到此为止——我们深入探讨了如何将你的应用很好地分解成组件并有效地渲染它们。接下来我们将研究如何在 React 中处理事件,并开始添加一些交互性。