跟着 MDN 学 React 框架 Day 5:组件化 React 应用——从单体到模块化
摘要:本文是 React 学习之旅的第五日记录,核心主题是将一个单体式的 React 应用重构为清晰、可复用的组件化架构。文章将引导读者识别并提取应用中的关键组件,从最重要且重复使用的待办事项项入手,创建独立的Todo组件文件。我们将深入学习如何通过 Props 机制向组件动态传递数据(如任务名称、完成状态、唯一 ID),从而用同一组件渲染出不同内容,真正体现组件的复用价值。随后,我们会将任务数据抽象为 JavaScript 对象数组,并利用Array.prototype.map()方法配合特殊的key属性,实现列表的高效渲染。最后,我们将提取Form和FilterButton组件,并整合所有组件,构建出一个模块化、易维护的组件树结构。
一、定义第一个组件:从识别到创建
在 React 开发的初期,我们的应用通常被写在一个庞大的App组件中,形成一个难以维护的"单体"结构。要让应用变得可管理、可扩展,我们必须将其分解为一系列职责单一、可描述的组件。React 本身并不强制规定何为组件、何不为组件,这给予了开发者极大的自由度,但也要求我们具备良好的判断力。一个实用的指导原则是:如果一个 UI 片段在应用中是明显的"块",或者它被频繁地复用,那么它就应该被抽离成一个独立组件。
遵循这个原则,我们审视当前的待办清单应用,最明显、重复度最高的 UI 片段就是每个任务项。应用中有三个几乎一模一样的任务列表项,每个都包含复选框、任务名称、编辑和删除按钮。这正是我们第一个要提取的组件。
在动手编写代码前,我们需要为组件建立合适的文件组织结构。良好的文件组织是项目可维护性的基石。我们将在src目录下创建一个专门存放组件的components文件夹,并在其中为第一个组件创建文件。请确保终端位于项目根目录,然后执行以下命令:
mkdirsrc/componentstouchsrc/components/Todo.js第一条命令创建了components文件夹;第二条命令在其中创建了一个空的Todo.js文件。现在,打开这个新文件,我们将开始编写第一个独立的 React 组件。
二、编写<Todo />组件:从复制到独立
每一个 React 组件文件都需要引入 React 核心库,因为 JSX 最终会被转换为React.createElement调用。在Todo.js的顶部,我们首先添加导入语句:
importReactfrom"react";接下来,我们需要定义并导出Todo组件。我们使用函数式组件的形式,这也是现代 React 推荐的方式。组件必须是一个首字母大写的函数,并且必须返回有效的 JSX 或者null。如果组件什么都不返回,React 会在浏览器控制台抛出错误。
exportdefaultfunctionTodo(){return(// 这里将放置我们的JSX);}现在,我们需要给这个空壳组件填充 UI 内容。回到src/App.js,找到<ul>无序列表中任意一个<li>任务项,将其完整的 JSX 代码复制下来,粘贴到Todo函数的return语句中。此时,Todo.js的内容如下:
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组件:
importTodofrom"./components/Todo";然后,将<ul>中原本的三个<li>元素全部替换为自闭合的<Todo />标签。修改后的列表部分看起来如下:
<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,赋上不同的任务名称:
<Todo name="Eat" /> <Todo name="Sleep" /> <Todo name="Repeat" />此时刷新浏览器,页面并不会有任何变化,因为Todo组件内部还没有使用这个 prop。我们需要回到Todo.js进行两项关键修改:
- 修改函数签名,让它接收一个
props参数。 - 在 JSX 中,将所有硬编码的 “Eat” 替换为对
props.name的引用。在 JSX 中,我们通过大括号{}来访问 JavaScript 变量或表达式。
修改后的Todo组件如下:
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,其值为布尔类型true或false。注意,在 JSX 中传递布尔值必须使用大括号包裹。
<Todo name="Eat" completed={true} /> <Todo name="Sleep" completed={false} /> <Todo name="Repeat" completed={false} />然后,回到Todo.js,将<input>元素的defaultChecked属性值从硬编码的{true}替换为{props.completed}。
<input id="todo-0" type="checkbox" defaultChecked={props.completed} />现在,刷新浏览器,你将看到只有 “Eat” 一项被勾选,这完全符合我们通过 Props 传入的初始状态。
用 Props 确保唯一 ID
还有一个遗留的 HTML 规范问题:每个<Todo />组件内的<input>元素id属性都是"todo-0"。id必须在整个文档中保持唯一,重复的 ID 会导致 CSS 样式错乱、JavaScript 选择器失效等严重问题。因此,我们需要为每个Todo组件传入一个唯一的idprop。
在App.js中:
<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:
<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——name、completed和id——让同一个Todo组件实例化出了三个外观和初始状态各不相同的任务项。这完美展示了组件的复用能力。
四、任务作为数据:实现数据驱动的渲染
尽管我们成功实现了组件的复用,但在App.js中手动编写三行极其相似的<Todo />代码仍然显得重复和低效。随着任务数量增加,这种硬编码方式将完全不可维护。根本问题在于,我们的 UI 渲染逻辑与数据是耦合的。现代前端开发的核心范式是"数据驱动渲染":UI 应该是数据的映射。为此,我们需要将任务信息抽象为一个数据结构,然后通过 JavaScript 的迭代能力动态生成 UI。
定义任务数据结构
审视每个任务的三个核心属性——唯一标识id、任务名称name和完成状态completed,它们可以完美地用一个 JavaScript 对象来表示。而多个任务则构成了一个对象数组。我们在src/index.js中,在ReactDOM.render()调用之前,定义一个常量数组DATA。采用全大写命名是 JavaScript 社区的一种惯例,用于向其他开发者传达"此数据在此定义后将永不改变"的信息。
constDATA=[{id:"todo-0",name:"Eat",completed:true},{id:"todo-1",name:"Sleep",completed:false},{id:"todo-2",name:"Repeat",completed:false},];接下来,我们需要将这个数据传递给App组件。我们将它作为一个名为tasks的 prop 传入:
ReactDOM.render(<App tasks={DATA}/>,document.getElementById("root"));此时,在App组件内部,我们就可以通过props.tasks访问到这个任务数组了。
使用 map() 方法进行迭代渲染
JavaScript 的Array.prototype.map()方法是实现数据驱动渲染的关键。它遍历数组中的每个元素,对每个元素执行一个回调函数,并返回一个由回调函数返回值组成的新数组。
我们在App组件的return语句之前,创建一个名为taskList的常量。我们将使用map()方法遍历props.tasks数组,并在每次迭代中返回一个配置好的<Todo />组件。
const taskList = props.tasks.map((task) => ( <Todo id={task.id} name={task.name} completed={task.completed} key={task.id} /> ));然后在 JSX 的<ul>内部,我们只需简单地引用taskList这个变量:
<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 />组件。我们使用命令批量创建它们:
touchsrc/components/Form.js src/components/FilterButton.js对于Form.js,我们遵循与Todo.js完全相同的模式:导入 React,定义并导出Form函数组件,然后将App.js中<form>及其内部的全部 JSX 剪切过来,粘贴在return语句中。最终代码如下:
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.js中filters这个<div>内的第一个按钮的 JSX 代码。注意,我们暂时只复制了一个按钮,因为后续我们将利用 Props 让它们产生差异。
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并完成最终的组装。在文件顶部导入Form和FilterButton,然后更新return语句,用自定义组件标签替换原本的原始 HTML 标记。筛选按钮区域我们暂时放置了三个<FilterButton />,尽管它们现在看起来一样,但我们已经为后续的动态化改造预留了接口。最终整合后的App.js内容如下:
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 的规则。通过name、completed和id三个 Props 的逐步引入,我们彻底掌握了父组件向子组件动态传递数据的模式,让同一组件渲染出各不相同的任务项。之后,我们迈向了数据驱动渲染的关键一步,将任务信息抽象为对象数组,并利用 JavaScript 的map()方法配合不可或缺的key属性,高效优雅地渲染出整个任务列表。最后,我们将Form和FilterButton提取为独立组件,并成功整合所有模块,构建了一个结构清晰、职责分明的组件树。
此刻,我们的应用已经具备了坚实的静态结构和数据基础,但所有按钮仍然像道具一样没有反应。在下一篇文章中,我们将进入 React 最激动人心的部分——事件处理与状态管理,为应用注入真正的交互灵魂。
