跟着 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的顶部我们首先添加导入语句importReactfromreact;接下来我们需要定义并导出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 classNametodo stack-small div classNamec-cb input idtodo-0 typecheckbox defaultChecked{true} / label classNametodo-label htmlFortodo-0 Eat /label /div div classNamebtn-group button typebutton classNamebtn Edit span classNamevisually-hiddenEat/span /button button typebutton classNamebtn btn__danger Delete span classNamevisually-hiddenEat/span /button /div /li ); }至此Todo组件本身已经完成。我们可以回到App.js去使用它。首先在文件顶部导入Todo组件importTodofrom./components/Todo;然后将ul中原本的三个li元素全部替换为自闭合的Todo /标签。修改后的列表部分看起来如下ul rolelist classNametodo-list stack-large stack-exception aria-labelledbylist-heading Todo / Todo / Todo / /ul然而刷新浏览器后你会发现一个不幸的结果三个相同的 “Eat” 任务被重复渲染了三次。这是因为我们直接将硬编码的数据如 “Eat”写死在了组件内部。为了让组件真正具有复用价值我们必须让它能接收外部数据并渲染出不同的内容。三、制作不同的Todo /Props 让组件活起来组件的强大之处在于它能让我们重用 UI 的绝大部分结构同时又允许动态地改变小部分内容。在 React 中这一机制就是 Props。Props 是父组件向子组件传递数据的桥梁就像给 HTML 元素设置属性一样。用 Props 传递任务名称首先我们在App.js中为每个Todo /实例传入一个名为name的 prop赋上不同的任务名称Todo nameEat / Todo nameSleep / Todo nameRepeat /此时刷新浏览器页面并不会有任何变化因为Todo组件内部还没有使用这个 prop。我们需要回到Todo.js进行两项关键修改修改函数签名让它接收一个props参数。在 JSX 中将所有硬编码的 “Eat” 替换为对props.name的引用。在 JSX 中我们通过大括号{}来访问 JavaScript 变量或表达式。修改后的Todo组件如下export default function Todo(props) { return ( li classNametodo stack-small div classNamec-cb input idtodo-0 typecheckbox defaultChecked{true} / label classNametodo-label htmlFortodo-0 {props.name} /label /div div classNamebtn-group button typebutton classNamebtn Edit span classNamevisually-hidden{props.name}/span /button button typebutton classNamebtn btn__danger Delete span classNamevisually-hidden{props.name}/span /button /div /li ); }刷新浏览器你会看到三个具有不同名称的任务项。注意visually-hidden类包裹的辅助文本中我们也动态替换了任务名这对屏幕阅读器用户非常重要。用 Props 控制完成状态解决了名称问题但所有任务的复选框都默认被勾选了。回顾原静态页面只有 “Eat” 是完成状态。这为我们提供了第二个 Props 用例。在App.js中为每个Todo /传入一个名为completed的 prop其值为布尔类型true或false。注意在 JSX 中传递布尔值必须使用大括号包裹。Todo nameEat completed{true} / Todo nameSleep completed{false} / Todo nameRepeat completed{false} /然后回到Todo.js将input元素的defaultChecked属性值从硬编码的{true}替换为{props.completed}。input idtodo-0 typecheckbox defaultChecked{props.completed} /现在刷新浏览器你将看到只有 “Eat” 一项被勾选这完全符合我们通过 Props 传入的初始状态。用 Props 确保唯一 ID还有一个遗留的 HTML 规范问题每个Todo /组件内的input元素id属性都是todo-0。id必须在整个文档中保持唯一重复的 ID 会导致 CSS 样式错乱、JavaScript 选择器失效等严重问题。因此我们需要为每个Todo组件传入一个唯一的idprop。在App.js中Todo nameEat completed{true} idtodo-0 / Todo nameSleep completed{false} idtodo-1 / Todo nameRepeat completed{false} idtodo-2 /在Todo.js中更新input的id属性和label的htmlFor属性使其动态绑定到props.iddiv classNamec-cb input id{props.id} typecheckbox defaultChecked{props.completed} / label classNametodo-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 rolelist classNametodo-list stack-large stack-exception aria-labelledbylist-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 classNamelabel-wrapper label htmlFornew-todo-input classNamelabel__lg What needs to be done? /label /h2 input typetext idnew-todo-input classNameinput input__lg nametext autoCompleteoff / button typesubmit classNamebtn 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 typebutton classNamebtn toggle-btn aria-pressedtrue span classNamevisually-hiddenShow /span spanall /span span classNamevisually-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 classNametodoapp stack-large h1TodoMatic/h1 Form / div classNamefilters btn-group stack-exception FilterButton / FilterButton / FilterButton / /div h2 idlist-heading3 tasks remaining/h2 ul rolelist classNametodo-list stack-large stack-exception aria-labelledbylist-heading {taskList} /ul /div ); } export default App;总结本文系统地完成了 React 应用从单体结构到组件化架构的完整重构过程。我们从识别可复用 UI 块出发创建了第一个独立的Todo组件并深刻理解了组件必须返回有效 JSX 的规则。通过name、completed和id三个 Props 的逐步引入我们彻底掌握了父组件向子组件动态传递数据的模式让同一组件渲染出各不相同的任务项。之后我们迈向了数据驱动渲染的关键一步将任务信息抽象为对象数组并利用 JavaScript 的map()方法配合不可或缺的key属性高效优雅地渲染出整个任务列表。最后我们将Form和FilterButton提取为独立组件并成功整合所有模块构建了一个结构清晰、职责分明的组件树。此刻我们的应用已经具备了坚实的静态结构和数据基础但所有按钮仍然像道具一样没有反应。在下一篇文章中我们将进入 React 最激动人心的部分——事件处理与状态管理为应用注入真正的交互灵魂。