JavaScript-设计模式学习指南第二版-全-
JavaScript 设计模式学习指南第二版(全)
原文:
zh.annas-archive.org/md5/22800d88044430cf6edc87d192447a9a译者:飞龙
前言
自从我 10 多年前编写第一版《学习 JavaScript 设计模式》以来,JavaScript 世界已经发生了翻天覆地的变化。那时,我正在处理大规模 Web 应用程序,并发现 JavaScript 代码的缺乏结构和组织使得维护和扩展这些应用程序变得困难。
快进到今天,Web 开发的景观发生了巨大变化。JavaScript 已成为世界上最流行的编程语言之一,并被用于从简单脚本到复杂 Web 应用程序的各种用途。JavaScript 语言已经演变,包括了模块、promises 和async/await,这极大地影响了我们如何设计应用程序架构。开发者编写组件的方式,如 React 中的方式,也显著影响了他们对可维护性的思考。这促使我们需要考虑到这些新变化的现代模式。
随着现代库和框架如 React、Vue 和 Angular 的兴起,开发者现在构建的应用比以往任何时候都更加复杂。我意识到需要更新《学习 JavaScript 设计模式》的版本,以反映 JavaScript 和 Web 应用程序开发中的变化。
在《学习 JavaScript 设计模式》的第二版中,我旨在帮助开发者将现代设计模式应用到他们的 JavaScript 代码和 React 应用程序中。本书涵盖了超过 20 种对构建可维护和可扩展应用程序至关重要的设计模式。本书不仅仅是关于设计模式,还涵盖了渲染和性能模式,这些对现代 Web 应用程序的成功至关重要。
本书的第一版侧重于经典设计模式,如模块模式、观察者模式和中介者模式。这些模式今天仍然重要和相关,但 Web 开发世界在过去的十年中发生了重大变化,出现了新的模式。本新版涵盖了这些新模式,如 promises、async/await以及模块模式的新变体。我们还涵盖了诸如 MVC、MVP 和 MVVM 等架构模式,并讨论现代框架与这些架构模式的关系。
如今的开发者接触到许多特定于库或框架的设计模式。React 成熟的生态系统及其利用新的 JavaScript 原语提供了一个优秀的平台,来讨论在框架或库上下文中的最佳实践和模式。除了经典设计模式外,本书还涵盖了现代 React 模式,如 Hooks、Higher-Order Components 和 Render Props。这些模式是特定于 React 的,对使用这一流行框架构建现代 Web 应用程序至关重要。
这本书不仅仅是关于设计模式,它还涵盖了最佳实践。我们涉及诸如代码组织、性能和渲染等主题,这些对于构建高质量的 Web 应用程序至关重要。你将学习动态导入、代码分割、服务器端渲染、水合以及 Islands 架构,这些都是构建快速响应的 Web 应用程序所必需的。
通过本书的学习,你将深入了解设计模式及如何将其应用到你的 JavaScript 代码和 React 应用程序中。你还将了解到哪些模式适用于现代 Web,哪些不适用。本书不仅仅是模式的参考,也是构建高质量 Web 应用程序的指南。你将学习如何结构化你的代码以获得最大的可维护性和可扩展性,以及如何优化性能。
书的结构
本书共分为 15 章,旨在从现代视角引导你学习 JavaScript 设计模式,融入更新的语言特性和 React 特定模式。每章都建立在前一章的基础上,帮助你逐步扩展知识并有效应用。
-
第一章,“设计模式简介”:熟悉设计模式的历史及其在编程世界中的重要性。
-
第二章,“‘模式’性测试、原型模式和三原则”:理解评估和完善设计模式的过程。
-
第三章,“模式的结构和编写”:学习良好编写模式的结构以及如何创建它们。
-
第四章,“反模式”:了解什么是反模式以及如何在你的代码中避免它们。
-
第五章,“现代 JavaScript 语法和特性”:探索最新的 JavaScript 语言特性及其对设计模式的影响。
-
第六章,“设计模式的分类”:深入探讨设计模式的不同分类:创建型、结构型和行为型。
-
第七章,“JavaScript 设计模式”:学习 JavaScript 中超过 20 种经典设计模式及其现代适应。
-
第八章,“JavaScript MV*模式”:了解像 MVC、MVP 和 MVVM 等架构模式及其在现代 Web 开发中的重要性。
-
第九章,“异步编程模式”:了解 JavaScript 中异步编程的强大之处及处理它的各种模式。
-
第十章,“模块化 JavaScript 设计模式”:发现组织和模块化你的 JavaScript 代码的模式。
-
第十一章,“命名空间模式”:学习各种将你的 JavaScript 代码命名空间化的技术,以避免全局命名空间污染。
-
第十二章,“React.js 设计模式”:探索 React 特定的模式,包括高阶组件、渲染属性和 Hooks。
-
第十三章,“渲染模式”:理解不同的渲染技术,如客户端渲染、服务器端渲染、渐进式水合和 Islands 架构。
-
第十四章,“React.js 应用结构”:学习如何为你的 React 应用程序进行更好的组织、可维护性和可扩展性。
-
第十五章,“总结”:总结本书的关键收获和最终思考。
本书贯穿始终,提供了实际示例来说明所讨论的模式和概念。通过你的学习,你将对 JavaScript 设计模式有扎实的理解,并能编写优雅、可维护和可扩展的代码。
无论你是经验丰富的网页开发者还是初学者,本书都将为你提供构建现代、可维护和可扩展的网页应用所需的知识和工具。我希望这本书能成为你在继续发展技能和构建令人惊叹的网页应用过程中的宝贵资源。
本书使用的约定
本书中使用以下排版约定:
Italic
表示新术语、网址、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及在段落内指代变量或函数名、数据库、数据类型、环境变量、语句和关键字等程序元素。
Constant width italic
显示应该由用户提供值或上下文确定的值替换的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般注释。
使用代码示例
补充材料(代码示例、练习等)可在https://github.com/addyosmani/learning-jsdp下载。
如果你有技术问题或使用代码示例遇到问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般情况下,如果本书附带示例代码,您可以在您的程序和文档中使用它。除非您重复使用代码的大部分,否则您无需联系我们以获得许可。例如,编写一个使用本书中几个代码片段的程序无需许可。出售或分发 O’Reilly 图书中的示例需要许可。引用本书回答问题并引用示例代码不需要许可。将本书中大量示例代码整合到产品文档中需要许可。
我们感谢您的认可,尽管通常不要求,但是通常包括标题、作者、出版商和 ISBN 的归属。例如:“学习 JavaScript 设计模式,第 2 版,作者 Addy Osmani(O’Reilly)。版权所有 2023 年 Adnan Osmani,978-1-098-13987-2。”
如果您认为您使用的代码示例超出了公平使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。
O’Reilly 在线学习
注意
超过 40 年来,O’Reilly Media 一直致力于为公司提供技术和商业培训、知识和见解,帮助其成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享其知识和专长。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和 200 多个其他出版商的大量文本和视频。有关更多信息,请访问https://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送至出版社:
-
O’Reilly Media,Inc.
-
1005 Gravenstein Highway North
-
Sebastopol,CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为本书设有一个网页,列出勘误、示例和任何其他信息。您可以访问https://oreil.ly/js_design_patterns_2e查看此页面。
电子邮件bookquestions@oreilly.com 以评论或询问有关本书的技术问题。
有关我们的图书和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上关注我们:https://linkedin.com/company/oreilly-media
在 Twitter 上关注我们:https://twitter.com/oreillymedia
在 YouTube 上观看我们:https://youtube.com/oreillymedia
致谢
我要感谢第二版的出色审阅人员,包括 Stoyan Stefanov,Julian Setiawan,Viswesh Ravi Shrimali,Adam Scott 和 Lydia Hallie。
第一版的热情、才华横溢的技术审阅者包括 Nicholas Zakas、Andrée Hansson、Luke Smith、Eric Ferraiuolo、Peter Michaux 和 Alex Sexton。他们——以及社区的其他成员——帮助审阅和改进了这本书,他们为这个项目带来的知识和热情简直令人惊叹。
特别感谢 Leena Sohoni-Kasture 对第二版编辑工作的贡献和反馈。
最后,我要感谢我的美妙妻子 Elle,在我编写这本出版物的过程中给予我的所有支持。
第一章:设计模式简介
好的代码就像是给将来维护它的开发者的一封情书!
设计模式为结构化代码提供了一个共同的词汇,使其更容易理解。它们有助于增强与其他开发者的这种连接的质量。设计模式的知识帮助我们识别需求中的重复主题,并将其映射到明确的解决方案。我们可以依赖那些遇到过类似问题并设计了优化方法来解决它的人们的经验。这些知识是无价的,因为它为编写或重构代码铺平了道路,使其易于维护。
无论是在服务器端还是客户端,JavaScript 都是现代 Web 应用程序开发的基石。本书的上一版聚焦于 JavaScript 环境中的几种流行设计模式。多年来,JavaScript 在功能和语法上显著发展。现在支持模块、类、箭头函数和模板字面量,这些之前没有。我们还有先进的 JavaScript 库和框架,这些极大地简化了许多 Web 开发者的生活。那么,在现代 JavaScript 环境中,设计模式还有多大的相关性呢?
传统上,需要注意的是设计模式既不是指导性的,也不是特定于某种语言的。当你认为它们适合时可以应用它们,但并非必须如此。就像数据结构或算法一样,你仍然可以使用现代编程语言(包括 JavaScript)应用经典的设计模式。在已经抽象化的现代框架或库中,你可能不需要一些这些设计模式。相反,某些框架甚至可能鼓励使用特定的模式。
在这个版本中,我们采用了一种实用主义的方法来讨论模式。我们将探讨为什么特定的模式可能适合实现某些功能,以及在现代 JavaScript 环境中是否仍然推荐使用某些模式。
随着应用程序变得更加交互,需要大量 JavaScript,这门语言因其对性能的负面影响而不断受到批评。开发者们不断寻找可以优化 JavaScript 性能的新模式。本版书籍突出了这些改进的相关性。我们还将讨论特定框架模式,如 React Hooks 和高阶组件,在 React.js 时代日益流行。
再退一步,让我们从探索设计模式的历史和重要性开始。如果你已经熟悉这段历史,可以跳过到“什么是模式?”继续阅读。
设计模式的历史
设计模式可以追溯到名为克里斯托弗·亚历山大的建筑师早期工作。他经常写作他解决设计问题的经验以及它们与建筑和城镇的关系。有一天,亚历山大意识到,当重复使用某些设计构造时,会产生期望的最优效果。
亚历山大与另外两位建筑师萨拉·石川和默里·西尔弗斯坦合作制定了一种模式语言。这种语言将帮助任何希望在任何规模上进行设计和建设的人。他们在 1977 年发表了一篇名为“模式语言”的论文,后来以完整精装书的形式发布。
大约在 1990 年左右,软件工程师开始将亚历山大(Alexander)所写关于设计模式的原则纳入首次文档,以指导希望提高编码技能的初学者。重要的是要注意,设计模式背后的概念自编程行业诞生以来就一直存在,尽管形式不够正式。
软件工程中关于设计模式的首个和可能是最具代表性的正式作品之一是 1995 年出版的书籍设计模式:可复用面向对象软件的元素——由埃里希·伽马、理查德·赫尔姆、拉尔夫·约翰逊和约翰·弗里西德斯编写。今天,大多数工程师都将这个团体称为四人帮(GoF)。
GoF 出版物在推动设计模式概念进一步发展方面发挥了特别重要作用。它描述了几种开发技术和陷阱,并提供了全球广泛使用的 23 种核心面向对象设计模式。我们将在第六章中更详细地讨论这些模式,它们也是我们在第七章中讨论的基础。
什么是模式?
模式是一种可重复使用的解决方案模板,您可以应用于软件设计中的重复问题和主题。类似于其他编程语言,在构建 JavaScript Web 应用程序时,您可以使用该模板来在不同情况下结构化 JavaScript 代码,从而帮助解决问题。
学习和使用设计模式对开发人员非常有利,原因如下:
模式是经过验证的解决方案。
它们是开发人员的结合经验和见解的结果,帮助定义它们。这些经过时间测试的方法是在解决软件开发中特定问题时确实有效的方法。
模式可以轻松重用。
模式通常提供一个即插即用的解决方案,您可以采用并根据需要进行调整。这一特性使它们非常强大。
模式可以表达性强。
模式可以帮助以一套结构和共享的词汇表达对广泛问题的优雅解决方案。
模式提供的额外优势包括以下几点:
模式有助于防止应用程序开发过程中可能导致重大问题的小问题。
当你使用已建立的模式来构建代码时,你可以放心不会出错,而是专注于整体解决方案的质量。模式鼓励你自然地编写更有结构和组织的代码,避免将来需要重构以保持清洁。
模式提供了泛化的解决方案,以一种不需要将其与特定问题联系起来的方式进行记录。
这种泛化的方法意味着你可以应用设计模式来改进代码结构,而不受应用程序(以及在许多情况下是编程语言)的限制。
一些模式可以通过避免重复来减少整体代码文件大小的占用。
设计模式鼓励开发人员更仔细地查看他们的解决方案,以找到可以实现即时减少重复的地方。例如,你可以减少执行类似过程的函数数量,而倾向于使用单一的通用函数来减少代码库的大小。这也被称为使代码更加 dry。
模式增加了开发人员的词汇量,使沟通更快速。
开发人员可以在与团队沟通时参考模式,在设计模式社区讨论时参考模式,或者在另一位开发人员后续维护代码时间接地参考模式。
流行的设计模式可以通过利用使用这些模式的开发人员的集体经验并回馈社区来进一步改进。
在某些情况下,这会导致全新的设计模式的创建,而在其他情况下,它可能会导致对特定模式使用的改进指南。这可以确保基于模式的解决方案继续比临时解决方案更加健壮。
注意
模式并不是确切的解决方案。模式的作用仅仅是为我们提供解决方案方案。模式并不能解决所有设计问题,也不能取代优秀的软件设计师。你仍然需要优秀的设计师来选择可以增强整体设计的正确模式。
设计模式的日常使用案例
如果你使用过 React.js,你可能遇到过 Provider 模式。如果没有,你可能经历过以下情况。
Web 应用程序中的组件树通常需要访问共享数据,如用户信息或用户访问权限。在 JavaScript 中传统的做法是为根级组件设置这些属性,然后从父组件传递到子组件。随着组件层次结构的加深和嵌套的增加,你会随着数据向下钻取,导致了属性传递的实践。这会导致代码难以维护,其中属性设置和传递将在每个依赖于该数据的子组件中重复。
React 和其他几个框架通过 Provider 模式解决了这个问题。使用 Provider 模式,React Context API 可以通过上下文提供器向多个组件广播状态/数据。需要共享数据的子组件可以作为上下文消费者连接到这个提供器,或者使用 useContext Hook。
这是优化解决常见问题方案的设计模式的一个极好示例。我们将在本书中详细讨论这个以及许多类似的模式。
总结
通过介绍设计模式的重要性及其在现代 JavaScript 中的相关性,我们现在可以深入学习 JavaScript 设计模式了。本书的前几章涵盖了模式的结构和分类以及识别反模式,然后再深入讨论 JavaScript 的设计模式细节。但首先,让我们在下一章看看一个提议的“原型模式”被认可为模式需要什么条件。
第二章:“Pattern”-ity 测试、原型模式和三规则
从提出一个新模式的那一刻起,到其可能被广泛采纳,一个模式可能需要经历设计社区和软件开发者的多轮深入检查。本章讨论了一个新引入的“原型模式”通过“模式”-ity 测试的旅程,直到它最终被认可为一个模式,如果它符合三规则。
这一章和下一章探讨了组织、撰写、呈现和审查新兴设计模式的方法。如果您更愿意先学习已确立的设计模式,可以暂时跳过这两章。
什么是原型模式?
请记住,并非每个算法、最佳实践或解决方案都代表了可能被视为完整模式的内容。可能有一些关键要素缺失,而模式社区通常对声称是模式的东西持谨慎态度,除非经过他人的广泛和批判性评估和测试,即使某些东西看起来符合模式的标准,我们也不应将其视为模式。
再次回顾亚历山大的工作,他认为一个模式应该既是一个过程,也是一个“东西”。这个定义很模糊,因为他接着说,应该是过程创造了“东西”。这就是为什么模式通常专注于解决一个可视化可识别的结构问题;我们应该能够通过将模式放入实践中来视觉地描述(或绘制)代表结构的图像。
“Pattern” 测试
当研究设计模式时,你可能经常会遇到术语“原型模式”。这是什么?嗯,一个尚未最终通过“模式”-ity 测试的模式通常被称为原型模式。原型模式可能源自于已经建立了值得与社区分享的特定解决方案的人的工作。然而,由于其相对年轻,社区尚未有机会适当地审核提出的解决方案。
或者,分享模式的个人可能没有时间或兴趣参与“模式”-ity 过程,可能会发布其原型模式的简短描述。这种类型的简短描述或片段被称为patlets。
全面记录合格模式所涉及的工作可能相当令人畏惧。回顾设计模式领域最早期的一些工作,如果一个模式能够做到以下几点,则可以认为它是“好”的:
解决特定问题
模式不仅仅是捕捉原则或策略。它们需要捕捉解决方案。这是一个好模式的最重要因素之一。
没有明显解决方案
我们可以发现,解决问题的技术通常试图从众所周知的第一原则中推导出来。最好的设计模式通常间接地为问题提供解决方案——这被认为是解决与设计相关的最具挑战性问题的必要方法。
描述了一个经过验证的概念
设计模式需要证明其按描述的方式运作,没有这种证明,设计就不能被认真考虑。如果一个模式在本质上是高度推测性的,只有勇敢的人才会尝试使用它。
描述了一种关系
在某些情况下,可能会出现一个模式描述了一种模块类型的情况。尽管实现看起来如何,但模式的官方描述必须描述更深层次的系统结构和机制,以解释它与代码的关系。
我们可能会原谅地认为,不符合指导方针的原型模式不值得学习;然而,这与事实相去甚远。许多原型模式实际上相当不错。我并不是说所有原型模式都值得一看,但在实践中确实有一些有用的原型模式可以帮助我们未来的项目。在考虑以上清单时,请慎重判断,你在选择过程中会没问题的。
三原则
模式有效的另一个要求是它展示了一些重复出现的现象。你通常可以在至少三个关键领域中确认这一点,称为三原则。为了使用这一原则展示重复性,必须展示以下内容:
适用性
这个模式被认为成功的原因是什么?
实用性
为什么这个模式被认为成功?
适用性
设计是否值得成为模式,因为它具有更广泛的适用性?如果是这样,这需要解释。在审查或定义模式时,牢记这些领域是至关重要的。
总结
本章展示了每个提出的原型模式并不总是被接受为模式。下一章分享了构建和记录模式的基本要素和最佳实践,以便社区能够轻松理解和消化它们。
第三章:结构化和编写模式
新想法的成功取决于其效用以及您向试图帮助的人们展示它的方式。开发人员要理解和采用设计模式,必须提供有关上下文、情况、先决条件和重要示例的相关信息。本章适用于试图理解特定模式的人以及试图引入新模式的人,因为它提供了有关模式如何结构化和编写的基本信息。
设计模式的结构
如果模式作者无法定义其目的,他们将无法成功创建和发布模式。同样,如果开发人员没有背景或上下文,理解或实现模式将是具有挑战性的。
模式作者必须概述新模式的设计、实现和目的。作者最初以建立关系的规则形式呈现新模式,关系是:
-
一个背景
-
在这个背景中产生的一套力量系统
-
允许这些力量在上下文中解决的配置
有了这些,现在让我们总结设计模式的组成要素。设计模式应具有以下元素,其中前五个元素最为重要:
模式名称
代表模式目的的唯一名称。
描述
模式帮助实现的简要描述。
背景概述
模式有效响应其用户需求的背景。
问题陈述
解释模式意图的问题陈述。
解决方案
描述用户问题如何通过一系列步骤和感知解决的说明清单。
设计
模式设计的描述,特别是用户与之交互时的行为。
实施
开发人员如何实现该模式的指南。
插图
模式中类的视觉表示(例如,图表)。
示例
模式在最小形式下的实现。
共需条件
支持正在描述的模式使用可能需要什么其他模式?
关系
这种模式与哪些模式类似?是否与其他模式非常相似?
已知用途
该模式是否在实际中使用?如果是,是在哪里和如何使用?
讨论
团队或作者对模式激动人心的好处的思考。
优秀的模式
理解设计模式的结构和目的可以帮助我们更深入地理解为何需要该模式的推理。它还帮助我们评估我们自己需求的模式。
一个好的模式理想情况下应为最终用户提供大量参考资料。模式还应提供为何必要的证据。
仅仅了解一个模式的概述并不足以帮助我们在日常可能遇到的代码中识别它们。我们不总是清楚我们正在查看的代码是否遵循一组模式或偶然类似于其中一个。
如果你怀疑你看到的代码使用了某种模式,请考虑写下一些代码中属于特定现有模式或一组模式的一些方面。也许代码遵循了与特定模式规则巧合重叠的良好原则和设计实践。
提示
在其中既没有交互也没有定义规则的解决方案不是模式。
尽管模式在规划和撰写阶段可能具有较高的初始成本,但从该投资中获得的价值可能是值得的。模式有价值,因为它们有助于在组织或团队中的所有开发人员在创建或维护解决方案时保持一致。如果您考虑要自己开发一个模式,请事先进行研究,因为您可能会发现使用或扩展现有的经过验证的模式比从头开始更有益。
撰写模式
如果您试图自己开发设计模式,我建议向那些已经经历并做得很好的人学习。花时间吸收来自几种不同设计模式描述的信息,并吸收对您有意义的内容。探索结构和语义——您可以通过检查您感兴趣的模式的交互和上下文来做到这一点,以识别有助于将这些模式组织在有价值的配置中的原则。
您可以利用现有格式编写自己的模式,或者看看是否有改进的方法,通过整合您的想法。近年来做到这一点的开发人员的一个例子是克里斯蒂安·海尔曼(Christian Heilmann),他采用了现有的模块模式(参见图 7-2)并对其进行了一些根本有价值的改变,以创建揭示模块模式(参见“揭示模块模式”)。
遵循以下清单将有助于您如果有兴趣创建新的设计模式或调整现有模式:
这种模式有多实用?
确保模式描述的是经过验证的解决方案,而不仅仅是尚未经过验证的推测性解决方案。
牢记最佳实践。
我们的设计决策应基于我们从理解最佳实践中得出的原则。
我们的设计模式应对用户透明。
设计模式应该对最终用户体验完全透明。它们主要为使用它们的开发人员提供服务,不应强制改变预期的用户体验。
记住,在模式设计中,原创性不是关键。
在撰写模式时,你无需成为记录解决方案的原始发现者,也不必担心你的设计与其他模式的次要部分重叠。如果这种方法足够强大,可以广泛应用,那么它有可能被认可为有效的模式。
模式需要一个强大的示例集。
一个好的模式描述需要跟随同样有效的示例集,展示模式成功应用的例子。为了展示广泛的使用,展现出良好设计原则的例子是理想的选择。
撰写模式是在创建通用、具体且最重要的是有用的设计之间进行谨慎平衡。在撰写模式时,尽量确保全面覆盖所有可能的应用领域。
无论你是否撰写模式,我希望这个对模式撰写的简要介绍能够为你提供一些见解,帮助你理解本书后续章节涵盖的模式。
总结
本章描绘了理想的“好”模式的图景。同样重要的是要理解,“坏”模式也存在,因此我们需要识别并避免它们。这就是为什么我们要在下一章讨论“反模式”的原因。
第四章:反模式
作为工程师,我们可能会遇到我们必须在最后期限前交付解决方案或者代码被连续打补丁而没有进行代码审查的情况。在这些情况下的代码可能不总是经过深思熟虑,并且可能会传播我们所称的 反模式 。本章描述了什么是反模式以及理解和识别它们的重要性。我们还将看一些 JavaScript 中典型的反模式。
什么是反模式?
如果一个模式代表最佳实践,那么反模式则代表了当提议的模式走错路时所学到的教训。受 GoF 的书籍 设计模式 启发,安德鲁·科尼格于 1995 年在他在 面向对象编程杂志,第 8 卷 中的文章中首次创造了反模式一词。他描述反模式如下:
反模式就像模式一样,只不过它不是解决方案,而是看似是解决方案但实际上不是解决方案。
他提出了两种反模式的概念。反模式:
-
描述一个 糟糕的 解决方案导致发生了不利情况
-
描述 如何 从当前情况中脱身并找到一个好的解决方案。
在这个话题上,亚历山大写道在实现良好设计结构和良好上下文之间取得良好平衡方面的困难:
这些笔记讨论的是设计的过程;是发明物理事物的过程,这些事物在响应功能时展示了新的物理秩序、组织、形式。...每个设计问题都始于努力实现两个实体之间的适配:问题中的形式和它的上下文。上下文定义了问题,形式是问题的解决方案。
理解反模式和了解设计模式一样重要。让我们分析其背后的原因。在创建应用程序时,项目的生命周期从构建开始。在这个阶段,您可能会根据需要选择可用的 良好 设计模式。但是,在初始发布后,应用程序需要进行维护。
已经在生产中的应用程序的维护可能特别具有挑战性。之前没有参与过该应用程序开发的开发人员可能会在项目中意外引入一个 糟糕的 设计。如果这些 糟糕的 实践已被确认为反模式,开发人员将能够提前识别并避免已知的常见错误。这类似于设计模式的知识使我们能够识别我们可以应用 已知 和 有用 的标准技术的领域。
随着解决方案的演变,其质量将取决于团队投入的技能水平和时间,可能是 好的 或 坏的 。在这里,好的 和 坏的 是根据上下文来考虑的——一个“完美”的设计如果在错误的上下文中应用,可能会被视为反模式。
总结一下,反模式是值得记录的糟糕设计。
JavaScript 中的反模式
开发人员有时会明知故犯地选择捷径和临时解决方案以加快代码交付速度。这些往往会变成永久性问题,并积累为技术债务,其本质上由反模式组成。JavaScript 是一种弱类型或无类型语言,这使得采取某些捷径更容易。以下是您可能在 JavaScript 中遇到的一些反模式示例:
-
通过在全局上下文中定义大量变量来污染全局命名空间。
-
将字符串而非函数传递给
setTimeout或setInterval,因为这会在内部触发eval()的使用。 -
修改
Object类原型(这是一个特别糟糕的反模式)。 -
在行内形式中使用 JavaScript 是不灵活的。
-
在这里使用
document.write,而原生文档对象模型(DOM)的替代方法如document.createElement更合适。多年来document.write被严重误用,并有许多缺点。如果在页面加载后执行,它可能会覆盖当前页面,因此选择document.createElement要明智得多。访问此链接查看其实际应用的实时示例。它也不适用于 XHTML,这是选择更友好的 DOM 方法如document.createElement的另一个原因。
熟悉反模式对于成功至关重要。一旦我们学会识别这些反模式,就可以重构代码以消除它们,从而立即提高解决方案的整体质量。
总结
本章介绍了可能导致问题的模式,即反模式以及 JavaScript 反模式的示例。在详细讨论 JavaScript 设计模式之前,我们必须简要介绍一些关键的现代 JavaScript 概念,这些概念对我们关于模式讨论具有重要意义。这是下一章的主题,介绍了现代 JavaScript 的特性和语法。
第五章:现代 JavaScript 语法和特性
JavaScript 现在已经存在了多个十年,并经历了多次修订。本书探讨了现代 JavaScript 背景下的设计模式,并对所有讨论的示例使用了现代的 ES2015+语法。本章讨论了 ES2015+ JavaScript 的特性和语法,这些对于进一步讨论当前 JavaScript 背景下的设计模式至关重要。
注意
ES2015 引入了一些对我们讨论的模式特别重要的 JavaScript 语法的基本更改。这些在BabelJS ES2015 指南中有很好的介绍。
本书依赖于现代 JavaScript 语法。您可能也对 TypeScript 感兴趣。TypeScript 是 JavaScript 的静态类型超集,提供了几个 JavaScript 不具备的语言特性。这些特性包括强类型、接口、枚举和高级类型推断,还可以影响设计模式。要了解有关 TypeScript 及其优势的更多信息,请考虑查阅 O'Reilly 书籍,如Programming TypeScript,作者 Boris Cherny。
解耦应用程序的重要性
模块化 JavaScript 允许您将应用程序逻辑上分割成称为模块的小块。一个模块可以被其他模块导入,这些模块又可以被更多的模块导入。因此,应用程序可以由许多嵌套模块组成。
在可伸缩的 JavaScript 世界中,当我们说一个应用程序是模块化时,通常意味着它由一组高度解耦的功能模块组成。松散耦合通过尽可能消除依赖关系来更轻松地维护应用程序。如果实现得当,它允许您看到对系统的一个部分进行更改可能如何影响另一个部分。
不像一些更传统的编程语言,旧版本的 JavaScript 直到 ES5(ECMA-262 第 5.1 版)没有为开发人员提供清晰地组织和导入代码模块的手段。这是在更近年才显得需要更有组织的 JavaScript 应用程序规范时的一个问题。AMD(异步模块定义)和CommonJS模块是在 JavaScript 初始版本中解耦应用程序最流行的模式之一。
解决这些问题的本地解决方案随着ES6 或 ES2015的到来而出现。TC39,负责定义 ECMAScript 及其未来版本的语法和语义的标准机构,一直密切关注 JavaScript 在大规模开发中的使用演变,并深刻意识到编写更模块化 JS 的需要。
使用 ECMAScript 2015 发布的 ECMAScript 模块的语法已经开发并标准化。今天,所有主要浏览器都支持 JavaScript 模块。它们已成为在 JavaScript 中实现现代模块化编程的事实标准。在本节中,我们将使用 ES2015+ 中的模块语法来探索代码示例。
具有导入和导出的模块
模块允许我们将应用程序代码分隔为独立单元,每个单元包含一个功能方面的代码。模块还鼓励代码的可重用性,并公开可集成到不同应用程序中的功能。
一种语言应该具有功能,允许您 import 模块依赖项并 export 模块接口(我们允许其他模块使用的公共 API/变量)以支持模块化编程。对于 JavaScript 模块(也称为 ES 模块),在 ES2015 中引入了对 JavaScript 的支持,允许您使用 import 关键字指定模块依赖项。同样,您可以使用 export 关键字从模块内部导出几乎任何内容:
-
import声明将模块的导出绑定为本地变量,并且可以重命名以避免名称冲突。 -
export声明声明模块的本地绑定是外部可见的,以便其他模块可以读取导出但不能修改它们。有趣的是,模块可以导出子模块,但不能导出在其他地方定义的模块。我们还可以重命名导出,使其外部名称与本地名称不同。
注意
.mjs 是用于 JavaScript 模块的扩展,帮助我们区分模块文件和经典脚本(.js)。.mjs 扩展确保相应的文件被运行时和构建工具(例如,Node.js、Babel)解析为模块。
以下示例展示了三个模块,分别是面包店员工、他们在烘焙时执行的功能以及面包店本身。我们看到一个模块导出的功能如何被另一个模块导入并使用:
// Filename: staff.mjs
// =========================================
// specify (public) exports that can be consumed by other modules
export const baker = {
bake(item) {
console.log( `Woo! I just baked ${item}` );
}
};
// Filename: cakeFactory.mjs
// =========================================
// specify dependencies
import baker from "/modules/staff.mjs";
export const oven = {
makeCupcake(toppings) {
baker.bake( "cupcake", toppings );
},
makeMuffin(mSize) {
baker.bake( "muffin", size );
}
}
// Filename: bakery.mjs
// =========================================
import {cakeFactory} from "/modules/cakeFactory.mjs";
cakeFactory.oven.makeCupcake( "sprinkles" );
cakeFactory.oven.makeMuffin( "large" );
通常,一个模块文件包含几个相关的函数、常量和变量。您可以在文件末尾使用单个导出语句,后跟一个逗号分隔的模块资源列表,将这些资源集体导出:
// Filename: staff.mjs
// =========================================
const baker = {
//baker functions
};
const pastryChef = {
//pastry chef functions
};
const assistant = {
//assistant functions
};
export { baker, pastryChef, assistant };
同样,您可以只导入您需要的函数:
import {baker, assistant} from "/modules/staff.mjs";
您可以通过指定值为 module 的 type 属性,告诉浏览器接受包含 JavaScript 模块的 <script> 标签:
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>
nomodule属性告诉现代浏览器不要将经典脚本作为模块加载。这对于不使用模块语法的回退脚本非常有用。它允许你在 HTML 中使用模块语法,并使其在不支持该语法的浏览器中正常工作。这在多个方面都很有用,包括性能。现代浏览器不需要为现代特性提供 polyfill,可以单独为传统浏览器提供更大的转码代码。
模块对象
一种更清晰的导入和使用模块资源的方法是将模块作为对象导入。这使得所有导出都作为对象的成员可用:
// Filename: cakeFactory.mjs
import * as Staff from "/modules/staff.mjs";
export const oven = {
makeCupcake(toppings) {
Staff.baker.bake( "cupcake", toppings );
},
makePastry(mSize) {
Staff.pastryChef.make( "pastry", type );
}
}
从远程源加载的模块
ES2015+还支持远程模块(例如第三方库),使得从外部位置加载模块变得简单。这里有一个示例,展示了如何拉取我们之前定义的模块并利用它:
import {cakeFactory} from "https://example.com/modules/cakeFactory.mjs";
// eagerly loaded static import
cakeFactory.oven.makeCupcake( "sprinkles" );
cakeFactory.oven.makeMuffin( "large" );
静态导入
刚刚讨论的导入类型称为静态导入。在主代码运行之前,需要使用静态导入下载和执行模块图形。有时这会导致在初始页面加载时前端加载大量代码,这可能是昂贵的并延迟关键功能的提前可用性:
import {cakeFactory} from "/modules/cakeFactory.mjs";
// eagerly loaded static import
cakeFactory.oven.makeCupcake( "sprinkles" );
cakeFactory.oven.makeMuffin( "large" );
动态导入
有时候,你不想预加载一个模块,而是在需要时按需加载。延迟加载模块允许你在需要时加载所需内容,例如,当用户点击链接或按钮时。这提高了初始加载性能。动态导入的引入使这成为可能。
动态导入引入了一种类似函数的导入形式。import(url)返回请求的模块命名空间对象的 promise,该对象在获取、实例化和评估模块及其所有依赖项后创建。这里有一个示例展示了对cakeFactory模块的动态导入:
form.addEventListener("submit", e => {
e.preventDefault();
import("/modules/cakeFactory.js")
.then((module) => {
// Do something with the module.
module.oven.makeCupcake("sprinkles");
module.oven.makeMuffin("large");
});
});
动态导入也可以使用await关键字支持:
let module = await import("/modules/cakeFactory.js");
使用动态导入时,只有在模块被使用时才会下载和评估模块图形。
流行的模式如交互式导入和可见性导入可以在原生 JavaScript 中利用动态导入功能轻松实现。
交互式导入
一些库可能仅在用户开始与网页上的特定功能进行交互时才需要。典型的例子包括聊天窗口、复杂的对话框或视频嵌入。这些功能的库不需要在页面加载时导入,而是可以在用户与它们进行交互时加载(例如通过点击组件外观或占位符)。操作可以触发相应库的动态导入,然后调用函数以激活所需的功能。
例如,你可以使用外部的lodash.sortby模块实现屏幕上的排序功能,该模块是动态加载的:
const btn = document.querySelector('button');
btn.addEventListener('click', e => {
e.preventDefault();
import('lodash.sortby')
.then(module => module.default)
.then(sortInput()) // use the imported dependency
.catch(err => { console.log(err) });
});
可见性导入
许多组件在初始页面加载时不可见,但随着用户向下滚动,它们变得可见。由于用户可能不会总是向下滚动,因此当这些组件变得可见时,相应的模块可以进行延迟加载。IntersectionObserver API 可以检测组件占位符即将变得可见的时候,动态导入可以加载相应的模块。
服务器端的模块
Node 15.3.0 以后支持 JavaScript 模块。它们无需实验标志,并且与 npm 软件包生态系统的其余部分兼容。Node 处理以 .mjs 和 .js 结尾的文件,并将顶级 type 字段值设置为 module 作为 JavaScript 模块:
{
"name": "js-modules",
"version": "1.0.0",
"description": "A package using JS Modules",
"main": "index.js",
"type": "module",
"author": "",
"license": "MIT"
}
使用模块的优点
模块化编程和使用模块提供了几个独特的优势。以下是其中一些:
模块脚本仅评估一次。
浏览器只会对模块脚本进行一次评估,而经典脚本会根据其添加到 DOM 的次数进行评估。这意味着,如果您有一个依赖模块层次结构,那么依赖于最内层模块的模块将首先进行评估。这是一件好事,因为这意味着最内层模块将首先进行评估,并且可以访问依赖于它的模块的导出内容。
模块自动推迟加载。
与其他脚本文件不同,如果不希望立即加载它们,您无需包含 defer 属性,浏览器会自动推迟模块的加载。
模块易于维护和重用。
模块促进解耦能够独立维护的代码片段,而不需要对其他模块进行重大更改。它们还允许在多个不同功能中重复使用相同的代码。
模块提供命名空间。
模块为相关变量和常量创建了一个私有空间,以便可以通过模块引用它们而不污染全局命名空间。
模块实现了死代码消除。
在引入模块之前,必须手动从项目中删除未使用的代码文件。使用模块导入后,像 webpack 和 Rollup 这样的打包工具可以自动识别未使用的模块并将其消除。在添加到捆绑包之前可能会删除死代码。这称为摇树。
所有现代浏览器都支持模块的 import 和 export,您可以在没有任何回退的情况下使用它们。
带有构造函数、getter 和 setter 的类
除了模块外,ES2015+ 还允许使用构造函数和一些私密性概念来定义类。JavaScript 类是用 class 关键字定义的。在下面的示例中,我们定义了一个 Cake 类,包含一个构造函数和两个 getter 和 setter:
class Cake{
// We can define the body of a class constructor
// function by using the keyword constructor
// with a list of class variables.
constructor( name, toppings, price, cakeSize ){
this.name = name;
this.cakeSize = cakeSize;
this.toppings = toppings;
this.price = price;
}
// As a part of ES2015+ efforts to decrease the unnecessary
// use of function for everything, you will notice that it is
// dropped for cases such as the following. Here an identifier
// followed by an argument list and a body defines a new method.
addTopping( topping ){
this.toppings.push( topping );
}
// Getters can be defined by declaring get before
// an identifier/method name and a curly body.
get allToppings(){
return this.toppings;
}
get qualifiesForDiscount(){
return this.price > 5;
}
// Similar to getters, setters can be defined by using
// the set keyword before an identifier
set size( size ){
if ( size < 0){
throw new Error( "Cake must be a valid size: " +
"either small, medium or large");
}
this.cakeSize = size;
}
}
// Usage
let cake = new Cake( "chocolate", ["chocolate chips"], 5, "large" );
JavaScript 类是基于原型的,并且是 JavaScript 函数的一个特殊类别,需要在引用之前定义。
您还可以使用extends关键字指示一个类继承自另一个类:
class BirthdayCake extends Cake {
surprise() {
console.log(`Happy Birthday!`);
}
}
let birthdayCake = new BirthdayCake( "chocolate", ["chocolate chips"], 5,
"large" );
birthdayCake.surprise();
所有现代浏览器和 Node 都支持 ES2015 类。它们也兼容 ES6 中引入的new-style class syntax。
JavaScript 模块和类的区别在于模块是导入和导出,而类则是用class关键字定义的。
阅读下去,你可能还会注意到前面的例子中缺少了function一词。这不是打字错误:TC39 有意减少我们对function关键字的滥用,希望能简化我们编写代码的方式。
JavaScript 类还支持super关键字,它允许您调用父类的构造函数。这对实现自身继承模式非常有用。您可以使用super来调用超类的方法:
class Cookie {
constructor(flavor) {
this.flavor = flavor;
}
showTitle() {
console.log(`The flavor of this cookie is ${this.flavor}.`);
}
}
class FavoriteCookie extends Cookie {
showTitle() {
super.showTitle();
console.log(`${this.flavor} is amazing.`);
}
}
let myCookie = new FavoriteCookie('chocolate');
myCookie.showTitle();
// The flavor of this cookie is chocolate.
// chocolate is amazing.
现代 JavaScript 支持公共和私有类成员。公共类成员可以被其他类访问。私有类成员只能在定义它们的类中访问。类字段默认为公共。通过使用#(哈希)前缀可以创建私有类字段:
class CookieWithPrivateField {
#privateField;
}
class CookieWithPrivateMethod {
#privateMethod() {
return 'delicious cookies';
}
}
JavaScript 类支持使用static关键字的静态方法和属性。静态成员可以在不实例化类的情况下引用。您可以使用静态方法创建实用函数,并使用静态属性来保存配置或缓存数据:
class Cookie {
constructor(flavor) {
this.flavor = flavor;
}
static brandName = "Best Bakes";
static discountPercent = 5;
}
console.log(Cookie.brandName); //output = "Best Bakes"
JavaScript 框架中的类
在过去几年中,一些现代 JavaScript 库和框架——特别是 React——引入了替代类的方法。React Hooks 使得在不使用 ES2015 类组件的情况下使用 React 状态和生命周期方法成为可能。在 Hooks 出现之前,React 开发者必须将功能组件重构为类组件,以便处理状态和生命周期方法。这通常很棘手,并且需要理解 ES2015 类的工作原理。React Hooks 是函数,允许您管理组件的状态和生命周期方法,而无需依赖于类。
请注意,构建 Web 的其他几种方法,例如Web Components社区,继续以类作为组件开发的基础。
总结
本章介绍了模块和类的 JavaScript 语言语法。这些特性使我们能够在遵循面向对象设计和模块化编程原则的同时编写代码。我们还将使用这些概念来对不同的设计模式进行分类和描述。下一章将讨论设计模式的不同类别。
相关阅读
第六章:设计模式的类别
本章记录了三大主要设计模式类别及其下属的不同模式。虽然每个设计模式都解决了特定的面向对象设计问题或问题,但我们可以根据它们解决这些问题的方式之间的相似性来划分类别。这形成了设计模式分类的基础。
背景
Gamma、Helm、Johnson 和 Vlissides(1995)在他们的书籍可重用面向对象软件的设计模式中将设计模式描述为:
设计模式命名、抽象并确定了常见设计结构的关键方面,使其能够用于创建可重用的面向对象设计。设计模式确定参与类及其实例、它们的角色和协作,以及责任的分配。
每个设计模式专注于特定的面向对象设计问题或问题。它描述了何时适用,是否可以应用于其他设计约束条件,并且其使用的后果和权衡。由于我们最终必须实现我们的设计,设计模式还提供了示例…代码来说明其实现。
虽然设计模式描述了面向对象的设计,但它们基于实际在主流面向对象编程语言中实现的解决方案…。
设计模式可以根据它们解决的问题类型进行分类。设计模式的三个主要类别是:
-
创建型设计模式
-
结构设计模式
-
行为设计模式
在接下来的章节中,我们将回顾这三种类别中属于每种类别的模式的几个示例。
创建型设计模式
创建型设计模式专注于处理对象创建机制,其中对象根据给定情况以合适的方式创建。在项目中,基本的对象创建方法可能会增加复杂性,而这些模式旨在通过控制创建过程来解决这个问题。
属于这一类别的一些模式包括构造器(Constructor)、工厂(Factory)、抽象工厂(Abstract)、原型(Prototype)、单例(Singleton)和建造者(Builder)。
结构设计模式
结构模式涉及对象组合,通常识别实现不同对象之间关系的简单方法。它们确保当系统的某一部分发生变化时,系统的整体结构无需改变。它们还有助于将系统中不适合特定目的的部分重塑为适合特定目的的部分。
属于这一类别的模式包括装饰器(Decorator)、外观(Facade)、享元(Flyweight)、适配器(Adapter)和代理(Proxy)。
行为设计模式
行为模式专注于改进或简化系统中不同对象之间的通信。它们识别对象之间的常见通信模式,并提供将通信责任分配给不同对象的解决方案,从而增加通信的灵活性。基本上,行为模式将动作从执行动作的对象中抽象出来。
一些行为模式包括迭代器、中介者、观察者和访问者。
设计模式类
2004 年,Elyse Nielsen 创建了一个“类”表格,总结了 23 种 GoF 设计模式。在我学习设计模式的早期阶段,我发现这个表格非常有用。我根据需要修改了它,以适应我们对设计模式的讨论。
我建议将此表作为参考,但请记住,我们将在本书的后续章节中讨论其他未提及的几种模式。
注释
我们在第五章中讨论了 JavaScript ES2015+类。当您审查以下表格时,JavaScript 类和对象将会相关。
现在让我们继续审查表格:
| 创建型 | 基于创建对象的概念 |
|---|---|
| 类 | |
| 工厂方法 | 根据接口数据或事件创建多个派生类的实例 |
| 对象 | |
| 抽象工厂 | 创建多个类族的实例,而不详细说明具体类 |
| 构建者 | 将对象构建与其表示分离;始终创建相同类型的对象 |
| 原型 | 用于复制或克隆的完全初始化的实例 |
| 单例 | 具有全局访问点的仅有单个实例的类 |
| 结构型 | 基于对象的构建块的概念 |
| 类 | |
| 适配器 | 匹配不同类的接口,使得类能够共同工作,尽管接口不兼容 |
| 对象 | |
| 桥接 | 将对象的接口与其实现分离,以便两者可以独立变化 |
| 组合 | 由简单和复合对象构成的结构,使得总对象不仅仅是其部分的总和 |
| 装饰器 | 动态地为对象添加替代处理 |
| 外观 | 一个单一的类,隐藏了整个子系统的复杂性 |
| 享元 | 用于有效共享信息的细粒度实例,信息实际上存储在其他地方 |
| 代理 | 表示真实对象的占位符对象 |
| 行为型 | 基于对象如何协同工作和互动的方式 |
| 类 | |
| 解释器 | 将语言元素包含到应用程序中,以匹配预期语言的语法 |
| 模板方法 | 在方法中创建算法的框架,然后将确切的步骤推迟到子类 |
| 对象 | |
| 责任链 | 通过对象链传递请求,以找到能处理请求的对象 |
| 命令模式 | 将命令的执行与调用者分离的方法 |
| 迭代器模式 | 顺序访问集合的元素而不需了解集合的内部工作机制 |
| 中介者模式 | 定义简化类之间通信的方式,以防止一组类显式地相互引用 |
| 备忘录模式 | 捕获对象的内部状态,以便稍后恢复 |
| 观察者模式 | 一种通知多个类进行变化以确保类之间一致性的方法 |
| 状态模式 | 当对象状态改变时改变其行为 |
| 策略模式 | 将算法封装在一个类中,将选择和实现分离 |
| 访问者模式 | 在不改变类本身的情况下为类添加新操作 |
概要
本章介绍了设计模式的类别,并解释了创建型、结构型和行为型模式之间的区别。我们讨论了这三类模式及其 GoF 模式在每个类别中的差异。我们还回顾了显示 GoF 模式如何与类和对象的概念相关联的“类”表。
这几章详细讲解了设计模式的理论细节和 JavaScript 语法的基础知识。在这个基础上,我们现在可以开始进入 JavaScript 设计模式的一些实际例子。
第七章:JavaScript 设计模式
上一章提供了三种不同类别设计模式的示例。其中一些设计模式在 Web 开发环境中是相关或必需的。我已经确定了一些可以在 JavaScript 中应用时有所帮助的经典模式。本章探讨了不同经典和现代设计模式的 JavaScript 实现。每个部分都专注于三个类别之一——创建型、结构型和行为型。让我们从创建型模式开始。
创建型模式
创建型模式提供了创建对象的机制。我们将涵盖以下模式:
-
“构造函数模式”
-
“模块模式”
-
“揭示模块模式”
-
“单例模式”
-
“原型模式”
-
“工厂模式”
构造函数模式
构造函数是一种特殊的方法,用于在为其分配内存后初始化新创建的对象。使用 ES2015+,引入了使用构造函数创建类的语法到 JavaScript 中。这使得可以使用默认的构造函数将对象创建为类的实例。
在 JavaScript 中,几乎所有东西都是对象,而类只是 JavaScript 原型继承的语法糖。在经典 JavaScript 中,我们通常对对象构造函数感兴趣。图 7-1 说明了这种模式。
注意
对象构造函数用于创建特定类型的对象——为对象准备并在对象首次创建时接受参数以设置成员属性和方法的值。

图 7-1 构造函数模式
对象创建
在 JavaScript 中创建新对象的三种常见方法如下:
// Each of the following options will create a new empty object
const newObject = {};
// or
const newObject = Object.create(Object.prototype);
// or
const newObject = new Object();
在这里,我们将每个对象声明为常量,这将创建一个只读的块级作用域变量。在最后一个示例中,Object构造函数为特定值创建一个对象包装器,或者在不传递值的情况下,它创建一个空对象并返回它。
现在,您可以通过以下方式为对象分配键和值:
// ECMAScript 3 compatible approaches
// 1\. Dot syntax
// Set properties
newObject.someKey = "Hello World";
// Get properties
var key = newObject.someKey;
// 2\. Square bracket syntax
// Set properties
newObject["someKey"] = "Hello World";
// Get properties
var key = newObject["someKey"];
// ECMAScript 5 only compatible approaches
// For more information see: http://kangax.github.com/es5-compat-table/
// 3\. Object.defineProperty
// Set properties
Object.defineProperty( newObject, "someKey", {
value: "for more control of the property's behavior",
writable: true,
enumerable: true,
configurable: true
});
// 4\. If this feels a little difficult to read, a short-hand could
// be written as follows:
var defineProp = function ( obj, key, value ){
config.value = value;
Object.defineProperty( obj, key, config );
};
// To use, we then create a new empty "person" object
var person = Object.create( null );
// Populate the object with properties
defineProp( person, "car", "Delorean" );
defineProp( person, "dateOfBirth", "1981" );
defineProp( person, "hasBeard", false );
// 5\. Object.defineProperties
// Set properties
Object.defineProperties( newObject, {
"someKey": {
value: "Hello World",
writable: true
},
"anotherKey": {
value: "Foo bar",
writable: false
}
});
// Getting properties for 3\. and 4\. can be done using any of the
// options in 1\. and 2.
您甚至可以将这些方法用于继承,如下所示:
// ES2015+ keywords/syntax used: const
// Usage:
// Create a race car driver that inherits from the person object
const driver = Object.create(person);
// Set some properties for the driver
defineProp(driver, 'topSpeed', '100mph');
// Get an inherited property (1981)
console.log(driver.dateOfBirth);
// Get the property we set (100mph)
console.log(driver.topSpeed);
基本构造函数
正如在第五章中讨论的,JavaScript 类在 ES2015 中引入,允许我们为 JavaScript 对象定义模板并使用 JavaScript 实现封装和继承。
总结一下,类必须包含并声明一个名为constructor()的方法,该方法将用于实例化一个新对象。关键字new允许我们调用构造函数。构造函数内部的关键字this引用创建的新对象。以下示例显示了一个基本构造函数:
class Car {
constructor(model, year, miles) {
this.model = model;
this.year = year;
this.miles = miles;
}
toString() {
return `${this.model} has done ${this.miles} miles`;
}
}
// Usage:
// We can create new instances of the car
let civic = new Car('Honda Civic', 2009, 20000);
let mondeo = new Car('Ford Mondeo', 2010, 5000);
// and then open our browser console to view the output of
// the toString() method being called on these objects
console.log(civic.toString());
console.log(mondeo.toString());
这是构造函数模式的一个简单版本,但存在一些问题。其中一个问题是它使得继承困难,另一个是像toString()这样的函数会为每个使用Car构造函数创建的新对象重新定义。这并不理想,因为Car类型的所有实例理想情况下应共享相同的函数。
带原型的构造函数
JavaScript 中的原型允许您轻松地为特定对象的所有实例定义方法,无论是函数还是类。当我们调用 JavaScript 构造函数来创建对象时,构造函数原型的所有属性都将对新对象可用。通过这种方式,可以拥有访问相同原型的多个Car对象。因此,可以扩展原始示例如下:
class Car {
constructor(model, year, miles) {
this.model = model;
this.year = year;
this.miles = miles;
}
}
// Note here that we are using Object.prototype.newMethod rather than
// Object.prototype to avoid redefining the prototype object
// We still could use Object.prototype for adding new methods,
// because internally we use the same structure
Car.prototype.toString = function() {
return `${this.model} has done ${this.miles} miles`;
};
// Usage:
let civic = new Car('Honda Civic', 2009, 20000);
let mondeo = new Car('Ford Mondeo', 2010, 5000);
console.log(civic.toString());
console.log(mondeo.toString());
所有Car对象现在都共享一个toString()方法的实例。
模块模式
模块是任何健壮应用架构的一个重要部分,通常有助于保持项目代码单元的清晰分离和组织。
经典 JavaScript 有几种实现模块的选项,例如:
-
对象字面量表示法
-
模块模式
-
AMD 模块
-
CommonJS 模块
我们已经在第五章中讨论了现代 JavaScript 模块(也称为“ES 模块”或“ECMAScript 模块”)。本节的示例将主要使用 ES 模块。
在 ES2015 之前,CommonJS 模块或 AMD 模块是流行的替代方案,因为它们允许你导出模块的内容。我们将在本书的第十章中探讨 AMD、CommonJS 和 UMD 模块。首先,让我们了解模块模式及其起源。
模块模式部分基于对象字面量,因此最好先刷新我们对它们的了解。
对象字面量
在对象字面量表示法中,对象被描述为一组逗号分隔的名称/值对,这些对被大括号({})括起来。对象内部的名称可以是字符串或标识符,后跟冒号。不应在对象的最后一个名称/值对之后使用逗号,因为这可能导致错误:
const myObjectLiteral = {
variableKey: variableValue,
functionKey() {
// ...
}
};
对象字面量不需要使用new运算符进行实例化,但不应在语句开头使用,因为开头的{可能被解释为新块的开始。在对象之外,可以使用赋值来向其添加新成员,如下所示:myModule.property = "someValue";。
这是使用对象字面量定义的模块的完整示例:
const myModule = {
myProperty: 'someValue',
// object literals can contain properties and methods.
// e.g., we can define a further object for module configuration:
myConfig: {
useCaching: true,
language: 'en',
},
// a very basic method
saySomething() {
console.log('Where is Paul Irish debugging today?');
},
// output a value based on the current configuration
reportMyConfig() {
console.log(
`Caching is: ${this.myConfig.useCaching ? 'enabled' : 'disabled'}`
);
},
// override the current configuration
updateMyConfig(newConfig) {
if (typeof newConfig === 'object') {
this.myConfig = newConfig;
console.log(this.myConfig.language);
}
},
};
// Outputs: What is Paul Irish debugging today?
myModule.saySomething();
// Outputs: Caching is: enabled
myModule.reportMyConfig();
// Outputs: fr
myModule.updateMyConfig({
language: 'fr',
useCaching: false,
});
// Outputs: Caching is: disabled
myModule.reportMyConfig();
使用对象字面量提供了一种封装和组织代码的方式。Rebecca Murphey 已经深入探讨了这个主题,如果你希望进一步了解对象字面量,可以阅读深入。
模块模式
模块模式最初被定义为为传统软件工程中的类提供私有和公共封装。
曾经,在 JavaScript 应用程序中组织任何合理大小的问题是一种挑战。开发者会依赖于单独的脚本来分割和管理可重用的逻辑块,不足为奇的是,在 HTML 文件中手动导入 10 到 20 个脚本来保持整洁。使用对象,模块模式只是一种在文件中封装逻辑的方法,包括公共和“私有”方法。随着时间的推移,出现了几种自定义模块系统,使这一过程更加顺畅。现在,开发者可以使用 JavaScript 模块来组织对象、函数、类或变量,使它们可以轻松地导出或导入到其他文件中。这有助于防止在不同模块中包含的类或函数名称之间的冲突。图 7-2 说明了模块模式。

图 7-2. 模块模式
私有性
模块模式通过闭包封装了“私有性”状态和组织。它提供了一种包装公共和私有方法和变量的方式,保护这些部分不会泄漏到全局范围,并意外地与另一个开发者的接口冲突。使用这种模式,你只暴露出公共 API,将其他所有内容保持在闭包内部。
这为我们提供了一个干净的解决方案,其中屏蔽逻辑承担了大部分工作,而我们只暴露出我们希望应用程序其他部分使用的接口。该模式使用了一个立即调用的函数表达式(IIFE),其中返回了一个对象。详情请参阅第十一章关于 IIFE 的更多信息。
请注意,JavaScript 内部没有显式的“私有性”概念,因为它没有像一些传统语言那样的访问修饰符。在技术上,你不能声明变量为公共或私有,因此我们使用函数作用域来模拟这个概念。在模块模式中,只有在模块内部声明的变量或方法才可用,这要归功于闭包。然而,在返回对象中定义的变量或方法对所有人都是可用的。
实现返回对象中变量的私有性的一种解决方法使用了WeakMap(),本章稍后在“带有 WeakMap 的现代模块模式”中讨论。WeakMap()只接受对象作为键,并且不能被迭代。因此,在模块内部,唯一访问对象的方式是通过它的引用。在模块外部,只能通过其中定义的公共方法访问它。因此,它确保了对象的私有性。
历史
从历史的角度来看,模块模式最初是在 2003 年由几个人开发的,其中包括理查德·康福德。后来,道格拉斯·克罗克福德在他的讲座中将其推广开来。另一个趣闻是,如果您曾经玩过雅虎的 YUI 库,模块模式的一些特性可能会让您感到非常熟悉。原因是在 YUI 创建其组件时,模块模式对其产生了很大的影响。
示例
让我们开始通过创建一个自包含模块来实现模块模式。在我们的实现中,我们使用import和export关键字。回顾我们之前的讨论,export允许您在模块外部提供对模块功能的访问。同时,import使我们能够将模块导出的绑定导入到我们的脚本中:
let counter = 0;
const testModule = {
incrementCounter() {
return counter++;
},
resetCounter() {
console.log(`counter value prior to reset: ${counter}`);
counter = 0;
},
};
// Default export module, without name
export default testModule;
// Usage:
// Import module from path
import testModule from './testModule';
// Increment our counter
testModule.incrementCounter();
// Check the counter value and reset
// Outputs: counter value prior to reset: 1
testModule.resetCounter();
在这里,代码的其他部分不能直接读取我们的incrementCounter()或resetCounter()的值。counter变量完全被屏蔽在我们的全局作用域之外,因此它的行为就像一个私有变量一样——它的存在仅限于模块的闭包内部,因此只有这两个函数能够访问它的作用域。我们的方法在效果上是命名空间化的,因此在代码的测试部分,我们需要用模块的名称作为前缀来调用任何函数(例如testModule)。
在使用模块模式时,定义一个简单的模板可能会有所帮助,以便我们可以用它来开始使用。以下是一个涵盖命名空间、公共和私有变量的示例:
// A private counter variable
let myPrivateVar = 0;
// A private function that logs any arguments
const myPrivateMethod = foo => {
console.log(foo);
};
const myNamespace = {
// A public variable
myPublicVar: 'foo',
// A public function utilizing privates
myPublicFunction(bar) {
// Increment our private counter
myPrivateVar++;
// Call our private method using bar
myPrivateMethod(bar);
},
};
export default myNamespace;
接下来是另一个示例,我们可以看到使用这种模式实现的购物篮。模块本身完全包含在名为basketModule的全局变量中。模块中的basket数组被保持私有,因此我们应用程序的其他部分无法直接读取它。它仅存在于模块的闭包内部,因此能够访问它的唯一方法是那些能够访问其作用域的方法(即addItem()、getItem()等):
// privates
const basket = [];
const doSomethingPrivate = () => {
//...
};
const doSomethingElsePrivate = () => {
//...
};
// Create an object exposed to the public
const basketModule = {
// Add items to our basket
addItem(values) {
basket.push(values);
},
// Get the count of items in the basket
getItemCount() {
return basket.length;
},
// Public alias to a private function
doSomething() {
doSomethingPrivate();
},
// Get the total value of items in the basket
// The reduce() method applies a function against an accumulator and each
// element in the array (from left to right) to reduce it to a single value.
getTotal() {
return basket.reduce((currentSum, item) => item.price + currentSum, 0);
},
};
export default basketModule;
在模块内部,您可能已经注意到我们返回一个object。这会自动分配给basketModule,以便我们可以像下面这样与其交互:
// Import module from path
import basketModule from './basketModule';
// basketModule returns an object with a public API we can use
basketModule.addItem({
item: 'bread',
price: 0.5,
});
basketModule.addItem({
item: 'butter',
price: 0.3,
});
// Outputs: 2
console.log(basketModule.getItemCount());
// Outputs: 0.8
console.log(basketModule.getTotal());
// However, the following will not work:
// Outputs: undefined
// This is because the basket itself is not exposed as a part of our
// public API
console.log(basketModule.basket);
// This also won't work as it exists only within the scope of our
// basketModule closure, not in the returned public object
console.log(basket);
这些方法在basketModule内部有效地命名空间化。我们所有的函数都被封装在这个模块中,给我们带来了几个优势,比如:
-
拥有可以被我们的模块专门使用的私有函数的自由。它们不会暴露给页面的其他部分(只有我们导出的 API 可以),因此它们被认为是真正私有的。
-
鉴于函数通常是声明和命名的,当我们尝试查找引发异常的函数时,在调试器中显示调用堆栈可能会更容易。
模块模式的变体
随着时间的推移,设计师们为满足他们的需求,引入了不同的模块模式变体。
导入混合
此模式变体演示了如何将全局变量(例如实用函数或外部库)作为参数传递给模块中的高阶函数。这有效地允许我们根据需要导入并本地别名它们:
// utils.js
export const min = (arr) => Math.min(...arr);
// privateMethods.js
import { min } from "./utils";
export const privateMethod = () => {
console.log(min([10, 5, 100, 2, 1000]));
};
// myModule.js
import { privateMethod } from "./privateMethods";
const myModule = () => ({
publicMethod() {
privateMethod();
},
});
export default myModule;
// main.js
import myModule from "./myModule";
const moduleInstance = myModule();
moduleInstance.publicMethod();
出口
这种变体允许我们声明全局变量而不消耗它们,并且同样支持在上一个示例中看到的全局导入概念:
// module.js
const privateVariable = "Hello World";
const privateMethod = () => {
// ...
};
const module = {
publicProperty: "Foobar",
publicMethod: () => {
console.log(privateVariable);
},
};
export default module;
优点
我们已经知道构造函数模式为什么有用,但为什么模块模式是一个不错的选择呢?首先,对于来自面向对象背景的开发人员来说,它比真正的封装概念要清晰得多,至少从 JavaScript 的角度来看是这样。通过导入混入,开发人员可以管理模块之间的依赖关系并根据需要传递全局变量,使代码更易于维护和模块化。
其次,它支持私有数据——因此,在模块模式中,我们只能访问我们使用 export 关键字显式导出的值。我们没有明确导出的值是私有的,仅在模块内部可用。这减少了意外污染全局作用域的风险。你不必担心会意外地覆盖由使用你的模块的开发人员创建的具有相同名称的值:它防止了命名冲突和全局作用域污染。
使用模块模式,我们可以封装不应公开的代码部分。这样可以减少处理多个依赖项和命名空间时的风险。请注意,要在所有 JavaScript 运行时中使用 ES2015 模块,需要像 Babel 这样的转译器。
缺点
模块模式的缺点在于我们以不同方式访问公共和私有成员。当我们希望改变可见性时,我们必须在每个使用成员的地方进行更改。
我们后来添加到对象中的方法中也无法访问私有成员。尽管如此,在许多情况下,模块模式仍然非常有帮助,并且在正确使用时肯定有助于改进应用程序的结构。
其他缺点包括无法为私有成员创建自动化单元测试以及在出现错误时需要热修复时的额外复杂性。无法简单地修复私有成员。相反,必须重写所有与有 bug 的私有成员交互的公共方法。开发人员也不能轻松扩展私有成员,因此值得记住,私有成员并不像它们最初看起来那样灵活。
欲深入了解模块模式,请参阅本·切里的深入文章。
使用 WeakMap 的现代模块模式
在 ES6 中引入的WeakMap对象是一种键值对的集合,其中键是弱引用。键必须是对象,而值可以是任意的。该对象本质上是一个映射,其中键是弱引用的。这意味着如果对象没有活动引用,键将成为垃圾收集(GC)的目标。示例 7-1,7-2 和 7-3 展示了使用WeakMap对象实现的模块模式的实现。
Example 7-1. 基本模块定义
let _counter = new WeakMap();
class Module {
constructor() {
_counter.set(this, 0);
}
incrementCounter() {
let counter = _counter.get(this);
counter++;
_counter.set(this, counter);
return _counter.get(this);
}
resetCounter() {
console.log(`counter value prior to reset: ${_counter.get(this)}`);
_counter.set(this, 0);
}
}
const testModule = new Module();
// Usage:
// Increment our counter
testModule.incrementCounter();
// Check the counter value and reset
// Outputs: counter value prior to reset: 1
testModule.resetCounter();
Example 7-2. 公共/私有变量的命名空间
const myPrivateVar = new WeakMap();
const myPrivateMethod = new WeakMap();
class MyNamespace {
constructor() {
// A private counter variable
myPrivateVar.set(this, 0);
// A private function that logs any arguments
myPrivateMethod.set(this, foo => console.log(foo));
// A public variable
this.myPublicVar = 'foo';
}
// A public function utilizing privates
myPublicFunction(bar) {
let privateVar = myPrivateVar.get(this);
const privateMethod = myPrivateMethod.get(this);
// Increment our private counter
privateVar++;
myPrivateVar.set(this, privateVar);
// Call our private method using bar
privateMethod(bar);
}
}
Example 7-3. 购物篮实现
const basket = new WeakMap();
const doSomethingPrivate = new WeakMap();
const doSomethingElsePrivate = new WeakMap();
class BasketModule {
constructor() {
// privates
basket.set(this, []);
doSomethingPrivate.set(this, () => {
//...
});
doSomethingElsePrivate.set(this, () => {
//...
});
}
// Public aliases to a private function
doSomething() {
doSomethingPrivate.get(this)();
}
doSomethingElse() {
doSomethingElsePrivate.get(this)();
}
// Add items to our basket
addItem(values) {
const basketData = basket.get(this);
basketData.push(values);
basket.set(this, basketData);
}
// Get the count of items in the basket
getItemCount() {
return basket.get(this).length;
}
// Get the total value of items in the basket
getTotal() {
return basket
.get(this)
.reduce((currentSum, item) => item.price + currentSum, 0);
}
}
使用现代库的模块
在构建使用 JavaScript 库(如 React)的应用程序时,可以使用模块模式。假设您的团队创建了大量自定义组件。在这种情况下,您可以将每个组件分离到自己的文件中,从而实质上为每个组件创建一个模块。以下是从material-ui按钮组件定制的按钮组件的示例,并将其导出为模块:
import React from "react";
import Button from "@material-ui/core/Button";
const style = {
root: {
borderRadius: 3,
border: 0,
color: "white",
margin: "0 20px"
},
primary: {
background: "linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)"
},
secondary: {
background: "linear-gradient(45deg, #2196f3 30%, #21cbf3 90%)"
}
};
export default function CustomButton(props) {
return (
<Button {...props} style={{ ...style.root, ...style[props.color] }}>
{props.children}
</Button>
);
}
揭示模块模式
现在我们对模块模式稍微熟悉一些了,让我们看看稍微改进的版本:Christian Heilmann 的揭示模块模式。
当 Heilmann 发现在想要从一个公共方法调用另一个方法或访问公共变量时,他必须重复主对象的名称时,揭示模块模式应运而生。他还不喜欢切换到对象字面量表示法来定义希望公开的内容。
他的努力导致了更新的模式,我们可以简单地在私有范围内定义所有函数和变量,并返回一个指向我们希望公开的私有功能的匿名对象。
使用 ES2015+中现代的模块实现方式,模块中定义的函数和变量的作用域已经是私有的。此外,我们使用export和import来揭示需要揭示的内容。
使用 ES2015+的揭示模块模式的一个示例如下:
let privateVar = 'Rob Dodson';
const publicVar = 'Hey there!';
const privateFunction = () => {
console.log(`Name:${privateVar}`);
};
const publicSetName = strName => {
privateVar = strName;
};
const publicGetName = () => {
privateFunction();
};
// Reveal public pointers to
// private functions and properties
const myRevealingModule = {
setName: publicSetName,
greeting: publicVar,
getName: publicGetName,
};
export default myRevealingModule;
// Usage:
import myRevealingModule from './myRevealingModule';
myRevealingModule.setName('Matt Gaunt');
在此示例中,我们通过其公共的publicSetName和publicGetName方法揭示私有变量privateVar。
您还可以使用此模式来揭示具有更具体命名方案的私有函数和属性:
let privateCounter = 0;
const privateFunction = () => {
privateCounter++;
}
const publicFunction = () => {
publicIncrement();
}
const publicIncrement = () => {
privateFunction();
}
const publicGetCount = () => privateCounter;
// Reveal public pointers to
// private functions and properties
const myRevealingModule = {
start: publicFunction,
increment: publicIncrement,
count: publicGetCount
};
export default myRevealingModule;
// Usage:
import myRevealingModule from './myRevealingModule';
myRevealingModule.start();
优点
此模式使我们脚本的语法更加一致。它还使得在模块的结尾更容易理解哪些函数和变量可以被公开访问,从而提高了可读性。
缺点
这种模式的一个缺点是,如果私有函数引用了公共函数,那么如果需要补丁,就无法覆盖该公共函数。这是因为私有函数将继续引用私有实现,并且该模式不适用于公共成员,只适用于函数。
公共对象成员,它们引用私有变量,也受到无补丁规则的约束。
因此,使用揭示模块模式创建的模块可能比使用原始模块模式创建的模块更容易受到破坏,您在使用时应当小心。
单例模式
单例模式是一种设计模式,将类的实例化限制为一个对象。当系统中需要精确一个对象来协调动作时,这是非常有用的。传统上,您可以通过创建一个带有创建类实例的方法的类来实现 Singleton 模式,只有当不存在实例时才创建该类的新实例。如果已经存在一个实例,则简单地返回对该对象的引用。
Singleton 与静态类(或对象)不同之处在于,我们可以延迟它们的初始化,因为它们需要某些在初始化时可能不可用的信息。任何不知道对 Singleton 类的先前引用的代码都不能轻松检索它。这是因为 Singleton 返回的既不是对象也不是“类”;它是一种结构。想象一下闭包变量实际上并不是闭包——提供闭包的函数作用域才是闭包。
ES2015+允许我们实现单例模式,以创建一个 JavaScript 类的全局实例,该实例只实例化一次。您可以通过模块导出公开 Singleton 实例。这使得访问它更加明确和受控,并将其与其他全局变量区分开来。您不能创建一个新的类实例,但可以使用类中定义的公共获取和设置方法来读取/修改实例。
我们可以按以下方式实现单例模式:
// Instance stores a reference to the Singleton
let instance;
// Private methods and variables
const privateMethod = () => {
console.log('I am private');
};
const privateVariable = 'Im also private';
const randomNumber = Math.random();
// Singleton
class MySingleton {
// Get the Singleton instance if one exists
// or create one if it doesn't
constructor() {
if (!instance) {
// Public property
this.publicProperty = 'I am also public';
instance = this;
}
return instance;
}
// Public methods
publicMethod() {
console.log('The public can see me!');
}
getRandomNumber() {
return randomNumber;
}
}
// [ES2015+] Default export module, without name
export default MySingleton;
// Instance stores a reference to the Singleton
let instance;
// Singleton
class MyBadSingleton {
// Always create a new Singleton instance
constructor() {
this.randomNumber = Math.random();
instance = this;
return instance;
}
getRandomNumber() {
return this.randomNumber;
}
}
export default MyBadSingleton;
// Usage:
import MySingleton from './MySingleton';
import MyBadSingleton from './MyBadSingleton';
const singleA = new MySingleton();
const singleB = new MySingleton();
console.log(singleA.getRandomNumber() === singleB.getRandomNumber());
// true
const badSingleA = new MyBadSingleton();
const badSingleB = new MyBadSingleton();
console.log(badSingleA.getRandomNumber() !== badSingleB.getRandomNumber());
// true
// Note: as we are working with random numbers, there is a mathematical
// possibility both numbers will be the same, however unlikely.
// The preceding example should otherwise still be valid.
使 Singleton 成为 Singleton 的是对实例的全局访问。GoF 书籍描述了 Singleton 模式的适用性如下:
-
必须有一个类的确切实例,并且客户端必须能够从一个众所周知的访问点访问它。
-
唯一的实例应该可以通过子类扩展,并且客户端应该能够在不修改其代码的情况下使用扩展实例。
这些观点中的第二个是指我们可能需要像这样的代码的情况:
constructor() {
if (this._instance == null) {
if (isFoo()) {
this._instance = new FooSingleton();
} else {
this._instance = new BasicSingleton();
}
}
return this._instance;
}
在这里,constructor就有点像工厂方法,我们不需要更新代码中访问它的每个点。FooSingleton(在这个例子中)将是BasicSingleton的子类,并实现相同的接口。
为什么将执行延迟视为 Singleton 的重要特性?在 C++中,它用于隔离动态初始化顺序的不可预测性,将控制权还给程序员。
需要注意类(对象)的静态实例与单例之间的区别。虽然可以将单例实现为静态实例,但也可以懒惰地构造它,直到需要资源或内存为止。
假设我们有一个可以直接初始化的静态对象。在这种情况下,我们需要确保代码总是以相同的顺序执行(例如,在初始化过程中 objCar 需要 objWheel),而当你有大量源文件时,这种方法不具有可扩展性。
单例和静态对象都很有用,但不应滥用,就像我们不应滥用其他模式一样。
在实践中,当系统中需要恰好一个对象来协调其他对象时,使用单例模式非常有帮助。以下是一个在这种情况下使用该模式的示例:
// options: an object containing configuration options for the Singleton
// e.g., const options = { name: "test", pointX: 5};
class Singleton {
constructor(options = {}) {
// set some properties for our Singleton
this.name = 'SingletonTester';
this.pointX = options.pointX || 6;
this.pointY = options.pointY || 10;
}
}
// our instance holder
let instance;
// an emulation of static variables and methods
const SingletonTester = {
name: 'SingletonTester',
// Method for getting an instance. It returns
// a Singleton instance of a Singleton object
getInstance(options) {
if (instance === undefined) {
instance = new Singleton(options);
}
return instance;
},
};
const singletonTest = SingletonTester.getInstance({
pointX: 5,
});
// Log the output of pointX just to verify it is correct
// Outputs: 5
console.log(singletonTest.pointX);
尽管单例有其有效的用途,但通常在 JavaScript 中需要使用它时,这表明我们可能需要重新评估我们的设计。不像 C++ 或 Java 需要定义一个类来创建对象,JavaScript 允许你直接创建对象。因此,你可以直接创建这样的对象而不是定义一个单例类。然而,在 JavaScript 中使用单例类也有一些缺点:
识别单例可能是困难的。
如果你导入一个大模块,你将无法识别特定的类是否为单例。因此,你可能会错误地将其用作常规类来实例化多个对象并错误地更新它。
测试具有挑战性。
由于隐藏依赖项、难以创建多个实例、难以存根依赖项等问题,单例可能更难以测试。
需要仔细协调。
单例的一个日常用例是存储将在全局范围内需要的数据,例如只需设置一次并由多个组件使用的用户凭据或 cookie 数据。实施正确的执行顺序变得至关重要,以便在数据可用后始终消耗数据,而不是相反。随着应用程序规模和复杂性的增长,这可能变得具有挑战性。
React 中的状态管理
使用 React 进行 Web 开发的开发人员可以通过状态管理工具(如 Redux 或 React Context)依赖全局状态,而不是单例。与单例不同,这些工具提供了一个只读状态而不是可变状态。
虽然使用这些工具并不能神奇地消除全局状态带来的缺点,但我们至少可以确保全局状态被我们打算的方式进行更改,因为组件不能直接更新它。
原型模式
GoF 将原型模式称为根据现有对象的模板通过克隆创建对象的一种模式。
我们可以将原型模式看作基于原型继承的模式,我们创建的对象充当其他对象的原型。prototype对象有效地用作构造函数创建的每个对象的蓝图。例如,如果构造函数的原型包含一个名为name的属性(如下面的代码示例所示),那么该构造函数创建的每个对象也将具有相同的属性。请参阅图 7-3 进行说明。

图 7-3. 原型模式
在现有(非 JavaScript)文献中审查该模式的定义时,可能再次找到与类相关的引用。事实上,原型继承完全避免了使用类。在理论上并不存在“定义”对象或核心对象;我们只是创建现有函数对象的副本。
使用原型模式的好处之一是,我们直接利用 JavaScript 本身提供的原型优势,而不是试图模仿其他语言的特性。在其他设计模式中,情况并非总是如此。
此模式不仅是实现继承的一种简便方法,而且还可以提高性能。在对象中定义函数时,它们都是通过引用创建的(因此所有子对象指向相同的函数),而不是创建各自的副本。
使用 ES2015+,我们可以使用类和构造函数来创建对象。虽然这确保了我们的代码看起来更干净,并遵循面向对象分析和设计(OOAD)原则,但类和构造函数在内部编译成函数和原型,从而确保我们仍然使用 JavaScript 的原型优势和相关性能提升。
对于那些感兴趣的人来说,真正的原型继承,正如 ECMAScript 5 标准中定义的那样,需要使用Object.create(我们在本节之前已经介绍过)。回顾一下,Object.create创建一个具有指定原型的对象,并且可选地包含指定的属性(例如,Object.create( prototype, optionalDescriptorObjects ))。
我们可以在以下示例中看到这一点:
const myCar = {
name: 'Ford Escort',
drive() {
console.log("Weeee. I'm driving!");
},
panic() {
console.log('Wait. How do you stop this thing?');
},
};
// Use Object.create to instantiate a new car
const yourCar = Object.create(myCar);
// Now we can see that one is a prototype of the other
console.log(yourCar.name);
Object.create还允许我们轻松实现高级概念,例如差异继承,其中对象能够直接从其他对象继承。我们之前看到,Object.create允许我们使用第二个提供的参数初始化对象属性。例如:
const vehicle = {
getModel() {
console.log(`The model of this vehicle is...${this.model}`);
},
};
const car = Object.create(vehicle, {
id: {
value: MY_GLOBAL.nextId(),
// writable:false, configurable:false by default
enumerable: true,
},
model: {
value: 'Ford',
enumerable: true,
},
});
在这里,您可以使用与我们之前查看过的Object.defineProperties和Object.defineProperty方法类似的语法,通过Object.create的第二个参数初始化第二个参数上的属性。
值得注意的是,在枚举对象属性时,原型关系可能会导致问题(正如 Crockford 建议的那样),需要在循环内容中包含hasOwnProperty()检查。
如果我们希望在不直接使用 Object.create 的情况下实现原型模式,我们可以仿真之前的示例如下:
class VehiclePrototype {
constructor(model) {
this.model = model;
}
getModel() {
console.log(`The model of this vehicle is... ${this.model}`);
}
clone() {}
}
class Vehicle extends VehiclePrototype {
constructor(model) {
super(model);
}
clone() {
return new Vehicle(this.model);
}
}
const car = new Vehicle('Ford Escort');
const car2 = car.clone();
car2.getModel();
注意
如果不小心,这种替代方法可能不允许用户以相同方式定义只读属性(因为如果不小心,vehiclePrototype 可能会被更改)。
原型模式的最终替代实现可能如下所示:
const beget = (() => {
class F {
constructor() {}
}
return proto => {
F.prototype = proto;
return new F();
};
})();
从 vehicle 函数中可以引用这种方法。然而,请注意,这里的 vehicle 模拟了构造函数,因为原型模式除了将对象链接到原型之外没有任何初始化概念。
工厂模式
工厂模式是用于创建对象的另一种创建型模式。它与其类别中的其他模式不同,因为它不要求我们明确使用构造器。相反,工厂可以提供一个通用接口来创建对象,我们可以指定要创建的工厂对象的类型(参见图 7-4)。

图 7-4. 工厂模式
想象一个 UI 工厂,我们想要创建一种 UI 组件。与其直接使用 new 操作符或其他创建构造器来创建此组件,不如向工厂对象请求一个新的组件。我们告诉工厂需要什么类型的对象(例如,“按钮”,“面板”),它就会实例化并返回给我们使用。
如果对象创建过程相对复杂,例如严重依赖于动态因素或应用程序配置时,这种方法特别有用。
以下示例基于我们之前使用构造器模式逻辑定义汽车的片段。它展示了如何使用工厂模式来实现 VehicleFactory:
// Types.js - Classes used behind the scenes
// A class for defining new cars
class Car {
constructor({ doors = 4, state = 'brand new', color = 'silver' } = {}) {
this.doors = doors;
this.state = state;
this.color = color;
}
}
// A class for defining new trucks
class Truck {
constructor({ state = 'used', wheelSize = 'large', color = 'blue' } = {}) {
this.state = state;
this.wheelSize = wheelSize;
this.color = color;
}
}
// FactoryExample.js
// Define a vehicle factory
class VehicleFactory {
constructor() {
this.vehicleClass = Car;
}
// Our Factory method for creating new Vehicle instances
createVehicle(options) {
const { vehicleType, ...rest } = options;
switch (vehicleType) {
case 'car':
this.vehicleClass = Car;
break;
case 'truck':
this.vehicleClass = Truck;
break;
// defaults to VehicleFactory.prototype.vehicleClass (Car)
}
return new this.vehicleClass(rest);
}
}
// Create an instance of our factory that makes cars
const carFactory = new VehicleFactory();
const car = carFactory.createVehicle({
vehicleType: 'car',
color: 'yellow',
doors: 6,
});
// Test to confirm our car was created using the vehicleClass/prototype Car
// Outputs: true
console.log(car instanceof Car);
// Outputs: Car object of color "yellow", doors: 6 in a "brand new" state
console.log(car);
我们已经定义了汽车和卡车类,它们具有设置与各自车辆相关属性的构造函数。VehicleFactory 可以根据传递的 vehicleType 创建一个新的车辆对象,Car 或 Truck。
有两种可能的方法来使用 VehicleFactory 类构建卡车。
在方法 1 中,我们修改了一个 VehicleFactory 实例以使用 Truck 类:
const movingTruck = carFactory.createVehicle({
vehicleType: 'truck',
state: 'like new',
color: 'red',
wheelSize: 'small',
});
// Test to confirm our truck was created with the vehicleClass/prototype Truck
// Outputs: true
console.log(movingTruck instanceof Truck);
// Outputs: Truck object of color "red", a "like new" state
// and a "small" wheelSize
console.log(movingTruck);
在方法 2 中,我们对 VehicleFactory 进行子类化以创建一个构建 Truck 的工厂类。
class TruckFactory extends VehicleFactory {
constructor() {
super();
this.vehicleClass = Truck;
}
}
const truckFactory = new TruckFactory();
const myBigTruck = truckFactory.createVehicle({
state: 'omg...so bad.',
color: 'pink',
wheelSize: 'so big',
});
// Confirms that myBigTruck was created with the prototype Truck
// Outputs: true
console.log(myBigTruck instanceof Truck);
// Outputs: Truck object with the color "pink", wheelSize "so big"
// and state "omg. so bad"
console.log(myBigTruck);
使用工厂模式的情况
当应用到以下情况时,工厂模式可以带来益处:
-
当我们的对象或组件设置涉及高复杂度时。
-
当我们需要一种方便的方式根据我们所处的环境生成不同实例的对象时。
-
当我们处理许多共享相同属性的小对象或组件时。
-
当使用其他对象实例来组成对象,这些对象只需要满足 API 合同(也称为鸭子类型)即可工作。这对解耦是有用的。
不适用工厂模式的情况
当这种模式应用于错误类型的问题时,可能会给应用程序引入大量不必要的复杂性。除非提供对象创建的接口是我们编写的库或框架的设计目标,否则我建议坚持使用显式构造函数,以避免不必要的开销。
由于对象创建过程实际上是在接口背后抽象化的,这也可能会在单元测试中引入问题,具体取决于这个过程的复杂性。
抽象工厂
也值得注意的是抽象工厂模式,其旨在封装具有共同目标的一组单独工厂。它将对象的实现细节与其通用使用分离开来。
当系统需要独立于它创建的对象的生成方式,或者它需要与多种类型的对象一起工作时,可以使用抽象工厂。
一个简单且易于理解的例子是车辆工厂,它定义了获取或注册车辆类型的方法。抽象工厂可以命名为 AbstractVehicleFactory。抽象工厂将允许定义诸如 car 或 truck 等车辆类型,具体工厂将仅实现满足车辆合同的类(例如 Vehicle.prototype.drive 和 Vehicle.prototype.breakDown):
class AbstractVehicleFactory {
constructor() {
// Storage for our vehicle types
this.types = {};
}
getVehicle(type, customizations) {
const Vehicle = this.types[type];
return Vehicle ? new Vehicle(customizations) : null;
}
registerVehicle(type, Vehicle) {
const proto = Vehicle.prototype;
// only register classes that fulfill the vehicle contract
if (proto.drive && proto.breakDown) {
this.types[type] = Vehicle;
}
return this;
}
}
// Usage:
const abstractVehicleFactory = new AbstractVehicleFactory();
abstractVehicleFactory.registerVehicle('car', Car);
abstractVehicleFactory.registerVehicle('truck', Truck);
// Instantiate a new car based on the abstract vehicle type
const car = abstractVehicleFactory.getVehicle('car', {
color: 'lime green',
state: 'like new',
});
// Instantiate a new truck in a similar manner
const truck = abstractVehicleFactory.getVehicle('truck', {
wheelSize: 'medium',
color: 'neon yellow',
});
结构模式
结构模式涉及类和对象的组合。例如,继承的概念允许我们组合接口和对象,以便它们可以获得新的功能。结构模式提供了组织类和对象的最佳方法和实践。
以下是我们将在本节讨论的 JavaScript 结构模式:
-
“外观模式”
-
“混合模式”
-
“装饰器模式”
-
“享元模式”
外观模式
当我们竖起一个外观时,我们向世界展示一个外在的外观,可能掩盖了一个完全不同的现实。这启发了我们将要审查的下一个模式的名称——外观模式。这种模式为更大量的代码提供了一个方便的高级接口,隐藏了其真正的底层复杂性。可以将其看作是简化向其他开发者呈现的 API,这几乎总是能提高可用性的一个特质(见图 7-5)。

图 7-5. 外观模式
Facades 是一种结构模式,在 JavaScript 库(比如 jQuery)中经常能见到,尽管其实现支持多种行为的方法,但公众仅能使用这些方法的“外观”或有限的抽象。
这使我们能够直接与外观模式交互,而不是在幕后与子系统交互。每当我们使用 jQuery 的 $(el).css() 或 $(el).animate() 方法时,我们都在使用一个外观模式:这是一个简化的公共接口,让我们避免手动调用 jQuery 核心中许多内部方法来使某些行为生效。这也绕过了手动与 DOM API 交互和维护状态变量的需要。
jQuery 核心方法应视为中间抽象。对开发者的更直接的负担是 DOM API 和外观模式使得 jQuery 库如此易于使用。
为了加深我们的学习,外观模式简化了类的接口,并将类与使用它的代码解耦。这使我们能够间接地与子系统交互,有时比直接访问子系统更不容易出错。外观模式的优势包括易用性,通常在实现模式时的占用空间较小。
让我们看看这个模式如何实现。这是一个未经优化的代码示例,但在这里,我们使用外观模式来简化在各种浏览器中监听事件的接口。我们通过创建一个通用方法来执行检查功能的任务,从而提供安全且跨浏览器兼容的解决方案:
const addMyEvent = (el, ev, fn) => {
if (el.addEventListener) {
el.addEventListener(ev, fn, false);
} else if (el.attachEvent) {
el.attachEvent(`on${ev}`, fn);
} else {
el[`on${ev}`] = fn;
}
};
类似地,我们都熟悉 jQuery 的 $(document).ready(…)。在内部,这由一个名为 bindReady() 的方法提供支持,其功能如下:
function bindReady() {
// Use the handy event callback
document.addEventListener('DOMContentLoaded', DOMContentLoaded, false);
// A fallback to window.onload, that will always work
window.addEventListener('load', jQuery.ready, false);
}
这是另一个外观模式的例子,其中全球使用的是 $(document).ready(…) 所暴露的有限接口,而更复杂的实现则被隐藏起来。
外观模式不仅仅可以单独使用。然而,您还可以将它们与其他模式(如模块模式)集成。接下来我们可以看到,我们的模块模式实例包含了一些已私下定义的方法。然后使用外观模式来提供一个更简单的 API 以访问这些方法:
// privateMethods.js
const _private = {
i: 5,
get() {
console.log(`current value: ${this.i}`);
},
set(val) {
this.i = val;
},
run() {
console.log('running');
},
jump() {
console.log('jumping');
},
};
export default _private;
// module.js
import _private from './privateMethods.js';
const module = {
facade({ val, run }) {
_private.set(val);
_private.get();
if (run) {
_private.run();
}
},
};
export default module;
// index.js
import module from './module.js';
// Outputs: "current value: 10" and "running"
module.facade({
run: true,
val: 10,
});
在这个例子中,调用 module.facade() 将触发模块内部的一系列私有行为,但用户并不关心这些细节。我们让用户更轻松地消费一个功能,而不用担心实现层面的细节。
混入模式
在传统的编程语言如 C++ 和 Lisp 中,混入是一种类,它提供了一个子类或一组子类可以轻松继承以进行函数重用的功能。
子类化
我们已经介绍了 ES2015+ 的功能,允许我们扩展基类或超类,并调用超类中的方法。扩展超类的子类称为子类。
子类化是指从基类或超类对象继承新对象的属性。子类仍然可以定义自己的方法,包括那些重写最初在超类中定义的方法。子类中的方法可以调用超类中的重写方法,称为方法链。类似地,它可以调用超类的构造函数,这称为构造函数链。
要演示子类化,我们首先需要一个基类,可以创建其自身的新实例。让我们围绕一个人的概念来建模这一点:
class Person{
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
this.gender = "male";
}
}
// a new instance of Person can then easily be created as follows:
const clark = new Person( 'Clark', 'Kent' );
接下来,我们想要指定一个新的类,该类是现有Person类的子类。让我们想象一下,我们希望添加明显的属性来区分Person和Superhero,同时继承Person超类的属性。由于超级英雄与普通人(例如名称、性别)共享许多共同特征,这理应充分说明子类化的工作原理:
class Superhero extends Person {
constructor(firstName, lastName, powers) {
// Invoke the superclass constructor
super(firstName, lastName);
this.powers = powers;
}
}
// A new instance of Superhero can be created as follows
const SuperMan = new Superhero('Clark','Kent', ['flight','heat-vision']);
console.log(SuperMan);
// Outputs Person attributes as well as power
Superhero构造函数创建了一个Superhero类的实例,该类是Person类的扩展。此类的对象具有其上面链中类的属性。如果我们在Person类中设置了默认值,Superhero可以使用特定于其类的值覆盖任何继承的值。
混合(Mixins)
在 JavaScript 中,我们可以通过从 Mixins 继承来收集扩展的功能。我们定义的每个新类都可以有一个超类,从中可以继承方法和属性。类还可以定义自己的属性和方法。我们可以利用这一点促进函数的重用,如在图 7-6 中所示。

图 7-6. 混合(Mixins)
Mixins 允许对象以最小的复杂性从它们那里借用(或继承)功能。因此,Mixins 是具有属性和方法的类,可以在多个其他类之间轻松共享。
虽然 JavaScript 类不能从多个超类继承,但我们仍然可以从各种类中混合功能。JavaScript 中的类既可以用作表达式,也可以用作语句。作为表达式时,它每次评估时返回一个新的类。extends 子句还可以接受返回类或构造函数的任意表达式。这些功能使我们能够将 Mixin 定义为一个函数,该函数接受一个超类并从中创建一个新的子类。
假设我们定义一个包含在标准 JavaScript 类中的实用函数的 Mixin 如下所示:
const MyMixins = superclass =>
class extends superclass {
moveUp() {
console.log('move up');
}
moveDown() {
console.log('move down');
}
stop() {
console.log('stop! in the name of love!');
}
};
在这里,我们创建了一个名为MyMixins的函数,它可以扩展一个动态的超类。现在我们将创建两个类,CarAnimator和PersonAnimator,MyMixins可以从这两个类扩展并返回一个子类,该子类具有MyMixins中定义的方法以及被扩展类中的方法:
// A skeleton carAnimator constructor
class CarAnimator {
moveLeft() {
console.log('move left');
}
}
// A skeleton personAnimator constructor
class PersonAnimator {
moveRandomly() {
/*...*/
}
}
// Extend MyMixins using CarAnimator
class MyAnimator extends MyMixins(CarAnimator) {}
// Create a new instance of carAnimator
const myAnimator = new MyAnimator();
myAnimator.moveLeft();
myAnimator.moveDown();
myAnimator.stop();
// Outputs:
// move left
// move down
// stop! in the name of love!
如我们所见,这使得将类似行为混合到类中变得相当简单。
以下示例有两个类:一个是Car,一个是Mixin。我们要做的是增强(另一种说法是扩展)Car,以便它可以继承Mixin中定义的特定方法,即driveForward()和driveBackward()。
本示例将演示如何增强构造函数以包含功能,而无需为每个构造函数重复此过程:
// Car.js
class Car {
constructor({ model = 'no model provided', color = 'no color provided' }) {
this.model = model;
this.color = color;
}
}
export default Car;
// Mixin.js and index.js remain unchanged
// index.js
import Car from './Car.js';
import Mixin from './Mixin.js';
class MyCar extends Mixin(Car) {}
// Create a new Car
const myCar = new MyCar({});
// Test to make sure we now have access to the methods
myCar.driveForward();
myCar.driveBackward();
// Outputs:
// drive forward
// drive backward
const mySportsCar = new MyCar({
model: 'Porsche',
color: 'red',
});
mySportsCar.driveSideways();
// Outputs:
// drive sideways
优缺点
Mixin 有助于减少系统中的功能重复,并增加功能的重用性。在应用程序可能需要跨对象实例共享行为的情况下,通过在 Mixin 中维护这些共享功能,我们可以轻松避免重复,并集中精力实现系统中真正独特的功能。
尽管如此,Mixin 的缺点还存在一些争议。一些开发人员认为向类或对象原型注入功能是一个坏主意,因为它会导致原型污染和关于函数来源的不确定性。在大型系统中,这可能是事实。
即使是在 React 中,在引入 ES6 类之前,Mixin 经常被用来向组件添加功能。React 团队不鼓励使用 Mixin,因为它会给组件增加不必要的复杂性,使得维护和重用变得困难。React 团队推荐使用高阶组件和 Hooks 来代替(https://oreil.ly/f1216)。
我认为,扎实的文档可以帮助减少关于混合函数来源的混淆。但是,就像每种模式一样,我们在实施时应该小心。
装饰者模式
装饰者是一种结构设计模式,旨在促进代码重用。与 Mixin 类似,您可以将它们视为对象子类化的另一种可行替代方法。
传统上,装饰者提供了动态向现有类添加行为的能力。其核心思想是装饰本身对类的基本功能并不是必需的。否则,我们可以将其直接编入超类。
我们可以使用它们来修改现有系统,以便在不大幅更改使用它们的基础代码的情况下添加对象的附加功能。开发人员经常使用它们的一个常见原因是,他们的应用程序可能包含需要许多不同类型对象的功能。想象一下,为 JavaScript 游戏定义数百个不同的对象构造函数(见图 7-7)。

图 7-7. 装饰者模式
对象构造函数可以代表不同类型的玩家,每种类型具有不同的能力。魔戒游戏可能需要Hobbit、Elf、Orc、Wizard、Mountain Giant、Stone Giant等构造函数,但这些可能会有数百种。如果考虑能力,想象一下需要为每种能力类型的组合创建子类,例如HobbitWithRing、HobbitWithSword、HobbitWithRingAndSword等。这种做法既不实际,而且在考虑到不断增加的不同能力时,管理起来也是不可行的。
装饰器模式并没有与对象创建方式紧密联系,而是专注于扩展它们的功能。与仅仅依赖原型继承不同,我们使用单个基类,并逐步添加提供额外功能的装饰器对象。其核心思想是,与其子类化,我们向基对象添加(装饰)属性或方法,使得流程更加简洁。
我们可以使用 JavaScript 类来创建可以装饰的基类。在 JavaScript 中向类的对象实例添加新属性或方法是一个简单的过程。考虑到这一点,我们可以实现一个简单的装饰器,就像例子 7-4 和 7-5 中展示的那样。
示例 7-4. 用新功能装饰构造函数
// A vehicle constructor
class Vehicle {
constructor(vehicleType) {
// some sane defaults
this.vehicleType = vehicleType || 'car';
this.model = 'default';
this.license = '00000-000';
}
}
// Test instance for a basic vehicle
const testInstance = new Vehicle('car');
console.log(testInstance);
// Outputs:
// vehicle: car, model:default, license: 00000-000
// Let's create a new instance of vehicle, to be decorated
const truck = new Vehicle('truck');
// New functionality we're decorating vehicle with
truck.setModel = function(modelName) {
this.model = modelName;
};
truck.setColor = function(color) {
this.color = color;
};
// Test the value setters and value assignment works correctly
truck.setModel('CAT');
truck.setColor('blue');
console.log(truck);
// Outputs:
// vehicle:truck, model:CAT, color: blue
// Demonstrate "vehicle" is still unaltered
const secondInstance = new Vehicle('car');
console.log(secondInstance);
// Outputs:
// vehicle: car, model:default, license: 00000-000
在这里,truck是Vehicle类的一个实例,我们还用额外的方法setColor和setModel来装饰它。
这种简化的实现是有效的,但它并不能展示装饰器提供的所有优点。因此,我们将通过《Head First Design Patterns》(链接)中关于 MacBook 购买的例子来演示一下我的变体。
示例 7-5. 使用多个装饰器装饰对象
// The constructor to decorate
class MacBook {
constructor() {
this.cost = 997;
this.screenSize = 11.6;
}
getCost() {
return this.cost;
}
getScreenSize() {
return this.screenSize;
}
}
// Decorator 1
class Memory extends MacBook {
constructor(macBook) {
super();
this.macBook = macBook;
}
getCost() {
return this.macBook.getCost() + 75;
}
}
// Decorator 2
class Engraving extends MacBook {
constructor(macBook) {
super();
this.macBook = macBook;
}
getCost() {
return this.macBook.getCost() + 200;
}
}
// Decorator 3
class Insurance extends MacBook {
constructor(macBook) {
super();
this.macBook = macBook;
}
getCost() {
return this.macBook.getCost() + 250;
}
}
// init main object
let mb = new MacBook();
// init decorators
mb = new Memory(mb);
mb = new Engraving(mb);
mb = new Insurance(mb);
// Outputs: 1522
console.log(mb.getCost());
// Outputs: 11.6
console.log(mb.getScreenSize());
在这个例子中,我们的装饰器重写了MacBook超类对象的.cost()函数,以返回 MacBook 的当前价格加上升级的费用。
这被视为装饰,因为原始的MacBook对象的构造方法(例如screenSize())以及我们可能定义为MacBook一部分的任何其他属性都保持不变和完整。
在上一个例子中,并没有定义一个明确的接口。当从创建者转移到接收者时,我们正在摆脱确保对象符合接口的责任。
伪经典装饰器
现在,我们将研究《Pro JavaScript Design Patterns》(PJDP)中首次以 JavaScript 形式呈现的装饰器的一个变体。
与之前的一些示例不同,Diaz 和 Harmes 更贴近其他编程语言(如 Java 或 C++)中装饰器的实现方式,使用了“接口”这一概念,我们稍后将更详细地定义它。
注意
这种装饰器模式的特定变体仅供参考。如果你觉得它过于复杂,我建议选择前面介绍的其中一种简单实现。
接口
PJDP 将装饰器模式描述为一种用于透明地将对象包装在具有相同接口的其他对象内部的模式。接口是定义对象应该具有的方法的一种方式。然而,它并不直接指定你应该如何实现这些方法。接口还可以选择性地指示方法需要什么参数。
那么,在 JavaScript 中为什么要使用接口呢?这样做的想法是它们是自我描述的,并促进重用。理论上,接口通过确保对接口的任何更改也必须传播到实现它们的对象,使代码更加稳定。
接下来是一个使用鸭子类型在 JavaScript 中实现接口的示例。这种方法有助于确定对象是否是基于它实现的方法的构造函数/对象的实例:
// Create interfaces using a predefined Interface
// constructor that accepts an interface name and
// skeleton methods to expose.
// In our reminder example summary() and placeOrder()
// represent functionality the interface should
// support
const reminder = new Interface('List', ['summary', 'placeOrder']);
const properties = {
name: 'Remember to buy the milk',
date: '05/06/2040',
actions: {
summary() {
return 'Remember to buy the milk, we are almost out!';
},
placeOrder() {
return 'Ordering milk from your local grocery store';
},
},
};
// Now create a constructor implementing these properties
// and methods
class Todo {
constructor({ actions, name }) {
// State the methods we expect to be supported
// as well as the Interface instance being checked
// against
Interface.ensureImplements(actions, reminder);
this.name = name;
this.methods = actions;
}
}
// Create a new instance of our Todo constructor
const todoItem = new Todo(properties);
// Finally test to make sure these function correctly
console.log(todoItem.methods.summary());
console.log(todoItem.methods.placeOrder());
// Outputs:
// Remember to buy the milk, we are almost out!
// Ordering milk from your local grocery store
经典 JavaScript 和 ES2015+ 都不支持接口。然而,我们可以创建我们自己的 Interface 类。在前面的示例中,Interface.ensureImplements 提供了严格的功能检查,你可以在这里找到代码来了解 Interface 构造函数。
接口的主要问题在于 JavaScript 没有内置支持,这可能导致尝试模拟其他语言的特性,这可能不是一个理想的选择。然而,如果你真的需要接口,你可以使用 TypeScript,因为它提供了对接口的内置支持。在 JavaScript 中,轻量级接口可以在不显著影响性能的情况下使用,我们将在接下来的部分中使用相同的概念来探讨抽象装饰器。
抽象装饰器
为了演示这个版本的装饰器模式的结构,我们假设有一个模拟 MacBook 的超类和一个商店,允许我们为额外费用为我们的 MacBook 添加多种增强功能。
升级可以包括将 RAM 升级到 4 GB 或 8 GB(当然现在可能更高!)、刻字、Parallels 或一个外壳。现在,如果我们为每种升级选项组合建模使用单独的子类,可能会看起来像这样:
const MacBook = class {
//...
};
const MacBookWith4GBRam = class {};
const MacBookWith8GBRam = class {};
const MacBookWith4GBRamAndEngraving = class {};
const MacBookWith8GBRamAndEngraving = class {};
const MacBookWith8GBRamAndParallels = class {};
const MacBookWith4GBRamAndParallels = class {};
const MacBookWith8GBRamAndParallelsAndCase = class {};
const MacBookWith4GBRamAndParallelsAndCase = class {};
const MacBookWith8GBRamAndParallelsAndCaseAndInsurance = class {};
const MacBookWith4GBRamAndParallelsAndCaseAndInsurance = class {};
…等等。
这种解决方案是不切实际的,因为每种可能的增强组合都需要一个新的子类。由于我们更倾向于保持简单,而不是维护一大堆子类,让我们看看如何使用装饰器更好地解决这个问题。
不像我们之前看到的所有组合那样,我们只会创建五个新的装饰器类。在这些增强类上调用的方法将传递给我们的MacBook类。
在下面的例子中,装饰器透明地包裹它们的组件,并且可以通过使用相同的接口进行交换。这是我们将为 MacBook 定义的接口:
const MacBook = new Interface('MacBook', [
'addEngraving',
'addParallels',
'add4GBRam',
'add8GBRam',
'addCase',
]);
// A MacBook Pro might thus be represented as follows:
class MacBookPro {
// implements MacBook
}
// ES2015+: We still could use Object.prototype for adding new methods,
// because internally we use the same structure
MacBookPro.prototype = {
addEngraving() {},
addParallels() {},
add4GBRam() {},
add8GBRam() {},
addCase() {},
getPrice() {
// Base price
return 900.0;
},
};
为了让我们以后添加更多选项更容易,我们定义了一个抽象装饰器类,其中包含实现MacBook接口所需的默认方法,其余选项将是其子类。抽象装饰器确保我们可以独立地使用尽可能多的装饰器来装饰基类(还记得之前的例子吗?),而无需为每种可能的组合派生一个类:
// MacBook decorator abstract decorator class
class MacBookDecorator {
constructor(macbook) {
Interface.ensureImplements(macbook, MacBook);
this.macbook = macbook;
}
addEngraving() {
return this.macbook.addEngraving();
}
addParallels() {
return this.macbook.addParallels();
}
add4GBRam() {
return this.macbook.add4GBRam();
}
add8GBRam() {
return this.macbook.add8GBRam();
}
addCase() {
return this.macbook.addCase();
}
getPrice() {
return this.macbook.getPrice();
}
}
在此示例中,MacBook装饰器接受一个对象(一个MacBook)作为我们的基础组件。它使用我们之前定义的MacBook接口,每个方法只是调用组件上的相同方法。现在我们可以使用MacBook装饰器创建可以添加的选项类:
// Let's now extend (decorate) the CaseDecorator
// with a MacBookDecorator
class CaseDecorator extends MacBookDecorator {
constructor(macbook) {
super(macbook);
}
addCase() {
return `${this.macbook.addCase()}Adding case to macbook`;
}
getPrice() {
return this.macbook.getPrice() + 45.0;
}
}
我们正在重写我们想要装饰的addCase()和getPrice()方法,并通过首先在原始的MacBook上调用这些方法,然后根据需要简单地附加一个字符串或数值(例如,45.00)来实现这一点。
由于本节中提供了大量信息,让我们试着在一个单一的示例中将所有内容汇总起来,希望能突出我们学到的内容:
// Instantiation of the macbook
const myMacBookPro = new MacBookPro();
// Outputs: 900.00
console.log(myMacBookPro.getPrice());
// Decorate the macbook
const decoratedMacBookPro = new CaseDecorator(myMacBookPro);
// This will return 945.00
console.log(decoratedMacBookPro.getPrice());
由于装饰器可以动态修改对象,它们是改变现有系统的完美模式。有时,围绕一个对象创建装饰器比为每种对象类型维护单独的子类简单得多。这使得维护可能需要许多子类对象的应用程序变得更加简单。
您可以在JSBin上找到此示例的功能版本。
优缺点
开发人员喜欢使用这种模式,因为它可以透明地使用,并且相当灵活。正如我们所见,对象可以被包装或“装饰”以添加新的行为,并继续使用而无需担心基本对象被修改。在更广泛的背景下,该模式还避免了我们需要依赖大量子类来获得相同好处。
然而,在实现这种模式时,我们应该注意到一些缺点。如果管理不善,它可能会显著复杂化我们的应用架构,因为它会将许多小但相似的对象引入我们的命名空间。问题在于其他不熟悉该模式的开发人员可能难以理解为什么要使用它,这会使得管理变得困难。
充足的注释或模式研究应该有助于后者。然而,只要我们处理好在应用程序中广泛使用装饰者的方式,我们在这两个方面应该都没问题。
享元
享元模式是一种经典的结构性解决方案,用于优化重复、缓慢和效率低下的代码。它旨在通过尽可能与相关对象共享数据(例如应用程序配置、状态等)来最小化应用程序中的内存使用(参见图 7-8)。

图 7-8. 享元模式
保罗·卡尔德和马克·林顿于 1990 年首次构想了这一模式,并以包括体重不足 112 磅的拳击级别命名。享元这个名称来源于这个体重分类,因为它指的是模式旨在帮助我们实现的小内存占用。
在实践中,享元数据共享可以涉及取出许多对象或数据结构使用的相似数据,并将此数据放入单个外部对象中。我们可以将此对象传递给那些依赖于此数据的对象,而不是在每个对象中存储相同的数据。
使用享元模式
您可以应用享元模式的两种方式。第一种是在数据层,我们处理内存中大量相似对象之间共享数据的概念。
您还可以在 DOM 层应用享元模式作为一个中央事件管理器,以避免将事件处理程序附加到父容器中的每个子元素,这些子元素具有类似的行为。
传统上,享元模式最常用于数据层,所以我们将首先看看这一点。
享元和数据共享
对于这个应用程序,我们需要了解关于经典享元模式的几个更多概念。在享元模式中,有两种状态的概念:内在的和外部的。内在信息可能被对象内部方法所需,没有这些信息它们绝对无法正常运行。然而,外部信息可以被移除并存储在外部。
您可以用由工厂方法创建的单个共享对象替换具有相同内在数据的对象。这使我们能够显著减少存储的隐式数据的总量。
好处在于我们可以监视已经实例化的对象,这样只有在内在状态与我们已经拥有的对象不同的情况下才会创建新副本。
我们使用一个管理器来处理外部状态。您可以以多种方式实现这一点,但其中一种方法是使管理器对象包含外部状态的中央数据库,以及它们所属的享元对象。
实现经典的享元模式
最近 JavaScript 中并没有广泛使用享元模式,因此我们可能会从 Java 和 C++世界中的许多实现中获得灵感。
我们代码中第一次接触享元模式的实现是我从Wikipedia的 Java 示例中实现的 JavaScript 样本。
此实现利用了三种类型的享元组件:
享元
对应于享元能够接收和处理外部状态的接口。
具体享元
实际实现了享元接口并存储了内在状态。具体享元需要可共享并能够操作外部状态。
享元工厂
管理享元对象并创建它们。它确保我们的享元是共享的,并将它们作为一组对象进行管理,可以在需要单个实例时查询。如果对象已经在组中创建,则返回它。否则,它将添加一个新对象到池中并返回它。
这些对应于我们实现中的以下定义:
-
CoffeeOrder: 享元 -
CoffeeFlavor: 具体享元 -
CoffeeOrderContext: 辅助工具 -
CoffeeFlavorFactory: 享元工厂 -
testFlyweight: 利用我们的享元
Duck punching “implements”
Duck punching 允许我们扩展语言或解决方案的能力,而无需修改运行时源代码。由于下一个解决方案需要 Java 关键字(implements)来实现接口,并且 JavaScript 中没有本地支持,因此让我们首先进行鸭子打补丁。
Function.prototype.implementsFor作用于对象构造函数,将接受一个父类(函数)或对象,并使用普通继承(用于函数)或虚拟继承(用于对象)从中继承:
// Utility to simulate implementation of an interface
class InterfaceImplementation {
static implementsFor(superclassOrInterface) {
if (superclassOrInterface instanceof Function) {
this.prototype = Object.create(superclassOrInterface.prototype);
this.prototype.constructor = this;
this.prototype.parent = superclassOrInterface.prototype;
} else {
this.prototype = Object.create(superclassOrInterface);
this.prototype.constructor = this;
this.prototype.parent = superclassOrInterface;
}
return this;
}
}
我们可以使用这个方法来弥补缺少implements关键字的问题,通过显式地让函数继承一个接口。接下来,CoffeeFlavor实现了CoffeeOrder接口,并且必须包含其接口方法,以便我们将这些实现的功能分配给一个对象:
// CoffeeOrder interface
const CoffeeOrder = {
serveCoffee(context) {},
getFlavor() {},
};
class CoffeeFlavor extends InterfaceImplementation {
constructor(newFlavor) {
super();
this.flavor = newFlavor;
}
getFlavor() {
return this.flavor;
}
serveCoffee(context) {
console.log(`Serving Coffee flavor ${this.flavor} to
table ${context.getTable()}`);
}
}
// Implement interface for CoffeeOrder
CoffeeFlavor.implementsFor(CoffeeOrder);
const CoffeeOrderContext = (tableNumber) => ({
getTable() {
return tableNumber;
},
});
class CoffeeFlavorFactory {
constructor() {
this.flavors = {};
this.length = 0;
}
getCoffeeFlavor(flavorName) {
let flavor = this.flavors[flavorName];
if (!flavor) {
flavor = new CoffeeFlavor(flavorName);
this.flavors[flavorName] = flavor;
this.length++;
}
return flavor;
}
getTotalCoffeeFlavorsMade() {
return this.length;
}
}
// Sample usage:
const testFlyweight = () => {
const flavors = [];
const tables = [];
let ordersMade = 0;
const flavorFactory = new CoffeeFlavorFactory();
function takeOrders(flavorIn, table) {
flavors.push(flavorFactory.getCoffeeFlavor(flavorIn));
tables.push(CoffeeOrderContext(table));
ordersMade++;
}
// Place orders
takeOrders('Cappuccino', 2);
// ...
// Serve orders
for (let i = 0; i < ordersMade; ++i) {
flavors[i].serveCoffee(tables[i]);
}
console.log(' ');
console.log(`total CoffeeFlavor objects made:
${flavorFactory.getTotalCoffeeFlavorsMade()}`);
};
testFlyweight();
将代码转换为使用享元模式
接下来,让我们继续实现一个系统来管理图书馆中的所有书籍,你可以列出每本书的基本元数据如下:
-
ID
-
标题
-
作者
-
类型
-
页数
-
出版商 ID
-
ISBN
我们还需要以下属性来跟踪哪位会员借阅了特定的书籍,他们借阅的日期以及预期的归还日期:
-
checkoutDate -
checkoutMember -
dueReturnDate -
availability
我们创建一个Book类来表示每本书,在使用享元模式进行任何优化之前,构造函数接收与书籍直接相关和跟踪书籍所需的所有属性:
class Book {
constructor(
id,
title,
author,
genre,
pageCount,
publisherID,
ISBN,
checkoutDate,
checkoutMember,
dueReturnDate,
availability
) {
this.id = id;
this.title = title;
this.author = author;
this.genre = genre;
this.pageCount = pageCount;
this.publisherID = publisherID;
this.ISBN = ISBN;
this.checkoutDate = checkoutDate;
this.checkoutMember = checkoutMember;
this.dueReturnDate = dueReturnDate;
this.availability = availability;
}
getTitle() {
return this.title;
}
getAuthor() {
return this.author;
}
getISBN() {
return this.ISBN;
}
// For brevity, other getters are not shown
updateCheckoutStatus(
bookID,
newStatus,
checkoutDate,
checkoutMember,
newReturnDate
) {
this.id = bookID;
this.availability = newStatus;
this.checkoutDate = checkoutDate;
this.checkoutMember = checkoutMember;
this.dueReturnDate = newReturnDate;
}
extendCheckoutPeriod(bookID, newReturnDate) {
this.id = bookID;
this.dueReturnDate = newReturnDate;
}
isPastDue(bookID) {
const currentDate = new Date();
return currentDate.getTime() > Date.parse(this.dueReturnDate);
}
}
对于小型书籍集合,这可能起初表现良好。然而,随着图书馆扩展到包含更广泛的库存以及每本书的多个版本和副本,我们可能会发现管理系统运行速度变慢。使用数千个书籍对象可能会耗尽可用内存,但我们可以使用享元模式优化我们的系统以改进这一点。
现在,我们可以将数据分为内在状态和外在状态:与书籍对象相关的数据(title、author等)是内在的,而与借阅相关的数据(checkoutMember、dueReturnDate等)被视为外在的。实际上,这意味着每个书籍属性组合只需要一个Book对象。仍然是相当数量的对象,但比之前少得多。
将为具有特定标题/ISBN 的书籍对象的所有必需副本创建以下书籍元数据组合的实例:
// Flyweight optimized version
class Book {
constructor({ title, author, genre, pageCount, publisherID, ISBN }) {
this.title = title;
this.author = author;
this.genre = genre;
this.pageCount = pageCount;
this.publisherID = publisherID;
this.ISBN = ISBN;
}
}
正如我们所见,外在状态已被移除。所有与图书馆借阅相关的内容都将移到一个管理器中,而由于对象数据现在被分割了,我们可以使用工厂进行实例化。
基本工厂
现在,让我们定义一个非常基本的工厂。我们将检查系统中是否已经创建了一个特定标题的书籍 —— 如果是,我们将返回它;如果没有,将创建并存储一个新书籍,以便稍后访问。这确保我们仅创建每个唯一的内在数据的单个副本:
// Book Factory Singleton
const existingBooks = {};
class BookFactory {
createBook({ title, author, genre, pageCount, publisherID, ISBN }) {
// Find if a particular book + metadata combination already exists
// !! or (bang bang) forces a boolean to be returned
const existingBook = existingBooks[ISBN];
if (!!existingBook) {
return existingBook;
} else {
// if not, let's create a new instance of the book and store it
const book = new Book({ title, author, genre, pageCount, publisherID,
ISBN });
existingBooks[ISBN] = book;
return book;
}
}
}
管理外在状态
接下来,我们需要将从Book对象中移除的状态存储在某个地方 —— 幸运的是,我们可以使用管理器(我们将其定义为单例)来封装它们。Book对象和已借阅它的图书馆成员的组合将被称为Book记录。我们的管理器将同时存储这两者,并包括我们在Book类的轻量级优化期间剥离的借阅相关逻辑:
// BookRecordManager Singleton
const bookRecordDatabase = {};
class BookRecordManager {
// add a new book into the library system
addBookRecord({ id, title, author, genre, pageCount, publisherID, ISBN,
checkoutDate, checkoutMember, dueReturnDate, availability }) {
const bookFactory = new BookFactory();
const book = bookFactory.createBook({ title, author, genre, pageCount,
publisherID, ISBN });
bookRecordDatabase[id] = {
checkoutMember,
checkoutDate,
dueReturnDate,
availability,
book,
};
}
updateCheckoutStatus({ bookID, newStatus, checkoutDate, checkoutMember,
newReturnDate }) {
const record = bookRecordDatabase[bookID];
record.availability = newStatus;
record.checkoutDate = checkoutDate;
record.checkoutMember = checkoutMember;
record.dueReturnDate = newReturnDate;
}
extendCheckoutPeriod(bookID, newReturnDate) {
bookRecordDatabase[bookID].dueReturnDate = newReturnDate;
}
isPastDue(bookID) {
const currentDate = new Date();
return currentDate.getTime() >
Date.parse(bookRecordDatabase[bookID].dueReturnDate);
}
}
这些更改的结果是,从Book 类提取的所有数据现在都存储在BookManager单例(BookDatabase)的一个属性中 —— 比我们之前使用的大量对象效率要高得多。与书籍借出相关的方法现在也基于此处,因为它们处理的是外部而不是内在的数据。
这个过程确实给我们的最终解决方案增加了一些复杂性。但是,与我们解决的性能问题相比,这只是一个小问题。数据方面,如果我们有 30 本相同的书,现在我们只存储一本。此外,每个函数占用内存。使用轻量级模式,这些函数存在于一个地方(管理器上),而不是每个对象上,从而节省了内存使用。对于之前提到的未优化版本的轻量级模式,我们仅存储对函数对象的引用,因为我们使用了Book构造函数的原型。尽管如此,如果我们以另一种方式实现它,函数将为每个书本实例创建。
轻量级模式与 DOM
DOM 支持两种方法来使对象检测事件——自顶向下(事件捕获)或自底向上(事件冒泡)。
在事件捕获中,事件首先由最外层元素捕获并传播到最内层元素。在事件冒泡中,事件被捕获并传递给最内层元素,然后冒泡到外部元素。
加里·奇索姆在描述这个背景下的轻量级模式时写了一个最好的隐喻,大致如下:
尝试将轻量级模式比喻为一个池塘。鱼张开嘴(事件),气泡上升到水面(冒泡),顶上的苍蝇在气泡达到水面时飞走(动作)。在这个例子中,我们可以轻松地将鱼张开嘴转换为按钮被点击,气泡为冒泡效果,苍蝇飞走为某个函数被执行。
冒泡是为了处理单个事件(例如click)可能由 DOM 层次结构中不同级别定义的多个事件处理程序处理的情况引入的。当发生这种情况时,事件冒泡会执行在最低可能级别为特定元素定义的事件处理程序,然后事件冒泡到包含元素,最终到达更高级别的元素。
轻量级模式可以用来进一步调整事件冒泡过程,正如我们将在“示例:集中式事件处理”中看到的那样。
示例:集中式事件处理
举例来说,假设我们在文档中有几个类似的元素,当用户执行某个操作(例如点击、悬停)时,会执行类似的行为。
通常,在构建手风琴组件、菜单或其他基于列表的小部件时,我们会将click事件绑定到父容器中每个链接元素上(例如,$('ul li a').on(…))。而不是将点击事件绑定到多个元素,我们可以轻松地将一个轻量级对象附加到容器顶部,它可以监听来自下方的事件。这些事件可以使用简单或复杂的逻辑来处理。
由于所提到的组件类型通常具有相同的重复标记,例如手风琴的每个部分,因此有很大可能每个元素被点击时的行为将是相似的,并与附近的相似类相关。我们将利用这些信息来使用享元在示例 7-6 中构建一个基本的手风琴。
这里使用stateManager命名空间来封装我们的享元逻辑,同时使用 jQuery 将初始点击绑定到一个容器div上。首先应用unbind事件以确保页面上没有其他逻辑将类似的处理程序附加到容器上。
为了确立点击容器中的哪个子元素,我们使用target检查,它提供了对被点击元素的引用,而不管其父元素如何。然后,我们利用这些信息处理click事件,而无需在页面加载时将事件绑定到特定的子元素上。
示例 7-6. 集中式事件处理
<div id="container">
<div class="toggle">More Info (Address)
<span class="info">
This is more information
</span>
</div>
<div class="toggle">Even More Info (Map)
<span class="info">
<iframe src="MAPS_URL"></iframe>
</span>
</div>
</div>
<script>
(function() {
const stateManager = {
fly() {
const self = this;
$('#container')
.off()
.on('click', 'div.toggle', function() {
self.handleClick(this);
});
},
handleClick(elem) {
$(elem)
.find('span')
.toggle('slow');
},
};
// Initialize event listeners
stateManager.fly();
})();
</script>
这里的好处在于,我们将许多独立的操作转换为共享的操作(潜在地节省内存)。
行为模式
行为模式有助于定义对象之间的通信。它们有助于改进或简化系统中分散对象之间的通信。
以下是我们将在本节讨论的 JavaScript 行为模式:
-
“观察者模式”
-
“中介者模式”
-
“命令模式”
观察者模式
观察者模式允许在一个对象改变时通知另一个对象,而无需该对象知道其依赖项。通常,这是一个模式,其中一个对象(称为主题)维护一个依赖于它的对象列表(观察者),自动通知它们其状态的任何更改。在现代框架中,观察者模式用于通知组件状态变化。图 7-9 说明了这一点。

图 7-9. 观察者模式
当一个主题需要通知观察者发生的有趣事件时,它会向观察者广播一个通知(可以包含与主题相关的特定数据)。当观察者不再希望由主题通知其变化时,它们可以从观察者列表中移除。
查看发布的与语言无关的设计模式定义有助于更广泛地了解它们的使用和优势。在 GoF 书籍《设计模式:可复用面向对象软件的元素》中提供的观察者模式的定义是:
一个或多个观察者对主题的状态感兴趣,并通过附加自己来向主题注册他们的兴趣。当我们的主题发生变化时,观察者可能感兴趣的事情,会发送一个通知消息,调用每个观察者的更新方法。当观察者不再对主题的状态感兴趣时,他们可以简单地分离自己。
现在我们可以扩展我们学到的内容,使用以下组件来实现观察者模式:
主题
维护观察者列表,方便添加或移除观察者。
观察者
提供了一个update接口,用于需要被通知主题状态变化的对象。
ConcreteSubject
在状态变化时向观察者广播通知,存储ConcreteObservers的状态。
ConcreteObserver
存储对ConcreteSubject的引用,为观察者实现update接口,以确保状态与主题保持一致。
ES2015+允许我们使用 JavaScript 类来实现观察者模式,这些类具有用于notify和update的方法。
首先,让我们使用ObserverList类模型化主题可能具有的依赖观察者列表:
class ObserverList {
constructor() {
this.observerList = [];
}
add(obj) {
return this.observerList.push(obj);
}
count() {
return this.observerList.length;
}
get(index) {
if (index > -1 && index < this.observerList.length) {
return this.observerList[index];
}
}
indexOf(obj, startIndex) {
let i = startIndex;
while (i < this.observerList.length) {
if (this.observerList[i] === obj) {
return i;
}
i++;
}
return -1;
}
removeAt(index) {
this.observerList.splice(index, 1);
}
}
接下来,让我们模拟可以向观察者列表添加、删除或通知观察者的Subject类:
class Subject {
constructor() {
this.observers = new ObserverList();
}
addObserver(observer) {
this.observers.add(observer);
}
removeObserver(observer) {
this.observers.removeAt(this.observers.indexOf(observer, 0));
}
notify(context) {
const observerCount = this.observers.count();
for (let i = 0; i < observerCount; i++) {
this.observers.get(i).update(context);
}
}
}
然后我们定义一个创建新观察者的框架。稍后我们将在这里用自定义行为重写Update功能:
// The Observer
class Observer {
constructor() {}
update() {
// ...
}
}
在我们使用之前的观察者组件的示例应用程序中,现在我们定义:
-
一个用于向页面添加新可观察复选框的按钮。
-
控制复选框将作为主题,通知其他复选框它们应更新为选中状态。
-
新添加的复选框的容器。
然后我们定义ConcreteSubject和ConcreteObserver处理程序,以添加新观察者到页面并实现更新接口。为此,我们使用继承来扩展我们的主题和观察者类。ConcreteSubject类封装了一个复选框,并在主复选框点击时生成通知。ConcreteObserver封装了每个观察复选框,并通过改变复选框的选中值来实现Update接口。接下来是关于这些如何在我们的示例中一起工作的内联注释。
这里是 HTML 代码:
<button id="addNewObserver">Add New Observer checkbox</button>
<input id="mainCheckbox" type="checkbox"/>
<div id="observersContainer"></div>
以下是一个示例:
// References to our DOM elements
// Concrete Subject
class ConcreteSubject extends Subject {
constructor(element) {
// Call the constructor of the super class.
super();
this.element = element;
// Clicking the checkbox will trigger notifications to its observers
this.element.onclick = () => {
this.notify(this.element.checked);
};
}
}
// Concrete Observer
class ConcreteObserver extends Observer {
constructor(element) {
super();
this.element = element;
}
// Override with custom update behavior
update(value) {
this.element.checked = value;
}
}
// References to our DOM elements
const addBtn = document.getElementById('addNewObserver');
const container = document.getElementById('observersContainer');
const controlCheckbox = new ConcreteSubject(
document.getElementById('mainCheckbox')
);
const addNewObserver = () => {
// Create a new checkbox to be added
const check = document.createElement('input');
check.type = 'checkbox';
const checkObserver = new ConcreteObserver(check);
// Add the new observer to our list of observers
// for our main subject
controlCheckbox.addObserver(checkObserver);
// Append the item to the container
container.appendChild(check);
};
addBtn.onclick = addNewObserver;
}
在这个例子中,我们看了如何实现和利用观察者模式,涵盖了主题、观察者、ConcreteSubject和ConcreteObserver的概念。
观察者模式与发布/订阅模式之间的区别
虽然了解观察者模式很有帮助,但在 JavaScript 世界中,我们经常会发现它通常使用一种称为发布/订阅模式的变体来实现。虽然这两种模式非常相似,但有值得注意的区别。
观察者模式要求希望接收主题通知的观察者(或对象)必须向触发事件的对象(主体)订阅此兴趣,如图 7-10 所示。

图 7-10. 发布/订阅
然而,发布/订阅模式使用一个位于希望接收通知的对象(订阅者)和触发事件的对象(发布者)之间的主题/事件通道。该事件系统允许代码定义特定于应用程序的事件,这些事件可以传递包含订阅者所需值的自定义参数。这里的想法是避免订阅者和发布者之间的依赖关系。
这与观察者模式不同,因为它允许任何实现适当事件处理程序的订阅者注册并接收发布者广播的主题通知。
这里是一个示例,展示了如果提供了功能实现来支持publish()、subscribe()和unsubscribe(),一个可能如何使用发布/订阅模式:
<!-- Add this HTML to your page -->
<div class="messageSender"></div>
<div class="messagePreview"></div>
<div class="newMessageCounter"></div>
// A simple Publish/Subscribe implementation
const events = (function () {
const topics = {};
const hOP = topics.hasOwnProperty;
return {
subscribe: function (topic, listener) {
if (!hOP.call(topics, topic)) topics[topic] = [];
const index = topics[topic].push(listener) - 1;
return {
remove: function () {
delete topics[topic][index];
},
};
},
publish: function (topic, info) {
if (!hOP.call(topics, topic)) return;
topics[topic].forEach(function (item) {
item(info !== undefined ? info : {});
});
},
};
})();
// A very simple new mail handler
// A count of the number of messages received
let mailCounter = 0;
// Initialize subscribers that will listen out for a topic
// with the name "inbox/newMessage".
// Render a preview of new messages
const subscriber1 = events.subscribe('inbox/newMessage', (data) => {
// Log the topic for debugging purposes
console.log('A new message was received:', data);
// Use the data that was passed from our subject
// to display a message preview to the user
document.querySelector('.messageSender').innerHTML = data.sender;
document.querySelector('.messagePreview').innerHTML = data.body;
});
// Here's another subscriber using the same data to perform
// a different task.
// Update the counter displaying the number of new
// messages received via the publisher
const subscriber2 = events.subscribe('inbox/newMessage', (data) => {
document.querySelector('.newMessageCounter').innerHTML = ++mailCounter;
});
events.publish('inbox/newMessage', {
sender: 'hello@google.com',
body: 'Hey there! How are you doing today?',
});
// We could then at a later point unsubscribe our subscribers
// from receiving any new topic notifications as follows:
// subscriber1.remove();
// subscriber2.remove();
这里的总体思想是促进松散耦合。与单个对象直接调用其他对象方法不同,它们订阅另一个对象的特定任务或活动,并在其发生时得到通知。
优点
观察者和发布/订阅模式鼓励我们深思不同应用程序部分之间的关系。它们还帮助我们识别包含直接关系的层,可以用一组主体和观察者替换。这实际上可以用来将应用程序分解为更小、更松散耦合的块,以改进代码管理和重用潜力。
使用观察者模式的进一步动机在于在不使类紧密耦合的情况下,需要保持相关对象之间的一致性。例如,当对象需要能够通知其他对象而不对这些对象进行假设时。
当使用任一模式时,观察者和主体之间可以存在动态关系。这提供了极大的灵活性,可能在应用程序的不同部分紧密耦合时不容易实现。
尽管它可能并非每个问题的最佳解决方案,但这些模式仍然是设计解耦系统的最佳工具之一,应被视为任何 JavaScript 开发者工具箱中的重要工具。
缺点
因此,这些模式的一些问题实际上源于它们的主要优势。在发布/订阅中,通过解耦发布者和订阅者,有时可能难以获得我们期望的应用程序特定部分正常运行的保证。
例如,发布者可以假设有一个或多个订阅者在监听它们。假设我们使用这样的假设来记录或输出关于某些应用程序过程的错误。如果执行日志记录的订阅者崩溃(或因某种原因无法正常工作),则由于系统的解耦性质,发布者将无法看到这一点。
该模式的另一个缺点是订阅者完全不知道彼此的存在,并且对于切换发布者的成本一无所知。由于订阅者和发布者之间的动态关系,追踪更新依赖关系可能很困难。
发布/订阅实现
发布/订阅在 JavaScript 生态系统中非常适用,主要是因为在核心上,ECMAScript 实现是事件驱动的。这在浏览器环境中尤其如此,因为 DOM 使用事件作为其主要交互 API 来进行脚本化。
尽管如此,ECMAScript 和 DOM 都没有提供用于在实现代码中创建自定义事件系统的核心对象或方法(除了可能是绑定到 DOM 的 DOM3 CustomEvent,因此并不普遍适用)。
一个示例的发布/订阅实现
为了更好地理解观察者模式的普通 JavaScript 实现可能如何工作,让我们浏览我在 GitHub 上发布的一个名为“pubsubz”的项目中的一个简化版本。这展示了订阅和发布的核心概念,以及取消订阅的想法。
我选择基于这段代码来示范,因为它与我期望在经典观察者模式的 JavaScript 版本中看到的方法签名和实现方法非常接近。
class PubSub {
constructor() {
// Storage for topics that can be broadcast
// or listened to
this.topics = {};
// A topic identifier
this.subUid = -1;
}
publish(topic, args) {
if (!this.topics[topic]) {
return false;
}
const subscribers = this.topics[topic];
let len = subscribers ? subscribers.length : 0;
while (len--) {
subscribers[len].func(topic, args);
}
return this;
}
subscribe(topic, func) {
if (!this.topics[topic]) {
this.topics[topic] = [];
}
const token = (++this.subUid).toString();
this.topics[topic].push({
token,
func,
});
return token;
}
unsubscribe(token) {
for (const m in this.topics) {
if (this.topics[m]) {
for (let i = 0, j = this.topics[m].length; i < j; i++) {
if (this.topics[m][i].token === token) {
this.topics[m].splice(i, 1);
return token;
}
}
}
}
return this;
}
}
const pubsub = new PubSub();
pubsub.publish('/addFavorite', ['test']);
pubsub.subscribe('/addFavorite', (topic, args) => {
console.log('test', topic, args);
});
这里我们定义了一个基本的 PubSub 类,其中包含:
-
一个带有已订阅的订阅者列表的主题列表。
-
Subscribe方法通过使用发布主题时调用的函数和唯一令牌来创建一个新的主题订阅者。 -
Unsubscribe方法根据传递的token值从列表中删除订阅者。Publish方法通过调用registered函数向给定主题的所有订阅者发布内容。
使用我们的实现
现在我们可以使用该实现来发布和订阅感兴趣的事件,如在示例 7-7 中所示。
示例 7-7. 使用我们的实现
// Another simple message handler
// A simple message logger that logs any topics and data received through our
// subscriber
const messageLogger = (topics, data) => {
console.log(`Logging: ${topics}: ${data}`);
};
// Subscribers listen for topics they have subscribed to and
// invoke a callback function (e.g., messageLogger) once a new
// notification is broadcast on that topic
const subscription = pubsub.subscribe('inbox/newMessage', messageLogger);
// Publishers are in charge of publishing topics or notifications of
// interest to the application. e.g.:
pubsub.publish('inbox/newMessage', 'hello world!');
// or
pubsub.publish('inbox/newMessage', ['test', 'a', 'b', 'c']);
// or
pubsub.publish('inbox/newMessage', {
sender: 'hello@google.com',
body: 'Hey again!',
});
// We can also unsubscribe if we no longer wish for our subscribers
// to be notified
pubsub.unsubscribe(subscription);
// Once unsubscribed, this for example won't result in our
// messageLogger being executed as the subscriber is
// no longer listening
pubsub.publish('inbox/newMessage', 'Hello! are you still there?');
UI 通知
接下来,让我们想象我们有一个负责显示实时股票信息的网络应用程序。
应用程序可能有一个显示股票统计数据的网格和一个指示最后更新点的计数器。当数据模型发生变化时,应用程序必须更新网格和计数器。在这种情况下,我们的主题(将发布主题/通知)是数据模型,而我们的订阅者是网格和计数器。
当我们的订阅者收到模型已更改的通知时,它们可以相应地更新自己。
在我们的实现中,我们的订阅者将监听主题newDataAvailable,以了解是否有新的股票信息可用。如果向此主题发布新通知,它将触发gridUpdate以向我们的网格添加包含此信息的新行。它还将更新上次更新计数器以记录添加数据的最后时间(示例 7-8)。
示例 7-8. UI 通知
// Return the current local time to be used in our UI later
getCurrentTime = () => {
const date = new Date();
const m = date.getMonth() + 1;
const d = date.getDate();
const y = date.getFullYear();
const t = date.toLocaleTimeString().toLowerCase();
return `${m}/${d}/${y} ${t}`;
};
// Add a new row of data to our fictional grid component
const addGridRow = data => {
// ui.grid.addRow( data );
console.log(`updated grid component with:${data}`);
};
// Update our fictional grid to show the time it was last
// updated
const updateCounter = data => {
// ui.grid.updateLastChanged( getCurrentTime() );
console.log(`data last updated at: ${getCurrentTime()} with ${data}`);
};
// Update the grid using the data passed to our subscribers
const gridUpdate = (topic, data) => {
if (data !== undefined) {
addGridRow(data);
updateCounter(data);
}
};
// Create a subscription to the newDataAvailable topic
const subscriber = pubsub.subscribe('newDataAvailable', gridUpdate);
// The following represents updates to our data layer. This could be
// powered by ajax requests that broadcast that new data is available
// to the rest of the application.
// Publish changes to the gridUpdated topic representing new entries
pubsub.publish('newDataAvailable', {
summary: 'Apple made $5 billion',
identifier: 'APPL',
stockPrice: 570.91,
});
pubsub.publish('newDataAvailable', {
summary: 'Microsoft made $20 million',
identifier: 'MSFT',
stockPrice: 30.85,
});
使用Ben Alman的 Pub/Sub 实现解耦应用程序
在以下电影评级示例中,我们将使用Ben Alman 的 jQuery 发布/订阅实现演示如何解耦 UI。注意,提交评级仅导致发布新用户和评级数据可用的事实。
由订阅者来委托数据发生了什么。在我们的情况下,我们将新数据推送到现有数组中,然后使用 Lodash 库的.template()方法进行模板化渲染。
示例 7-9 包含 HTML/模板代码。
示例 7-9. Pub/Sub 的 HTML/模板代码
<script id="userTemplate" type="text/html">
<li><%- name %></li>
</script>
<script id="ratingsTemplate" type="text/html">
<li><strong><%- title %></strong> was rated <%- rating %>/5</li>
</script>
<div id="container">
<div class="sampleForm">
<p>
<label for="twitter_handle">Twitter handle:</label>
<input type="text" id="twitter_handle" />
</p>
<p>
<label for="movie_seen">Name a movie you've seen this year:</label>
<input type="text" id="movie_seen" />
</p>
<p>
<label for="movie_rating">Rate the movie you saw:</label>
<select id="movie_rating">
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5" selected>5</option>
</select>
</p>
<p>
<button id="add">Submit rating</button>
</p>
</div>
<div class="summaryTable">
<div id="users"><h3>Recent users</h3></div>
<div id="ratings"><h3>Recent movies rated</h3></div>
</div>
</div>
JavaScript 代码在示例 7-10 中。
示例 7-10. Pub/Sub 的 JavaScript 代码
;($ => {
// Pre-compile templates and "cache" them using closure
const userTemplate = _.template($('#userTemplate').html());
const ratingsTemplate = _.template($('#ratingsTemplate').html());
// Subscribe to the new user topic, which adds a user
// to a list of users who have submitted reviews
$.subscribe('/new/user', (e, data) => {
if (data) {
$('#users').append(userTemplate(data));
}
});
// Subscribe to the new rating topic. This is composed of a title and
// rating. New ratings are appended to a running list of added user
// ratings.
$.subscribe('/new/rating', (e, data) => {
if (data) {
$('#ratings').append(ratingsTemplate(data));
}
});
// Handler for adding a new user
$('#add').on('click', e => {
e.preventDefault();
const strUser = $('#twitter_handle').val();
const strMovie = $('#movie_seen').val();
const strRating = $('#movie_rating').val();
// Inform the application a new user is available
$.publish('/new/user', {
name: strUser,
});
// Inform the app a new rating is available
$.publish('/new/rating', {
title: strMovie,
rating: strRating,
});
});
})(jQuery);
解耦基于 Ajax 的 jQuery 应用程序
在我们的最后一个例子中,我们将实际看看如何在开发过程的早期使用 Pub/Sub 解耦我们的代码,这样可以在后期节省一些可能痛苦的重构。
在 Ajax 密集型应用程序中,我们经常希望在收到请求的响应后执行不止一个独特的操作。我们可以将所有后请求逻辑添加到成功回调中,但这种方法存在缺点。
高度耦合的应用程序有时会增加重复使用功能所需的工作量,因为增加了函数/代码依赖性。如果我们只是尝试一次抓取结果集,将我们的后请求逻辑硬编码在回调中可能还可以。但是,当我们希望对相同数据源进行进一步的 Ajax 调用(和不同的结束行为)而不是多次重写代码的部分时,这种方法就不太合适了。与其稍后回到每个调用相同数据源的层并将它们概括起来,我们可以从一开始就使用 Pub/Sub 并节省时间。
使用观察者,我们还可以轻松地将应用程序范围内关于不同事件的通知分离到我们舒适的任何粒度——这是使用其他模式可能不太优雅的事情。
注意,在我们即将介绍的示例中,当用户表明他想进行搜索查询时,会进行一个主题通知。当请求返回并且实际数据可供使用时,会进行另一个通知。然后,决定如何使用这些事件(或返回的数据)留给订阅者。其中的好处是,如果我们愿意,我们可以有 10 个不同的订阅者以不同的方式使用返回的数据,但对于 Ajax 层来说,它并不关心。它唯一的职责是请求和返回数据,然后将其传递给想要使用它的人。这种关注点的分离可以使我们的代码整体设计更加清晰。
HTML/模板代码显示在示例 7-11 中。
示例 7-11. Ajax 的 HTML/模板代码
<form id="flickrSearch">
<input type="text" name="tag" id="query"/>
<input type="submit" name="submit" value="submit"/>
</form>
<div id="lastQuery"></div>
<ol id="searchResults"></ol>
<script id="resultTemplate" type="text/html">
<% _.each(items, function( item ){ %>
<li><img src="<%= item.media.m %>"/></li>
<% });%>
</script>
示例 7-12 展示了 JavaScript 代码。
示例 7-12. Ajax 的 JavaScript 代码
($ => {
// Pre-compile template and "cache" it using closure
const resultTemplate = _.template($('#resultTemplate').html());
// Subscribe to the new search tags topic
$.subscribe('/search/tags', (e, tags) => {
$('#lastQuery').html(`Searched for: ${tags}`);
});
// Subscribe to the new results topic
$.subscribe('/search/resultSet', (e, results) => {
$('#searchResults')
.empty()
.append(resultTemplate(results));
});
// Submit a search query and publish tags on the /search/tags topic
$('#flickrSearch').submit(function(e) {
e.preventDefault();
const tags = $(this)
.find('#query')
.val();
if (!tags) {
return;
}
$.publish('/search/tags', [$.trim(tags)]);
});
// Subscribe to new tags being published and perform a search query
// using them. Once data has returned publish this data for the rest
// of the application to consume. We used the destructuring assignment
// syntax that makes it possible to unpack values from data structures
// into distinct variables.
$.subscribe('/search/tags', (e, tags) => {
$.getJSON(
'http://api.flickr.com/services/feeds/photos_public.gne?jsoncallback=?',
{
tags,
tagmode: 'any',
format: 'json',
},
// The destructuring assignment as function parameter
({ items }) => {
if (!items.length) {
return;
}
//shorthand property names in object creation,
// if variable name equal to object key
$.publish('/search/resultSet', { items });
}
);
});
})(jQuery);
React 生态系统中的观察者模式
使用观察者模式的一个流行库是 RxJS。RxJS 的文档指出:
ReactiveX 将观察者模式与迭代器模式和函数式编程与集合相结合,以填补管理事件序列的理想方式的需求。
使用 RxJS,我们可以创建观察者并订阅特定事件!让我们看看他们文档中的一个示例,它记录用户是否在文档中拖动:
import ReactDOM from "react-dom";
import { fromEvent, merge } from "rxjs";
import { sample, mapTo } from "rxjs/operators";
import "./styles.css";
merge(
fromEvent(document, "mousedown").pipe(mapTo(false)),
fromEvent(document, "mousemove").pipe(mapTo(true))
)
.pipe(sample(fromEvent(document, "mouseup")))
.subscribe(isDragging => {
console.log("Were you dragging?", isDragging);
});
ReactDOM.render(
<div className="App">Click or drag anywhere and check the console!</div>,
document.getElementById("root")
);
观察者模式有助于解耦应用程序设计中的几种不同场景。如果你还没有使用过,我建议从这里提到的预写实现中选择一个并尝试一下。这是其中一个更容易入门但也是最强大的设计模式之一。
中介者模式
中介者模式是一种设计模式,允许一个对象在事件发生时通知一组其他对象。中介者模式与观察者模式的区别在于,中介者模式允许一个对象被通知其他对象中发生的事件。相反,观察者模式允许一个对象订阅其他对象中发生的多个事件。
在观察者模式的部分中,我们讨论了通过单个对象传递多个事件源的方法。这也被称为发布/订阅或事件聚合。当开发人员面对这个问题时,通常会考虑中介者,所以让我们来探讨它们的不同之处。
字典将中介者称为协助协商和冲突解决的中立方。^(1) 在我们的世界中,中介者是一种行为设计模式,允许我们通过一个统一的接口公开系统中的不同部分之间的通信。
如果一个系统中存在过多的组件直接关系,那么有必要通过一个中心控制点来进行通信。中介者通过确保组件之间的交互集中管理,而不是显式地相互引用,来促进松耦合。这可以帮助我们解耦系统并提高组件可重用性的潜力。
一个现实世界的类比可以是典型的机场交通管制系统。控制塔(中介者)负责决定哪些飞机可以起飞和降落,因为所有通信(监听或广播的通知)都是从飞机到控制塔的,而不是从飞机到飞机。中心化的控制器是该系统成功的关键,这就是中介者在软件设计中扮演的角色(图 7-11)。

图 7-11. 中介者模式
另一个类比可以是 DOM 事件冒泡和事件委托。如果系统中的所有订阅都针对文档而不是单个节点进行,则文档实际上充当了一个中介者。高级别对象负责通知订阅者有关交互事件的发生,而不是绑定到单个节点的事件。
当涉及到中介者模式和事件聚合器模式时,有时由于实现的相似性,这些模式可能看起来可以互换使用。然而,这些模式的语义和意图是非常不同的。
即使两者的实现都使用了一些相同的核心构造,我相信它们之间存在着明显的差异。它们不应该在交流中互换或混淆,因为它们的差异。
一个简单的中介者
中介者是一个协调多个对象之间交互(逻辑和行为)的对象。它根据其他对象的动作(或不动作)和输入决定何时调用哪些对象。
你可以使用一行代码编写一个中介者:
const mediator = {};
当然,这只是 JavaScript 中的一个对象字面量。再次强调,我们在谈论语义。中介者的目的是控制对象之间的工作流程;我们实际上不需要比一个对象字面量更多的东西来实现这一点。
以下示例展示了一个mediator对象的基本实现,其中包含一些触发和订阅事件的实用方法。这里的orgChart对象是一个中介者,它在发生特定事件时分配要执行的操作。在此,经理在完成新雇员的详细信息时分配给员工,并保存员工记录:
const orgChart = {
addNewEmployee() {
// getEmployeeDetail provides a view that users interact with
const employeeDetail = this.getEmployeeDetail();
// when the employee detail is complete, the mediator (the 'orgchart'
// object) decides what should happen next
employeeDetail.on('complete', employee => {
// set up additional objects that have additional events, which are
// used by the mediator to do additional things
const managerSelector = this.selectManager(employee);
managerSelector.on('save', employee => {
employee.save();
});
});
},
// ...
};
我过去经常将这种对象称为“工作流”对象,但事实上它是一个中介者。它是一个处理多个其他对象之间工作流的对象,将工作流知识的责任聚合到一个对象中。结果是一个更容易理解和维护的工作流。
相似性和差异
无疑,在我展示的事件聚合器和中介者示例之间存在相似之处。这些相似之处归结为两个主要内容:事件和第三方对象。但这些差异充其量只是表面上的。当我们深入研究模式的意图并看到实现可能截然不同时,这些模式的本质就更为明显了。
事件
展示的示例中,事件聚合器和中介者都使用事件。显然,事件聚合器处理事件——毕竟名字就是这样。中介者只是因为在处理现代 JavaScript Web 应用程序框架时使用事件会更方便。没有任何东西说中介者必须使用事件构建。您可以通过将中介者引用传递给子对象或使用其他几种方法来使用回调方法构建中介者。
因此,这两种模式为何都使用事件的差异在于。作为一种模式,事件聚合器设计用于处理事件。尽管如此,中介者仅仅使用它们是因为这样做很方便。
第三方对象
按设计,事件聚合器和中介者都利用第三方对象来简化交互。事件聚合器本身是事件发布者和事件订阅者之间的中心枢纽。中介者也是其他对象的第三方,但是差异在哪里呢?为什么我们不把事件聚合器称为中介者?答案主要取决于应用逻辑和工作流程在哪里编码。
对于事件聚合器来说,第三方对象仅用于促进事件从未知数量的来源传递到未知数量的处理程序。所有需要启动的工作流程和业务逻辑都直接放入触发事件的对象以及处理事件的对象中。
在中介者的情况下,业务逻辑和工作流程都聚合到中介者本身中。中介者根据自己知道的因素决定何时调用对象的方法和更新属性。它封装了工作流程和流程,协调多个对象以产生所需的系统行为。参与此工作流程的各个对象知道如何执行其任务。但中介者通过在比单个对象更高层次上做出决策来告诉对象何时执行任务。
事件聚合器促进了“发出并忘记”通信模型。触发事件的对象并不在乎是否有任何订阅者。它只是触发事件然后继续执行。中介者可能使用事件来做出决策,但它绝对不是“发出并忘记”。中介者关注已知的一组输入或活动,以便能够协调和协调其他行为与已知的一组参与者(对象)。
关系:何时使用哪个
了解事件聚合器和中介者之间的相似性和差异对于语义原因至关重要。同样重要的是要知道何时使用哪种模式。模式的基本语义和意图可以回答何时使用的问题,但是使用模式的经验将帮助您理解必须作出的更微妙的观点和细微的决策。
事件聚合器使用
总体而言,当你要监听的对象太多或者对象之间没有任何关系时,可以使用事件聚合器。
当两个对象已经存在直接关系时——例如父视图和子视图——使用事件聚合器可能会有益处。让子视图触发一个事件,父视图可以处理该事件。在 JavaScript 框架术语中,Backbone 的集合(Collection)和模型(Model)经常使用这种方式,其中所有模型事件都被冒泡到其父集合(Collection)中。集合经常使用模型事件来修改自身或其他模型的状态。处理集合中的“selected”项目是一个很好的例子。
jQuery 的on()方法作为事件聚合器是监听太多对象的一个很好的例子。如果有 10、20 或 200 个 DOM 元素可以触发“click”事件,逐个设置监听器可能是一个不好的主意。这可能会迅速降低应用程序的性能和用户体验。相反,使用 jQuery 的on()方法可以聚合所有事件,并将 10、20 或 200 个事件处理程序的开销减少到 1 个。
间接关系也是使用事件聚合器的绝佳时机。在现代应用程序中,拥有多个视图对象需要通信但没有直接关系是很普遍的。例如,菜单系统可能有一个处理菜单项点击的视图。但是当我们点击菜单项时,我们不希望菜单直接与显示所有详细信息和内容的内容视图耦合在一起——长期来看,将内容和菜单耦合在一起会使代码难以维护。因此,我们可以使用事件聚合器来触发menu:click:foo事件,并让“foo”对象处理click事件以在屏幕上显示其内容。
中介者使用
当两个或更多对象存在间接工作关系,并且业务逻辑或工作流需要指导这些对象的交互和协调时,中介者是最佳选择。向导界面在这方面是一个很好的例子,正如orgChart示例所示。多个视图有助于向导的整个工作流程。与其通过直接引用彼此来紧密耦合视图,我们可以通过引入中介者来解耦它们,并更明确地对它们之间的工作流进行建模。
中介从实现细节中提取工作流,并在更高层次上创建更自然的抽象,通过快速的一瞥就能看出工作流是什么。我们不再需要深入了解工作流中每个视图的细节来了解工作流。
事件聚合器(Pub/Sub)和中介的结合
显示事件聚合器和中介之间区别的关键,以及为什么这些模式名称不应该互换,最好通过展示它们如何一起使用来说明。事件聚合器的菜单示例是介绍中介的理想场所。
单击菜单项可能会触发应用程序中的一系列变化。其中一些变化是独立的,使用事件聚合器是有意义的。而有些变化可能是内部相关的,并且可能使用中介来实现这些变化。
然后可以设置一个中介来监听事件聚合器。它可以运行其逻辑和处理,以促进和协调许多相互关联但与原始事件源无关的对象:
const MenuItem = MyFrameworkView.extend({
events: {
'click .thatThing': 'clickedIt',
},
clickedIt(e) {
e.preventDefault();
// assume this triggers "menu:click:foo"
MyFramework.trigger(`menu:click:${this.model.get('name')}`);
},
});
// ... somewhere else in the app
class MyWorkflow {
constructor() {
MyFramework.on('menu:click:foo', this.doStuff, this);
}
static doStuff() {
// instantiate multiple objects here.
// set up event handlers for those objects.
// coordinate all of the objects into a meaningful workflow.
}
}
在这个示例中,当点击具有正确模型的MenuItem时,将触发menu:click:foo事件。MyWorkflow类的一个实例将处理这个特定事件,并协调它知道的所有对象,以创建期望的用户体验和工作流程。
我们因此将事件聚合器和中介结合起来,以在代码和应用程序中创造有意义的体验。现在,通过事件聚合器在菜单和工作流程之间实现了清晰的分离,而通过中介仍然保持了工作流的清洁和可维护性。
现代 JavaScript 中的中介/中间件
Express.js是一种流行的 Web 应用程序服务器框架。我们可以为用户可以访问的某些路由添加回调函数。
假设我们希望在用户点击根路径(/)时向请求添加一个标头。我们可以在中间件回调中添加这个标头:
const app = require("express")();
app.use("/", (req, res, next) => {
req.headers["test-header"] = 1234;
next();
});
next()方法调用请求-响应周期中的下一个回调函数。我们可以创建一系列中间件函数,它们位于请求和响应之间或者反过来。通过一个或多个中间件函数,我们可以跟踪和修改请求对象一直到响应。
每当用户访问根端点(/)时,将调用中间件回调:
const app = require("express")();
const html = require("./data");
app.use(
"/",
(req, res, next) => {
req.headers["test-header"] = 1234;
next();
},
(req, res, next) => {
console.log(`Request has test header: ${!!req.headers["test-header"]}`);
next();
}
);
app.get("/", (req, res) => {
res.set("Content-Type", "text/html");
res.send(Buffer.from(html));
});
app.listen(8080, function() {
console.log("Server is running on 8080");
});
中介与 Facade 的比较
我们很快将介绍 Facade 模式,但是作为参考,一些开发人员可能还想知道中介模式与 Facade 模式之间是否存在相似之处。它们都抽象了现有模块的功能,但也有一些细微的差别。
中介者在模块之间集中通信,这些模块显式地引用它。在某种意义上,这是多方向的。然而,外观模式定义了一个更简单的接口,用于模块或系统,但不添加任何额外的功能。系统中的其他模块并不直接了解外观模式的概念,可以被认为是单向的。
命令模式
命令模式旨在将方法调用、请求或操作封装到单个对象中,并允许我们对可以在我们自己的决策下执行的方法调用进行参数化和传递。此外,它使我们能够解耦调用操作的对象与实现它们的对象,从而在交换具体类(对象)时提供更大的灵活性。
具体 类最好通过基于类的编程语言来解释,并与抽象类的概念相关联。抽象 类定义了一个接口,但不一定为其所有成员函数提供实现。它充当其他类从中派生的基类。实现缺失功能的派生类称为具体 类(见图 7-12)。基类和具体类可以使用适用于 JavaScript 类的 extends 关键字进行实现(ES2015+)。

图 7-12. 命令模式
命令模式的一般思想是提供一种方法,将发出命令的责任与执行命令的任何事物分离开来,而是将此责任委托给不同的对象。
在实现上,简单的命令对象绑定了动作和希望调用该动作的对象。它们始终包括一个执行操作(如 run() 或 execute())。具有相同接口的所有命令对象可以根据需要轻松交换,这是该模式的重要优势之一。
为了演示命令模式,我们将创建一个简单的汽车购买服务:
const CarManager = {
// request information
requestInfo(model, id) {
return `The information for ${model} with ID ${id} is foobar`;
},
// purchase the car
buyVehicle(model, id) {
return `You have successfully purchased Item ${id}, a ${model}`;
},
// arrange a viewing
arrangeViewing(model, id) {
return `You have booked a viewing of ${model} ( ${id} ) `;
},
};
CarManager 对象是我们的命令对象,负责发出关于汽车信息的请求、购买汽车和安排查看的命令。直接访问该对象以调用 CarManager 方法是微不足道的。假设这样做没有问题——从技术上讲,这是完全有效的 JavaScript。然而,在某些情况下,这可能是不利的。
举个例子,想象一下如果 CarManager 背后的核心 API 发生了变化。这将需要修改我们应用程序中直接访问这些方法的所有对象。这种耦合方式实际上违背了尽可能松散耦合对象的面向对象编程方法论。相反,我们可以通过进一步抽象 API 来解决这个问题。
现在让我们扩展CarManager,以便我们的命令模式应用程序能实现以下目标:接受可以在CarManager对象上执行的任何命名方法,并传递可能用到的任何数据,例如车型和 ID。
这里是我们希望能够实现的内容:
CarManager.execute('buyVehicle', 'Ford Escort', '453543');
根据这个结构,我们现在应该为carManager.execute方法添加定义,如下所示:
carManager.execute = function(name) {
return (
carManager[name] &&
carManager[name].apply(carManager, [].slice.call(arguments, 1))
);
};
因此,我们最终的样本调用看起来是这样的:
carManager.execute('arrangeViewing', 'Ferrari', '14523');
carManager.execute('requestInfo', 'Ford Mondeo', '54323');
carManager.execute('requestInfo', 'Ford Escort', '34232');
carManager.execute('buyVehicle', 'Ford Escort', '34232');
概要
通过这样,我们可以总结我们对传统设计模式的讨论,这些模式可以在设计类、对象和模块时使用。我尝试整合了创造性、结构性和行为性模式的理想组合。我们还研究了为经典面向对象编程语言如 Java 和 C++创建的模式,并将其调整适用于 JavaScript。
这些模式将帮助我们设计许多特定于领域的对象(例如购物车、车辆或书籍),这些对象构成了我们应用程序业务模型的一部分。在下一章中,我们将探讨如何更大局观地结构化应用程序,使得这个模型能够向其他应用程序层(如视图或展示者)传递信息。
^(1) 维基百科; Dictionary.com.
第八章:JavaScript MV* 模式
对象设计和应用架构是应用设计的两个主要方面。我们在上一章中已经涵盖了与第一个相关的模式。在本章中,我们将回顾三种基本的架构模式:MVC(Model-View-Controller)、MVP(Model-View-Presenter)和 MVVM(Model-View-ViewModel)。过去,这些模式被广泛用于构建桌面和服务器端应用程序。现在它们也已经适应了 JavaScript。
由于大多数当前使用这些模式的 JavaScript 开发人员选择使用各种库或框架来实现类似 MVC/MV* 的结构,我们将比较这些解决方案在解释 MVC 时与这些模式的经典视角有何不同。
注意
在基于 MVC/MVVM 的大多数现代基于浏览器的 UI 设计框架中,您可以轻松区分模型和视图层。然而,第三个组件在名称和功能上各不相同。在 MV* 中的 * 表示不同框架中第三个组件的形式。
MVC
MVC 是一种架构设计模式,通过关注点分离来促进应用程序组织的改进。它强制将业务数据(模型)与 UI(视图)隔离开来,第三个组件(控制器)传统上负责管理逻辑和用户输入。Trygve Reenskaug 最初在 Smalltalk-80(1979 年)期间设计了这种模式,最初称为 Model-View-Controller-Editor。MVC 后来在 1995 年的 Design Patterns: Elements of Reusable Object-Oriented Software(即“GoF”书籍)中深入描述,这本书在推广其使用方面发挥了作用。
Smalltalk-80 MVC
理解原始 MVC 模式旨在解决什么问题是至关重要的,因为自其起源以来,它已经发生了相当大的变化。回到 20 世纪 70 年代,GUI 只有寥寥无几。一种被称为 Separated Presentation 的概念因其将模拟现实世界中的思想(例如照片、人物)的领域对象与呈现对象清晰分开而变得著名。
Smalltalk-80 中的 MVC 进一步发展了这一概念,并旨在将应用程序逻辑与 UI 分离。其理念是,解耦应用程序的这些部分也将允许在应用程序中为其他界面重用模型。关于 Smalltalk-80 的 MVC 架构有一些值得注意的有趣点:
-
模型代表特定于领域的数据,并对 UI(视图和控制器)不知情。当模型发生变化时,它会通知其观察者。
-
视图表示了模型的当前状态。观察者模式被用来通知视图模型何时被更新或修改。
-
View 负责呈现,但不仅仅有一个单一的 View 和 Controller——每个部分或元素在屏幕上显示都需要一个 View-Controller 对。
-
Controller 在这对中的角色是处理用户交互(例如按键和点击等动作)并为 View 做出决策。
开发者有时会感到惊讶,当他们了解到观察者模式(现在通常作为发布/订阅的变体实现)几十年前就已经作为 MVC 架构的一部分包含在内。在 Smalltalk-80 的 MVC 中,View 观察 Model。如列表中所述,每当 Model 发生变化时,Views 会作出反应。一个简单的例子是一个依赖股市数据的应用程序。为了使应用程序有用,我们的 Models 中的任何数据变化都应立即刷新 View。
Martin Fowler多年来在 MVC 起源方面做了出色的工作。如果您对 Smalltalk-80 的一些更深入的历史信息感兴趣,我建议阅读他的作品。
JavaScript 开发者的 MVC
我们已经回顾了 20 世纪 70 年代,但让我们回到现在。在现代,MVC 模式已经在多种编程语言和应用程序类型中使用,包括我们最关心的 JavaScript。现在,JavaScript 有几个框架支持 MVC(或其变体,我们称之为 MV*系列),使开发者能够轻松地为他们的应用程序添加结构。
最早的框架包括 Backbone、Ember.js 和 AngularJS。最近,React、Angular 和 Vue.js 生态系统已被用来实现 MV*模式的各种变体。考虑到避免“意大利面条”代码的重要性,这个术语描述的是由于缺乏结构而非常难以阅读或维护的代码,现代 JavaScript 开发者必须理解这种模式提供了什么。这使我们能够有效地欣赏这些框架使我们能够以不同的方式做什么(图 8-1)。

图 8-1. MVC 模式
MVC 包括三个核心组件,如下节所述。
Models
Models 管理应用程序的数据。它们不关心 UI 或呈现层,但代表了应用程序可能需要的独特数据形式。当一个 Model 变化时(例如,当它被更新时),它通常会通知它的观察者(例如 Views,我们马上会介绍的概念)发生了变化,以便它可以相应地做出反应。
要进一步理解 Models,让我们想象我们有一个照片库应用程序。在照片库中,照片的概念会有自己的 Model,因为它代表了一种独特的领域特定数据。这样的 Model 可能包含相关属性,如标题、图像来源和附加元数据。您可以将特定的照片存储在一个 Model 的实例中,而且一个 Model 也可以是可重用的。
模型的内置功能因框架而异。但是,支持属性验证是它们的标准功能之一,其中属性表示模型的属性,例如模型标识符。在实际应用中使用模型时,我们通常也希望模型具有持久性。持久性使我们能够编辑和更新模型,并确保它们的最新状态保存在内存、本地存储或与数据库同步。
此外,一个模型可能有多个观察它的视图。比如说,我们的照片模型包含了元数据,比如它的位置(经度和纬度)、出现在照片中的朋友(一个标识符列表),以及标签列表,开发者可以决定提供一个单一的视图来展示这三个方面。
在 MVC/MV*框架中,提供将模型分组的方法以形成集合并不少见。将模型分组管理,使得我们能够基于来自组中任何模型的通知编写应用逻辑。这样做避免了手动观察单个模型实例的需要。
早期关于 MVC 的文本还可能提到模型管理应用程序状态的概念。在 JavaScript 应用程序中,“状态”具有不同的涵义,通常指当前“状态”——即用户屏幕上的视图或子视图(具有特定数据)。当讨论单页应用程序(SPA)时,状态经常被讨论,需要模拟状态的概念。
综上所述,模型主要关注业务数据。
视图
视图是模型的视觉表现形式,展示其当前状态的过滤视图。虽然 Smalltalk 视图是关于绘制和维护位图的,JavaScript 视图则构建和组织一组 DOM 元素。
视图通常观察一个模型,并在模型变化时得到通知,允许视图相应地更新自己。设计模式文献通常将视图称为“哑”的,因为它们对应用程序中的模型和控制器的了解是有限的。
用户可以与视图交互,包括读取和编辑(即获取或设置模型的属性值)。由于视图是表示层,我们通常以用户友好的方式呈现编辑和更新的能力。例如,在我们之前讨论的照片库应用程序中,我们可以通过“编辑”视图来促进模型的编辑,用户可以选择特定照片并编辑其元数据。
更新模型的实际任务落到控制器(我们稍后会介绍)的手中。
让我们通过一个传统的 JavaScript 示例实现进一步探索视图。现在我们可以看到一个函数,它创建一个单一的照片视图,同时消耗一个模型和一个控制器实例。
我们在视图中定义了一个render()实用程序,负责使用 JavaScript 模板引擎(Lodash 模板)渲染photoModel的内容,并更新由photoEl引用的视图内容。
然后,photoModel将我们的render()回调添加为其订阅者之一,以便我们使用观察者模式在模型更改时触发视图更新。
读者可能会想知道用户交互在这里如何发挥作用。当用户点击视图中的任何元素时,不是视图的责任来决定下一步该做什么。它依赖于控制器来代替它做出这个决定。我们的示例实现通过向photoEl添加事件监听器来实现这一点,它将委托处理点击行为返回给控制器,并在需要的情况下传递模型信息。
该架构的好处在于每个组件都发挥其在使应用程序按需运行中所需的作用:
const buildPhotoView = (photoModel, photoController) => {
const base = document.createElement( "div" );
const photoEl = document.createElement( "div" );
base.appendChild(photoEl);
const render = () => {
// We use Lodash's template method
// which generates the HTML for our photo entry
photo entry
photoEl.innerHTML = _.template("#photoTemplate", {
src: photoModel.getSrc()
});
};
photoModel.addSubscriber( render );
photoEl.addEventListener( "click", () => {
photoController.handleEvent( "click", photoModel );
});
const show = () => {
photoEl.style.display = "";
};
const hide = () => {
photoEl.style.display = "none";
};
return {
showView: show,
hideView: hide
};
};
模板化
当讨论支持 MVC/MV*的 JavaScript 框架时,简要介绍 JavaScript 模板化是值得的。正如前一节中提到的,模板化与视图相关联。
长期以来,手动通过字符串连接在内存中创建大块 HTML 标记一直被认为(并已经证明)是一种性能不良的做法。开发人员已经陷入了通过它们的数据进行低效迭代的困境,将其包装在嵌套的div中,并使用诸如document.write之类的过时技术将生成的“模板”注入 DOM 中。这通常意味着在我们的标准标记中内联脚本标记。标记可以很快变得难以阅读,并且更重要的是,具有这种代码的非平凡应用可能会导致维护灾难。
现代 JavaScript 模板化解决方案已经转向使用标记模板字面量,这是 ES6(ECMAScript 2015)的一个强大特性。标记模板字面量允许您使用 JavaScript 的模板字面量语法创建可重用模板,以及一个自定义处理函数,用于操纵和填充数据模板。这种方法消除了额外的模板化库的需要,并提供了一种清晰、可维护的创建动态 HTML 内容的方式。
使用标记模板字面量内部的变量可以通过${variable}语法轻松插入,这比传统的变量分隔符如{{name}}更简洁易读。这使得更简单地维护干净的模型和模板成为可能,同时允许框架处理从模型填充模板的大部分工作。这带来了许多好处,特别是在选择外部存储模板时。这可以在构建更大的应用程序时根据需要动态加载模板。
示例 8-1 和 8-2 是两个 JavaScript 模板的示例。一个是使用标记模板字面量实现的,另一个是使用 Lodash 模板。
示例 8-1. 标记模板字面量代码
// Sample data
const photos = [
{
caption: 'Sample Photo 1',
src: 'photo1.jpg',
metadata: 'Some metadata for photo 1',
},
{
caption: 'Sample Photo 2',
src: 'photo2.jpg',
metadata: 'Some metadata for photo 2',
},
];
// Tagged template literal function
function photoTemplate(strings, caption, src, metadata) {
return strings[0] + caption + strings[1] + src + strings[2] + metadata
+ strings[3];
}
// Define the template as a tagged template literal string
const template = (caption, src, metadata) => photoTemplate`<li class="photo">
<h2>${caption}</h2>
<img class="source" src="${src}"/>
<div class="metadata">
${metadata}
</div>
</li>`;
// Loop through the data and populate the template
const photoList = document.createElement('ul');
photos.forEach((photo) => {
const photoItem = template(photo.caption, photo.src, photo.metadata);
photoList.innerHTML += photoItem;
});
// Insert the populated template into the DOM
document.body.appendChild(photoList);
示例 8-2. Lodash.js 模板
<li class="photo">
<h2><%- caption %></h2>
<img class="source" src="<%- src %>"/>
<div class="metadata">
<%- metadata %>
</div>
</li>
请注意,模板本身并不是 Views。View 是观察 Model 并保持视觉表示最新的对象。模板可能是一种声明性方式,用来指定 View 对象的部分甚至全部,以便框架可以从模板规范生成它。
还值得注意的是,在传统的 Web 开发中,导航到独立 Views 之间需要使用页面刷新。然而,在单页 JavaScript 应用程序中,一旦从服务器获取数据,就可以在同一页面内动态地渲染新的 View,而无需任何刷新。因此,导航角色落到了路由器上,它帮助管理应用程序状态(例如,允许用户书签所导航到的特定 View)。然而,由于路由器既不是 MVC 的一部分,也不在每个类似 MVC 的框架中出现,因此在本节中将不会对其进行更详细的讨论。
总结一下,Views 在视觉上代表我们的应用程序数据,而模板可以用来生成 Views。现代模板技术,如标记模板字面量,提供了一种干净、高效和可维护的方式来在 JavaScript 应用程序中创建动态 HTML 内容。
Controllers
Controllers 是 Models 和 Views 之间的中介,经典上负责在用户操作 View 时更新 Model。它们管理应用程序中 Models 和 Views 之间的逻辑和协调。
MVC 给我们带来了什么?
这种 MVC 中的关注点分离有助于简化应用程序功能的模块化,并实现以下功能:
-
更容易的整体维护。当应用程序需要更新时,很明显是否是数据中心的变更,即对 Models 和可能是 Controllers 的变更,或者仅仅是视觉上的变更,即对 Views 的变更。
-
解耦 Models 和 Views 意味着编写业务逻辑的单元测试变得更加简单。
-
消除了在应用程序中低级 Model 和 Controller 代码的重复(即,我们可能一直在使用的代码)。
-
根据应用程序的大小和角色的分离,这种模块化允许负责核心逻辑的开发者和负责 UI 的开发者同时工作。
在 JavaScript 中实现 Smalltalk-80 MVC
大多数现代 JavaScript 框架试图发展 MVC 范例以适应 Web 应用程序开发的不同需求。然而,有一个框架试图遵循 Smalltalk-80 中模式的纯粹形式。Maria.js由 Peter Michaux 提供了一个忠实于 MVC 起源的实现:Models 是 Models,Views 是 Views,而 Controllers 仅仅是 Controllers。尽管一些开发者可能认为 MV*框架应该处理更多关注点,但这是一个有价值的参考,以便在需要 JavaScript 实现原始 MVC 的情况下了解。
MVC 摘要
在审查了经典的 MVC 模式之后,我们现在应该理解它如何帮助我们在应用程序中清晰地分离关注点。我们还应该欣赏 JavaScript MVC 框架在对 MVC 模式的解释上可能存在的差异。虽然可以根据需求进行相当开放的变化,但它们仍然分享一些原始模式提供的基本概念。
在审查新的 JavaScript MVC/MV* 框架时,请记住:迈出一步,审视它是如何选择处理架构的(具体来说,它如何支持实现模型、视图、控制器或其他替代方案),因为这可以更好地帮助我们理解如何最佳使用该框架。
MVP
模型-视图-展示者(MVP)是 MVC 设计模式的一个衍生,专注于改进展示逻辑。它起源于上世纪 90 年代初的 Taligent 公司,当时他们正在为 C++ CommonPoint 环境设计一个模型。虽然 MVC 和 MVP 都旨在跨多个组件分离关注点,但它们之间存在一些根本的差异。
在这里,我们将专注于最适合基于 Web 的架构的 MVP 版本。
模型、视图和展示者
MVP 中的 P 代表展示者。这是一个包含视图 UI 业务逻辑的组件。与 MVC 不同,来自视图的调用被委托给展示者,后者与视图解耦,而是通过接口与其进行通信。这有许多优势,例如能够在单元测试中模拟视图(MVP 模式)(图 8-2)。

图 8-2. MVP 模式
MVP 最常见的实现方式是使用被动视图(一种在所有意图和目的上都是“哑”的视图),几乎没有逻辑。MVC 和 MVP 之间的区别在于 C 和 P 扮演的角色不同。在 MVP 中,P 观察模型并在模型更改时更新视图。P 将模型有效地绑定到视图中,这是 MVC 中控制器的责任。
当视图请求时,展示者执行与用户请求相关的任何工作,并将数据返回给它们。在这方面,它们检索数据、操纵数据,并确定数据应如何在视图中显示。在某些实现中,展示者还与服务层交互以持久化数据(模型)。模型可能会触发事件,但是展示者的角色是订阅这些事件,以便它可以更新视图。在这种被动的架构中,我们没有直接数据绑定的概念。视图公开了展示者可以使用的设置器来设置数据。
与 MVC 相比,这种改变的好处在于增加了应用程序的可测试性,并在视图和模型之间提供了更清晰的分离。然而,这并不是没有代价的,因为模式中缺乏数据绑定支持通常意味着需要单独处理这项任务。
尽管 被动视图 的常见实现是视图实现一个接口,但也有其它变体,包括使用可以将视图与 Presenter 解耦合的事件。由于 JavaScript 中没有接口构造,我们在这里更多地使用协议而不是显式接口。从技术上讲仍然是 API,从这个角度来看将其称为接口可能是公平的。
还有一个 监控控制器 的 MVP 变体,更接近 MVC 和 MVVM 模式,因为它直接从模型向视图提供数据绑定。
MVP 还是 MVC?
现在我们已经讨论了 MVP 和 MVC,那么如何为您的应用程序选择最合适的模式呢?
MVP 通常用于需要尽可能重用展示逻辑的企业级应用程序中。具有非常复杂视图和大量用户交互的应用程序可能会发现 MVC 在这里不太合适,因为解决这个问题可能意味着严重依赖多个控制器。在 MVP 中,所有这些复杂逻辑都可以封装在一个 Presenter 中,显著简化维护工作。
MVP 视图是通过接口定义的,而接口在技术上是系统和视图之间的唯一联系点(除了一个 Presenter),这种模式还允许开发人员编写展示逻辑,而不需要等待设计师为应用程序制作布局和图形。
依赖实现而言,MVP 可能比 MVC 更容易进行单元测试。通常引用的原因是可以使用 Presenter 作为 UI 的完整模拟,因此可以独立于其他组件进行单元测试。根据我个人的经验,这取决于我们在哪些语言中实现 MVP(在选择 JavaScript 项目与 ASP.NET 项目的 MVP 之间存在很大差异)。
我们对 MVC 可能存在的基本关注点可能也适用于 MVP,因为它们之间的区别主要是语义上的。只要我们将关注点清晰地分离为模型、视图和控制器(或者 Presenter),我们无论选择哪种变体,应该都能实现大部分相同的好处。
少数(如果有的话)JavaScript 架构框架声称以其经典形式实现 MVC 或 MVP 模式。许多 JavaScript 开发人员不认为 MVC 和 MVP 是互斥的(我们更有可能在诸如 ASP.NET 或 Google Web Toolkit 等 Web 框架中严格实现 MVP)。这是因为我们可以在应用程序中具有额外的 Presenter/View 逻辑,仍然将其视为 MVC 的一种变体。
MVVM
MVVM(模型-视图-视图模型)是一种基于 MVC 和 MVP 的架构模式,旨在更清晰地将 UI 开发与应用程序中的业务逻辑和行为分离。为此,该模式的许多实现利用声明性数据绑定,允许在视图与其他层之间进行分离。
这使得 UI 和开发几乎同时进行在同一代码库中成为可能。UI 开发人员在其文档标记(HTML)中编写与 ViewModel 的绑定,而应用程序逻辑的开发人员则维护模型和 ViewModel(图 8-3)。

图 8-3. MVVM 模式
历史(History)
MVVM(以名称命名)最初由微软定义,用于Windows Presentation Foundation(WPF)和Silverlight,由 John Grossman 在关于 Avalon(WPF 的代号)的博客文章中于 2005 年正式宣布。它还在 Adobe Flex 社区中作为使用 MVC 的替代方案找到了一些流行度。
在微软采纳 MVVM 名称之前,社区中有一股潮流从 MVP 过渡到 MVPM:即模型-视图-展示模型。Martin Fowler 在 2004 年写了一篇关于展示模型的文章,供有兴趣的人阅读更多。展示模型的概念早在这篇文章之前就已经存在很长时间了。然而,这篇文章被认为是该概念的一个重要突破,并有助于其普及。
在微软宣布 MVVM 作为 MVP 的替代方案后,“alt.net”圈子中有相当大的哗然。许多人声称公司在 GUI 世界的主导地位使他们能够接管社区,根据市场营销需求重新命名现有概念。渐进派认识到,虽然 MVVM 和 MVPM 本质上是相同的想法,但它们呈现的形式略有不同。
MVVM 最初是在 JavaScript 中实现的,形式为结构性框架,如 KnockoutJS、Kendo MVVM 和 Knockback.js,并得到了社区的整体积极响应。
让我们来回顾组成 MVVM 的三个组件:
模型(Model)
表示领域特定信息
视图(View)
用户界面(UI)
ViewModel
模型(Model)和视图(View)之间的接口
模型(Model)
与 MV*家族的其他成员一样,MVVM 中的模型代表应用程序将使用的特定于域的数据或信息。一个典型的域特定数据例子可能是用户账户(例如名称、头像、电子邮件)或音乐曲目(例如标题、年份、专辑)。
模型保存信息,但通常不处理行为。它们不格式化信息或影响数据在浏览器中的呈现,因为这不是它们的责任。相反,视图管理数据格式化,而行为被视为应该封装在另一层中与模型交互的业务逻辑:视图模型(ViewModel)。
这个规则的唯一例外似乎是验证,对于模型来验证用于定义或更新现有模型的数据是可以接受的(例如,输入的电子邮件地址是否符合特定的正则表达式要求?)。
视图
与 MVC 一样,视图是用户与应用程序唯一交互的部分。视图是一个交互式 UI,代表了 ViewModel 的状态。在这个意义上,视图被认为是活动的而不是被动的,这对于 MVC 和 MVP 视图也是正确的。在 MVC、MVP 和 MVVM 中,视图也可以是被动的,但这意味着什么呢?
被动视图仅输出显示,不接受任何用户输入。这样的视图也可能对我们应用程序中的模型没有真正的了解,并且可以由 Presenter 操作。MVVM 的活动视图包含数据绑定、事件和行为,这需要对 ViewModel 有一定的理解。虽然这些行为可以映射到属性,但视图仍然负责处理来自 ViewModel 的事件。
重要的是要记住,视图不负责处理状态;它保持与 ViewModel 的同步。
ViewModel
ViewModel 可以被视为一个专门的控制器,充当数据转换器。它将模型信息转换为视图信息,从视图传递命令到模型。
例如,让我们假设我们有一个模型,其中包含以 UNIX 格式存储的date属性(例如,1333832407)。与其说我们的模型意识到用户对日期的视图(例如,2012 年 04 月 07 日 @ 下午 5:00),需要将地址转换为显示格式,不如说我们的模型保存数据的原始格式。我们的视图包含格式化后的日期,而我们的 ViewModel 充当了两者之间的中间人。
在这个意义上,ViewModel 可以被看作是一个模型而不是一个视图,但它确实处理了大部分视图的显示逻辑。ViewModel 还可以公开方法来帮助维护视图的状态,根据视图上的操作更新模型,并在视图上触发事件。
总结,ViewModel 位于我们的 UI 层后面。它公开了视图(来自模型)所需的数据,并且可以是视图获取数据和执行操作的源头。
总结:视图和 ViewModel
视图和 ViewModel 使用数据绑定和事件进行通信。正如我们在初始 ViewModel 示例中看到的那样,ViewModel 不仅仅是暴露模型属性,还提供对其他方法和功能(如验证)的访问。
我们的视图处理其自身的 UI 事件,并根据需要将其映射到 ViewModel 上。模型和 ViewModel 上的属性通过双向数据绑定进行同步和更新。
触发器(数据触发器)还允许我们对模型属性状态的更改做出进一步的反应。
ViewModel 与模型比较
虽然在 MVVM 中 ViewModel 可能完全负责模型,但这种关系有一些微妙之处值得注意。ViewModel 可以公开模型或模型属性以进行数据绑定,并包含用于获取和操作视图中公开的属性的接口。
优缺点
现在,我们希望更好地理解 MVVM 是什么以及它如何工作。让我们回顾一下采用这种模式的优点和缺点。
优点
-
MVVM 促进了 UI 及其支持其的构建块的更容易并行开发。
-
MVVM 抽象了视图,因此减少了代码背后所需的业务逻辑(或粘合剂)的数量。
-
与事件驱动代码相比,ViewModel 可能更容易进行单元测试。
-
ViewModel(更像是模型而不是视图)可以在没有 UI 自动化和交互问题的情况下进行测试。
缺点
-
对于较简单的 UI,MVVM 可能过于复杂。
-
数据绑定可以是声明性的,易于使用,但在调试时可能比命令式代码更难,因为我们只是设置断点。
-
在非平凡应用程序中,数据绑定可能会产生大量的簿记工作。我们也不希望陷入绑定比绑定的对象更重的情况。
-
在更大的应用程序中,预先设计 ViewModel 以获取必要的泛化可能更具挑战性。
MVC 与 MVP 与 MVVM 的比较
MVP 和 MVVM 都是 MVC 的衍生物。MVC 及其衍生物之间的关键区别在于每个层次对其他层次的依赖以及它们彼此之间的紧密绑定程度。
在 MVC 中,视图位于架构的顶部,控制器在其旁边。模型位于控制器下方,因此我们的视图知道我们的控制器,控制器知道模型。在这里,我们的视图直接访问模型。但是,向视图公开完整的模型可能会根据应用程序的复杂性而带来安全性和性能成本。MVVM 试图避免这些问题。
在 MVP 中,控制器的角色被 Presenter 取代。Presenter 与视图处于同一级别,监听来自视图和模型的事件,并在它们之间进行中介操作。与 MVVM 不同,没有将视图绑定到视图模型的机制,因此我们依赖于每个视图实现一个接口,允许 Presenter 与视图交互。
因此,MVVM 允许我们创建视图特定的模型子集,其中可以包含状态和逻辑信息,避免将整个模型暴露给视图。与 MVP 的 Presenter 不同,ViewModel 不需要引用视图。视图可以绑定到 ViewModel 中的属性,从而将模型中包含的数据暴露给视图。正如我们所提到的,视图的抽象意味着在其背后所需的逻辑更少。
然而,这种方法的一个缺点是在 ViewModel 和 View 之间需要进行解释,这可能会带来性能成本。这种解释的复杂性也有所不同:它可以简单到复制数据,也可以复杂到将数据转换为视图所需的形式。MVC 没有这个问题,因为整个模型都是可用的,可以避免这种操作。
现代 MV* 模式
初期用于实现 MVC 和 MVVM 的 Backbone 和 KnockoutJS 等框架不再流行或更新。它们已为 React、Vue.js、Angular、Solid 等其他库和框架让路。从 Backbone 或 KnockoutJS 的角度理解架构仍然可能是相关的,因为它让我们了解我们来自何处,以及现代框架带来了哪些变化。
MV* 模式始终可以使用最新的原生 JavaScript 实现,正如这个例子中展示的列表:ToDo list MVC application。然而,开发者通常更倾向于使用库和框架来构建更大型、可扩展的应用程序。
技术上讲,现代库和框架如 React 或 Vue.js 构成了应用程序的视图或表示层。在大多数情况下,这些框架对于如何实现模型和管理应用程序状态非常灵活。Vue 正式宣称自己是 MVVM 模式中的 ViewModel 层。以下是关于 React 中 MV* 的一些额外思考。
MV* 和 React.js
很明显,React 不是一个 MVC 框架。它是一个用于构建 UI 的 JavaScript 库,并且通常用于创建单页面应用程序(SPA)。
React 之所以不被认为是 MVC,是因为它与在后端的概念和使用方式不太匹配。React 是一个渲染库,理想情况下负责视图层。它没有像 MVC 那样的中心控制器作为指挥官/路由器。
React 遵循声明式编程方法——你描述应用程序的期望状态,React 根据该状态渲染适当的视图。React 不使用 MVC 设计模式,因为在 React 中,服务器不会向浏览器提供“视图”,而是“数据”。React 在浏览器上解析数据以生成实际的视图。从这个意义上说,你可以说 React 是 MVC 模式中的“V”(视图),但它并不是传统意义上的 MVC 框架。
另一种看待它的方式是,React 在垂直(按关注点)而不是水平(按技术)方向上切分了 MVC。你可以说 React 中的组件最初是小型垂直切片的封装 MVC:包含状态(模型)、渲染(视图)和控制流逻辑(局部化的迷你控制器)。
这些天,随着许多组件逻辑被提取到 Hooks 中,你可以将组件视为视图,将 Hooks 视为控制器。如果有帮助的话,你也可以考虑“模型 ⇒ 悬挂资源,视图 ⇒ 组件,控制器 ⇒ Hook”,但不要太认真对待。
Next.js 是建立在 React 之上的一个框架,使得构建服务器渲染的 React 应用程序变得容易。它包括诸如自动代码拆分、优化性能和便捷部署到生产环境等功能。与 React 一样,Next.js 并不是一个 MVC 框架,但当你使用服务器端渲染(SSR)或静态站点生成器(SSG)时,它可以像 MVC 一样工作。当 Next.js 作为后端运行,与数据库交互并提供视图进行预渲染时,它确实像 MVC 模式,之后再通过响应式功能进行激活。
总结
我们已经分析了模型(Model)、视图(View)、控制器(Controller)、展示者(Presenter)和视图模型(ViewModel)的概念及其在不同架构模式中的适用性。今天,在 JavaScript 最相关的前端上,我们可能看不到这些模式直接应用。然而,它们可能帮助我们理清网页应用程序的整体架构。它们也可以应用于垂直切片的个别前端组件,每个组件都有一个视图模型或模型来支持视图。
到目前为止,我们已经涵盖了微观(类)和宏观(架构)级别的一系列模式。下一章将帮助我们为现代 JavaScript 应用程序设计应用程序流程。我们将探讨可以帮助我们更好地管理浏览器上长时间运行任务的异步编程模式。
第九章:异步编程模式
异步 JavaScript 编程允许您在后台执行长时间运行的任务,同时允许浏览器响应事件并运行其他代码来处理这些事件。在 JavaScript 中,异步编程相对较新,当本书第一版发布时,并没有支持它的语法。
JavaScript 的概念,如promise、async和await,使您的代码更整洁,易于阅读,而不会阻塞主线程。async函数作为 ES7 的一部分在 2016 年被引入,并且现在所有浏览器都支持。让我们看一些使用这些特性来结构应用流程的模式。
异步编程
在 JavaScript 中,同步代码以阻塞方式执行,这意味着代码按顺序一个语句一个语句地执行。当前语句的执行完成后,才能运行下面的代码。调用同步函数时,该函数内部的代码将从头到尾执行,然后控制权返回给调用者。
另一方面,异步代码以非阻塞方式执行,这意味着 JavaScript 引擎可以在当前运行的代码等待时切换到后台执行此代码。调用异步函数时,函数内部的代码将在后台执行,控制立即返回给调用者。
下面是 JavaScript 中同步代码的一个示例:
function synchronousFunction() {
// do something
}
synchronousFunction();
// the code inside the function is executed before this line
下面是 JavaScript 中异步代码的一个示例:
function asynchronousFunction() {
// do something
}
asynchronousFunction();
// the code inside the function is executed in the background
// while control returns to this line
您通常可以使用异步代码来执行长时间运行的操作,而不会阻塞您的其他代码。当进行网络请求、读写数据库或执行任何其他类型的输入/输出(I/O)操作时,异步代码非常合适。
使用async、await和promise等语言特性可以更轻松地在 JavaScript 中编写异步代码。它们允许您以看起来和行为类似于同步代码的方式编写异步代码,使其更易于阅读和理解。
让我们简要地看一下回调、promise 和async/await之间的区别,然后深入探讨每一种:
// using callbacks
function makeRequest(url, callback) {
fetch(url)
.then(response => response.json())
.then(data => callback(null, data))
.catch(error => callback(error));
}
makeRequest('http://example.com/', (error, data) => {
if (error) {
console.error(error);
} else {
console.log(data);
}
});
在第一个示例中,makeRequest函数使用回调来返回网络请求的结果。调用者向makeRequest传递一个callback函数,该函数在结果(data)或错误时回调:
// using promises
function makeRequest(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.json())
.then(data => resolve(data))
.catch(error => reject(error));
});
}
makeRequest('http://example.com/')
.then(data => console.log(data))
.catch(error => console.error(error));
在第二个示例中,makeRequest函数返回一个promise,该promise在网络请求的结果解析后解析,或在出现错误时拒绝。调用者可以使用返回的promise的then和catch方法来处理请求的结果:
// using async/await
async function makeRequest(url) {
try {
const response = await fetch(url);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
makeRequest('http://example.com/');
在第三个示例中,makeRequest函数使用async关键字声明,允许它使用await关键字等待网络请求的结果。调用者可以使用try和catch关键字来处理函数执行过程中可能发生的任何错误。
背景
JavaScript 中的回调函数可以作为参数传递给另一个函数,并在某些异步操作完成后执行。回调函数通常用于处理异步操作的结果,例如网络请求或用户输入。
使用回调函数的一个主要缺点是可能导致所谓的“回调地狱”——嵌套回调变得难以阅读和维护。考虑以下示例:
function makeRequest1(url, callback) {
// make network request
callback(null, response);
}
function makeRequest2(url, callback) {
// make network request
callback(null, response);
}
function makeRequest3(url, callback) {
// make network request
callback(null, response);
}
makeRequest1('http://example.com/1', (error, data1) => {
if (error) {
console.error(error);
return;
}
makeRequest2('http://example.com/2', (error, data2) => {
if (error) {
console.error(error);
return;
}
makeRequest3('http://example.com/3', (error, data3) => {
if (error) {
console.error(error);
return;
}
// do something with data1, data2, data3
});
});
});
在这个例子中,makeRequest1函数发起网络请求,然后调用callback函数处理请求的结果。callback函数然后使用makeRequest2函数进行第二次网络请求,并使用其结果调用另一个callback函数。这种模式继续用于第三个网络请求。
Promise 模式
Promise 是 JavaScript 中处理异步操作的更现代方法。Promise 是表示异步操作结果的对象。它可以处于三种状态之一:pending(进行中)、fulfilled(已成功)或 rejected(已失败)。Promise 就像是一个合同,可以通过成功或失败来解决。
您可以使用Promise构造函数创建一个 promise,该构造函数接受一个函数作为参数。该函数接收两个参数:resolve和reject。当异步操作成功完成时调用resolve函数,如果操作失败则调用reject函数。
下面是一个示例,展示了如何使用 promise 进行网络请求:
function makeRequest(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.json())
.then(data => resolve(data))
.catch(error => reject(error));
});
}
makeRequest('http://example.com/')
.then(data => console.log(data))
.catch(error => console.error(error));
在这个例子中,makeRequest 函数返回一个表示网络请求结果的promise。函数内部使用fetch方法进行 HTTP 请求。如果请求成功,promise 将以响应数据完成。如果失败,则以错误拒绝。调用者可以在返回的 promise 上使用then和catch方法来处理请求的结果。
使用 promise 而不是回调函数的主要优势之一是它们提供了一种更结构化和可读的方法来处理异步操作。这使您可以避免“回调地狱”,编写更易于理解和维护的代码。
下面的章节提供了更多示例,这些示例将有助于您理解在 JavaScript 中可以使用的不同 promise 设计模式。
Promise 链式调用
这种模式允许您链式连接多个 promise 以创建更复杂的async逻辑:
function makeRequest(url) {
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.json())
.then(data => resolve(data))
.catch(error => reject(error));
});
}
function processData(data) {
// process data
return processedData;
}
makeRequest('http://example.com/')
.then(data => processData(data))
.then(processedData => console.log(processedData))
.catch(error => console.error(error));
Promise 错误处理
这种模式使用catch方法来处理 promise 链执行过程中可能出现的错误:
makeRequest('http://example.com/')
.then(data => processData(data))
.then(processedData => console.log(processedData))
.catch(error => console.error(error));
Promise 并行
这种模式允许您使用Promise.all方法并行运行多个 promise:
Promise.all([
makeRequest('http://example.com/1'),
makeRequest('http://example.com/2')
]).then(([data1, data2]) => {
console.log(data1, data2);
});
Promise 顺序执行
这种模式允许您使用Promise.resolve方法按顺序运行 promise:
Promise.resolve()
.then(() => makeRequest1())
.then(() => makeRequest2())
.then(() => makeRequest3())
.then(() => {
// all requests completed
});
Promise 记忆化
这种模式使用缓存存储 promise 函数调用的结果,避免重复请求:
const cache = new Map();
function memoizedMakeRequest(url) {
if (cache.has(url)) {
return cache.get(url);
}
return new Promise((resolve, reject) => {
fetch(url)
.then(response => response.json())
.then(data => {
cache.set(url, data);
resolve(data);
})
.catch(error => reject(error));
});
}
在此示例中,我们将演示如何使用memoizedMakeRequest函数避免重复请求:
const button = document.querySelector('button');
button.addEventListener('click', () => {
memoizedMakeRequest('http://example.com/')
.then(data => console.log(data))
.catch(error => console.error(error));
});
现在,当按钮被点击时,将调用memoizedMakeRequest函数。如果请求的 URL 已经在缓存中,则返回缓存数据。否则,将进行新的请求,并将结果缓存以供将来使用。
Promise 管道
这种模式使用 promise 和函数式编程技术来创建async转换的管道:
function transform1(data) {
// transform data
return transformedData;
}
function transform2(data) {
// transform data
return transformedData;
}
makeRequest('http://example.com/')
.then(data => pipeline(data)
.then(transform1)
.then(transform2))
.then(transformedData => console.log(transformedData))
.catch(error => console.error(error));
Promise 重试
这种模式允许您在 promise 失败时重试:
function makeRequestWithRetry(url) {
let attempts = 0;
const makeRequest = () => new Promise((resolve, reject) => {
fetch(url)
.then(response => response.json())
.then(data => resolve(data))
.catch(error => reject(error));
});
const retry = error => {
attempts++;
if (attempts >= 3) {
throw new Error('Request failed after 3 attempts.');
}
console.log(`Retrying request: attempt ${attempts}`);
return makeRequest();
};
return makeRequest().catch(retry);
}
Promise 装饰器
这种模式使用高阶函数创建一个装饰器,可应用于 promise 以添加额外的行为:
function logger(fn) {
return function (...args) {
console.log('Starting function...');
return fn(...args).then(result => {
console.log('Function completed.');
return result;
});
};
}
const makeRequestWithLogger = logger(makeRequest);
makeRequestWithLogger('http://example.com/')
.then(data => console.log(data))
.catch(error => console.error(error));
Promise 竞速
这种模式允许您并行运行多个 promise,并返回首个解决的结果:
Promise.race([
makeRequest('http://example.com/1'),
makeRequest('http://example.com/2')
]).then(data => {
console.log(data);
});
async/await 模式
async/await是一种语言特性,允许程序员像编写同步代码一样编写异步代码。它建立在 promises 之上,使得处理异步代码更加简单和清晰。
这是一个示例,展示了如何使用async/await进行异步 HTTP 请求:
async function makeRequest() {
try {
const response = await fetch('http://example.com/');
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
在此示例中,makeRequest函数是异步的,因为它使用了async关键字。在函数内部,使用await关键字暂停函数的执行,直到fetch调用解析。如果调用成功,则将数据记录到控制台。如果失败,则捕获错误并记录到控制台。
现在让我们看看其他一些使用async的模式。
async 函数组合
这种模式涉及将多个async函数组合在一起,以创建更复杂的async逻辑:
async function makeRequest(url) {
const response = await fetch(url);
const data = await response.json();
return data;
}
async function processData(data) {
// process data
return processedData;
}
async function main() {
const data = await makeRequest('http://example.com/');
const processedData = await processData(data);
console.log(processedData);
}
async 迭代
这种模式允许您使用for-await-of循环迭代async可迭代对象:
async function* createAsyncIterable() {
yield 1;
yield 2;
yield 3;
}
async function main() {
for await (const value of createAsyncIterable()) {
console.log(value);
}
}
async 错误处理
这种模式使用try-catch块来处理async函数执行过程中可能出现的错误:
async function main() {
try {
const data = await makeRequest('http://example.com/');
console.log(data);
} catch (error) {
console.error(error);
}
}
async 并行处理
这种模式允许您使用Promise.all方法并行运行多个async任务:
async function main() {
const [data1, data2] = await Promise.all([
makeRequest('http://example.com/1'),
makeRequest('http://example.com/2')
]);
console.log(data1, data2);
}
async 顺序执行
这种模式允许您使用Promise.resolve方法按顺序运行async任务:
async function main() {
let result = await Promise.resolve();
result = await makeRequest1(result);
result = await makeRequest2(result);
result = await makeRequest3(result);
console.log(result);
}
async 记忆化
这种模式使用缓存存储async函数调用的结果,避免重复请求:
const cache = new Map();
async function memoizedMakeRequest(url) {
if (cache.has(url)) {
return cache.get(url);
}
const response = await fetch(url);
const data = await response.json();
cache.set(url, data);
return data;
}
async 事件处理
这种模式允许您使用async函数处理事件:
const button = document.querySelector('button');
async function handleClick() {
const response = await makeRequest('http://example.com/');
console.log(response);
}
button.addEventListener('click', handleClick);
async/await 管道
这种模式使用async/await和函数式编程技术创建async转换的管道:
async function transform1(data) {
// transform data
return transformedData;
}
async function transform2(data) {
// transform data
return transformedData;
}
async function main() {
const data = await makeRequest('http://example.com/');
const transformedData = await pipeline(data)
.then(transform1)
.then(transform2);
console.log(transformedData);
}
async 重试
这种模式允许您在async操作失败时重试:
async function makeRequestWithRetry(url) {
let attempts = 0;
while (attempts < 3) {
try {
const response = await fetch(url);
const data = await response.json();
return data;
} catch (error) {
attempts++;
console.log(`Retrying request: attempt ${attempts}`);
}
}
throw new Error('Request failed after 3 attempts.');
}
async/await 装饰器
这种模式使用高阶函数创建一个装饰器,可以应用于async函数以添加额外的行为:
function asyncLogger(fn) {
return async function (...args) {
console.log('Starting async function...');
const result = await fn(...args);
console.log('Async function completed.');
return result;
};
}
@asyncLogger
async function main() {
const data = await makeRequest('http://example.com/');
console.log(data);
}
其他实际示例
除了前几节讨论的模式外,让我们看一些在 JavaScript 中使用async/await的实际示例。
发送 HTTP 请求
async function makeRequest(url) {
try {
const response = await fetch(url);
const data = await response.json();
console.log(data);
} catch (error) {
console.error(error);
}
}
从文件系统中读取文件
async function readFile(filePath) {
try {
const fileData = await fs.promises.readFile(filePath);
console.log(fileData);
} catch (error) {
console.error(error);
}
}
写入文件到文件系统
async function writeFile(filePath, data) {
try {
await fs.promises.writeFile(filePath, data);
console.log('File written successfully.');
} catch (error) {
console.error(error);
}
}
执行多个 async 操作
async function main() {
try {
const [data1, data2] = await Promise.all([
makeRequest1(),
makeRequest2()
]);
console.log(data1, data2);
} catch (error) {
console.error(error);
}
}
顺序执行多个 async 操作
async function main() {
try {
const data1 = await makeRequest1();
const data2 = await makeRequest2();
console.log(data1, data2);
} catch (error) {
console.error(error);
}
}
缓存 async 操作的结果
const cache = new Map();
async function makeRequest(url) {
if (cache.has(url)) {
return cache.get(url);
}
try {
const response = await fetch(url);
const data = await response.json();
cache.set(url, data);
return data;
} catch (error) {
throw error;
}
}
使用 async/await 处理事件
const button = document.querySelector('button');
button.addEventListener('click', async () => {
try {
const data = await makeRequest('http://example.com/');
console.log(data);
} catch (error) {
console.error(error);
}
});
在失败时重试 async 操作
async function makeRequest(url) {
try {
const response = await fetch(url);
const data = await response.json();
return data;
} catch (error) {
throw error;
}
}
async function retry(fn, maxRetries = 3, retryDelay = 1000) {
let retries = 0;
while (retries <= maxRetries) {
try {
return await fn();
} catch (error) {
retries++;
console.error(error);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error(`Failed after ${retries} retries.`);
}
retry(() => makeRequest('http://example.com/')).then(data => {
console.log(data);
});
创建一个 async/await 装饰器
function asyncDecorator(fn) {
return async function(...args) {
try {
return await fn(...args);
} catch (error) {
throw error;
}
};
}
const makeRequest = asyncDecorator(async function(url) {
const response = await fetch(url);
const data = await response.json();
return data;
});
makeRequest('http://example.com/').then(data => {
console.log(data);
});
总结
本章涵盖了一系列广泛的模式和示例,这些模式在编写用于在后台执行长时间运行任务的异步代码时非常有用。我们看到callback函数为 promises 和async/await执行一个或多个async任务铺平了道路。
在下一章中,我们将看一看应用架构模式的另一个角度。我们将看看模块化开发的模式是如何随着时间的推移而演变的。
第十章:模块化 JavaScript 设计模式
在可扩展 JavaScript 的世界中,当我们说一个应用程序是 模块化 的时候,通常意味着它由一组高度解耦且独立的功能模块组成。松散耦合通过尽可能去除 依赖关系,有助于更轻松地维护应用程序。当有效实施时,很容易看出系统的一部分变更如何影响另一部分。
在前几章中,我们讨论了模块化编程的重要性以及实现现代模块化设计模式的方式。虽然 ES2015 向 JavaScript 引入了原生模块,但在 2015 年之前,编写模块化 JavaScript 仍然是可能的。
在本节中,我们将探讨使用经典 JavaScript(ES5)语法的三种模块化 JavaScript 格式:异步模块定义(AMD)、CommonJS 和通用模块定义(UMD)。要了解更多关于 JavaScript 模块的信息,请参阅 第五章,其中涵盖了 ES2015+ 语法用于模块的导入、导出等。
脚本加载器注意事项
谈论 AMD 和 CommonJS 模块时,很难不谈论 脚本加载器。脚本加载是一种达到目标的手段。只有使用兼容的脚本加载器才能实现模块化 JavaScript。
有几个优秀的加载器可用于处理 AMD 和 CommonJS 格式的模块加载,但我个人更喜欢 RequireJS 和 curl.js。
AMD
AMD 格式作为定义模块的提案被引入,其中模块和依赖项可以异步加载。AMD 格式的总体目标是为开发人员提供可以使用的模块化 JavaScript 解决方案。它具有几个明显的优点,包括异步加载和高度灵活,这消除了代码与模块标识之间常见的紧密耦合。许多开发人员喜欢使用 AMD,并且可以认为它是通向当时不可用的 JavaScript 模块的可靠过渡阶段。
AMD 最初作为 CommonJS 列表上的一个模块格式草案,但由于无法达成全面共识,该格式的进一步开发移至 amdjs 组。
它被包括 Dojo、MooTools 甚至 jQuery 在内的项目所采纳。尽管在野外偶尔可以见到 CommonJS AMD 格式 这个术语,但最好将其称为仅仅是 AMD 或异步模块支持,因为并非所有 CommonJS 列表上的参与者都希望追求它。
注意
曾经有一段时间,这个提案被称为“模块传输/C”。然而,由于规范并非面向传输现有的 CommonJS 模块,而是用于定义模块,因此选择 AMD 命名约定更为合理。
模块入门
关于 AMD 值得注意的前两个概念是 define 方法用于简化模块定义和 require 方法用于处理依赖项加载。define 用于定义具名或匿名模块,使用以下签名:
define(
module_id /*optional*/,
[dependencies] /*optional*/,
definition function {} /*function for instantiating the module or object*/
);
如内联注释所示,module_id 是一个可选参数,通常仅在使用非 AMD 连接工具时才需要(可能还有一些其他情况下它也很有用)。当省略此参数时,我们将模块称为 匿名。
在处理匿名模块时,模块身份的概念是 DRY(不要重复自己),这使得避免文件名和代码重复变得轻而易举。由于代码更具可移植性,可以轻松地将其移动到其他位置(或者文件系统中的其他位置)而无需修改代码本身或更改其模块 ID。将 module_id 视为类似于文件夹路径的概念。
注意
开发人员可以在多个环境中使用 AMD 优化器运行相同的代码,该优化器适用于 CommonJS 环境,如 r.js。
回到 define 的签名,dependencies 参数表示我们正在定义的模块所需的一组依赖项数组,第三个参数(definition function 或 factory function)是一个执行以实例化我们的模块的函数。一个最简单的模块可以像 示例 10-1 中定义的那样。
示例 10-1. 理解 AMD:define()
// A module_id (myModule) is used here for demonstration purposes only
define( "myModule",
["foo", "bar"],
// module definition function
// dependencies (foo and bar) are mapped to function parameters
function ( foo, bar ) {
// return a value that defines the module export
// (i.e., the functionality we want to expose for consumption)
// create your module here
var myModule = {
doStuff:function () {
console.log( "Yay! Stuff" );
}
};
return myModule;
});
// An alternative version could be...
define( "myModule",
["math", "graph"],
function ( math, graph ) {
// Note that this is a slightly different pattern
// With AMD, it's possible to define modules in a few
// different ways due to its flexibility with
// certain aspects of the syntax
return {
plot: function( x, y ){
return graph.drawPie( math.randomGrid( x, y ) );
}
};
});
另一方面,require 通常用于在顶层 JavaScript 文件中加载代码或者在模块内部加载依赖项。其使用示例在 示例 10-2 中。
示例 10-2. 理解 AMD:require()
// Consider "foo" and "bar" are two external modules
// In this example, the "exports" from the two modules
// loaded are passed as function arguments to the
// callback (foo and bar) so that they can similarly be accessed
require(["foo", "bar"], function ( foo, bar ) {
// rest of your code here
foo.doSomething();
});
示例 10-3 展示了动态加载的依赖:
示例 10-3. 动态加载的依赖
define(function ( require ) {
var isReady = false, foobar;
// note the inline require within our module definition
require(["foo", "bar"], function ( foo, bar ) {
isReady = true;
foobar = foo() + bar();
});
// we can still return a module
return {
isReady: isReady,
foobar: foobar
};
});
示例 10-4 展示了如何定义一个兼容 AMD 的插件。
示例 10-4. 理解 AMD:插件
// With AMD, it's possible to load in assets of almost any kind
// including text-files and HTML. This enables us to have template
// dependencies which can be used to skin components either on
// page-load or dynamically.
define( ["./templates", "text!./template.md","css!./template.css" ],
function( templates, template ){
console.log( templates );
// do something with our templates here
}
});
注意
尽管在前面的示例中包含 css! 用于加载层叠样式表(CSS)依赖,但重要的是要记住,这种方法有一些注意事项,例如无法确定 CSS 是否完全加载。根据我们的构建过程的不同方法,这可能导致 CSS 作为优化文件的依赖项被包含在内,因此在这种情况下谨慎使用 CSS 作为加载的依赖项。如果您有兴趣尝试这样做,我们可以探索 @VIISON’s RequireJS CSS plug-in。
这个例子可以简单地被看作是requirejs(["app/myModule"], function(){}),这表明顶级加载器正在被使用。这是如何启动具有不同 AMD 加载器的顶级模块加载的方式。然而,如果define()函数作为本地 require 传递,所有require([])示例都适用于 curl.js 和 RequireJS(示例 10-5 和 10-6)。
示例 10-5. 使用 RequireJS 加载 AMD 模块
require(["app/myModule"],
function( myModule ){
// start the main module which in turn
// loads other modules
var module = new myModule();
module.doStuff();
});
示例 10-6. 使用 curl.js 加载 AMD 模块
curl(["app/myModule.js"],
function( myModule ){
// start the main module which in turn
// loads other modules
var module = new myModule();
module.doStuff();
});
接下来是具有延迟依赖关系的模块代码:
<pre xmlns="http://www.w3.org/1999/xhtml" id="I_programlisting11_id234274"
data-type="programlisting" data-code-language="javascript">
// This could be compatible with jQuery's Deferred implementation,
// futures.js (slightly different syntax) or any one of a number
// of other implementations
define(["lib/Deferred"], function( Deferred ){
var defer = new Deferred();
require(["lib/templates/?index.html","lib/data/?stats"],
function( template, data ){
defer.resolve( { template: template, data:data } );
}
);
return defer.promise();
});
</pre>
如前所述,在以往的章节中,设计模式在改善我们处理常见开发问题的解决方案结构化方法上可以发挥极大的效果。约翰·汉关于 AMD 模块设计模式的出色演讲,涵盖了单例模式、装饰者模式、中介者模式等等。我强烈推荐查看他的幻灯片。
使用 jQuery 的 AMD 模块
jQuery 只有一个文件。然而,考虑到库的插件化特性,我们可以演示如何定义一个使用它的 AMD 模块:
// Code in app.js. baseURl set to the lib folder
// containing jquery, jquery.color, and lodash files.
define(["jquery","jquery.color","lodash"], function( $, colorPlugin, _ ){
// Here we've passed in jQuery, the color plugin, and Lodash
// None of these will be accessible in the global scope, but we
// can easily reference them below.
// Pseudorandomize an array of colors, selecting the first
// item in the shuffled array
var shuffleColor = _.first( _.shuffle(["#AAA","#FFF","#111","#F16"]));
console.log(shuffleColor);
// Animate the background color of any elements with the class
// "item" on the page using the shuffled color
$( ".item" ).animate( {"backgroundColor": shuffleColor } );
// What we return can be used by other modules
return function () {};
});
但是,这个例子缺少了一些东西,那就是注册概念。
将 jQuery 注册为异步兼容模块
jQuery 1.7 中引入的一个关键特性是支持将 jQuery 注册为异步模块。多个兼容的脚本加载器(包括 RequireJS 和 curl)能够使用异步模块格式加载模块,这意味着在使事情运行起来时需要的 hack 更少。
如果开发者希望使用 AMD,并且不希望她的 jQuery 版本泄漏到全局空间,她应该在使用 jQuery 的顶级模块中调用noConflict。此外,由于页面上可能存在多个版本的 jQuery,AMD 加载器必须考虑特殊情况,因此 jQuery 只在 AMD 加载器中注册为已识别这些问题的加载器所支持的模块,这些问题由加载器指定的define.amd.jQuery表示。RequireJS 和 curl 是两个这样做的加载器。
命名 AMD 为大多数用例提供了一个强大而安全的安全保护:
// Account for the existence of more than one global
// instance of jQuery in the document, cater for testing
// .noConflict()
var jQuery = this.jQuery || "jQuery",
$ = this.$ || "$",
originaljQuery = jQuery,
original$ = $;
define(["jquery"] , function ( $ ) {
$( ".items" ).css( "background","green" );
return function () {};
});
为什么 AMD 是编写模块化 JavaScript 的更好选择?
我们现在已经回顾了几个代码示例,展示了 AMD 的功能。它似乎不仅仅是一个典型的模块模式,那么为什么它对于模块化应用开发是更好的选择呢?
-
提供了一个明确的建议,来定义灵活的模块。
-
比目前的全局命名空间和
<script>标签解决方案干净得多。有一种清晰的方法来声明独立的模块及其可能的依赖关系。 -
模块定义是封装的,帮助我们避免全局命名空间的污染。
-
可以说比某些替代方案(例如,CommonJS,我们很快将会讨论)更有效。它没有跨域、本地或调试问题,并且不依赖于服务器端工具来使用。大多数 AMD 加载器支持在浏览器中加载模块,无需构建过程。
-
提供了一种“传输”方法,可以在单个文件中包含多个模块。其他如 CommonJS 的方法尚未就传输格式达成一致。
-
如果需要的话,可以延迟加载脚本。
注意
大多数提到的观点对于 YUI 的模块加载策略也是有效的。
与 AMD 相关的阅读
支持 AMD 的脚本加载器和框架
浏览器内:
服务器端:
AMD 结论
在几个项目中使用 AMD 后,我得出结论,它符合开发人员从更好的模块格式中可能希望得到的许多需求。它避免了担心全局变量,支持命名模块,不需要服务器转换即可运行,并且在依赖管理方面使用起来非常愉快。
对于使用 Backbone.js、ember.js 或其他结构化框架来保持应用程序组织化的模块化开发,这也是一个很好的补充。
由于 AMD 在 Dojo 和 CommonJS 世界中得到了广泛讨论,我们知道它已经有了时间成熟和演变。我们也知道它已经在实际项目中经过大公司的考验来构建非常规模的应用程序(IBM,BBC iPlayer),所以如果它不起作用,他们很有可能会放弃它,但他们没有。
也就是说,仍然有一些地方可以改进 AMD。使用该格式一段时间的开发人员可能会觉得 AMD 的包装代码是一种烦人的开销。虽然我也有这个担忧,但有一些工具,如Volo,帮助解决了这些问题,我认为总体来说,使用 AMD 的利弊远远超过了不利因素。
CommonJS
CommonJS 模块提案为声明服务器端模块提供了一个简单的 API。与 AMD 不同,它试图涵盖更广泛的问题,如 I/O、文件系统、promises 等。
最初由凯文·丹古尔在 2009 年启动的项目中称为 ServerJS,后来该格式由CommonJS,一个志愿工作组正式规范化,旨在设计、原型化和标准化 JavaScript API。他们试图为模块和包制定标准。
入门指南
从结构上看,CommonJS 模块是可重用的 JavaScript 片段,它导出特定对象,供任何依赖代码使用。与 AMD 不同,这些模块通常没有函数包装器(例如,在这里我们不会看到define)。
CommonJS 模块包含两个主要部分:一个名为exports的自由变量,其中包含模块希望向其他模块提供的对象,以及模块可以使用的require函数来导入其他模块的 exports(示例 10-7、10-8 和 10-9)。
示例 10-7. 理解 CommonJS:require() 和 exports
// package/lib is a dependency we require
var lib = require("package/lib");
// behavior for our module
function foo() {
lib.log("hello world!");
}
// export (expose) foo to other modules
exports.foo = foo;
示例 10-8. exports 的基本使用
// Import the module containing the foo function
var exampleModule = require("./example-10-9");
// Consume the 'foo' function from the imported module
exampleModule.foo();
在示例 10-8 中,我们首先使用require()函数从示例 10-7 导入包含foo函数的模块。然后,我们通过从导入的模块调用它来消费foo函数,使用exampleModule.foo()。
示例 10-9. 第一个 CommonJS 示例的 AMD 等效
// CommonJS module getting started
// AMD-equivalent of CommonJS example
// AMD module format
define(function(require){
var lib = require( "package/lib" );
// some behavior for our module
function foo(){
lib.log( "hello world!" );
}
// export (expose) foo for other modules
return {
foobar: foo
};
});
这可以通过 AMD 支持的简化的 CommonJS 包装功能来完成。
使用多个依赖项
app.js:
var modA = require( "./foo" );
var modB = require( "./bar" );
exports.app = function(){
console.log( "Im an application!" );
}
exports.foo = function(){
return modA.helloWorld();
}
bar.js:
exports.name = "bar";
foo.js:
require( "./bar" );
exports.helloWorld = function(){
return "Hello World!!"
}
Node.js 中的 CommonJS
ES 模块格式已成为封装 JavaScript 代码以便复用的标准格式,但在 Node.js 中默认使用 CommonJS。CommonJS 模块是为Node.js打包 JavaScript 代码的原始方式,尽管从版本 13.2.0 开始,Node.js 稳定支持 ES 模块。
默认情况下,Node.js 将以下内容视为 CommonJS 模块:
-
扩展名为 .cjs 的文件
-
当最近的父级package.json文件包含值为commonjs的顶级字段type时,具有扩展名为.js的文件
-
当最近的父级package.json文件不包含顶级字段type时,具有.js扩展名的文件
-
具有不是.mjs、.cjs、.json、.node或.js扩展名的文件
调用require()始终使用 CommonJS 模块加载器,而调用import()则始终使用 ECMAScript 模块加载器,不考虑在最近的父级package.json中配置的 type 值。
许多 Node.js 库和模块使用 CommonJS 编写。为了浏览器支持,所有主流浏览器支持 ES 模块语法,并且你可以在像 React 和 Vue.js 这样的框架中使用 import/export。这些框架使用像 Babel 这样的转译器将 import/export 语法编译成require(),而旧版本的 Node.js 本身支持这种方式。如果在 Node 中运行代码,使用 ES6 模块语法编写的库将在底层转译为 CommonJS。
CommonJS 适合浏览器吗?
有些开发人员认为 CommonJS 更适合服务器端开发,这也是在 ES2015 成为事实标准之前,是否应该使用 AMD 或 CommonJS 存在争议的原因之一。一些反对 CommonJS 的观点是,许多 CommonJS API 涉及服务器导向特性,这些特性在 JavaScript 的浏览器级别可能无法实现,例如io、system和js可以考虑由于其功能的性质而不可实现。
无论如何,了解如何构造 CommonJS 模块非常有用,这样我们就能更好地理解它们在定义可以在各处使用的模块时的适用性。在客户端和服务器都有应用的模块包括验证、转换和模板引擎。一些开发人员在选择使用哪种格式时,选择在可以在服务器端使用模块时使用 CommonJS,而在不是这种情况下则使用 AMD 或 ES2015。
ES2015 和 AMD 模块可以定义更细粒度的东西,如构造函数和函数。CommonJS 模块只能定义对象,如果我们试图从中获取构造函数,则可能会变得繁琐。对于 Node.js 中的新项目,ES2015 模块为服务器端提供了与客户端代码相同的语法,同时也确保了更容易的同构 JavaScript 路径,可以在浏览器或服务器上运行。
尽管这超出了本节的范围,但你可能注意到在讨论 AMD 和 CommonJS 时提到了不同类型的require方法。对于类似命名约定的担忧是混淆,社区对于全局require函数的优点存在分歧。John Hann 在这里建议,与其称其为require,这可能不能实现告知用户全局和内部require之间差异的目标,更好地将全局加载器方法命名为其他名称可能更有意义(例如,库的名称)。因此,像 curl.js 这样的加载器使用curl()而不是require。
CommonJS 相关阅读
AMD 和 CommonJS:竞争,但同样有效的标准
AMD 和 CommonJS 都是具有不同终极目标的有效模块格式。
AMD 采用了面向浏览器的开发方法,选择了异步行为和简化的向后兼容性,但它没有任何文件 I/O 的概念。它原生支持在浏览器中运行的对象、函数、构造函数、字符串、JSON 和许多其他类型的模块。它非常灵活。
另一方面,CommonJS 采用了服务器优先的方法,假设同步行为,没有全局包袱,并试图满足未来(在服务器上)。我的意思是,因为 CommonJS 支持未包装的模块,它可能感觉更接近 ES2015+ 规范,从而免除了 AMD 强制的 define() 包装器。然而,CommonJS 模块仅支持对象作为模块。
UMD:AMD 和 CommonJS 兼容的插件模块
这些解决方案对希望创建能够在浏览器和服务器端环境中工作的模块的开发人员可能有些欠缺。为了帮助缓解这一问题,James Burke、我和其他几位开发人员创建了通用模块定义(UMD)。
UMD 是一种实验性模块格式,允许定义在客户端和服务器环境中均可运行的模块,并支持写作时的大多数流行脚本加载技术。尽管再次引入(另一种)模块格式的想法可能令人生畏,我们将为了全面性而简要介绍 UMD。
我们开始定义 UMD,看看在 AMD 规范中支持的简化的 CommonJS 包装器。希望以 CommonJS 模块的方式编写模块的开发人员可以使用以下兼容 CommonJS 的格式:
基本的 AMD 混合格式
define( function ( require, exports, module ){
var shuffler = require( "lib/shuffle" );
exports.randomize = function( input ){
return shuffler.shuffle( input );
}
});
然而,重要的是要注意,如果模块不包含依赖数组并且定义函数包含至少一个参数,那么模块才真正被视为 CommonJS 模块。这在某些设备上(例如 PS3)可能无法正常工作。有关包装器的更多信息,请参阅RequireJS 文档。
进一步说,我们希望提供几种不同的模式,这些模式与 AMD 和 CommonJS 兼容,并解决了开发人员在其他环境中希望开发这些模块时遇到的典型兼容性问题。
下面我们可以看到一种变体,允许我们使用 CommonJS、AMD 或浏览器全局来创建模块。
使用 CommonJS、AMD 或浏览器全局来创建模块
定义一个名为 commonJsStrict 的模块,它依赖于另一个名为 b 的模块。文件名暗示了模块的名称,最佳实践是文件名和导出的全局变量具有相同的名称。
如果模块 b 在浏览器中也使用相同的样板类型,它将创建一个名为 .b 的全局变量供使用。如果我们不希望支持浏览器全局修补程序,我们可以删除 root 并将 this 作为顶部函数的第一个参数传递:
(function ( root, factory ) {
if ( typeof exports === 'object' ) {
// CommonJS
factory( exports, require('b') );
} else if ( typeof define === 'function' && define.amd ) {
// AMD. Register as an anonymous module.
define( ['exports', 'b'], factory);
} else {
// Browser globals
factory( (root.commonJsStrict = {}), root.b );
}
}(this, function ( exports, b ) {
//use b in some fashion.
// attach properties to the exports object to define
// the exported module properties.
exports.action = function () {};
}));
UMD 仓库包含了各种变体,涵盖了在浏览器中最佳运行的模块、最适合提供导出的模块、在 CommonJS 运行时最佳运行的模块,甚至是最适合定义 jQuery 插件的模块,我们将在下面讨论这些内容。
在所有环境中都有效的 jQuery 插件
UMD 提供了两种适用于 jQuery 插件的模式:一种定义适用于 AMD 和浏览器全局的插件,另一种也可以在 CommonJS 环境中使用。jQuery 不太可能在大多数 CommonJS 环境中使用,请记住这一点,除非我们正在处理与之兼容良好的环境。
现在我们将定义一个由核心和该核心的扩展组成的插件。核心插件加载到 $.core 命名空间中,可以通过命名空间模式轻松地使用插件扩展(即通过 script 标签加载的插件自动填充到 core 下的 plugin 命名空间中,即 $.core.plugin.methodName())。
这种模式非常好用,因为插件扩展可以访问基础中定义的属性和方法,或者通过一些调整覆盖默认行为,以便进行扩展以实现更多功能。加载器也不需要使这些功能完全可用。
有关正在进行的详细信息,请参阅这些代码示例中的内联注释。
usage.html:
<script type="text/javascript" src="jquery.min.js"></script>
<script type="text/javascript" src="pluginCore.js"></script>
<script type="text/javascript" src="pluginExtension.js"></script>
<script type="text/javascript">
$(function(){
// Our plug-in "core" is exposed under a core namespace in
// this example, which we first cache
var core = $.core;
// Then use some of the built-in core functionality to
// highlight all divs in the page yellow
core.highlightAll();
// Access the plug-ins (extensions) loaded into the "plugin"
// namespace of our core module:
// Set the first div in the page to have a green background.
core.plugin.setGreen( "div:first");
// Here we're making use of the core's "highlight" method
// under the hood from a plug-in loaded in after it
// Set the last div to the "errorColor" property defined in
// our core module/plug-in. If we review the code further down,
// we can see how easy it is to consume properties and methods
// between the core and other plug-ins
core.plugin.setRed("div:last");
});
</script>
pluginCore.js:
// Module/plug-in core
// Note: the wrapper code we see around the module is what enables
// us to support multiple module formats and specifications by
// mapping the arguments defined to what a specific format expects
// to be present. Our actual module functionality is defined lower
// down, where a named module and exports are demonstrated.
//
// Note that dependencies can just as easily be declared if required
// and should work as demonstrated earlier with the AMD module examples.
(function ( name, definition ){
var theModule = definition(),
// this is considered "safe":
hasDefine = typeof define === "function" && define.amd,
hasExports = typeof module !== "undefined" && module.exports;
if ( hasDefine ){ // AMD Module
define(theModule);
} else if ( hasExports ) { // Node.js Module
module.exports = theModule;
} else { // Assign to common namespaces or simply the global object (window)
( this.jQuery || this.ender || this.$ || this)[name] = theModule;
}
})( "core", function () {
var module = this;
module.plugins = [];
module.highlightColor = "yellow";
module.errorColor = "red";
// define the core module here and return the public API
// This is the highlight method used by the core highlightAll()
// method and all of the plug-ins highlighting elements different
// colors
module.highlight = function( el,strColor ){
if( this.jQuery ){
jQuery(el).css( "background", strColor );
}
}
return {
highlightAll:function(){
module.highlight("div", module.highlightColor);
}
};
});
pluginExtension.js:
// Extension to module core
(function ( name, definition ) {
var theModule = definition(),
hasDefine = typeof define === "function",
hasExports = typeof module !== "undefined" && module.exports;
if ( hasDefine ) { // AMD Module
define(theModule);
} else if ( hasExports ) { // Node.js Module
module.exports = theModule;
} else {
// Assign to common namespaces or simply the global object (window)
// account for flat-file/global module extensions
var obj = null,
namespaces,
scope;
obj = null;
namespaces = name.split(".");
scope = ( this.jQuery || this.ender || this.$ || this );
for ( var i = 0; i < namespaces.length; i++ ) {
var packageName = namespaces[i];
if ( obj && i == namespaces.length - 1 ) {
obj[packageName] = theModule;
} else if ( typeof scope[packageName] === "undefined" ) {
scope[packageName] = {};
}
obj = scope[packageName];
}
}
})( "core.plugin" , function () {
// Define our module here and return the public API.
// This code could be easily adapted with the core to
// allow for methods that overwrite and extend core functionality
// in order to expand the highlight method to do more if we wish.
return {
setGreen: function ( el ) {
highlight(el, "green");
},
setRed: function ( el ) {
highlight(el, errorColor);
}
};
});
UMD 并不旨在取代 AMD 或 CommonJS,而只是为希望在更多环境中使其代码正常工作的开发人员提供一些补充帮助。有关更多信息或对这种实验性格式提出建议,请参阅此 GitHub 页面。
有关 UMD 和 AMD 的相关阅读
总结
本节回顾了在 ES2015+ 之前使用不同模块格式编写模块化 JavaScript 的几个选项。
这些格式相对于单独使用模块模式具有几个优点,包括避免管理全局变量的需要,更好地支持静态和动态依赖管理,改进脚本加载器的兼容性,增强服务器上模块的兼容性等。
在结束我们对经典设计和架构模式的讨论之前,我想触及一个领域,即我们可以在下一章关于命名空间模式中应用模式来结构化和组织我们的 JavaScript 代码。
第十一章:命名空间模式
在本章中,我们将探讨 JavaScript 中的命名空间模式。命名空间可以被视为代码单元在唯一标识符下的逻辑分组。您可以在多个命名空间中引用该标识符,并且每个标识符可以包含一组嵌套(或子)命名空间的层次结构。
在应用程序开发中,我们使用命名空间有许多重要原因。JavaScript 命名空间帮助我们避免与全局命名空间中的其他对象或变量发生冲突。它们还有助于帮助我们组织代码库中的功能块,使其更容易被引用和使用。
对任何严肃的脚本或应用程序进行命名空间设置至关重要,因为这对于保护我们的代码免受页面上另一个脚本使用相同变量或方法名称可能造成的破坏至关重要。由于定期注入页面的大量第三方标签,这可能是我们在职业生涯中不可避免要解决的常见问题。作为全局命名空间的良好“公民”,我们也必须尽力不要阻止其他开发人员的脚本执行由于相同的问题。
虽然 JavaScript 不像其他语言那样内置支持命名空间,但它确实有对象和闭包,你可以利用它们来达到类似的效果。
命名空间基础知识
你可以在几乎任何严肃的 JavaScript 应用程序中找到命名空间。除非我们正在处理一个简单的代码片段,否则我们必须尽力确保正确实现命名空间,因为它不仅易于掌握,而且还可以避免第三方代码破坏我们自己的代码。本节中我们将要探讨的模式有:
-
单一全局变量
-
前缀命名空间
-
对象字面量表示法
-
嵌套命名空间
-
立即调用函数
-
表达式
-
命名空间注入
单一全局变量
在 JavaScript 中,一种流行的命名空间模式是选择一个单一的全局变量作为我们的主要参考对象。以下是这种模式的基本实现,我们返回一个具有函数和属性的对象:
const myUniqueApplication = (() => {
function myMethod() {
// code
return;
}
return {
myMethod,
};
})();
// Usage
myUniqueApplication.myMethod();
// In this updated example, we use an immediately invoked function expression
// (IIFE) to create a unique namespace for our application, which is stored in
// the myUniqueApplication variable. The IIFE returns an object with functions
// and properties, and we can access these using dot notation
// (e.g., myUniqueApplication.myMethod()).
虽然这种方法在某些情况下很有效,但单一全局变量模式的最大挑战在于确保没有其他人在页面上使用与我们相同的全局变量名称。
前缀命名空间
根据 Peter Michaux 所述,解决上述问题的一个解决方案是使用前缀命名空间。这本质上是一个简单的概念,但其核心思想是选择一个唯一的前缀命名空间(在本例中是 myApplication_),然后按照以下方式定义任何方法、变量或其他对象:
const myApplication_propertyA = {};
const myApplication_propertyB = {};
function myApplication_myMethod(){ //...
}
这有效地减少了特定变量存在于全局范围内的机会,但请记住,一个具有唯一命名的对象可能会产生相同的效果。
使用这种模式的最大问题是,一旦我们的应用程序扩展,可能会导致许多全局对象。此外,非常依赖于我们的前缀在全局命名空间中不被其他开发人员使用,因此如果选择使用这种方式,一定要小心。
关于单一全局变量模式的更多信息,请阅读他的优秀文章。
对象字面量表示法
对象字面量表示法,我们在模块模式部分也进行了介绍,可以被视为一个包含一系列键值对的对象,其中冒号分隔每对键和值,键还可以表示新的命名空间:
const myApplication = {
// As we've seen, we can easily define functionality for
// this object literal...
getInfo() {
//...
},
// but we can also populate it to support
// further object namespaces containing anything
// anything we wish:
models : {},
views : {
pages : {}
},
collections : {}
};
也可以选择直接向命名空间添加属性:
myApplication.foo = () => "bar"
myApplication.utils = {
toString() {
//...
},
export() {
//...
}
}
对象字面量不会污染全局命名空间,但有助于逻辑上组织代码和参数。如果您希望创建易于阅读的结构,并支持深层嵌套,它们确实非常有益。与简单的全局变量不同,对象字面量通常会考虑通过相同名称变量的存在性测试,因此碰撞发生的几率显著降低。
以下示例演示了几种检查对象命名空间是否已存在并在不存在时定义它的方法:
// This doesn't check for existence of "myApplication" in
// the global namespace. Bad practice as we can easily
// clobber an existing variable/namespace with the same name
const myApplication = {};
// The following options *do* check for variable/namespace existence.
// If already defined, we use that instance, otherwise we assign a new
// object literal to myApplication.
//
// Option 1: var myApplication = myApplication || {};
// Option 2 if( !MyApplication ){ MyApplication = {} };
// Option 3: window.myApplication || ( window.myApplication = {} );
// Option 4: var myApplication = $.fn.myApplication = function() {};
// Option 5: var myApplication = myApplication === undefined ? {} :
// myApplication;
您经常会看到开发人员选择选项 1 或选项 2——它们在结果上都是直接且等效的。
选项 3 假设您正在全局命名空间中工作,但也可以编写为:
myApplication || (myApplication = {});
此变体假设myApplication已经初始化,因此只适用于参数/参数场景,如以下示例:
function foo() {
myApplication || ( myApplication = {} );
}
// myApplication hasn't been initialized,
// so foo() throws a ReferenceError
foo();
// However accepting myApplication as an
// argument
function foo( myApplication ) {
myApplication || ( myApplication = {} );
}
foo();
// Even if myApplication === undefined, there is no error
// and myApplication gets set to {} correctly
选项 4 可以帮助编写 jQuery 插件,其中:
// If we were to define a new plug-in...
var myPlugin = $.fn.myPlugin = function() { ... };
// Then later rather than having to type:
$.fn.myPlugin.defaults = {};
// We can do:
myPlugin.defaults = {};
这样可以获得更好的压缩(缩小)效果,并可以节省作用域查找的开销。
选项 5 与选项 4 有些相似,但它是一个长形式,用于评估myApplication是否内联为undefined,如果不是,则将其定义为对象,并设置为myApplication的当前值。
它仅仅是为了全面起见而显示出来,但在大多数情况下,选项 1 到选项 4 将足够满足大多数需求。
当然,在使用对象字面量组织和结构化代码的方式及其变体中有很多不同之处。对于希望为特定的自封闭模块公开嵌套 API 的较小应用程序,您可能会发现自己使用我们在本书前面讨论过的揭示模块模式:
const namespace = (() => {
// defined within the local scope
const privateMethod1 = () => { /* ... */ };
const privateMethod2 = () => { /* ... */ };
privateProperty1 = "foobar";
return {
// the object literal returned here can have as many
// nested depths as we wish; however, as mentioned,
// this way of doing things works best for smaller,
// limited-scope applications in my personal opinion
publicMethod1: privateMethod1,
// nested namespace with public properties
properties:{
publicProperty1: privateProperty1
},
// another tested namespace
utils:{
publicMethod2: privateMethod2
}
...
}
})();
在这里使用对象字面量的好处是,它们为我们提供了一个非常优雅的键值语法,使我们能够轻松地封装任何独特的逻辑或功能,从而清晰地与其他代码分离开来,并为扩展我们的代码提供坚实的基础:
const myConfig = {
language: "english",
defaults: {
enableGeolocation: true,
enableSharing: false,
maxPhotos: 20
},
theme: {
skin: "a",
toolbars: {
index: "ui-navigation-toolbar",
pages: "ui-custom-toolbar"
}
}
};
请注意,JSON 是对象字面量表示法的子集,它与前面的代码之间只有轻微的语法差异(例如,JSON 键必须是字符串)。如果出于任何原因,希望使用 JSON 来存储配置数据(例如,在发送到后端时进行简单的存储),请随意。
嵌套命名空间
对象字面量模式的扩展是嵌套命名空间。它是另一种常见的模式,提供了更低的碰撞风险,因为即使命名空间已经存在,它的嵌套子级也不太可能相同。
例如,像这样:
YAHOO.util.Dom.getElementsByClassName("test");
旧版本的 Yahoo!YUI 库经常使用嵌套对象命名空间模式。在我在 AOL 担任工程师期间,我们在许多较大的应用程序中也使用了这种模式。嵌套命名空间的示例实现可能如下所示:
const myApp = myApp || {};
// perform a similar existence check when defining nested
// children
myApp.routers = myApp.routers || {};
myApp.model = myApp.model || {};
myApp.model.special = myApp.model.special || {};
// nested namespaces can be as complex as required:
// myApp.utilities.charting.html5.plotGraph(/*..*/);
// myApp.modules.financePlanner.getSummary();
// myApp.services.social.facebook.realtimeStream.getLatest();
注意
这段代码与 YUI3 处理命名空间的方式有所不同。YUI3 模块使用一个具有远低且更浅的命名空间的沙盒 API 宿主对象。
我们也可以选择将新的嵌套命名空间/属性声明为索引属性,如下所示:
myApp["routers"] = myApp["routers"] || {};
myApp["models"] = myApp["models"] || {};
myApp["controllers"] = myApp["controllers"] || {};
这两种选择都具有可读性和组织性,并提供了一种相对安全的应用程序命名空间方式,类似于我们在其他语言中可能已经习惯了的方式。唯一的真正注意事项是,它需要我们的浏览器 JavaScript 引擎首先定位myApp对象,然后深入到实际希望使用的函数。
这可能意味着执行查找的更多工作;然而,像Juriy Zaytsev这样的开发者先前测试并发现单个对象命名空间与“嵌套”方法之间的性能差异相当可忽略。
立即调用函数表达式(Immediately Invoked Function Expressions)
本书前面简要介绍了立即调用函数表达式(IIFE)的概念;一个IIFE,实际上是一个未命名的函数,在定义后立即被调用。如果这听起来很熟悉,那是因为你可能之前已经见过它被称为自执行(或自调用)的anonymous函数。不过,我认为 Ben Alman 对 IIFE 的命名更加准确。在 JavaScript 中,因为在这样的上下文中明确定义的变量和函数只能在其内部访问,函数调用提供了一种实现隐私的简便方法。
IIFE 是一种封装应用逻辑以保护它免受全局命名空间污染的流行方法,但它们在命名空间的世界中也有其用途。
这里是 IIFE 的示例:
// an (anonymous) immediately invoked function expression
(() => { /*...*/})();
// a named immediately invoked function expression
(function foobar () { /*..*/}());
// this is technically a self-executing function which is quite different
function foobar () { foobar(); }
第一个示例的略微扩展版本可能如下所示:
const namespace = namespace || {};
// here a namespace object is passed as a function
// parameter, where we assign public methods and
// properties to it
(o => {
o.foo = "foo";
o.bar = () => "bar";
})(namespace);
console.log( namespace );
虽然可读性强,但这个示例可以显著扩展以解决常见的开发问题,例如定义隐私级别(公共/私有函数和变量)以及便捷的命名空间扩展。让我们再看一些代码:
// namespace (our namespace name) and undefined are passed here
// to ensure: 1\. namespace can be modified locally and isn't
// overwritten outside of our function context;
// 2\. the value of undefined is guaranteed as being truly
// undefined. This is to avoid issues with undefined being
// mutable pre-ES5.
;((namespace, undefined) => {
// private properties
const foo = "foo";
const bar = "bar";
// public methods and properties
namespace.foobar = "foobar";
namespace.sayHello = () => {
speak( "hello world" );
};
// private method
function speak(msg) {
console.log( `You said: ${msg}` );
};
// check to evaluate whether "namespace" exists in the
// global namespace - if not, assign window.namespace an
// object literal
})(window.namespace = window.namespace || {});
// we can then test our properties and methods as follows
// public
// Outputs: foobar
console.log( namespace.foobar );
// Outputs: hello world
namespace.sayHello();
// assigning new properties
namespace.foobar2 = "foobar";
// Outputs: foobar
console.log( namespace.foobar2 );
可扩展性当然是任何可扩展命名空间模式的关键,而立即调用函数表达式(IIFE)可以很容易地实现这一点。在以下示例中,我们的“命名空间”再次作为参数传递给我们的匿名函数,然后通过额外的功能进行扩展(或装饰):
// let's extend the namespace with new functionality
((namespace, undefined) => {
// public method
namespace.sayGoodbye = () => {
console.log( namespace.foo );
console.log( namespace.bar );
speak( "goodbye" );
}
})(window.namespace = window.namespace || {});
// Outputs: goodbye
namespace.sayGoodbye();
如果您想了解更多关于这种模式的信息,我建议阅读本的立即调用函数表达式帖子。
命名空间注入
命名空间注入是 IIFE 的另一种变体,在这种变体中,我们使用this作为命名空间代理从函数包装器内部“注入”特定命名空间的方法和属性。这种模式提供的好处是可以轻松地将功能行为应用于多个对象或命名空间,并且在应用一组基本方法以供稍后构建时(例如,获取器和设置器)时非常有用。
这种模式的缺点是,可能有更简单或更优化的方法来实现这个目标(例如,深度对象扩展或合并),我在本节前面已经介绍过。
接下来我们可以看到这种模式在实际中的应用示例,我们使用它来填充两个命名空间的行为:一个最初定义的(utils),另一个我们动态创建作为utils功能分配的一部分(称为tools的新命名空间):
const myApp = myApp || {};
myApp.utils = {};
(function () {
let val = 5;
this.getValue = () => val;
this.setValue = newVal => {
val = newVal;
}
// also introduce a new subnamespace
this.tools = {};
}).apply( myApp.utils );
// inject new behavior into the tools namespace
// which we defined via the utilities module
(function () {
this.diagnose = () => "diagnosis"
}).apply( myApp.utils.tools );
// note, this same approach to extension could be applied
// to a regular IIFE, by just passing in the context as
// an argument and modifying the context rather than just
// "this"
// Usage:
// Outputs our populated namespace
console.log( myApp );
// Outputs: 5
console.log( myApp.utils.getValue() );
// Sets the value of `val` and returns it
myApp.utils.setValue( 25 );
console.log( myApp.utils.getValue() );
// Testing another level down
console.log( myApp.utils.tools.diagnose() );
之前,Angus Croll 建议使用调用 API 来在上下文和参数之间提供自然分离。这种模式可能更像一个模块创建者,但由于模块仍然提供了封装解决方案,为了全面性,我们将简要介绍它:
// define a namespace we can use later
const ns = ns || {};
const ns2 = ns2 || {};
// the module/namespace creator
const creator = function( val ){
var val = val || 0;
this.next = () => val++;
this.reset = () => {
val = 0;
}
};
creator.call( ns );
// ns.next, ns.reset now exist
creator.call( ns2 , 5000 );
// ns2 contains the same methods
// but has an overridden value for val
// of 5000
如前所述,这种类型的模式有助于将一组相似的基本功能分配给多个模块或命名空间。然而,我建议仅在明确声明对象/闭包内部功能直接访问不合理时使用它。
高级命名空间模式
我们现在将探讨一些高级模式和实用程序,这些在处理更复杂的应用程序时我发现非常宝贵,其中一些需要重新思考应用程序命名空间的传统方法。我要指出的是,我并不主张以下内容作为命名空间的方式,而是作为我实际工作中发现有效的方式。
自动化嵌套命名空间
正如我们所看到的,嵌套命名空间可以为代码单元提供有序的层次结构。这样一个命名空间的示例可能是:application.utilities.drawing.canvas.2d。这也可以通过对象字面量模式扩展为:
const application = {
utilities:{
drawing:{
canvas:{
paint:{
//...
}
}
}
}
};
这种模式的一个明显挑战是,每个我们想要创建的额外层级都需要另一个对象被定义为顶级命名空间中某个父对象的子对象。当我们的应用程序复杂度增加时,这可能特别繁琐。
如何更好地解决这个问题?在《JavaScript 模式》中,Stoyan Stefanov 提出了一个聪明的方法,用于在现有全局变量下自动定义嵌套命名空间。他建议使用一个便利方法,该方法接受一个字符串参数作为一个嵌套,解析它,并自动将所需的对象填充到我们的基本命名空间中。
他建议使用的方法如下,我已经更新为一个通用函数,以便更轻松地在多个命名空间中重用:
// top-level namespace being assigned an object literal
const myApp = {};
// a convenience function for parsing string namespaces and
// automatically generating nested namespaces
function extend( ns, ns_string ) {
const parts = ns_string.split(".");
let parent = ns;
let pl;
pl = parts.length;
for ( let i = 0; i < pl; i++ ) {
// create a property if it doesn't exist
if ( typeof parent[parts[i]] === "undefined" ) {
parent[parts[i]] = {};
}
parent = parent[parts[i]];
}
return parent;
}
// Usage:
// extend myApp with a deeply nested namespace
const mod = extend(myApp, "modules.module2");
// the correct object with nested depths is output
console.log(mod);
// minor test to check the instance of mod can also
// be used outside of the myApp namespace as a clone
// that includes the extensions
// Outputs: true
console.log(mod == myApp.modules.module2);
// further demonstration of easier nested namespace
// assignment using extend
extend(myApp, "moduleA.moduleB.moduleC.moduleD");
extend(myApp, "longer.version.looks.like.this");
console.log(myApp);
图 11-1 显示了 Chrome 开发者工具的输出。在以前,我们可能需要显式声明命名空间的各种嵌套对象,现在可以通过一行更简洁的代码轻松实现。

图 11-1. Chrome 开发者工具输出
依赖声明模式
现在我们将探讨一种对嵌套命名空间模式的轻微增强,我们将其称为依赖声明模式。我们都知道,对对象的局部引用可以减少总体查找时间,但让我们将其应用于命名空间,看看在实践中它可能是什么样子:
// common approach to accessing nested namespaces
myApp.utilities.math.fibonacci( 25 );
myApp.utilities.math.sin( 56 );
myApp.utilities.drawing.plot( 98,50,60 );
// with local/cached references
const utils = myApp.utilities;
const maths = utils.math;
const drawing = utils.drawing;
// easier to access the namespace
maths.fibonacci( 25 );
maths.sin( 56 );
drawing.plot( 98, 50,60 );
// note that this is particularly performant when
// compared to hundreds or thousands of calls to nested
// namespaces vs. a local reference to the namespace
在这里使用本地变量几乎总比在顶级全局变量(例如 myApp)上工作要快。这也比在每个后续行上访问嵌套属性/子命名空间更方便和更高效,并且可以提高在更复杂应用程序中的可读性。
Stoyan 建议在函数或模块的顶部声明所需的本地化命名空间(使用单变量模式),并将其称为依赖声明模式。其中一个好处是减少定位依赖项并解决它们的时间,特别是在我们有一个可扩展的架构,在需要时动态加载模块到我们的命名空间中。
在我看来,这种模式在模块化级别工作时效果最好,将命名空间本地化以供一组方法使用。在每个函数级别上本地化命名空间,特别是在命名空间依赖之间有显著重叠时,我建议尽可能避免。相反,将其定义在更高的位置,并让它们都访问同一个引用。
深度对象扩展
自动命名空间的另一种方法是深度对象扩展。使用对象字面量表示的命名空间可以轻松地与其他对象(或命名空间)扩展(或合并),以便两个命名空间的属性和函数在合并后都可以在同一个命名空间下访问。
这在 JavaScript 框架中实现起来相对容易(例如,参见 jQuery 的 $.extend);然而,如果要使用传统的 JS 扩展对象(命名空间),下面的例程可能会有所帮助:
// Deep object extension using Object.assign and recursion
function extendObjects(destinationObject, sourceObject) {
for (const property in sourceObject) {
if (
sourceObject[property] &&
typeof sourceObject[property] === "object" &&
!Array.isArray(sourceObject[property])
) {
destinationObject[property] = destinationObject[property] || {};
extendObjects(destinationObject[property], sourceObject[property]);
} else {
destinationObject[property] = sourceObject[property];
}
}
return destinationObject;
}
// Example usage
const myNamespace = myNamespace || {};
extendObjects(myNamespace, {
utils: {},
});
console.log("test 1", myNamespace);
extendObjects(myNamespace, {
hello: {
world: {
wave: {
test() {
// ...
},
},
},
},
});
myNamespace.hello.test1 = "this is a test";
myNamespace.hello.world.test2 = "this is another test";
console.log("test 2", myNamespace);
myNamespace.library = {
foo() {},
};
extendObjects(myNamespace, {
library: {
bar() {
// ...
},
},
});
console.log("test 3", myNamespace);
const shorterNamespaceAccess = myNamespace.hello.world;
shorterNamespaceAccess.test3 = "hello again";
console.log("test 4", myNamespace);
注释
这种实现在所有对象上不具备跨浏览器兼容性,并且仅应视为概念验证。可以发现Lodash.js extend() 方法更简单、更跨浏览器友好的实现来开始使用。
对于将在其应用程序中使用 jQuery 的开发人员,可以通过$.extend来实现相同的对象命名空间扩展性,如下所示:
// top-level namespace
const myApplication = myApplication || {};
// directly assign a nested namespace
myApplication.library = {
foo() {
// ...
},
};
// deep extend/merge this namespace with another
// to make things interesting, let's say it's a namespace
// with the same name but with a different function
// signature: $.extend( deep, target, object1, object2 )
$.extend(true, myApplication, {
library: {
bar() {
// ...
},
},
});
console.log("test", myApplication);
为了全面性,请查看此链接,了解 jQuery $.extend 方法在本节其余命名空间实验中的等效项。
建议
回顾我们在本节中探讨的命名空间模式,我个人偏好于对象字面量模式的嵌套对象命名空间。在可能的情况下,我会使用自动化的嵌套命名空间实现这一点。然而,这仅仅是个人偏好。
IIFEs 和单个全局变量对于中小型范围内的应用程序可能效果很好。然而,对于需要命名空间和深层子命名空间的更大代码库,需要一种简洁的解决方案,促进可读性和扩展性。这种模式很好地实现了所有这些目标。
我建议尝试一些推荐的高级实用程序方法来扩展命名空间,因为它们可以从长远来看节省时间。
概要
本章讨论了如何通过命名空间为您的 JavaScript 和 jQuery 应用程序带来结构,并防止变量和函数名称之间的冲突。在大型 JavaScript 应用程序中组织我们的项目文件可以帮助您更好地管理模块和命名空间,增强开发体验。
现在,我们已经涵盖了使用纯 JavaScript 的设计和架构的不同方面。我们提到了 React,但没有详细讨论任何 React 模式。在下一章中,我们的目标是详细讨论这些内容。
第十二章:React.js 设计模式
多年来,使用 JavaScript 组合 UI 的简单方法的需求不断增加。前端开发人员寻找由许多不同的库和框架提供的开箱即用的解决方案。React 在这个领域的流行程度已经持续很长一段时间,自从它在 2013 年首次发布以来。本章将讨论在 React 宇宙中有帮助的设计模式。
React,也称为 React.js,是由 Facebook 设计的开源 JavaScript 库,用于构建 UI 或 UI 组件。当然,并不是唯一的 UI 库。Preact、Vue、Angular、Svelte、Lit 等等也非常适合使用可重用元素来组成界面。然而,鉴于 React 的流行度,我们选择它来讨论当前十年的设计模式。
React 入门
前端开发人员谈论代码时,通常是在设计 Web 界面的上下文中。我们将界面组合看作是按钮、列表、导航等元素。React 提供了一种优化和简化的方式来表达这些元素中的界面。它还通过将界面组织成组件、属性和状态三个关键概念,帮助构建复杂和棘手的界面。
由于 React 专注于组合,它可以完美地映射到设计系统的元素。因此,为 React 设计奖励您以模块化的方式思考。它允许您在将页面或视图组合在一起之前开发单独的组件,以便您充分理解每个组件的范围和目的——这个过程被称为组件化。
使用的术语
在本章中,我们将经常使用以下术语。让我们快速看看它们各自的含义:
React/React.js/ReactJS
由 Facebook 于 2013 年创建的 React 库
ReactDOM
提供面向客户端和服务器渲染的 DOM 特定方法的 react-dom 包
JSX
JavaScript 的语法扩展
Redux
集中式状态容器
Hooks
一种在不编写类的情况下使用状态和其他 React 特性的新方法
ReactNative
用 JavaScript 开发跨平台原生应用程序的库
webpack
JavaScript 模块打包工具,在 React 社区中很受欢迎
单页应用程序(SPA)
在同一页上加载新内容而无需进行完整的页面刷新/重新加载的 Web 应用程序
基本概念
在讨论 React 设计模式之前,了解一些在 React 中使用的基本概念将会很有帮助:
JSX
JSX 是 JavaScript 的扩展,它使用类似 XML 的语法将模板 HTML 嵌入到 JS 中。它意图被转换为有效的 JavaScript,虽然这种转换的语义是特定于实现的。JSX 随着 React 库的流行而兴起,但也看到了其他实现。
组件
组件是任何 React 应用程序的构建块。它们类似于接受任意输入(属性)并返回描述应该显示在屏幕上的 React 元素的 JavaScript 函数。React 应用程序中的所有内容都是组件的一部分。基本上,React 应用程序只是组件中的组件中的组件。因此,开发人员不是在 React 中构建页面;他们构建组件。组件让您将 UI 拆分为独立的、可重用的部分。如果您习惯设计页面,从组件的角度思考可能看起来像是一个重大变化。但如果您使用设计系统或样式指南,这可能比看起来的范式转变要小。
属性
属性是 React 组件的内部数据的简写形式。它们写在组件调用内部,并传递给组件。它们还使用与 HTML 属性相同的语法,例如,prop = value。关于属性值值得记住的两件事是:(1)我们确定属性值并在构建组件之前将其作为蓝图的一部分使用,(2)属性值永远不会改变,即一旦传递给组件,属性就是只读的。您可以通过每个组件都可以访问的 this.props 属性引用来访问属性。
状态
状态是一个保存可能随组件生命周期而变化的信息的对象。它是存储在组件属性中的数据的当前快照。数据随时间变化,因此需要技术来管理数据变化,以确保组件在工程师希望的时间看起来正确——这称为状态管理。
客户端渲染
在客户端渲染(CSR)中,服务器仅为页面渲染基本的 HTML 容器。显示页面内容所需的逻辑、数据获取、模板化和路由由在客户端执行的 JavaScript 代码处理。CSR 作为构建单页面应用程序的一种方法变得流行。它有助于模糊网站和安装应用程序之间的差异,并且最适用于高度交互式应用程序。默认情况下,大部分应用程序逻辑在客户端执行。它通过 API 调用与服务器交互以获取或保存数据。
服务器端渲染
SSR 是最古老的网页内容渲染方法之一。SSR 生成完整的 HTML,用于响应用户请求时渲染页面内容。内容可能包括来自数据存储或外部 API 的数据。React 可以进行同构渲染,这意味着它可以在浏览器和服务器等其他平台上运行。因此,可以使用 React 在服务器上渲染 UI 元素。
水合
在服务器渲染的应用程序中,当前导航的 HTML 是在服务器上生成并发送到客户端的。由于服务器生成了标记,客户端可以快速解析它并在屏幕上显示。UI 变得交互式所需的 JavaScript 在此之后加载。只有在 JavaScript 捆绑包加载和处理后,才会附加使按钮等 UI 元素变得交互式的事件处理程序。这个过程称为水合。React 检查当前的 DOM 节点并用相应的 JavaScript 进行水合处理。
创建一个新的应用程序
较旧的文档建议使用 Create React App(CRA)来构建一个新的仅客户端的单页面应用程序来学习 React。这是一个 CLI 工具,用于为启动项目创建 React 应用程序的脚手架。然而,CRA 提供了一种受限的开发体验,这对许多现代 Web 应用程序来说太过局限。React 建议使用生产级别的 React 驱动框架,如 Next.js 或 Remix 来构建新的 Web 应用程序或网站。这些框架提供了大多数应用程序和网站最终需要的功能,如静态 HTML 生成、基于文件的路由、SPA 导航和真正的客户端代码。
React 在多年来已经发生了变化。引入到库中的不同特性催生了解决常见问题的各种方式。以下是我们将在接下来的章节中详细探讨的 React 的一些流行设计模式:
-
“高阶组件”
-
“渲染属性模式”
-
“Hooks 模式”
-
“静态导入”
-
“动态导入”
-
“代码分割”
-
“PRPL 模式”
-
“加载优先级”
高阶组件
我们经常希望在应用程序中的多个组件中使用相同的逻辑。这种逻辑可以包括向组件应用特定样式、需要授权或添加全局状态。通过使用高阶组件(HOC)模式,我们可以在多个组件中重用相同的逻辑。这种模式允许我们在整个应用程序中重用组件逻辑。
高阶组件(HOC)是一个接收另一个组件的组件。HOC 可以包含一个特定功能,该功能可以应用于我们传递给它的组件。HOC 返回应用了附加功能的组件。
假设我们始终希望向应用程序中的多个组件添加特定样式。我们可以创建一个高阶组件(HOC),将样式对象添加到作为参数传递的组件中,而不是每次在本地创建样式对象:
function withStyles(Component) {
return props => {
const style = { padding: '0.2rem', margin: '1rem' }
return <Component style={style} {...props} />
}
}
const Button = () = <button>Click me!</button>
const Text = () => <p>Hello World!</p>
const StyledButton = withStyles(Button)
const StyledText = withStyles(Text)
我们刚刚创建了StyledButton和StyledText组件,这是Button和Text组件的修改版本。它们现在都包含了在withStyles高阶组件中添加的样式。
进一步来看,让我们看一个从 API 获取的狗图片列表的应用程序。在获取数据时,我们希望向用户显示“Loading…”屏幕。我们可以使用一个高阶组件添加这个逻辑,而不是直接将其添加到DogImages组件中。
让我们创建一个名为withLoader的高阶组件。一个高阶组件应该接收一个组件并返回该组件。在这种情况下,withLoader高阶组件应该接收应该显示Loading…直到数据被获取的元素。为了使withLoader高阶组件非常可重用,我们不会在该组件中硬编码狗 API URL。相反,我们可以将 URL 作为参数传递给withLoader高阶组件,因此在从不同 API 端点获取数据时,可以使用此加载器在任何需要加载指示器的组件上:
function withLoader(Element, url) {
return props => {};
}
高阶组件返回一个元素,一个函数组件props ⇒ {}在这种情况下,我们希望在数据仍在获取时显示一个带有Loading…文本的逻辑。一旦数据被获取,组件应该将获取的数据作为属性传递。完整的withLoader代码如下所示:
import React, { useEffect, useState } from "react";
export default function withLoader(Element, url) {
return (props) => {
const [data, setData] = useState(null);
useEffect(() => {
async function getData() {
const res = await fetch(url);
const data = await res.json();
setData(data);
}
getData();
}, []);
if (!data) {
return <div>Loading...</div>;
}
return <Element {...props} data={data} />;
};
}
我们刚刚创建了一个可以接收任何组件和 URL 的高阶组件:
-
在
useEffect钩子中,withLoader高阶组件从我们传递的 API 端点获取数据。在数据正在获取的时候,我们返回包含Loading…文本的元素。 -
一旦数据被获取,我们将数据设置为已获取的数据。由于数据不再为
null,我们可以显示传递给高阶组件的元素。
现在,为了在DogImages列表上显示Loading…指示器,我们将“包装好的”withLoading高阶组件导出到DogImages组件周围。withLoader高阶组件还期望url,以了解从哪个端点获取数据。在这种情况下,我们想要添加狗 API 端点。由于withLoader高阶组件返回了带有额外数据属性的元素,因此在DogImages组件中,我们可以访问数据属性:
import React from "react";
import withLoader from "./withLoader";
function DogImages(props) {
return props.data.message.map((dog, index) => (
<img src={dog} alt="Dog" key={index} />
));
}
export default withLoader(
DogImages,
"https://dog.ceo/api/breed/labrador/images/random/6"
);
高阶组件模式允许我们为多个组件提供相同的逻辑,同时将所有逻辑保存在一个地方。withLoader高阶组件不关心接收的组件或 URL;只要是有效的组件和有效的 API 端点,它将简单地将来自该 API 端点的数据传递给我们传递的组件。
组合
我们还可以组合多个高阶组件。比如说,我们还想添加一个功能,当用户悬停在DogImages列表上时显示一个悬停文本框。
我们必须创建一个高阶组件(HOC),为我们传递的元素提供一个悬停属性。基于这个属性,我们可以根据用户是否悬停在DogImages列表上来有条件地渲染文本框。
现在我们可以将withHover高阶组件包装在withLoader高阶组件外部:
export default withHover(
withLoader(DogImages, "https://dog.ceo/api/breed/labrador/images/random/6")
);
现在,DogImages元素包含了我们从withHover和withLoader传递的所有属性。
在某些情况下,我们还可以使用 Hooks 模式来实现类似的结果。我们将在本章稍后详细讨论这种模式,但现在,让我们简单地说,使用 Hooks 可以减少组件树的深度,而使用 HOC 模式则很容易导致组件树深度嵌套。适合使用 HOC 的最佳情况是以下情况为真:
-
应用程序中许多组件需要使用相同的未定制行为。
-
组件可以独立工作,无需添加自定义逻辑。
优点
使用 HOC 模式可以将我们想要重用的逻辑集中在一处。这样做可以减少通过重复复制代码而在应用程序中意外传播错误的风险,从而可能引入新的错误。通过将逻辑集中在一处,我们可以保持代码的 DRY,并有效地强制执行关注点的分离。
缺点
高阶组件可以向元素传递的道具名称可能会导致名称冲突。例如:
function withStyles(Component) {
return props => {
const style = { padding: '0.2rem', margin: '1rem' }
return <Component style={style} {...props} />
}
}
const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)
在这种情况下,withStyles 高阶组件(HOC)向我们传递的元素添加了一个名为 style 的属性。然而,Button 组件已经有一个名为 style 的属性,这将被覆盖!确保高阶组件能够通过重命名或合并属性来处理意外的名称冲突:
function withStyles(Component) {
return props => {
const style = {
padding: '0.2rem',
margin: '1rem',
...props.style
}
return <Component style={style} {...props} />
}
}
const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)
当使用多个组合的高阶组件时,它们都将道具传递给包装在其中的元素时,找出哪个高阶组件负责哪个道具可能是具有挑战性的。这可能会阻碍调试和轻松扩展应用程序。
渲染道具模式
在高阶组件部分,我们看到如果多个组件需要访问相同的数据或包含相同的逻辑,重用组件逻辑将是方便的。
另一种使组件可重用的方法是使用渲染道具模式。渲染道具是组件上的一个属性,其值是一个返回 JSX 元素的函数。组件本身除了调用渲染道具而不实现自己的渲染逻辑外,不渲染任何内容。
假设我们有一个 Title 组件,应该只渲染我们传递的值。我们可以为此使用渲染道具。让我们将想要 Title 组件渲染的值传递给渲染道具:
<Title render={() => <h1>I am a render prop!</h1>} />
我们可以通过返回调用的渲染道具来在 Title 组件中渲染这些数据:
const Title = props => props.render();
我们必须向组件元素传递一个名为 render 的道具,这是一个返回 React 元素的函数:
import React from "react";
import { render } from "react-dom";
import "./styles.css";
const Title = (props) => props.render();
render(
<div className="App">
<Title
render={() => (
<h1>
<span role="img" aria-label="emoji">
✨
</span>
I am a render prop!{" "}
<span role="img" aria-label="emoji">
✨
</span>
</h1>
)}
/>
</div>,
document.getElementById("root")
);
渲染道具的有趣之处在于接收该道具的组件是可重用的。我们可以多次使用它,每次传递不同的值给渲染道具。
尽管它们被称为渲染道具,但渲染道具不一定要被称为 render。任何渲染 JSX 的道具都被视为渲染道具。因此,在下面的示例中,我们有三个渲染道具:
const Title = (props) => (
<>
{props.renderFirstComponent()}
{props.renderSecondComponent()}
{props.renderThirdComponent()}
</>
);
render(
<div className="App">
<Title
renderFirstComponent={() => <h1>First render prop!</h1>}
renderSecondComponent={() => <h2> Second render prop!</h2>}
renderThirdComponent={() => <h3>Third render prop!</h3>}
/>
</div>,
document.getElementById("root")
);
我们刚刚看到,我们可以使用渲染道具使组件可重用,因为我们每次可以传递不同的数据给渲染道具。
使用渲染属性的组件通常不仅仅调用渲染属性。相反,我们通常希望从接受渲染属性的组件传递数据到作为渲染属性传递的元素:
function Component(props) {
const data = { ... }
return props.render(data)
}
现在渲染属性可以接收我们传递的这个值作为其参数:
<Component render={data => <ChildComponent data={data} />}
提升状态
在我们看另一个使用渲染属性模式的用例之前,让我们先了解在 React 中“状态提升”的概念。
假设我们有一个温度转换器,您可以在一个有状态的输入元素中提供摄氏度输入。两个其他组件中的对应的 Fahrenheit 和 Kelvin 值会立即反映出来。为了能够与其他组件共享其状态,我们将不得不将状态移至需要它的组件的最近公共祖先。这就是所谓的“状态提升”:
function Input({ value, handleChange }) {
return <input value={value} onChange={e => handleChange(e.target.value)} />;
}
function Kelvin({ value = 0 }) {
return <div className="temp">{value + 273.15}K</div>;
}
function Fahrenheit({ value = 0 }) {
return <div className="temp">{(value * 9) / 5 + 32}°F</div>;
}
export default function App() {
const [value, setValue] = useState("");
return (
<div className="App">
<h1>Temperature Converter</h1>
<Input value={value} handleChange={setValue} />
<Kelvin value={value} />
<Fahrenheit value={value} />
</div>
);
}
状态提升是一种有价值的 React 状态管理模式,因为有时我们希望一个组件能够与其兄弟组件共享其状态。在具有少量组件的小型应用中,我们可以避免使用 Redux 或 React Context 这样的状态管理库,而是使用这种模式将状态提升到最近的共同祖先组件中。
虽然这是一个有效的解决方案,但是在处理许多子组件的大型应用中提升状态可能会比较棘手。每次状态更改都可能导致所有子组件重新渲染,即使这些组件不处理数据,也可能对应用程序的性能产生负面影响。我们可以使用渲染属性模式来解决这个问题。我们将改变 Input 组件,使其能够接收渲染属性:
function Input(props) {
const [value, setValue] = useState("");
return (
<>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
{props.render(value)}
</>
);
}
export default function App() {
return (
<div className="App">
<h1>Temperature Converter</h1>
<Input
render={value => (
<>
<Kelvin value={value} />
<Fahrenheit value={value} />
</>
)}
/>
</div>
);
}
将子元素作为函数
除了常规的 JSX 组件外,我们还可以将函数作为子元素传递给 React 组件。通过 children 属性,我们可以访问到这个函数,技术上也算是一个渲染属性。
让我们改变 Input 组件。我们不再显式地传递渲染属性,而是将一个函数作为 Input 组件的子元素:
export default function App() {
return (
<div className="App">
<h1>Temperature Converter</h1>
<Input>
{value => (
<>
<Kelvin value={value} />
<Fahrenheit value={value} />
</>
)}
</Input>
</div>
);
}
我们可以通过 props.children 属性访问到这个函数,该属性在 Input 组件上是可用的。我们不再使用用户输入值调用 props.render,而是使用用户输入的值调用 props.children:
function Input(props) {
const [value, setValue] = useState("");
return (
<>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
{props.children(value)}
</>
);
}
这样,Kelvin 和 Fahrenheit 组件可以访问该值,而不用担心渲染属性的名称。
优点
使用渲染属性模式可以轻松地在多个组件之间共享逻辑和数据。通过使用渲染属性或子元素属性,组件可以被设计为可重用。虽然高阶组件模式主要解决相同的问题,即可重用性和数据共享,但渲染属性模式解决了在使用高阶组件模式时可能遇到的一些问题。
通过使用渲染属性模式,我们不再自动合并属性,从而避免了使用高阶组件模式可能遇到的命名冲突问题。我们会显式地通过父组件提供的值将属性传递给子组件。
由于我们明确传递 props,我们解决了 HOC 的隐式 props 问题。应该传递到元素的 props 都在渲染属性的参数列表中是可见的。通过这种方式,我们确切地知道特定 props 来自何处。我们可以通过渲染属性将应用程序的逻辑与渲染组件分离。接收渲染属性的有状态组件可以将数据传递给仅仅渲染数据的无状态组件。
缺点
React Hooks 在很大程度上解决了我们尝试使用渲染属性解决的问题。由于 Hooks 改变了我们向组件添加可重用性和数据共享的方式,它们可以在许多情况下替代渲染属性模式。
由于我们无法向渲染属性添加生命周期方法,因此只能在不需要修改接收到的数据的组件上使用它。
Hooks 模式
React 16.8 引入了一个名为Hooks的新功能。Hooks 使得可以在不使用 ES2015 类组件的情况下使用 React 状态和生命周期方法。虽然 Hooks 不一定是一个设计模式,但 Hooks 在应用程序设计中扮演了重要角色。Hooks 可以替代许多传统的设计模式。
让我们看看类组件是如何实现添加状态和生命周期方法的。
类组件
在 React 引入 Hooks 之前,我们必须使用类组件向组件添加状态和生命周期方法。React 中的典型类组件可能如下所示:
class MyComponent extends React.Component {
// Adding state and binding custom methods
constructor() {
super()
this.state = { ... }
this.customMethodOne = this.customMethodOne.bind(this)
this.customMethodTwo = this.customMethodTwo.bind(this)
}
// Lifecycle Methods
componentDidMount() { ...}
componentWillUnmount() { ... }
// Custom methods
customMethodOne() { ... }
customMethodTwo() { ... }
render() { return { ... }}
}
类组件可以包含以下内容:
-
构造函数中的状态
-
生命周期方法如
componentDidMount和componentWillUnmount用于根据组件的生命周期执行副作用。 -
添加额外逻辑到类的自定义方法
虽然在引入 React Hooks 后我们仍然可以使用类组件,但使用类组件可能会有一些缺点。例如,考虑以下示例,其中一个简单的div作为按钮:
function Button() {
return <div className="btn">disabled</div>;
}
而不是始终显示为禁用状态,我们希望在用户单击按钮时将其更改为启用状态,并为按钮添加一些额外的 CSS 样式。为此,我们需要向组件添加状态来知道状态是启用还是禁用。这意味着我们必须完全重构功能组件,并使其成为一个保持按钮状态的类组件:
export default class Button extends React.Component {
constructor() {
super();
this.state = { enabled: false };
}
render() {
const { enabled } = this.state;
const btnText = enabled ? "enabled" : "disabled";
return (
<div
className={`btn enabled-${enabled}`}
onClick={() => this.setState({ enabled: !enabled })}
>
{btnText}
</div>
);
}
}
在这个例子中,组件很简单,重构并不需要太多的工作。然而,你的真实组件可能包含更多的代码行,这使得重构组件变得更加困难。
除了确保在重构组件时不要意外改变任何行为之外,还必须理解 ES2015+类的工作方式。在不意外更改数据流的情况下正确重构组件可能是具有挑战性的。
重构
在几个组件之间共享代码的标准方法是使用 HOC 或 Render Props 模式。虽然这两种模式都是有效的,并且使用它们是一种良好的实践,但在以后的某个时候添加这些模式需要重新构造应用程序。
除了重构应用程序外,组件越大,使用许多包装组件在更深层嵌套组件之间共享代码也会导致一种被称为“包装地狱”的情况。在开发工具中看到以下类似结构并不罕见:
<WrapperOne>
<WrapperTwo>
<WrapperThree>
<WrapperFour>
<WrapperFive>
<Component>
<h1>Finally in the component!</h1>
</Component>
</WrapperFive>
</WrapperFour>
</WrapperThree>
</WrapperTwo>
</WrapperOne>
包装地狱可能会使您难以理解数据在应用程序中的流动方式,从而更难弄清为什么会发生意外行为。
复杂性
随着我们向类组件添加更多逻辑,组件的大小会迅速增加。组件内部的逻辑可能会变得混乱和无结构,使开发人员难以理解类组件中某些逻辑的使用位置。这可能会使调试和优化性能变得更加困难。生命周期方法在代码中也需要大量重复。
钩子
在 React 中,并非总是使用类组件是一个很好的特性。为了解决 React 开发人员在使用类组件时可能遇到的常见问题,React 引入了 React Hooks。React Hooks 是您可以用来管理组件状态和生命周期方法的函数。React Hooks 使得:
-
添加状态到函数组件
-
在不使用
componentDidMount和componentWillUnmount等生命周期方法的情况下管理组件的生命周期 -
在整个应用程序中重用相同的有状态逻辑
首先,让我们看看如何使用 React Hooks 向函数组件添加状态。
状态钩子
React 提供了一个名为useState的 Hook,用于在函数组件内部管理状态。
让我们看看如何使用useState钩子将类组件重构为函数组件。我们有一个名为Input的类组件,它渲染一个输入字段。当用户在输入字段中键入任何内容时,输入状态中的值会更新:
class Input extends React.Component {
constructor() {
super();
this.state = { input: "" };
this.handleInput = this.handleInput.bind(this);
}
handleInput(e) {
this.setState({ input: e.target.value });
}
render() {
<input onChange={handleInput} value={this.state.input} />;
}
}
要使用useState钩子,我们需要访问 React 的useState方法。useState方法期望一个参数:这是状态的初始值,在本例中是一个空字符串。
我们可以从useState方法中解构出两个值:
-
状态的当前值
-
我们可以更新状态的方法:
const [value, setValue] = React.useState(initialValue);
您可以将第一个值与类组件的this.state.[value]进行比较。第二个值可以与类组件的this.setState方法进行比较。
由于我们处理输入的值,让我们称状态的当前值为输入,用于更新状态的方法称为setInput。初始值应为空字符串:
const [input, setInput] = React.useState("");
现在我们可以将Input类组件重构为有状态的函数组件:
function Input() {
const [input, setInput] = React.useState("");
return <input onChange={(e) => setInput(e.target.value)} value={input} />;
}
输入字段的值等于输入状态的当前值,就像在类组件示例中一样。当用户在输入字段中输入时,输入状态的值相应地更新,使用setInput方法:
import React, { useState } from "react";
export default function Input() {
const [input, setInput] = useState("");
return (
<input
onChange={e => setInput(e.target.value)}
value={input}
placeholder="Type something..."
/>
);
}
Effect Hook
我们已经看到可以使用useState组件在函数组件内处理状态。但是,类组件的另一个好处是可以将生命周期方法添加到组件中。
使用useEffect钩子,我们可以“挂接”到组件的生命周期。useEffect钩子有效地结合了componentDidMount、componentDidUpdate和componentWillUnmount生命周期方法:
componentDidMount() { ... }
useEffect(() => { ... }, [])
componentWillUnmount() { ... }
useEffect(() => { return () => { ... } }, [])
componentDidUpdate() { ... }
useEffect(() => { ... })
让我们使用状态钩子部分中使用的输入示例。每当用户在输入字段中输入任何内容时,该值也应该记录到控制台中。
我们需要一个useEffect钩子来“监听”输入值。我们可以通过将输入添加到useEffect钩子的依赖数组中来实现这一点。依赖数组是useEffect钩子接收的第二个参数:
import React, { useState, useEffect } from "react";
export default function Input() {
const [input, setInput] = useState("");
useEffect(() => {
console.log(`The user typed ${input}`);
}, [input]);
return (
<input
onChange={e => setInput(e.target.value)}
value={input}
placeholder="Type something..."
/>
);
}
当用户键入值时,输入字段的值现在会记录到控制台中。
自定义钩子
除了 React 提供的内置钩子(useState,useEffect,useReducer,useRef,useContext,useMemo,useImperativeHandle,useLayoutEffect,useDebugValue,useCallback),我们还可以轻松创建自己的自定义钩子。
您可能已经注意到所有的钩子都以“use”开头。重要的是要以“use”开头,以便 React 检查是否违反了 Hooks 的规则。
假设我们想追踪用户在输入时可能按下的特定键。我们的自定义钩子应该能够接收我们想要定位的键作为参数。
我们希望为用户传递的参数添加keydown和keyup事件侦听器。如果用户按下该键,则keydown事件将被触发,Hook 内的状态应该切换为 true。否则,当用户停止按下该按钮时,将触发keyup事件,并且状态将切换为 false:
function useKeyPress(targetKey) {
const [keyPressed, setKeyPressed] = React.useState(false);
function handleDown({ key }) {
if (key === targetKey) {
setKeyPressed(true);
}
}
function handleUp({ key }) {
if (key === targetKey) {
setKeyPressed(false);
}
}
React.useEffect(() => {
window.addEventListener("keydown", handleDown);
window.addEventListener("keyup", handleUp);
return () => {
window.removeEventListener("keydown", handleDown);
window.removeEventListener("keyup", handleUp);
};
}, []);
return keyPressed;
}
我们可以在我们的输入应用程序中使用这个自定义钩子。当用户按下 q、l 或 w 键时,让我们将其记录到控制台中:
import React from "react";
import useKeyPress from "./useKeyPress";
export default function Input() {
const [input, setInput] = React.useState("");
const pressQ = useKeyPress("q");
const pressW = useKeyPress("w");
const pressL = useKeyPress("l");
React.useEffect(() => {
console.log(`The user pressed Q!`);
}, [pressQ]);
React.useEffect(() => {
console.log(`The user pressed W!`);
}, [pressW]);
React.useEffect(() => {
console.log(`The user pressed L!`);
}, [pressL]);
return (
<input
onChange={e => setInput(e.target.value)}
value={input}
placeholder="Type something..."
/>
);
}
我们可以重复使用useKeyPress钩子,而不必重复编写相同的代码,而是将键按逻辑局限于Input组件。
Hooks 的另一个重要优势是社区可以构建和共享 Hooks。我们自己编写了useKeyPress钩子,但这并不是必要的。如果我们安装了它,别人已经构建并准备在我们的应用程序中使用该钩子。
以下是一些列出社区构建的所有钩子并准备在您的应用程序中使用的网站:
额外的钩子指导
就像其他组件一样,特殊函数是在你想要向编写的代码添加钩子时使用的。以下是一些常见钩子函数的简要概述:
useState
useState 钩子使开发人员能够在函数组件内更新和操作状态,而无需将其转换为类组件。该钩子的一个优点是它简单,并且不需要像其他 React Hooks 那样复杂。
useEffect
useEffect 钩子用于在函数组件中的主要生命周期事件期间运行代码。函数组件的主体不允许突变、订阅、计时器、日志记录和其他副作用。如果允许这些副作用,可能会导致 UI 内的混乱错误和一致性问题。useEffect 钩子防止所有这些“副作用”,并允许 UI 顺畅运行。它将 componentDidMount、componentDidUpdate 和 componentWillUnmount 结合到一个地方。
useContext
useContext 钩子接受一个上下文对象,即 React.createcontext 返回的值,并返回该上下文的当前值。useContext 钩子还与 React 上下文 API 配合使用,通过各种层级共享数据,而无需将应用程序的 props 传递给其他组件。请注意,传递给 useContext 钩子的参数必须是上下文对象本身,任何调用 useContext 的组件在上下文值更改时都会重新渲染。
useReducer
useReducer 钩子提供了一种替代 setState 的方法。特别适合处理涉及多个子值或下一个状态依赖于上一个状态的复杂状态逻辑。它使用数组解构接受一个 reducer 函数和一个初始状态输入,并返回当前状态和一个调度函数作为输出。useReducer 还优化了触发深度更新的组件的性能。
使用 Hooks 的优缺点
使用 Hooks 的一些好处如下:
代码行数较少
Hooks 允许您按关注点和功能组织代码,而不是按生命周期组织。这不仅使代码更清晰简洁,而且更短。以下是使用 React 的可搜索产品数据表的简单有状态组件的比较,以及在使用 useState 关键字后使用 Hooks 的外观。
有状态组件:
class TweetSearchResults extends React.Component {
constructor(props) {
super(props);
this.state = {
filterText: '',
inThisLocation: false
};
this.handleFilterTextChange =
this.handleFilterTextChange.bind(this);
this.handleInThisLocationChange =
this.handleInThisLocationChange.bind(this);
}
handleFilterTextChange(filterText) {
this.setState({
filterText: filterText
});
}
handleInThisLocationChange(inThisLocation) {
this.setState({
inThisLocation: inThisLocation
})
}
render() {
return (
<div>
<SearchBar
filterText={this.state.filterText}
inThisLocation={this.state.inThisLocation}
onFilterTextChange={this.handleFilterTextChange}
onInThisLocationChange={this.handleInThisLocationChange}
/>
<TweetList
tweets={this.props.tweets}
filterText={this.state.filterText}
inThisLocation={this.state.inThisLocation}
/>
</div>
);
}
}
下面是使用 Hooks 的同一组件:
const TweetSearchResults = ({tweets}) => {
const [filterText, setFilterText] = useState('');
const [inThisLocation, setInThisLocation] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inThisLocation={inThisLocation}
setFilterText={setFilterText}
setInThisLocation={setInThisLocation}
/>
<TweetList
tweets={tweets}
filterText={filterText}
inThisLocation={inThisLocation}
/>
</div>
);
}
简化复杂组件
JavaScript 类可能很难管理,与热重载结合使用可能会很困难,并且可能需要更好的缩小。React Hooks 解决了这些问题,并确保函数式编程变得更容易。使用 Hooks,我们不需要类组件。
重复使用有状态逻辑
在 JavaScript 中,类鼓励多级继承,这会快速增加整体复杂性和错误的潜在可能性。然而,Hooks 允许您在不编写类的情况下使用状态和其他 React 功能。使用 React,您始终可以重复使用有状态逻辑,而无需重复编写代码。这降低了错误发生的机会,并允许使用普通函数进行组合。
共享非可视逻辑
在 Hooks 实现之前,React 没有提取和共享非可视逻辑的方法。这最终导致了更多的复杂性,例如 HOC 模式和 Render Props,用于解决常见问题。引入 Hooks 解决了这个问题,因为它允许将有状态的逻辑提取到一个简单的 JavaScript 函数中。
当然,使用 Hooks 也有一些潜在的缺点需要牢记:
-
必须遵守其规则。使用 linter 插件,更容易知道哪个规则被破坏了。
-
需要花费相当多的时间练习以正确使用(例如
useEffect)。 -
需要注意错误的使用(例如
useCallback、useMemo)。
React Hooks 与类比较
当 Hooks 被引入到 React 中时,它带来了一个新问题:我们如何知道何时使用带有 Hooks 和类组件的函数组件?借助 Hooks,即使在函数组件中,也可以获取状态和部分生命周期 Hooks。Hooks 允许您在不编写类的情况下使用本地状态和其他 React 功能。以下是帮助您做出决定的一些 Hooks 和类之间的区别:
-
Hooks 帮助避免多层次结构,并使代码更清晰。通常情况下,当您在 DevTools 中查看时,如果使用 HOC 或 Render Props,则必须重构应用程序以包含多个层次结构。
-
Hooks 提供了在 React 组件之间的统一性。类由于需要理解绑定和函数调用的上下文,使人类和机器感到困惑。
静态导入
import 关键字允许我们导入另一个模块导出的代码。默认情况下,我们静态导入的所有模块都会添加到初始捆绑包中。使用默认的 ES2015+ 导入语法 import module from [module] 导入的模块是静态导入的。在本节中,我们将学习在 React.js 上下文中使用静态导入的用法。
让我们看一个例子。一个简单的聊天应用包含一个 Chat 组件,在其中我们静态导入并渲染三个组件:UserProfile、ChatList 和 ChatInput,用于输入和发送消息。在 ChatInput 模块中,我们静态导入一个 EmojiPicker 组件,在用户切换表情符号时显示表情符号选择器。我们将使用 webpack 来捆绑我们的模块依赖项:
import React from "react";
// Statically import Chatlist, ChatInput and UserInfo
import UserInfo from "./components/UserInfo";
import ChatList from "./components/ChatList";
import ChatInput from "./components/ChatInput";
import "./styles.css";
console.log("App loading", Date.now());
const App = () => (
<div className="App">
<UserInfo />
<ChatList />
<ChatInput />
</div>
);
export default App;
模块在引擎到达导入它们的行时立即执行。当您打开控制台时,您可以看到模块加载的顺序。
由于组件是静态导入的,webpack 将这些模块捆绑到初始捆绑包中。我们可以在构建应用程序后查看 webpack 创建的捆绑包:
| 资源 | main.bundle.js |
|---|---|
| 大小 | 1.5 MiB |
| 块 | 主 [已发布] |
| 块名称 | 主 |
我们的聊天应用程序源代码被捆绑成一个捆绑包:main.bundle.js。大捆绑包大小可以根据用户的设备和网络连接显著影响我们应用程序的加载时间。在 App 组件可以将其内容呈现到用户屏幕之前,必须首先加载和解析所有模块。
幸运的是,有许多方法可以加快加载时间!我们不必总是一次性导入所有模块:可能有基于用户交互的模块只应该在需要时才导入,就像本例中的 EmojiPicker 或在页面下方渲染的模块。与其静态导入所有组件,不如在 App 组件呈现其内容后动态导入模块,用户可以与我们的应用程序交互。
动态导入
在“静态导入”一节讨论的聊天应用程序中,有四个关键组件:UserInfo、ChatList、ChatInput 和 EmojiPicker。然而,只有这些组件中的三个在初始页面加载时立即使用:UserInfo、ChatList 和 ChatInput。EmojiPicker 不直接可见,只有在用户点击表情符号以切换 EmojiPicker 时才可能渲染。这意味着我们不必要地将 EmojiPicker 模块添加到初始捆绑包中,可能增加了加载时间。
为了解决这个问题,我们可以动态导入 EmojiPicker 组件。我们不再静态导入它,而是仅在想要显示 EmojiPicker 时才导入它。在 React 中动态导入组件的一种简单方法是使用 React Suspense。React.Suspense 组件接收应该动态加载的组件,使得 App 组件能够通过暂停 EmojiPicker 模块的导入来更快地渲染其内容。当用户点击表情符号时,EmojiPicker 组件首次被渲染。EmojiPicker 组件呈现一个 Suspense 组件,该组件接收懒加载的模块:在本例中是 EmojiPicker。Suspense 组件接受一个 fallback 属性,该属性接收在挂起组件加载时应显示的组件!
不必将 EmojiPicker 不必要地添加到初始捆绑包中,我们可以将其拆分成自己的捆绑包,并减少初始捆绑包的大小。
较小的初始捆绑包大小意味着更快的初始加载速度:用户不必在空白加载屏幕上等待太久。fallback 组件让用户知道我们的应用程序没有冻结:他们需要等待一小段时间,直到模块被处理和执行。
| 资源 | 大小 | 块 | 块名称 |
|---|---|---|---|
| emoji-picker.bundle.js | 1.48 KiB | 1 [emitted] | emoji-picker |
| main.bundle.js | 1.33 MiB | main [emitted] | main |
| vendors~emoji-picker.bundle.js | 171 KiB | 2 [emitted] | vendors~emoji-picker |
以前,初始捆绑包大小为 1.5 MiB,现在通过暂停 EmojiPicker 的导入,我们将其减小为 1.33 MiB。在控制台中,您可以看到一旦切换到 EmojiPicker,EmojiPicker 就会执行:
import React, { Suspense, lazy } from "react";
// import Send from "./icons/Send";
// import Emoji from "./icons/Emoji";
const Send = lazy(() =>
import(/*webpackChunkName: "send-icon" */ "./icons/Send")
);
const Emoji = lazy(() =>
import(/*webpackChunkName: "emoji-icon" */ "./icons/Emoji")
);
// Lazy load EmojiPicker when <EmojiPicker /> renders
const Picker = lazy(() =>
import(/*webpackChunkName: "emoji-picker" */ "./EmojiPicker")
);
const ChatInput = () => {
const [pickerOpen, togglePicker] = React.useReducer(state => !state, false);
return (
<Suspense fallback={<p id="loading">Loading...</p>}>
<div className="chat-input-container">
<input type="text" placeholder="Type a message..." />
<Emoji onClick={togglePicker} />
{pickerOpen && <Picker />}
<Send />
</div>
</Suspense>
);
};
console.log("ChatInput loaded", Date.now());
export default ChatInput;
构建应用程序时,我们可以看到 webpack 创建的不同捆绑包。通过动态导入 EmojiPicker 组件,我们将初始捆绑包大小从 1.5 MiB 减小到 1.33 MiB!虽然用户可能仍需等待 EmojiPicker 完全加载,但通过确保应用程序在用户等待组件加载时仍可呈现和交互,我们改善了用户体验。
可加载组件
SSR 尚不支持 React Suspense。React Suspense 的一个很好的替代方案是 loadable-components 库,您可以在 SSR 应用程序中使用它:
import React from "react";
import loadable from "@loadable/component";
import Send from "./icons/Send";
import Emoji from "./icons/Emoji";
const EmojiPicker = loadable(() => import("./EmojiPicker"), {
fallback: <div id="loading">Loading...</div>
});
const ChatInput = () => {
const [pickerOpen, togglePicker] = React.useReducer(state => !state, false);
return (
<div className="chat-input-container">
<input type="text" placeholder="Type a message..." />
<Emoji onClick={togglePicker} />
{pickerOpen && <EmojiPicker />}
<Send />
</div>
);
};
export default ChatInput;
像 React Suspense 一样,我们可以将懒加载的模块传递给可加载组件,它将在请求 EmojiPicker 模块时导入该模块。在模块加载期间,我们可以渲染一个回退组件。
尽管可加载组件在 SSR 应用程序中是 React Suspense 的一个很好的替代方案,但它们在 CSR 应用程序中也很有帮助,用于暂停模块的导入:
import React from "react";
import Send from "./icons/Send";
import Emoji from "./icons/Emoji";
import loadable from "@loadable/component";
const EmojiPicker = loadable(() => import("./components/EmojiPicker"), {
fallback: <p id="loading">Loading...</p>
});
const ChatInput = () => {
const [pickerOpen, togglePicker] = React.useReducer(state => !state, false);
return (
<div className="chat-input-container">
<input type="text" placeholder="Type a message..." />
<Emoji onClick={togglePicker} />
{pickerOpen && <EmojiPicker />}
<Send />
</div>
);
};
console.log("ChatInput loaded", Date.now());
export default ChatInput;
交互导入
在聊天应用程序示例中,当用户点击表情符号时,我们动态导入了 EmojiPicker 组件。这种类型的动态导入称为 交互导入。我们通过用户的交互触发组件的导入。
可见性导入
除了用户交互外,我们经常有一些组件在初始页面加载时不需要可见。一个很好的例子是延迟加载图像或在视口中直接不可见的组件,只有当用户向下滚动到组件并使其可见时才加载。当用户滚动到组件并使其可见时触发动态导入的操作称为 可见性导入。
要知道组件当前是否在我们的视口中,我们可以使用 IntersectionObserver API 或诸如 react-loadable-visibility 或 react-lazyload 这样的库,以快速将可见性导入添加到我们的应用程序中。现在我们可以看一下聊天应用程序示例,当用户看到 EmojiPicker 时,它会被导入和加载:
import React from "react";
import Send from "./icons/Send";
import Emoji from "./icons/Emoji";
import LoadableVisibility from "react-loadable-visibility/react-loadable";
const EmojiPicker = LoadableVisibility({
loader: () => import("./EmojiPicker"),
loading: <p id="loading">Loading</p>
});
const ChatInput = () => {
const [pickerOpen, togglePicker] = React.useReducer(state => !state, false);
return (
<div className="chat-input-container">
<input type="text" placeholder="Type a message..." />
<Emoji onClick={togglePicker} />
{pickerOpen && <EmojiPicker />}
<Send />
</div>
);
};
console.log("ChatInput loading", Date.now());
export default ChatInput;
代码拆分
在前面的部分中,我们看到了如何在需要时动态导入组件。在具有多个路由和组件的复杂应用程序中,我们必须确保我们的代码在适当的时间进行优化捆绑和拆分,以允许静态和动态导入的混合使用。
您可以使用基于路由的拆分模式来拆分您的代码,或者依赖于现代打包工具如 webpack 或 Rollup 来拆分和捆绑您应用程序的源代码。
基于路由的拆分
特定资源可能仅在特定页面或路由上才需要,我们可以通过添加基于路由的分割来请求仅在特定路由上需要的资源。通过结合 React Suspense 或loadable-components等库与react-router,我们可以根据当前路由动态加载组件。例如:
import React, { lazy, Suspense } from "react";
import { render } from "react-dom";
import { Switch, Route, BrowserRouter as Router } from "react-router-dom";
const App = lazy(() => import(/* webpackChunkName: "home" */ "./App"));
const Overview = lazy(() =>
import(/* webpackChunkName: "overview" */ "./Overview")
);
const Settings = lazy(() =>
import(/* webpackChunkName: "settings" */ "./Settings")
);
render(
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/">
<App />
</Route>
<Route path="/overview">
<Overview />
</Route>
<Route path="/settings">
<Settings />
</Route>
</Switch>
</Suspense>
</Router>,
document.getElementById("root")
);
module.hot.accept();
通过按路由懒加载组件,我们只请求包含当前路由所需代码的捆绑包。由于大多数人已经习惯在重定向期间可能有一些加载时间,这是延迟加载组件的理想场所。
捆绑拆分
在构建现代 Web 应用时,像 webpack 或 Rollup 这样的捆绑工具会将应用的源代码捆绑成一个或多个捆绑包。当用户访问网站时,会请求并加载用于向用户显示数据和功能的捆绑包。
JavaScript 引擎(如 V8)可以在用户请求数据时解析和编译数据。尽管现代浏览器已经进化到尽可能快速和高效地解析和编译代码,但开发人员仍然负责优化请求数据的加载和执行时间。我们希望尽可能缩短执行时间,以防止阻塞主线程。
虽然现代浏览器可以在捆绑包到达时即时流式传输,但在用户设备上绘制第一个像素点之前可能仍需相当长的时间。捆绑包越大,在引擎到达进行第一次渲染调用的行之前所需的时间就越长。在此之前,用户必须盯着空白屏幕,这可能会令人沮丧。
我们希望尽快向用户展示数据。较大的捆绑包会导致加载时间、处理时间和执行时间增加。如果能够减小捆绑包的大小以加快速度,那将是极好的。我们可以将一个包含不必要代码的巨型捆绑包分割成多个较小的捆绑包来代替。在确定我们捆绑包大小时,有一些关键指标是必须考虑的。
通过对应用程序进行捆绑拆分,我们可以减少加载、处理和执行捆绑包所需的时间。这反过来减少了在用户屏幕上绘制第一个内容(称为首次内容呈现,FCP)的时间,也减少了用于将最大组件呈现到屏幕上的时间或最大内容呈现时间(LCP)指标。
尽管在屏幕上看到数据很好,但我们希望看到的不仅仅是内容。为了拥有完全功能的应用程序,我们希望用户也能与其进行交互。只有在捆绑包加载和执行后,UI 才变得可交互。所有内容在屏幕上绘制并变得可交互所需的时间称为交互时间(TTI)。
更大的捆绑包不一定意味着更长的执行时间。可能发生的情况是,我们加载了用户甚至可能根本不使用的大量代码。捆绑包的某些部分将仅在特定用户交互上执行,用户可能会执行或不执行。
引擎仍然必须加载、解析和编译甚至在用户可以在屏幕上看到任何内容之前都没有使用的代码。尽管由于浏览器处理这两个步骤的高效方式,解析和编译成本实际上可以忽略不计,但获取比必要更大的捆绑包可能会影响应用程序的性能。在低端设备或较慢网络上的用户在捆绑包被获取之前将看到加载时间显著增加。
不要最初请求当前导航中优先级不高的代码部分,我们可以将这些代码与渲染初始页面所需的代码分开。
PRPL 模式
使应用程序对全球用户群体可访问可能是一个挑战。应用程序应在低端设备和网络连接质量差的地区表现出色。为了确保我们的应用程序在恶劣条件下能够尽可能高效地加载,我们可以使用推送渲染预缓存懒加载(PRPL)模式。
PRPL 模式专注于四个主要性能考虑因素:
-
高效推送关键资源可以最小化与服务器之间的往返次数并减少加载时间。
-
尽快渲染初始路由以改善用户体验。
-
在后台预缓存经常访问的路由的资产,以最小化对服务器的请求次数并实现更好的离线体验。
-
懒加载不经常请求的路由或资源。
当我们访问一个网站时,浏览器会向服务器请求所需的资源。入口点指向的文件通常是从服务器返回的,通常是我们应用程序的初始 HTML 文件。浏览器的 HTML 解析器在从服务器接收数据时立即开始解析这些数据。如果解析器发现需要更多资源,比如样式表或脚本,将向服务器发送额外的 HTTP 请求以获取这些资源。
反复请求资源并不理想,因为我们试图最小化客户端和服务器之间的往返次数。
我们长时间使用 HTTP/1.1 在客户端和服务器之间通信。尽管与 HTTP/1.0 相比,HTTP/1.1 引入了许多改进,比如在发送带有保持活动标头的新 HTTP 请求之前保持客户端和服务器之间的 TCP 连接活动,仍然存在一些问题需要解决。HTTP/2 相对于 HTTP/1.1 引入了重大变化,使我们能够优化客户端和服务器之间的消息交换。
虽然 HTTP/1.1 在请求和响应中使用换行分隔的纯文本协议,但 HTTP/2 将请求和响应拆分为更小的帧。包含头部和主体字段的 HTTP 请求至少分为两个帧:头部帧和数据帧。
HTTP/1.1 在客户端和服务器之间最多允许六个 TCP 连接。在可以通过同一 TCP 连接发送新请求之前,必须解决上一个请求。如果最后一个请求解决时间较长,这个请求将阻塞其他请求的发送。这种常见问题称为先行线阻塞,会增加特定资源的加载时间。
HTTP/2 使用双向流。单个 TCP 连接可以在客户端和服务器之间传输多个请求和响应帧。一旦服务器接收到该特定请求的所有请求帧,它将重新组装它们并生成响应帧。这些响应帧被发送回客户端,客户端重新组装它们。由于流是双向的,我们可以通过同一流发送请求和响应帧。
HTTP/2 通过允许在上一个请求解决之前在同一 TCP 连接上发送多个请求来解决先行线阻塞问题!HTTP/2 还引入了一种更优化的数据获取方式,称为服务器推送。服务器可以通过“推送”这些资源自动发送额外资源,而不是每次通过发送 HTTP 请求显式请求资源。
客户端接收到额外资源后,这些资源将被存储在浏览器缓存中。当解析入口文件时发现这些资源时,浏览器可以快速从缓存获取资源,而不是向服务器发出 HTTP 请求。
虽然推送资源减少了接收额外资源的时间,但服务器推送并不具备 HTTP 缓存感知性。推送的资源在下次访问网站时将不可用,必须再次请求。为了解决这个问题,PRPL 模式在初始加载后使用服务工作线程缓存这些资源,以确保客户端不会发出不必要的请求。
作为网站作者,我们通常清楚哪些资源是在早期获取的关键资源,而浏览器则尽力猜测。通过添加预加载资源提示到关键资源,我们可以帮助浏览器。
通过告诉浏览器您希望预加载特定资源,您正在告诉浏览器您希望比浏览器正常发现时更早获取它。预加载是优化加载当前路由关键资源所需时间的一个很好的方式。
尽管预加载资源是减少往返次数和优化加载时间的好方法,但过多地推送文件可能会有害。浏览器的缓存是有限的,通过请求客户端不需要的资源,您可能会不必要地使用带宽。PRPL 模式侧重于优化初始加载。在完全呈现初始路由之前,不会加载其他任何资源。
我们可以通过将应用程序拆分为小型、高效的捆包来实现这一点。这些捆包应允许用户在需要时仅加载他们所需的资源,同时最大化缓存使用。
缓存较大的捆包可能会成为问题。多个捆包可能共享相同的资源。
浏览器需要帮助识别哪些捆绑资源在多个路由之间共享,因此无法缓存这些资源。缓存资源对于减少与服务器的往返次数以及使我们的应用支持离线至关重要。
在使用 PRPL 模式时,我们需要确保请求的捆包包含我们在该时刻需要的最小资源量,并且浏览器可以缓存这些资源。在某些情况下,这可能意味着根本不使用任何捆包会更高效,我们可以简单地使用未捆绑的模块。
通过将应用程序捆绑为最小资源动态请求的好处可以通过配置浏览器和服务器以支持 HTTP/2 推送和有效缓存资源轻松模拟。对于不支持 HTTP/2 服务器推送的浏览器,我们可以创建优化的构建以最小化往返次数。客户端无需知道它是否接收到捆绑或未捆绑的资源:服务器会为每个浏览器提供适当的构建。
PRPL 模式通常将应用外壳作为其主要入口点,这是一个包含大部分应用逻辑并在多个路由之间共享的最小文件。它还包括应用的路由器,可以动态请求所需资源。
PRPL 模式确保在用户设备上看到初始路由之前不会请求或呈现任何其他资源。一旦成功加载初始路由,服务工作线程可以安装以在后台获取其他经常访问路由的资源。
由于这些数据是在后台获取的,用户不会遇到任何延迟。如果用户想要访问由服务工作线程缓存的经常访问路由,则服务工作线程可以快速从缓存中获取所需资源,而无需向服务器发送请求。
对于不经常访问的路由,可以动态导入资源。
加载优先级
加载优先级模式鼓励您明确优先处理特定资源的请求,这些资源您知道将较早需要。
预加载(<link rel="preload">)是浏览器的优化,允许较晚发现的关键资源提前请求。如果您习惯手动思考如何排序关键资源的加载,它可能对核心 Web Vitals(CWV)中的加载性能和指标产生积极影响。然而,预加载并非万能药,需要考虑一些权衡:
<link rel="preload" href="emoji-picker.js" as="script">
...
</head>
<body>
...
<script src="stickers.js" defer></script>
<script src="video-sharing.js" defer></script>
<script src="emoji-picker.js" defer></script>
当优化像交互时间 (TTI) 或首次输入延迟 (FID) 这样的指标时,预加载对于加载 JavaScript 包(或块)以实现交互性是有帮助的。请记住,在使用预加载时需要格外小心,因为您希望在改善交互性的同时避免延迟必要的资源(如主要图像或字体),这些资源对于首次内容绘制 (FCP) 或最大内容绘制 (LCP) 是必要的。
如果您试图优化第一方 JavaScript 的加载,请考虑在文档<head>中使用<script defer>而不是<body>,以帮助提前发现这些资源。
单页应用中的预加载
虽然预取是缓存即将请求的资源的好方法,但我们可以预加载需要立即使用的资源。这可能是在初始渲染中使用的特定字体或用户立即看到的某些图片。
假设我们的EmojiPicker组件在初始渲染时应立即可见。虽然您不应将其包含在主包中,但应并行加载它。与预取一样,我们可以添加魔法注释,告知 webpack 此模块应预加载:
const EmojiPicker = import(/* webpackPreload: true */ "./EmojiPicker");
在构建应用程序后,我们可以看到EmojiPicker将被预取。实际输出在我们文档头部具有rel="preload"的link标签中可见:
<link rel="prefetch" href="emoji-picker.bundle.js" as="script" />
<link rel="prefetch" href="vendors~emoji-picker.bundle.js" as="script" />
预加载的EmojiPicker可以与初始包并行加载。与预取不同,浏览器在预取资源时仍需判断其网络连接和带宽是否足够,而预加载的资源将无论如何都会预加载。
而不是在初始渲染后等待EmojiPicker加载完成,资源将立即可用于我们。随着我们以更考虑周到的顺序加载资产,初始加载时间可能会显著增加,这取决于用户的设备和互联网连接。仅预加载那些在初始渲染后约 1 秒钟必须可见的资源。
预加载 + 异步 Hack
如果你希望浏览器高优先级下载脚本但不阻塞解析器等待脚本,你可以利用此处展示的预加载 + async 技巧。在这种情况下,预加载可能会延迟其他资源的下载,但这是开发者需要权衡的一个折衷:
<link rel="preload" href="emoji-picker.js" as="script" />
<script src="emoji-picker.js" async></script>
Chrome 95+中的预加载
由于 Chrome 95+中预加载队列跳过行为的一些修复,该功能现在稍微更安全。Chrome 关于预加载的新建议由 Chrome 的 Pat Meenan 提出:
-
将其放在 HTTP 标头中将超越其他一切。
-
一般来说,预加载将按照解析器处理它们的顺序进行加载,适用于中等及以上的任何情况,因此在 HTML 开头放置预加载时要小心。
-
字体预加载可能最好放在头部的末尾或者主体的开头。
-
导入预加载应该在需要导入的
script标签之后进行(这样实际脚本会首先加载/解析)。 -
图像预加载的优先级较低,应相对于
async脚本和其他低/最低优先级标签进行排序。
列表虚拟化
列表虚拟化有助于提高大型数据列表的渲染性能。在动态列表中,您仅渲染可见的内容行,而不是整个列表。渲染的行仅是完整列表的一个小子集,而可见的内容(窗口)随用户滚动而移动。在 React 中,您可以通过 react-virtualized 来实现这一点。这是由 Brian Vaughn 开发的窗口化库,仅在滚动视口中渲染当前可见的项目。这意味着您无需支付一次性渲染成千上万行数据的成本。
窗口化/虚拟化如何工作?
虚拟化项目列表涉及维护和移动列表周围的窗口。react-virtualized 中的窗口化通过以下方式工作:
-
拥有一个小的容器 DOM 元素(例如,
<ul>),具有相对定位(窗口)。 -
拥有一个用于滚动的大 DOM 元素。
-
绝对定位容器内的子元素,设置它们的样式为 top、left、width 和 height。
-
而不是一次性渲染列表中的成千上万个元素(可能导致较慢的初始渲染或影响滚动性能),虚拟化专注于仅渲染对用户可见的项目。
这可以帮助在中低端设备上保持列表渲染速度快。随着用户滚动,您可以获取/显示更多项目,卸载先前的条目并替换为新条目。
react-window 是由相同作者重写的 react-virtualized,旨在更小、更快且更易于树抖动。在可树抖动库中,尺寸取决于您选择使用的哪些 API 表面。使用它代替 react-virtualized,我看到大约 20 到 30 KB(经过压缩)的节省。这两个包的 API 大致相似;在它们不同的地方,react-window 倾向于更简单。
在 react-window 案例中,以下是主要组件。
列表
列表渲染一个窗口化列表(行)的元素,这意味着仅显示给用户可见的行。列表使用一个网格(内部)来渲染行,将属性传递给该内部网格。以下代码片段展示了在 React 中渲染列表与使用 react-window 的区别。
使用 React 渲染简单数据的列表(itemsArray):
import React from "react";
import ReactDOM from "react-dom";
const itemsArray = [
{ name: "Drake" },
{ name: "Halsey" },
{ name: "Camila Cabello" },
{ name: "Travis Scott" },
{ name: "Bazzi" },
{ name: "Flume" },
{ name: "Nicki Minaj" },
{ name: "Kodak Black" },
{ name: "Tyga" },
{ name: "Bruno Mars" },
{ name: "Lil Wayne" }, ...
]; // our data
const Row = ({ index, style }) => (
<div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
{itemsArray[index].name}
</div>
);
const Example = () => (
<div
style=
class="List"
>
{itemsArray.map((item, index) => Row({ index }))}
</div>
);
ReactDOM.render(<Example />, document.getElementById("root"));
使用 react-window 渲染列表:
import React from "react";
import ReactDOM from "react-dom";
import { FixedSizeList as List } from "react-window";
const itemsArray = [...]; // our data
const Row = ({ index, style }) => (
<div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
{itemsArray[index].name}
</div>
);
const Example = () => (
<List
className="List"
height={150}
itemCount={itemsArray.length}
itemSize={35}
width={300}
>
{Row}
</List>
);
ReactDOM.render(<Example />, document.getElementById("root"));
网格
Grid 在垂直和水平轴向上使用虚拟化渲染表格数据。它只根据当前水平/垂直滚动位置需要渲染填充自身的 Grid 单元格。
如果我们想要使用网格布局渲染与之前相同的列表,假设我们的输入是多维数组,我们可以使用FixedSizeGrid来实现如下:
import React from 'react';
import ReactDOM from 'react-dom';
import { FixedSizeGrid as Grid } from 'react-window';
const itemsArray = [
[{},{},{},...],
[{},{},{},...],
[{},{},{},...],
[{},{},{},...],
];
const Cell = ({ columnIndex, rowIndex, style }) => (
<div
className={
columnIndex % 2
? rowIndex % 2 === 0
? 'GridItemOdd'
: 'GridItemEven'
: rowIndex % 2
? 'GridItemOdd'
: 'GridItemEven'
}
style={style}
>
{itemsArray[rowIndex][columnIndex].name}
</div>
);
const Example = () => (
<Grid
className="Grid"
columnCount={5}
columnWidth={100}
height={150}
rowCount={5}
rowHeight={35}
width={300}
>
{Cell}
</Grid>
);
ReactDOM.render(<Example />, document.getElementById('root'));
Web 平台的改进
一些现代浏览器现在支持CSS content-visibility。content-visibility:auto允许您在需要之前跳过渲染和绘制屏幕外内容。如果您有一个包含昂贵渲染的长 HTML 文档,请考虑尝试此属性。
对于渲染动态内容列表,我仍然建议使用像react-window这样的库。要比使用content-visibility:hidden版本或像许多列表虚拟化库今天可能做的那样,依据屏幕外条件使用display:none或移除 DOM 节点更具侵略性的版本。
结论
再次强调,使用预加载要谨慎,并始终评估其在生产环境中的影响。如果您的图片预加载早于其他资源,这可以帮助浏览器发现它(并相对其他资源进行排序)。当使用不当时,预加载可能会导致图片延迟 FCP(例如 CSS、字体),这与您的意图相反。此外,请注意,为了使这种重新排序效果有效,还取决于服务器正确地优先处理请求。
在需要获取但不执行脚本的情况下,您可能会发现<link rel="preload">有帮助。以下的web.dev 文章讨论了如何使用预加载来:
摘要
在本章中,我们讨论了推动现代 Web 应用程序架构和设计的一些关键考虑因素。我们还看到了 React.js 设计模式如何解决这些问题的不同方式。
本章前面,我们介绍了 CSR、SSR 和 hydration 的概念。JavaScript 对页面性能有重大影响。渲染技术的选择会影响 JavaScript 在页面生命周期中的加载或执行时间点。因此,在讨论 JavaScript 模式和下一章主题时,讨论渲染模式非常重要。
第十三章:渲染模式
随着我们转向更加交互式的网站,处理事件的数量和客户端渲染的内容量增加,导致像 React.js 一样的 SPA 主要在客户端渲染。
然而,网页可以像它们服务的功能一样静态或动态。例如,博客/新闻页面仍然可以在服务器上生成并按原样推送给客户端的静态内容。静态内容是无状态的,不触发事件,并且在渲染后不需要重新注水。相反,动态内容(按钮、筛选器、搜索栏)必须在渲染后重新连接到其事件。DOM 必须在客户端重新生成(虚拟 DOM)。这种重新生成、重新注水和事件处理函数会导致发送到客户端的 JavaScript 增加。
渲染模式为特定用例提供了渲染内容的理想解决方案。此表中的渲染模式很受欢迎:
| 渲染模式 | |
|---|---|
| 客户端渲染(CSR) | HTML 完全在客户端上渲染 |
| 服务器端渲染(SSR) | 在客户端重启动前在服务器上动态渲染 HTML 内容 |
| 静态渲染 | 在构建时构建静态站点以在服务器上呈现页面 |
| 增量静态生成 | 能够在初始构建后动态增强或修改静态站点(Next.js ISR,Gatsby DSG) |
| 流式 SSR | 将服务器渲染的内容拆分为更小的流式块 |
| 边缘渲染 | 在发送到客户端之前在边缘修改渲染的 HTML |
| 混合渲染 | 结合构建时、服务器和客户端渲染,以创建更灵活的 Web 开发方法(例如,React Server Components 和 Next.js App Router) |
| 部分注水 | 仅在客户端注水部分组件(例如,React Server Components 和 Gatsby) |
| 逐步注水 | 控制客户端上组件注水的顺序 |
| 岛屿架构 | 在否则静态站点中具有多个入口点的孤立动态行为的孤岛(Astro、Eleventy) |
| 逐步增强 | 确保应用即使没有 JavaScript 也能正常运行 |
本章介绍了一些这些渲染模式,并将帮助您决定哪种模式最适合您的需求。它将帮助您做出基础决策,例如:
-
我想要如何以及在何处渲染内容?
-
内容应该在 Web 服务器、构建服务器、边缘网络还是直接在客户端上渲染呢?
-
内容应该一次性渲染、部分渲染还是逐步渲染呢?
渲染模式的重要性
选择给定用例的最适合的渲染模式可以为开发团队创造的开发体验(DX)和为最终用户设计的用户体验(UX)带来天壤之别。选择正确的模式可能导致更快的构建和卓越的低处理成本加载性能。另一方面,错误的模式选择可能会毁掉本可以实现伟大商业创意的应用程序。
要创建出色的用户体验,我们必须优化我们的应用程序以支持用户中心的指标,例如核心 Web 要素(CWV):
首字节时间(TTFB)
客户端接收页面内容的首字节所需的时间
首次内容绘制(FCP)
导航后浏览器渲染第一个内容片段所需的时间
交互时间(TTI)
页面开始加载到快速响应用户输入所需的时间
最大内容绘制(LCP)
加载和渲染页面主要内容所需的时间
累积布局移位(CLS)
测量视觉稳定性,以避免意外的布局移位
首次输入延迟(FID)
用户与页面交互开始到事件处理程序能够运行的时间
CWV 指标衡量最与用户体验相关的参数。优化 CWV 可以确保应用程序具有出色的用户体验和优化的搜索引擎优化(SEO)。
要为我们的产品/工程团队创建出色的开发体验(DX),我们必须通过确保更快的构建时间、简单的回滚操作、可扩展的基础设施等功能来优化我们的开发环境:
快速的构建时间
项目应该快速构建,以便快速迭代和部署。
低服务器成本
网站应限制和优化服务器执行时间,以减少执行成本。
动态内容
页面应能够高效加载动态内容。
简单的回滚操作
您可以快速恢复到先前的构建版本并部署它。
可靠的正常运行时间
用户应始终能够通过运行良好的服务器访问您的网站。
可扩展的基础设施
您的项目可能会随着发展而增大或减小,而不会面临性能问题。
基于这些原则建立开发环境使我们的开发团队能够高效地构建出色的产品。
我们现在已经列出了许多期望。但是,如果选择正确的渲染模式,您可以自动获得大多数这些好处。
渲染模式已经发展了很长一段时间,从 SSR 和 CSR 到今天在不同论坛上讨论和评判的高度细化模式。虽然这可能令人不知所措,但重要的是要记住每种模式都是为了解决特定的用例而设计的。对于一个用例有益的模式特征,在另一个用例中可能是有害的。同样重要的是,同一网站上不同类型的页面可能需要不同的渲染模式。
Chrome 团队鼓励开发者考虑静态或 SSR 方案,而不是完全重新注水的方法。随着时间的推移,渐进式加载和渲染技术可以帮助在使用现代框架时取得性能和功能交付的良好平衡。
下面的部分详细介绍了不同的模式。
客户端渲染(Client-Side Rendering)
我们已经在前一章中讨论了 React 的 CSR。这里是一个简要概述,以帮助我们将其与其他渲染模式联系起来。
使用 React CSR,大部分应用程序逻辑在客户端执行,并通过 API 调用与服务器交互以获取或保存数据。因此,几乎所有的 UI 都在客户端生成。整个 Web 应用程序在第一次请求时加载。当用户通过点击链接进行导航时,不会生成新的请求到服务器以渲染页面。代码在客户端运行以更改视图/数据。
CSR 允许我们拥有支持导航但无需页面刷新的 SPA,并提供出色的用户体验。由于用于更改视图的数据处理是有限的,页面之间的路由通常更快,使 CSR 应用程序看起来更具响应性。
随着页面复杂度的增加,展示图片、显示来自数据存储的数据以及包含事件处理,用于渲染页面所需的 JavaScript 代码的复杂性和大小也会增加。CSR 导致了大量的 JavaScript 捆绑包,增加了页面的 FCP 和 TTI。大型负载和网络请求的瀑布流(例如用于 API 响应)也可能导致内容无法快速呈现,从而无法被爬虫索引。这可能影响网站的 SEO。
加载和处理过多的 JavaScript 可能会影响性能。然而,即使在主要是静态网站上,通常也需要一些交互性和 JavaScript。以下部分讨论的渲染技术试图在以下方面找到平衡:
-
与 CSR 应用程序相媲美的互动性
-
SEO 和性能优势与 SSR 应用程序相媲美
服务器端渲染(Server-Side Rendering)
使用 SSR,我们为每个请求生成 HTML。这种方法最适合包含高度个性化数据的页面,例如基于用户 cookie 的数据或一般从用户请求获取的任何数据。它也适用于应该是渲染阻塞的页面,也许基于认证状态。
SSR 是最古老的 Web 内容渲染方法之一。SSR 生成完整的 HTML,以响应用户请求渲染页面内容。内容可能包括来自数据存储或外部 API 的数据。
连接和获取操作在服务器上处理。用于格式化内容的 HTML 也在服务器上生成。因此,通过 SSR,我们可以避免为数据获取和模板化进行额外的往返。因此,客户端不需要渲染代码,也不需要将其对应的 JavaScript 发送到客户端。
使用 SSR,每个请求都被服务器独立处理并视为新请求处理。即使两个连续请求的输出不是很不同,服务器也会重新处理并生成它。由于服务器对多个用户共享,处理能力在给定时间由所有活跃用户共享。
个性化仪表板是页面上高度动态内容的一个很好的例子。大部分内容基于用户的身份或授权级别,这可能包含在用户的 cookie 中。此仪表板仅在用户经过身份验证时显示,并可能显示不应对其他人可见的用户特定敏感数据。
SSR 的核心原则是在服务器上呈现 HTML,并与必要的 JavaScript 一起在客户端重新注入它。重新注入是在服务器呈现后在客户端重新生成 UI 组件状态。由于重新注入会带来成本,每个 SSR 变体都尝试优化重新注入过程。
静态渲染
使用静态渲染,整个页面的 HTML 在构建时生成,并在下一次构建之前不会更改。HTML 内容是静态的,并且可以轻松地在内容交付网络(CDN)或边缘网络上缓存。当客户端请求特定页面时,CDN 可以快速提供预渲染的缓存 HTML。这大大缩短了在典型 SSR 设置中处理请求、呈现 HTML 内容和响应请求所需的时间。
此过程最适合那些不经常更改并且无论谁请求都显示相同数据的页面。像网站的“关于我们”,“联系我们”和“博客”页面,或电子商务应用程序的产品页面都是静态渲染的理想候选者。像 Next.js,Gatsby 和 VuePress 这样的框架支持静态生成。
在其核心,纯静态渲染不涉及任何动态数据。让我们通过一个 Next.js 的例子来理解它:
// pages/about.js
export default function About() {
return <div>
<h1>About Us</h1>
{/* ... */}
</div>
}
在构建站点时(使用next build),此页面将预渲染为一个 HTML 文件about.html,可通过路由/about访问。
您可以有以下几种静态渲染的变体:
使用数据库动态数据静态生成列表页面
列表页面是在服务器上使用数据生成的。这适用于列表本身不是非常动态的页面。在 Next.js 中,您可以在页面组件中导出getStaticProps()函数来实现此目的。
使用动态路由静态生成详细页面
产品页面或博客页面通常遵循填充数据占位符的固定模板。在这种情况下,通过将模板与动态数据合并,可以在服务器上生成单独的页面。Next.js 的动态路由功能通过使用getStaticPaths()函数来实现这一点。
使用客户端获取的静态渲染
这种模式对于相对动态的列表页面很有帮助,应始终显示最新的列表。您仍然可以使用静态渲染网站,在您想放置动态列表数据的地方渲染 UI 骨架组件。然后,在页面加载后,我们可以使用 SWR 获取数据。SWR(受到陈旧-同时重新验证模式启发)是用于数据获取的 React Hooks。使用自定义 API 路由从 CMS 获取数据并返回该数据。当用户请求页面时,预生成的 HTML 文件将发送到客户端。用户最初看到没有任何数据的骨架 UI。客户端从 API 路由获取数据,接收响应并显示列表。
静态渲染的主要亮点包括以下几点:
-
HTML 在构建时生成。
-
可以轻松通过 CDN/Vercel Edge 网络进行缓存。
-
对于不需要基于请求数据的页面,纯静态渲染是最佳选择。
-
静态页面与客户端获取结合使用最适合包含应在每次页面加载时刷新的数据的页面,并且这些数据包含在稳定的占位符组件中。
增量静态再生成
ISR 是静态渲染和 SSR 的混合体,因为它允许我们仅预渲染某些静态页面,并在用户请求时按需渲染动态页面。这导致构建时间缩短,并允许在特定间隔后自动使缓存失效并重新生成页面。
ISR 在两个方面工作,用于在构建后增量引入现有静态站点的更新:
允许添加新页面
懒加载概念用于在构建后将新页面包含在网站中。这意味着新页面在第一次请求时立即生成。在生成过程中,前端可以向用户显示回退页面或加载指示器。
更新现有页面
对每个页面定义合适的超时时间。这将确保在经过定义的超时周期后重新验证页面。超时时间可以设置得低至 1 秒。用户在页面完成重新验证之前将继续看到先前的页面版本。因此,ISR 使用陈旧-同时重新验证策略,在重新验证进行时,用户接收到缓存或陈旧版本。重新验证完全在后台进行,无需完全重建。
按需 ISR
在这种 ISR 的变体中,再生发生在特定事件而不是固定间隔上。对于常规 ISR,更新的页面仅在处理了页面用户请求的边缘节点上缓存。按需 ISR 重新生成并在边缘网络上重新分发页面,以便全球用户可以自动从边缘缓存中看到页面的最新版本,而无需看到过时内容。我们还避免了不必要的再生和无服务器函数调用,与常规 ISR 相比,减少了运营成本。因此,按需 ISR 为我们带来了性能优势和出色的开发体验。按需 ISR 最适合应基于特定事件重新生成的页面。它使我们能够以合理的成本拥有快速和动态的网站,始终在线。
静态渲染摘要
静态渲染是在构建时可以生成 HTML 的网站的优秀模式。现在我们已经涵盖了静态生成的不同变体,每种都适合不同的用例:
纯静态渲染
最适合不包含动态数据的页面
静态与客户端获取
最适合在每次页面加载时刷新数据并具有稳定占位符组件的页面
增量静态再生
最适合应在特定间隔或按需重新生成的页面
按需 ISR
最适合应基于特定事件重新生成的页面
存在静态不是最佳选择的用例。例如,SSR 非常适合高度动态、个性化的页面,这些页面对每个用户都不同。
流式 SSR
使用 SSR 或静态渲染,可以减少 JavaScript 的量,使页面交互时间(TTI)接近 FCP 的时间。流式传输内容可以进一步减少 TTI/FCP,同时仍然在服务器端渲染应用程序。不再生成一个包含当前导航所需标记的大型 HTML 文件,而是将其分割为较小的块。节点流允许我们将数据流式传输到响应对象中,这意味着我们可以持续向客户端发送数据。当客户端接收到数据块时,可以开始渲染内容。
React 内置的renderToNodeStream允许我们将应用程序以较小的块发送。客户端在接收数据时可以开始绘制 UI,因此我们可以创建非常高效的首次加载体验。在接收到的 DOM 节点上调用hydrate方法将附加相应的事件处理程序,使 UI 变得交互。
流式传输对网络背压响应良好。如果网络拥塞并且无法传输更多字节,则渲染器收到信号并停止流式传输,直到网络情况得以清理。因此,服务器使用的内存较少,对 I/O 条件更响应灵敏。这使得您的 Node.js 服务器能够同时渲染多个请求,并防止较重的请求长时间阻塞较轻的请求。结果,即使在复杂条件下,网站仍能保持响应性。
React 在 2016 年发布的 React 16 中引入了对流式传输的支持。它在 ReactDOMServer 中包含以下 API 以支持流式传输:
ReactDOMServer.renderToNodeStream(element)
该函数的输出 HTML 与 ReactDOMServer.renderToString(element) 相同,但格式为 Node.js 的 ReadableStream,而不是字符串。该函数仅在服务器上工作,以流的形式渲染 HTML。接收此流的客户端可以调用 ReactDOM.hydrate() 来注水页面并使其交互。
ReactDOMServer.renderToStaticNodeStream(element)
这对应于 ReactDOMServer.renderToStaticMarkup(element)。输出的 HTML 格式相同,但以流格式提供。您可以使用它在服务器上渲染静态的非交互式页面,然后将其流式传输到客户端。
由这两个函数输出的可读流在开始读取后可以逐字节发出。您可以通过将可读流传送到可写流(如响应对象)来实现此目的。响应对象在等待新的数据块被渲染时,逐步向客户端发送数据块。
边缘 SSR
边缘 SSR 能够让你从 CDN 的所有区域进行服务器渲染,并体验接近零冷启动。
无服务器函数可用于在服务器端生成整个页面。边缘运行时还允许 HTTP 流式传输,因此您可以在文档部分准备就绪时立即流式传输它们,并逐个注水这些组件。这减少了首次内容渲染(FCP)的时间。
此模式的一个用例是为用户构建特定于区域的列表页面。页面的大部分仅包含静态数据;仅需基于请求的数据的列表。现在,我们可以选择仅在服务器端渲染列表组件并在边缘渲染其余部分,而不是必须完全服务器渲染整个页面以实现此行为。因此,我们现在可以在边缘获取静态渲染的优秀性能,并享有 SSR 的动态优势。
混合渲染
正如其名称所示,混合渲染结合了不同的方法,侧重于提供最佳结果。它代表了开发人员在如何处理 Web 开发时的心理转变,从仅客户端开始点到更多样化的渲染策略组合。可以静态提供的页面将被预渲染。对于应用程序中的其他页面(例如 ISR 或 SSR 或 CSR,并用于后续导航的流式传输),可能会选择动态策略。
混合渲染在概念上挑战了传统的术语(SPA、MPA、SSR、SSG),并强调了描述现代网络开发实践的新术语的必要性。一个 Web 应用程序不再需要被分类为 SPA 或 MPA。它可以根据提供的功能轻松过渡。因此,它提供了 SPA 的优点(无需服务器)同时避免了静态渲染(无页面重新加载的导航)的问题。
注意重点不是从编写 SPA 到不编写 SPA,而是从被锁定于 SPA 到根据每个页面需求使用合适的渲染模式,因此进入了混合时代。这种转变主要是心态上的,开发人员从构建时和客户端渲染开始,并根据需要逐页添加服务器渲染。
随着网络开发景观向混合渲染收敛,我们看到许多框架,无论是在 React 宇宙内还是之外,都开始支持它。例如:
-
Next.js 13 结合 React Server Components 和Next.js 应用程序路由器,展示了混合渲染的潜力。
-
Astro 2.0带来了静态和动态渲染的最佳结合,而不是选择 SSG 或 SSR。
-
Angular Universal 11.1支持原生混合渲染。它可以对静态路由执行预渲染(SSG),对动态路由执行 SSR。
-
Nuxt 3.0允许你配置路由规则以支持混合渲染。
渐进式水合
渐进式水合意味着你可以随时间逐个水合节点,这样你可以在任何时候只请求最少必要的 JavaScript。通过逐步水合应用程序,我们可以延迟页面较不重要部分的水合。
通过这种方式,我们减少了请求页面交互所需的 JavaScript 量,并且仅在用户需要时才水合节点,例如,当组件在视口中可见时。渐进式水合还有助于避免最常见的 SSR 再水合陷阱,即服务器渲染的 DOM 树被销毁并立即重建。
渐进式水合的理念是通过分块激活你的应用程序来提供优异的性能。任何渐进式水合解决方案都应考虑其对整体用户体验的影响。你不能让屏幕的块一个接一个地弹出,并阻止已加载的块上的任何活动或用户输入。因此,全面的渐进式水合实现需求如下:
-
允许所有组件使用 SSR
-
支持将代码拆分为单独的组件或块
-
支持按开发者定义的顺序在客户端水合这些块
-
不会阻塞已经水合的块上的用户输入
-
允许在延迟水合的块中使用一些加载指示器
一旦 React 并发模式可用,它将满足所有这些要求。它允许 React 同时处理不同的任务,并根据给定的优先级在它们之间进行切换。在切换时,不需要提交部分渲染树,这样渲染任务可以在 React 切换回相同任务时继续进行。
并发模式可用于实现渐进 hydration。在这种情况下,页面上每个 chunk 的 hydration 成为 React 并发模式的一个任务。如果需要执行高优先级的任务,如用户输入,React 将暂停 hydration 任务并切换到接受用户输入的状态。像lazy()和Suspense()这样的功能允许您使用声明式加载状态。这些状态可用于在懒加载 chunk 时显示加载指示器。SuspenseList()可用于定义懒加载组件的优先级。Dan Abramov 展示了一个很棒的demo,展示了并发模式的运行并实现了渐进 hydration。
岛屿架构
Katie Sylor-Miller 和 Jason Miller 流行了术语岛屿架构,用来描述一种旨在通过可以独立交付的“互动岛屿”减少通过的 JavaScript 体积的范式。岛屿是一种基于组件的架构,建议采用具有静态和动态岛屿的隔离页面视图。大多数页面都是静态和动态内容的组合。通常,页面由纯非交互式 HTML 组成,并且不需要 hydration。动态区域是 HTML 和脚本的组合,在渲染后能够重新 hydration 自己。
岛屿架构有助于 SSR 页面及其所有静态内容。但在这种情况下,渲染的 HTML 将包含动态内容的占位符。动态内容占位符包含独立的组件小部件。每个小部件类似于一个应用程序,结合了服务器渲染的输出和 JavaScript 来在客户端 hydration 应用程序。
岛屿架构可能会与渐进式 hydration 混淆,但它们是非常不同的。在渐进式 hydration 中,页面的 hydration 架构是自顶向下的。页面控制着各个组件的调度和 hydration。而在岛屿架构中,每个组件都有自己的 hydration 脚本,可以独立于页面上的任何其他脚本异步执行。一个组件的性能问题不应影响其他组件。
实施岛屿
Island 架构借鉴了不同来源的概念,并旨在最优地结合它们。基于模板的静态站点生成器(如Jekyll和Hugo)支持将静态组件渲染到页面上。大多数现代 JavaScript 框架也支持同构渲染,允许您在服务器和客户端上使用相同的代码来渲染元素。
Jason Miller 的帖子建议使用requestIdleCallback()来实现组件水合的调度方法。支持 Island 架构的框架应执行以下操作:
-
支持在服务器上以零 JavaScript 进行页面的静态渲染。
-
支持通过静态内容中的占位符嵌入独立的动态组件。每个动态组件包含其脚本,并可以在主线程空闲时使用
requestIdleCallback()进行水合。 -
允许在服务器上以同构方式渲染组件,并在客户端上进行水合以识别同一组件。
目前,以下框架在某种程度上支持这一点:
Marko
Marko 是由 eBay 开发和维护的开源框架,旨在提高服务器渲染性能。它通过结合流式渲染和自动部分水合支持 Island 架构。HTML 和其他静态资产会在准备好时即时流式传输到客户端。自动部分水合允许交互式组件自行水合。水合代码仅针对可在浏览器上更改状态的交互式组件进行提供。它是同构的,Marko 编译器根据其将运行的位置(客户端或服务器)生成优化代码。
Astro
Astro 是一个静态站点生成器,可以从在其他框架(如 React、Preact、Svelte、Vue 等)中构建的 UI 组件生成轻量级的静态 HTML 页面。需要客户端 JavaScript 的组件会单独加载其依赖项。因此,它提供了内置的部分水合能力。Astro 还可以根据组件何时变得可见而进行懒加载。
Eleventy + Preact
Markus Oberlehner 演示了 Eleventy(11ty)的使用,这是一个具有同构 Preact 组件的静态站点生成器,可以进行部分水合。它还支持懒加载水合。组件本身可以声明式地控制其水合。交互式组件使用WithHydration包装器,以便它们在客户端上进行水合。
请注意,Marko 和 Eleventy 早于 Jason 提供的 Islands 定义,但包含了支持其所需功能的一些特性。然而,Astro 是根据该定义构建的,并天生支持 Island 架构。
优缺点
实施 Islands 的一些潜在好处如下:
性能
减少发送到客户端的 JavaScript 代码量。发送的代码仅包含用于交互式组件所需的脚本。这远远少于重新创建整个页面的虚拟 DOM 所需的脚本并重新 hydrate 所有元素的脚本。较小的 JavaScript 大小自动对应更快的页面加载。
SEO
由于所有静态内容都在服务器上呈现,页面对 SEO 友好。
重要内容的优先级设置
主要内容(尤其是博客、新闻文章和产品页面)几乎立即向用户提供。
辅助功能
使用标准静态 HTML 链接访问其他页面有助于提高网站的可访问性。
基于组件的
该设计提供了基于组件的架构的所有优势,例如可重用性和可维护性。
尽管有这些优势,这一概念仍处于初期阶段。开发人员实施 Islands 的唯一选项是使用少数可用的框架之一或自行开发架构。将现有网站迁移到 Astro 或 Marko 将需要额外的工作。该架构也不适用于像社交媒体应用程序这样可能需要成千上万个 Islands 的高度互动页面。
React 服务器组件
React 服务器组件(RSC)是设计用于在服务器上运行的无状态 React 组件。它们旨在通过服务器驱动的心理模型实现现代 UX。这些零捆绑大小的组件促进了在服务器和客户端组件之间的无缝代码转换体验或“编织”。这与组件的 SSR 不同,可能会导致客户端 JavaScript 包的显著减少。
RSC 使用async/await作为从服务器组件获取数据的主要方式。它们允许您将数据获取作为组件树的一部分,支持顶层await和服务器端数据序列化。组件因此可以定期重新获取。具有在有新数据时重新渲染组件的应用程序可以在服务器上运行,从而限制需要发送到客户端的代码量。这结合了客户端应用程序的丰富交互性和传统服务器渲染的改进性能。
RSC 协议使服务器能够为客户端公开一个特殊的端点,以请求组件树的部分,允许 SPA 样式的路由与 MPA 样式的架构结合。这允许将服务器组件树与客户端树合并而无状态丢失,并支持扩展到更多组件。
服务器组件不能替代 SSR。当两者配合使用时,它们支持快速以中间格式渲染,然后通过 SSR 基础设施将其呈现为 HTML,从而仍然实现快速首次绘制。我们将客户端组件作为服务器组件发出,类似于其他数据获取机制中使用的 SSR。
RSC 提供了组件的规范。RSC 的采纳取决于框架是否实现了该功能。技术上可以在任何 React 框架中使用 RSC,通过其 App Router 功能启用了 React 自己的部分水合功能。React 团队认为 RSC 最终将被广泛采用并改变生态系统。Next.js 已经通过其 App Router 功能引入了支持。
使用 RSC 和 Next.js App Router 进行混合渲染
Next.js 13 引入了 App Router,带来了新特性、约定和对 RSC 的支持。应用目录中的组件默认为 RSC,促进了自动采纳和提升了性能。
RSC 提供了诸如利用服务器基础设施、将大型依赖保留在服务器端等优点,从而提升了性能并减少了客户端捆绑包的大小。Next.js App Router 结合了服务器渲染和客户端交互,逐步增强了应用程序,提供了无缝的用户体验。
可以添加客户端组件以引入类似于 Next.js 12 及更早版本中的客户端交互功能。"use client" 指令可以标记组件为客户端组件。如果未被其他客户端组件导入,则未带 "use client" 指令的组件将自动渲染为服务器组件。
Server 和 Client 组件可以交错在同一组件树中,React 负责合并这两种环境。在生产中采用 RSC 和应用目录后,Next.js 用户看到了 性能改进。
总结
本章介绍了许多试图平衡 CSR 和 SSR 能力的模式。根据应用程序类型或页面类型的不同,某些模式可能比其他模式更适合。图表中的 图 13-1 比较了不同模式的亮点,并为每个模式提供了使用案例。

图 13-1. 渲染模式
来自 2022 年构建 JavaScript 网站的模式 的以下表格提供了另一种通过关键应用特征进行旋转的视图。对于寻找常见 应用原型 的合适模式的任何人都应该很有帮助。
| 投资组合 | 内容 | 商店前端 | 社交网络 | 沉浸式 | |
|---|---|---|---|---|---|
| Holotype | 个人博客 | CNN | Amazon | 社交网络 | Figma |
| 互动性 | 最小化 | 关联文章 | 购买 | 多点实时 | 一切 |
| 会话深度 | 浅层 | 浅层 | 浅层到中层 | 扩展 | 深层 |
| 价值观 | 简约性 | 发现性 | 负载性能 | 动态性 | 沉浸感 |
| 路由 | 服务器 | 服务器,混合 | 混合,过渡 | 过渡,客户端 | 客户端 |
| 渲染 | 静态 | 静态,SSR | 静态,SSR | SSR | CSR |
| 水合 | 无 | 逐步、部分 | 部分、可继续 | 任意 | 无 (CSR) |
| 示例框架 | 11ty | Astro, Elder | Marko, Qwik, Hydrogen | Next, Remix | 创建 React 应用 |
我们现在已经讨论了一些有趣的 React 模式,包括组件、状态管理、渲染等。像 React 这样的库并不强制执行特定的应用程序结构,但是有推荐的最佳实践来组织你的 React 项目。让我们在下一章节探讨这个问题。
第十四章:React.js 应用程序结构
在构建小型爱好项目或尝试新概念或库时,开发人员可以开始向文件夹添加文件,而无需计划或组织结构。这些可能包括 CSS、辅助组件、图像和页面。随着项目的增长,单个资源文件夹变得难以管理。任何规模相当大的代码库都应根据逻辑标准组织成应用程序文件夹结构。如何结构化文件和应用程序组件的决定可能是个人/团队选择。这也通常取决于应用程序领域和使用的技术。
本章主要关注 React.js 应用程序的文件夹结构,这有助于更好地管理我们的项目随着其增长。
介绍
React.js 本身并没有提供有关项目结构的指导方针,但确实建议了一些常用的方法。让我们先看看这些方法,并在讨论具有复杂性和 Next.js 应用程序的项目的文件夹结构之前了解它们的优缺点。
在高层次上,您可以以两种方式对 React 应用程序中的文件进行分组:
按功能分组
为每个应用程序模块、功能或路由创建文件夹。
按文件类型分组
为不同类型的文件创建文件夹。
让我们详细看看这种分类。
按模块、功能或路由分组
在这种情况下,文件结构将反映业务模型或应用程序流程。例如,如果您有一个电子商务应用程序,您将为产品、产品列表、结账等创建文件夹。专门为产品模块所需的 CSS、JSX 组件、测试、子组件或辅助库都位于产品文件夹中:
common/
Avatar.js
Avatar.css
ErrorUtils.js
ErrorUtils.test.js
product/
index.js
product.css
price.js
product.test.js
checkout/
index.js
checkout.css
checkout.test.js
按功能分组文件的优势在于,如果模块发生更改,所有受影响的文件都位于同一文件夹中,更改会局限于代码的特定部分。
缺点是应定期识别跨模块使用的常见组件、逻辑或样式,以避免重复,并促进一致性和重用。
按文件类型分组
在这种分组方式中,您将为 CSS、组件、测试文件、图像、库等创建不同的文件夹。因此,逻辑相关的文件将根据文件类型位于不同的文件夹中:
css/
global.css
checkout.css
product.css
lib/
date.js
currency.js
gtm.js
pages/
product.js
productlist.js
checkout.js
这种方法的优势是:
-
您有一个可以在项目中重复使用的标准结构。
-
对于对应用程序特定逻辑了解有限的新团队成员,仍然可以找到类似样式或测试文件。
-
在不同路由或模块中导入的常见组件(如日期选择器)和样式可以一次更改,以确保效果在整个应用程序中可见。
缺点是:
-
特定模块逻辑的更改可能需要跨不同文件夹的文件进行更改。
-
随着应用程序中功能的增加,不同文件夹中的文件数量会增加,这使得查找特定文件变得困难。
对于小到中型应用程序每个文件夹中的文件数目(50 到 100)较少的项目,这些方法中的任何一种都可以很容易地设置。但是,对于更大的项目,您可能需要根据应用程序的逻辑结构采取一种混合方法。让我们看看一些可能性。
基于领域和通用组件的混合分组
在这里,您将所有应用程序中需要的常见组件放在一个组件文件夹中,并将所有应用程序流特定的路由或功能放在领域文件夹(名称可以是domain、pages或routes)。每个文件夹可以有特定组件和相关文件的子文件夹:
css/
global.css
components/
User/
profile.js
profile.test.js
avatar.js
date.js
currency.js
gtm.js
errorUtils.js
domain/
product/
product.js
product.css
product.test.js
checkout/
checkout.js
checkout.css
checkout.test.js
因此,您可以通过将相关文件放置在一起,结合“按文件类型分组”和“按功能分组”的优势,这些文件经常一起更改,并且通用的可重用组件和样式在整个应用程序中使用。
根据应用程序的复杂性,您可以将其修改为较平的结构,没有子文件夹或更嵌套的结构:
较平的结构
以下示例说明了一个较平的结构:
domain/
product.js
product.css
product.test.js
checkout.js
checkout.css
checkout.test.js
嵌套结构
以下示例显示了一个更嵌套的结构:
domain/
product/
productType/
features.js
features.css
size.js
price/
listprice.js
discount.js
注意
最好避免深层嵌套超过三到四个级别,因为在文件夹之间写相对导入或移动文件时更新这些导入会变得更加困难。
这种方法的一个变体是根据视图或路由创建文件夹,除了基于领域创建的文件夹,如此处所述 here。然后,路由组件可以根据当前路由协调要显示的视图。Next.js 使用了类似的结构。
现代 React 功能的应用程序结构
现代 React 应用程序使用不同的功能,如 Redux、有状态容器、Hooks 和 Styled Components。让我们看看这些代码在上一节提出的应用程序结构中的位置。
Redux
Redux 文档强烈建议在一个地方协调给定特性的逻辑。在给定的特性文件夹内,该特性的 Redux 逻辑应该被编写为一个单独的“切片”文件,最好使用 Redux Toolkit 的 createSlice API。该文件捆绑了 {actionTypes, actions, reducer} 到一个独立的、隔离的模块。这也被称为“鸭子”模式(来自 Redux)。例如,如此处所示 here:
/src
index.tsx: Entry point file that renders the React component tree
/app
store.ts: store setup
rootReducer.ts: root reducer (optional)
App.tsx: root React component
/common: hooks, generic components, utils, etc
/features: contains all "feature folders"
/todos: a single feature folder
todosSlice.ts: Redux reducer logic and associated actions
Todos.tsx: a React component
另一个不使用创建容器或 Hooks 的详细示例可以在这里找到 here。
容器
如果你已经将代码结构化为展示组件和有状态容器组件,你可以为容器组件创建一个单独的文件夹。容器可以让你将复杂的有状态逻辑与组件的其他方面分离开来:
/src
/components
/component1
index.js
styled.js
/containers
/container1
你可以在同一篇文章中找到一个包含容器的应用的完整结构。
Hooks
Hooks 可以像任何其他类型的代码一样适应混合结构。你可以在应用程序级别有一个用于所有 React 组件消费的常见 Hooks 的文件夹。只有一个组件使用的 React Hooks 应保留在组件的文件中或组件文件夹中的单独hooks.js文件中。你可以在这里找到一个示例结构:
/components
/productList
index.js
test.js
style.css
hooks.js
/hooks
/useClickOutside
index.js
/useData
index.js
Styled Components
如果你使用的是 Styled Components 而不是 CSS,你可以使用style.js文件代替之前提到的组件级 CSS 文件。例如,如果你有一个titlebar组件,结构会是这样的:
/src/components/button/
index.js
style.js
应用级theme.js文件将包含用于背景和文本颜色的值。一个globals 组件可以包含其他组件可以使用的常见样式元素的定义。
其他最佳实践
除了文件夹结构,当构建 React 应用程序时,你可以考虑一些其他最佳实践,如下所示:
-
使用你的 API 包装第三方库,以便在需要时可以进行替换。
-
与组件一起使用PropTypes来确保属性值的类型检查。
构建性能取决于文件数量和依赖关系。如果你使用像 webpack 这样的捆绑器,一些建议可以帮助提高构建时间。
当使用加载器时,只将其应用于需要转换的模块。例如:
const path = require('path');
module.exports = {
//...
module: {
rules: [
{
test: /\.js$/,
include: path.resolve(__dirname, 'src'),
loader: 'babel-loader',
},
],
},
};
如果你使用混合/嵌套的文件夹结构,下面来自 webpack 的示例展示了如何在结构中包含和加载不同路径的文件:
const path = require('path');
module.exports = {
//...
module: {
rules: [
{
test: /\.css$/,
include: [
// Include paths relative to the current directory starting with
// `app/styles` e.g. `app/styles.css`, `app/styles/styles.css`,
// `app/stylesheet.css`
path.resolve(__dirname, 'app/styles'),
// add an extra slash to only include the content of the directory
// `vendor/styles/`
path.join(__dirname, 'vendor/styles/'),
],
},
],
},
};
没有import、require、define等引用其他模块的文件不需要解析其依赖关系。你可以使用noParse选项来避免解析它们。
Next.js 应用程序结构
Next.js是一个可扩展的 React 应用程序的生产就绪框架。虽然你可以使用混合结构,但应用程序中的所有路由必须分组在 pages 文件夹下。(页面的 URL = 根 URL + 页面文件夹中的相对路径)。
扩展之前讨论过的结构,你可以为常见组件、样式、Hooks 和实用函数创建文件夹。在领域账户相关的代码可以被组织成功能组件,不同路由可以使用这些组件。最后,你会有一个页面文件夹用于所有路由。根据这个指南提供的示例,这里有一个例子:
--- public/
Favicon.ico
images/
--- common/
components/
datePicker/
index.js
style.js
hooks/
utils/
styles/
--- modules/
auth/
auth.js
auth.test.js
product/
product.js
product.test.js
--- pages/
_app.js
_document.js
index.js
/products
[id].js
Next.js 也为许多不同类型的应用程序提供了示例。你可以使用create-next-app来创建 Next.js 提供的模板文件结构。例如,要为基本的博客应用创建模板:
yarn create next-app --example blog my-blog
总结
本章讨论了多种不同的选项来组织 React 项目。根据项目的大小、类型和使用的组件,你可以选择最适合你的那种。坚持为项目结构定义一个明确定义的模式将帮助你向团队其他成员解释项目,并防止项目变得混乱和不必要复杂。
下一章是本书的结尾章节,提供了在学习 JavaScript 设计模式时可能有帮助的额外链接。
第十五章:结论
这就是关于 JavaScript 和 React 设计模式世界的入门冒险。希望你觉得有益。
设计模式使我们能够在几十年来定义解决方案和架构的开发者的基础上构建。本书的内容应足以为你在脚本、插件和 Web 应用程序中使用我们讨论过的模式提供足够的信息。
我们需要意识到这些模式,但也必须知道如何以及何时使用它们。在采用之前,研究每种模式的利弊。花时间尝试这些模式,以充分理解它们提供的内容,并根据模式在应用程序中的实际价值做出使用判断。
如果我激发了你对这个领域的兴趣,并且你想了解更多关于设计模式的内容,有许多优秀的通用软件开发和 JavaScript 书籍可以选择。
我很高兴推荐以下内容:
-
《企业应用架构模式》 by Martin Fowler
-
《JavaScript 设计模式》 by Stoyan Stefanov
如果你对继续探索 React 设计模式感兴趣,你可能想要探索由 Lydia Hallie 和我共同创建的免费资源Patterns.dev。
感谢阅读《学习 JavaScript 设计模式》。关于学习 JavaScript 的更多教育材料,请阅读我在http://addyosmani.com博客上的更多内容,或在 Twitter 上关注@addyosmani。
祝你在 JavaScript 冒险中一切顺利!
附录:参考文献
-
Ross Harmes 和 Dustin Diaz,《专业 JavaScript 设计模式》(https://oreil.ly/RID62)。
-
Subramanyan Murali,《Guhan,JavaScript 设计模式》(https://oreil.ly/3NxNQ)。
-
James Moaoriello,《什么是设计模式,我需要它们吗?》(https://oreil.ly/m16E-)。
-
Alex Barnett,《软件设计模式》(https://oreil.ly/bOdi1)。
-
Gunni Rode,《评估软件设计模式》(https://oreil.ly/hhqwh)。
-
Stoyan Stevanov,《JavaScript 模式》(https://oreil.ly/awdqz)。
-
Jared Spool,《设计模式的元素》(https://oreil.ly/qeKIq)。
-
实用 JS 设计模式示例;讨论,Stack Overflow。
-
jQuery 中的设计模式,Stack Overflow。
-
Anoop Mashudanan,《简化软件设计》(https://oreil.ly/5PqFD)。
-
t3knomanser,《JavaScript 设计模式》(https://oreil.ly/O8VfS)。
-
在 JavaScript 编程中使用 GoF 设计模式的工作(https://oreil.ly/cerR5)。
-
JavaScript 对象字面量的优势,Stack Overflow。


浙公网安备 33010602011771号