JavaScript-设计模式-全-
JavaScript 设计模式(全)
原文:
zh.annas-archive.org/md5/0a1cecc6d3d9beac06426851bc2d48f4译者:飞龙
前言
欢迎您!JavaScript 设计模式是允许我们在 JavaScript 中编写更健壮、可扩展和可扩展应用程序的技术。JavaScript 是网络浏览器中可用的主要编程语言,也是支持超出浏览器之外的最受欢迎的编程语言之一。
设计模式是解决常见问题的解决方案,可以重复使用。最常讨论的设计模式来自面向对象编程的世界。
JavaScript 作为一个轻量级、多范式、动态、单线程的语言,与其他主流编程语言相比,具有不同的优势和劣势。软件工程师通常除了精通另一种编程语言外,还会使用 JavaScript。JavaScript 的不同特性意味着直接实现设计模式可能会导致非惯用和性能不佳的 JavaScript 应用程序。
关于 JavaScript 和设计模式有很多资源,但本书提供了对现代(ECMAScript 6+)JavaScript 中设计模式的一致和全面的观点,并提供了如何在专业环境中部署它们的实际示例。除了这个适用于项目的完整模式库之外,本书还概述了如何构建应用程序的不同部分以实现大规模的高性能。
在本书中,您将获得基于在 Elsevier、Canon 和 Eurostar 等公司构建和部署 JavaScript 和 React 应用程序九年的经验,提供多个系统演变、性能项目和下一代前端应用程序架构的最新指导。
本书面向的对象
本书是为希望利用 JavaScript 和 Web 平台来提高生产力、软件质量和应用程序性能的开发者和软件架构师而编写的。
对软件设计模式熟悉将是一个加分项,但不是必需的。
本内容的目标受众是开发者和架构师,他们面临的三个主要挑战如下:
-
他们熟悉编程概念,但不知道如何有效地在 JavaScript 中实现它们
-
他们希望以可维护和可扩展的方式构建 JavaScript 代码和应用
-
他们希望为他们的 JavaScript 应用程序的用户提供更多性能
本书涵盖的内容
第一章**,使用创建型设计模式,涵盖了创建型设计模式,这些模式有助于组织对象的创建。我们将探讨在 JavaScript 中实现原型、单例和工厂模式。
第二章**,实现结构化设计模式,探讨了结构化设计模式,这些模式有助于组织实体之间的关系。我们将使用 JavaScript 实现代理、装饰器、享元和适配器模式。
第三章**,利用行为设计模式,深入探讨了行为设计模式,这些模式有助于组织对象之间的通信。我们将学习 JavaScript 中的观察者、状态、策略和访问者模式。
第四章**,探索响应式视图库模式,探讨了响应式视图库,如 React,已经占据了 JavaScript 应用程序的领域。随着这些库的出现,带来了新的模式供我们探索、实现和对比。
第五章**,渲染策略和页面活化,探讨了优化页面性能,这在当今是一个关键问题。这不仅关系到提高页面上的客户转化率,也关系到搜索引擎优化,因为像 Google 这样的搜索引擎将核心 Web Vitals 纳入考虑范围。
第六章**,微前端、区域和岛屿架构,探讨了微前端。类似于服务层中的微服务运动,微前端旨在将大范围分割成更小的块,以便以更高的速度进行工作和交付。
第七章**,异步编程性能模式,探讨了 JavaScript 的单线程事件循环并发模型是其最大的优势之一,但在性能敏感的情况下往往被误解或未充分利用。以高效和可扩展的方式编写 JavaScript 中的异步处理代码是提供大规模平滑用户体验的关键。
第八章**,事件驱动编程模式,探讨了 JavaScript 中的事件驱动编程在安全性敏感的应用程序中至关重要,因为它是一种在不同 Web 上下文中传递信息的方式。事件驱动应用程序通常可以优化以实现更好的性能和可扩展性。
第九章**,最大化性能 – 懒加载和代码拆分,讨论了为了最大化 JavaScript 应用程序的性能,减少加载和解释未使用的 JavaScript 代码量是关键。可以应用于此问题的技术被称为懒加载和代码拆分。
第十章**,资产加载策略和主线程之外的代码执行,探讨了在应用程序的生命周期中,有时不可避免地需要加载更多的 JavaScript 或资产。你将了解在 JavaScript 以及其他网络资源的具体情况下,资产加载优化,以及如何在不影响主浏览器线程的情况下执行 JavaScript。
为了充分利用这本书
您需要具备 JavaScript 和 Web 开发的先验经验。本书中的一些更高级的主题将吸引那些有使用 JavaScript 构建 Web 中级经验的开发者。
| 本书中涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Node.js 20+ | Windows, macOS, 或 Linux |
| NPM v8+ | Windows, macOS, 或 Linux |
| ECMAScript 6+ | Windows, macOS, 或 Linux |
| React v16+ | Windows, macOS, 或 Linux |
| Next.js | Windows, macOS, 或 Linux |
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/JavaScript-Design-Patterns。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“为了使代码更容易理解,我们将切换到tagName的小写版本。”
代码块设置如下:
<script>
// handle receiving messages from iframe -> parent
const allowedMessageOrigins = ['http://127.0.0.1:8000'];
window.addEventListener('message', (event) => {
if (!allowedMessageOrigins.includes(event.origin)) {
console.warn(
`Dropping message due to non-allowlisted origin ${event.origin}`,
event,
);
return;
}
// no change to the rest of the message handler
});
</script>
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“当打开选择时,事情似乎工作正常,我们看到所有选项的Fruit: 前缀。”
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并附上材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了JavaScript 设计模式,我们非常乐意听到您的想法!请点击此处直接转到此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但又无法随身携带您的印刷书籍?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您将获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容。
按照以下简单步骤获取这些好处:
- 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-80461-227-9
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一部分:设计模式
在本部分,您将了解设计模式及其在现代 JavaScript 中如何有效实现。您将学习如何以及在何种情况下以“经典”面向对象的方式实现创建型、结构型和行为型设计模式,以及如何利用现代 JavaScript 特性使这种实现更符合语言习惯。最后,您将看到设计模式在 JavaScript 生态系统中的实际应用案例,从而学习如何识别它们。
本部分包含以下章节:
-
第一章, 使用创建型设计模式
-
第二章, 实现结构型设计模式
-
第三章, 利用行为型设计模式
第一章:与创建型设计模式一起工作
JavaScript 设计模式是允许我们在 JavaScript 中编写更健壮、可扩展和可扩展的应用程序的技术。JavaScript 是一种非常流行的编程语言,部分原因是它作为在网页上提供交互功能的一种方式。其受欢迎的另一个原因是 JavaScript 的轻量级、动态、多范式特性,这意味着其他生态系统的设计模式可以适应以利用 JavaScript 的优势。JavaScript 的具体优势和劣势也可以为语言及其使用环境中的新模式提供信息。
创建型设计模式为对象创建提供结构,这使得开发不需要知道如何创建彼此实例的不同模块、类和对象的应用程序和系统成为可能。与 JavaScript 最相关的模式——原型、单例和工厂模式——将被探讨,以及它们何时有用以及如何以惯用的方式实现。
本章我们将涵盖以下主题:
-
创建型设计模式的全面定义以及原型、单例和工厂模式的定义
-
原型模式的多种实现及其用例
-
单例设计模式的实现,包括急切和延迟初始化,单例的用例以及现代 JavaScript 中的单例模式是什么样的
-
如何使用类、现代 JavaScript 的替代方案以及用例来实现工厂模式
到本章结束时,你将能够识别何时使用创建型设计模式是有用的,并就其多种实现中选择一个明智的决定,从更惯用的 JavaScript 形式到经典形式。
什么是创建型设计模式?
创建型设计模式处理对象创建。它们允许消费者创建对象实例,而无需了解如何实例化对象的具体细节。由于在面向对象的语言中,对象的实例化仅限于类的构造函数,因此允许不调用构造函数就创建对象实例可以减少消费者和被实例化的类之间的噪声和紧密耦合。
在 JavaScript 中,当我们讨论“对象创建”时存在歧义,因为 JavaScript 的多范式特性意味着我们可以不使用类或构造函数来创建对象。例如,在 JavaScript 中,这是一个使用对象字面量进行对象创建的例子 – const config = { forceUpdate: true }。实际上,现代惯用的 JavaScript 往往更倾向于过程和函数范式,而不是面向对象。这意味着创建型设计模式可能需要调整才能在 JavaScript 中完全有用。
总结来说,在面向对象的 JavaScript 中,创建型设计模式非常有用,因为它们隐藏了实例化细节,从而降低了耦合度,允许更好的模块分离。
在下一节中,我们将遇到第一个创建型设计模式——原型设计模式。
在 JavaScript 中实现原型模式
让我们先定义一下原型模式。
原型设计模式允许我们根据另一个现有实例(我们的原型)创建一个实例。
更正式地说,一个 prototype 类公开了一个 clone() 方法。消费代码,而不是调用 new SomeClass,将调用 new SomeClassPrototype(someClassInstance).clone()。这个方法调用将返回一个带有从 someClassInstance 复制的所有值的 new SomeClass 实例。
实现
让我们想象一个场景,我们正在构建一个棋盘。有两种关键的方块类型——白色和黑色。除了这些信息之外,每个方块还包含其行、列以及位于其上的棋子信息。
一个 BoardSquare 类构造函数可能看起来如下:
class BoardSquare {
constructor(color, row, file, startingPiece) {
this.color = color;
this.row = row;
this.file = file;
}
}
BoardSquare 上可能有一组有用的方法,例如 occupySquare 和 clearSquare,如下所示:
class BoardSquare {
// no change to the rest of the class
occupySquare(piece) {
this.piece = piece;
}
clearSquare() {
this.piece = null;
}
}
由于 BoardSquare 有很多属性,实例化它相当繁琐:
const whiteSquare = new BoardSquare('white');
const whiteSquareTwo = new BoardSquare('white');
// ...
const whiteSquareLast = new BoardSquare('white');
注意传递给 new BoardSquare 的参数重复,如果我们想将所有棋盘方块变为黑色,我们需要逐个更改每个 new BoardSquare 调用的参数。这可能会非常容易出错;只需在 color 值中犯一个难以发现的错误就可能导致错误:
const blackSquare = new BoardSquare('black');
const blackSquareTwo = new BoardSquare('black');
// ...
const blackSquareLast = new BoardSquare('black');
使用经典的原型实现我们的实例化逻辑如下。我们需要一个 BoardSquarePrototype 类;其构造函数接受一个 prototype 属性,并将其存储在实例上。BoardSquarePrototype 公开了一个 clone() 方法,该方法不接受任何参数,并返回一个带有 prototype 上所有属性的 BoardSquare 实例:
class BoardSquarePrototype {
constructor(prototype) {
this.prototype = prototype;
}
clone() {
const boardSquare = new BoardSquare();
boardSquare.color = this.prototype.color;
boardSquare.row = this.prototype.row;
boardSquare.file = this.prototype.file;
return boardSquare;
}
}
使用 BoardSquarePrototype 需要以下步骤:
-
首先,我们想要一个 BoardSquare 的实例来初始化——在这种情况下,使用 'white'。然后,它将在调用 BoardSquarePrototype 构造函数时作为 prototype 属性传递:
const whiteSquare = new BoardSquare('white'); const whiteSquarePrototype = new BoardSquarePrototype (whiteSquare); -
然后,我们可以使用 whiteSquarePrototype 与 .clone() 方法来创建 whiteSquare 的副本。注意,color 被复制,但每次调用 clone() 都会返回一个新的实例。
const whiteSquareTwo = whiteSquarePrototype.clone(); // ... const whiteSquareLast = whiteSquarePrototype.clone(); console.assert( whiteSquare.color === whiteSquareTwo.color && whiteSquareTwo.color === whiteSquareLast.color, 'Prototype.clone()-ed instances have the same color as the prototype' ); console.assert( whiteSquare !== whiteSquareTwo && whiteSquare !== whiteSquareLast && whiteSquareTwo !== whiteSquareLast, 'each Prototype.clone() call outputs a different instances' );
根据代码中的断言,克隆的实例具有相同的 color 值,但它们是 Square 对象的不同实例。
一个用例
为了说明从白色方块变为黑色方块需要改变什么,让我们看看一些示例代码,其中变量名中没有引用 'white':
const boardSquare = new BoardSquare('white');
const boardSquarePrototype = new BoardSquarePrototype(boardSquare);
const boardSquareTwo = boardSquarePrototype.clone();
// ...
const boardSquareLast = boardSquarePrototype.clone();
console.assert(
boardSquareTwo.color === 'white' &&
boardSquare.color === boardSquareTwo.color &&
boardSquareTwo.color === boardSquareLast.color,
'Prototype.clone()-ed instances have the same color as
the prototype'
);
console.assert(
boardSquare !== boardSquareTwo &&
boardSquare !== boardSquareLast &&
boardSquareTwo !== boardSquareLast,
'each Prototype.clone() call outputs a different
instances'
);
在这种情况下,我们只需更改传递给 BoardSquare 的 color 值,就可以改变从原型克隆的所有实例的颜色:
const boardSquare = new BoardSquare('black');
// rest of the code stays the same
console.assert(
boardSquareTwo.color === 'black' &&
boardSquare.color === boardSquareTwo.color &&
boardSquareTwo.color === boardSquareLast.color,
'Prototype.clone()-ed instances have the same color as
the prototype'
);
console.assert(
boardSquare !== boardSquareTwo &&
boardSquare !== boardSquareLast &&
boardSquareTwo !== boardSquareLast,
'each Prototype.clone() call outputs a different
instances'
);
原型模式在需要对象实例的“模板”的情况下很有用。这是一个创建“默认对象”但带有自定义值的好模式。它允许更快、更轻松地进行更改,因为它们在模板对象上只实现一次,但应用于所有 clone()-ed 实例。
使用现代 JavaScript 增强对原型实例变量变化的鲁棒性
我们可以在 JavaScript 的原型实现中做出一些改进。
第一个是在 clone() 方法中。为了使我们的原型类对原型构造函数/实例变量的变化具有鲁棒性,我们应该避免逐个复制属性。
例如,如果我们添加一个新的 startingPiece 参数,该参数由 BoardSquare 构造函数接受并将 piece 实例变量设置为,我们当前的 BoardSquarePrototype 实现将无法复制它,因为它只复制 color、row 和 file:
class BoardSquare {
constructor(color, row, file, startingPiece) {
this.color = color;
this.row = row;
this.file = file;
this.piece = startingPiece;
}
// same rest of the class
}
const boardSquare = new BoardSquare('white', 1, 'A',
'king');
const boardSquarePrototype = new BoardSquarePrototype
(boardSquare);
const otherBoardSquare = boardSquarePrototype.clone();
console.assert(
otherBoardSquare.piece === undefined,
'prototype.piece was not copied over'
);
注意
Object.assign 的参考:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign。
如果我们将 BoardSquarePrototype 类修改为使用 Object.assign(new BoardSquare(), this.prototype),它将复制 prototype 的所有可枚举属性:
class BoardSquarePrototype {
constructor(prototype) {
this.prototype = prototype;
}
clone() {
return Object.assign(new BoardSquare(), this.prototype);
}
}
const boardSquare = new BoardSquare('white', 1, 'A',
'king');
const boardSquarePrototype = new BoardSquarePrototype
(boardSquare);
const otherBoardSquare = boardSquarePrototype.clone();
console.assert(
otherBoardSquare.piece === 'king' &&
otherBoardSquare.piece === boardSquare.piece,
'prototype.piece was copied over'
);
JavaScript 中没有类的原型模式
由于历史原因,JavaScript 在语言中深深嵌入了一个原型概念。实际上,类是在 ECMAScript 标准中后来引入的,ECMAScript 6 在 2015 年发布(参考,ECMAScript 1 在 1997 年发布)。
这就是为什么很多 JavaScript 完全放弃了使用类的使用。JavaScript 的“对象原型”可以用来使对象相互继承方法和变量。
克隆对象的一种方法是通过使用 Object.create 来克隆具有其方法的对象。这依赖于 JavaScript 原型系统:
const square = {
color: 'white',
occupySquare(piece) {
this.piece = piece;
},
clearSquare() {
this.piece = null;
},
};
const otherSquare = Object.create(square);
这里的一个细微差别是 Object.create 实际上并没有复制任何东西;它只是创建了一个新对象,并将其原型设置为 square。这意味着如果 otherSquare 上没有找到属性,它们将在 square 上访问:
console.assert(otherSquare.__proto__ === square, 'uses JS
prototype');
console.assert(
otherSquare.occupySquare === square.occupySquare &&
otherSquare.clearSquare === square.clearSquare,
"methods are not copied, they're 'inherited' using the
prototype"
);
delete otherSquare.color;
console.assert(
otherSquare.color === 'white' && otherSquare.color ===
square.color,
'data fields are also inherited'
);
关于 JavaScript 原型的进一步说明,以及它在类成为 JavaScript 部分之前就存在,是 JavaScript 中的子类化是设置对象原型的另一种语法。看看下面的 extends 示例。BlackSquare extends Square 将 BlackSquare 的 prototype.__proto__ 属性设置为 Square.prototype:
class Square {
constructor() {}
occupySquare(piece) {
this.piece = piece;
}
clearSquare() {
this.piece = null;
}
}
class BlackSquare extends Square {
constructor() {
super();
this.color = 'black';
}
}
console.assert(
BlackSquare.prototype.__proto__ === Square.prototype,
'subclass prototype has prototype of superclass'
);
在本节中,我们学习了如何使用具有公开 clone() 方法的原型类实现原型模式,原型模式可以帮助的代码情况,以及如何使用现代 JavaScript 功能进一步改进我们的原型实现。我们还涵盖了 JavaScript 的“原型”,为什么它存在,以及它与原型设计模式的关系。
在本章的下一部分,我们将探讨另一种创建型设计模式,即单例设计模式,以及一些在 JavaScript 中的实现方法和用例。
JavaScript 中的单例模式,具有 eager 和 lazy 初始化
首先,让我们定义单例设计模式。
单例模式允许一个对象只被实例化一次,向消费者公开这个单一实例,并控制单一实例的实例化。
单例是另一种在不使用构造函数的情况下获取对象实例的方法,尽管对象必须设计为单例。
实现
单例的一个经典例子是日志记录器。在应用程序中很少需要(并且通常,这是一个问题)实例化多个日志记录器。拥有单例意味着初始化位置受到控制,并且日志记录器配置将在整个应用程序中保持一致——例如,日志级别不会根据我们在应用程序中从哪里调用日志记录器而改变。
一个简单的日志记录器看起来如下,它有一个接受 logLevel 和 transport 的构造函数,以及一个私有的 isLevelEnabled 方法,它允许我们丢弃日志记录器未配置保留的日志(例如,当级别是 warn 时,我们丢弃 info 消息)。日志记录器最终实现了 info、warn 和 error 方法,它们的行为如前所述;它们只有在级别“启用”时(即,“高于”配置的日志级别)才会调用相关的 transport 方法。
支持 isLevelEnabled 的可能 logLevel 值存储在 Logger 的静态字段上:
class Logger {
static logLevels = ['info', 'warn', 'error'];
constructor(logLevel = 'info', transport = console) {
if (Logger.#loggerInstance) {
throw new TypeError(
'Logger is not constructable, use getInstance()
instead'
);
}
this.logLevel = logLevel;
this.transport = transport;
}
isLevelEnabled(targetLevel) {
return (
Logger.logLevels.indexOf(targetLevel) >=
Logger.logLevels.indexOf(this.logLevel)
);
}
info(message) {
if (this.isLevelEnabled('info')) {
return this.transport.info(message);
}
}
warn(message) {
if (this.isLevelEnabled('warn')) {
this.transport.warn(message);
}
}
error(message) {
if (this.isLevelEnabled('error')) {
this.transport.error(message);
}
}
}
为了使 Logger 成为单例,我们需要实现一个返回缓存实例的 getInstance 静态方法。为了做到这一点,我们将在 Logger 上使用一个静态的 loggerInstance。getInstance 将检查 Logger.loggerInstance 是否存在,如果存在则返回它;否则,它将创建一个新的 Logger 实例,将其设置为 loggerInstance,然后返回它:
class Logger {
static loggerInstance = null;
// rest of the class
static getInstance() {
if (!Logger.loggerInstance) {
Logger.loggerInstance = new Logger('warn', console);
}
return Logger.loggerInstance;
}
}
在另一个模块中使用它就像调用 Logger.getInstance() 一样简单。所有的 getInstance 调用都将返回同一个 Logger 实例:
const a = Logger.getInstance();
const b = Logger.getInstance();
console.assert(a === b, 'Logger.getInstance() returns the
same reference');
我们实现了一个具有“lazy”初始化的单例。初始化发生在第一次调用 getInstance 时。在下一节中,我们将看到如何扩展我们的代码以实现 loggerInstance 的“eager”初始化,其中 loggerInstance 将在 Logger 代码评估时初始化。
确保只构建一个单例实例
单例的一个特点是“单一实例”概念。我们希望“强制”消费者使用 getInstance 方法。
为了做到这一点,我们可以在构造函数被调用时检查 loggerInstance 的存在:
class Logger {
// rest of the class
constructor(logLevel = 'info', transport = console) {
if (Logger.loggerInstance) {
throw new TypeError(
'Logger is not constructable, use getInstance()
instead'
);
}
this.logLevel = logLevel;
this.transport = transport;
}
// rest of the class
}
在我们调用 getInstance(因此,Logger.loggerInstance 被填充)的情况下,构造函数现在将抛出一个错误:
Logger.getInstance();
new Logger('info', console); // new TypeError('Logger is
not constructable, use getInstance() instead');
这种行为有助于确保消费者不会实例化自己的记录器,而是使用getInstance。所有使用getInstance的消费者意味着设置记录器的配置被Logger类封装。
实现中仍然存在一个差距,因为如以下示例所示,在调用getInstance()之前构造new Logger()将成功:
new Logger('info', console); // Logger { logLevel: 'info',
transport: ... }
new Logger('info', console); // Logger { logLevel: 'info',
transport: ... }
Logger.getInstance();
new Logger('info', console); // new TypeError('Logger is
not constructable, use getInstance() instead');
在多线程语言中,我们的实现也可能存在潜在的竞争条件——多个消费者并发调用Logger.getInstance()可能会导致存在多个实例。然而,由于流行的 JavaScript 运行时是单线程的,我们不必担心这种竞争条件——getInstance是一个“同步”方法,所以对它的多次调用会被依次解释。作为参考,Node.js、Deno 以及主流浏览器 Chrome、Safari、Edge 和 Firefox 都提供了一个单线程的 JavaScript 运行时。
eager-initialization 的单例
eager-initialization 可以用来确保单例已准备好使用,并且像在实例存在时禁用构造函数这样的特性对所有情况都有效。
我们可以通过在Logger构造函数中设置Logger.loggerInstance来 eager-initialize:
class Logger {
// rest of the class unchanged
constructor(logLevel = 'info', transport = console) {
// rest of the constructor unchanged
Logger.loggerInstance = this;
}
}
这种方法的一个缺点是构造函数执行全局状态突变,这在“单一职责原则”的角度来看并不理想;构造函数现在除了设置对象实例的责任之外,还有某种副作用(突变全局状态)。
通过在记录器的模块中运行Logger.getInstance()来 eager-initialize 的另一种方法是,它与一个export default语句配对很有用:
export class Logger {
// no changes to the Logger class
}
export default Logger.getInstance();
在添加了前面的导出之后,现在有两种方式可以访问记录器实例。第一种是通过按名称导入Logger并调用Logger.getInstance():
import { Logger } from './logger.js';
const logger = Logger.getInstance();
logger.warn('testing testing 12'); // testing testing 12
使用记录器的第二种方式是通过导入默认导出:
import logger from './logger.js';
logger.warn('testing testing 12'); // testing testing 12
任何现在导入Logger的代码都将获得一个预先确定的记录器单例实例。
用例
单例模式在应用程序中仅应有一个对象实例时特别出色——例如,一个不需要在每次请求中设置/拆除的记录器。
由于单例类控制其实例化方式,因此它也适合难以配置的对象(再次以记录器为例,以及度量导出器和 API 客户端都是很好的例子)。如果像我们的例子一样“禁用”构造函数,实例化过程将完全封装。
限制应用程序使用单个对象实例在内存占用方面具有性能优势。
单例的主要缺点是其对全局状态的依赖(在我们的例子中,是静态的loggerInstance)。测试单例很困难,尤其是在构造函数被“禁用”的情况下(就像我们的例子一样),因为我们的测试总是希望有一个单例实例。
在某种程度上,单例也可以被认为是“全局状态”,这带来了所有缺点。全局状态有时可能是设计不佳的标志,更新/消费全局状态容易出错(例如,如果消费者正在读取状态,但随后状态被更新而没有再次读取)。
“类单例”模式的改进
在我们的单例日志实现中,从外部修改单例的内部状态是可能的。这并不是我们单例特有的;这是 JavaScript 的本质。默认情况下,它的字段和方法是公开的。
然而,在我们的单例场景中,这是一个更大的问题,因为消费者可以使用诸如Logger.loggerInstance = null或delete Logger.loggerInstance之类的语句重置loggerInstance。请看以下示例:
const logger = Logger.getInstance();
Logger.loggerInstance = null;
const logger = new Logger('info', console); // should throw but creates a new instance
为了阻止消费者修改loggerInstance静态字段,我们可以将其设为私有字段。JavaScript 中的私有字段是 ECMAScript 2023 规范(第 13 版 ECMAScript)的一部分。
要定义私有字段,我们使用字段名称前的#前缀——在这种情况下,loggerInstance变为#loggerInstance。isLevelEnabled方法变为#isLevelEnabled,我们还声明logLevel和transport分别为#logLevel和#transport:
export class Logger {
// other static fields are unchanged
static #loggerInstance = null;
#logLevel;
#transport;
constructor(logLevel = 'info', transport = console) {
if (Logger.#loggerInstance) {
throw new TypeError(
'Logger is not constructable, use getInstance()
instead'
);
}
this.#logLevel = logLevel;
this.#transport = transport;
}
#isLevelEnabled(targetLevel) {
// implementation unchanged
}
info(message) {
if (this.#isLevelEnabled('info')) {
return this.#transport.info(message);
}
}
warn(message) {
if (this.#isLevelEnabled('warn')) {
this.#transport.warn(message);
}
}
error(message) {
if (this.#isLevelEnabled('error')) {
this.#transport.error(message);
}
}
getInstance() {
if (!Logger.#loggerInstance) {
Logger.#loggerInstance = new Logger('warn', console);
}
return Logger.#loggerInstance;
}
}
无法删除loggerInstace或将其设置为null,因为尝试访问Logger.#loggerInstance是一个语法错误:
Logger.#loggerInstance = null;
^
SyntaxError: Private field '#loggerInstance' must be
declared in an enclosing class
另一种有用的技术是不允许修改对象上的字段。为了禁止修改,我们可以在实例创建后使用Object.freeze将其冻结。
class Logger {
// no changes to the logger class
}
export default Object.freeze(new Logger('warn', console));
现在,当有人尝试更改Logger实例的字段时,他们会收到TypeError:
import logger from './logger.js';
logger.transport = {}; // new TypeError('Cannot add
property transport, object is not extensible')
我们现在已经重构了单例实现,通过使用私有字段和Object.freeze来禁止外部对其进行修改。接下来,我们将看到如何使用EcmaScript(ES)模块来提供单例功能。
不使用类字段且使用 ES 模块行为的单例
JavaScript 模块系统具有以下缓存行为——如果加载了一个模块,任何进一步的导入该模块的导出都将被缓存为导出的实例。
因此,在 JavaScript 中可以如下创建单例。
class MySingleton {
constructor(value) {
this.value = value;
}
}
export default new MySingleton('my-value');
多次导入默认导出将只存在一个MySingleton对象的实例。此外,如果我们不导出类,那么构造函数不需要是“受保护的”。
import('./my-singleton.js') result in the same object. They both return the same object because the output of the import for a given module is a singleton:
await Promise.all([
import('./my-singleton.js'),
import('./my-singleton.js'),
]).then(([import1, import2]) => {
console.assert(
import1.default.value === 'my-value' &&
import2.default.value === 'my-value',
'instance variable is equal'
);
console.assert(
import1.default === import2.default,
'multiple imports of a module yield the same default
object value, a single MySingleton instance'
);
console.assert(import1 === import2, 'import objects are a
single reference');
});
对于我们的日志记录器,这意味着我们可以在 JavaScript 中实现一个急切初始化的单例,而不需要任何对构造函数或getInstance方法的严格保护。注意logLevel和isLevelEnabled分别作为公共实例属性和公共方法的使用(因为可能需要从消费者那里访问它们)。同时,#transport保持私有,我们已经删除了loggerInstance和getInstance。我们保留了Object.freeze(),这意味着尽管logLevel可以从消费者那里读取,但它不可修改:
class Logger {
static logLevels = ['info', 'warn', 'error'];
#transport;
constructor(logLevel = 'info', transport = console) {
this.logLevel = logLevel;
this.#transport = transport;
}
isLevelEnabled(targetLevel) {
return (
Logger.logLevels.indexOf(targetLevel) >=
Logger.logLevels.indexOf(this.logLevel)
);
}
info(message) {
if (this.isLevelEnabled('info')) {
return this.#transport.info(message);
}
}
warn(message) {
if (this.isLevelEnabled('warn')) {
this.#transport.warn(message);
}
}
error(message) {
if (this.isLevelEnabled('error')) {
this.#transport.error(message);
}
}
}
export default Object.freeze(new Logger('warn', console));
在本章的这一部分,我们学习了如何使用公开getInstance()方法的类来实现单例模式,以及单例的急切初始化和懒加载初始化之间的区别。我们介绍了一些 JavaScript 特性,如私有类字段和Object.freeze,这些特性在实现单例模式时可能很有用。最后,我们探讨了 JavaScript/ECMAScript 模块具有单例类似的行为,并且可以依赖它们为类实例提供这种行为。
在下一节中,我们将探讨本章涵盖的最后一个创建型设计模式——工厂设计模式。
JavaScript 中的工厂模式
类似于关于 JavaScript“原型”与原型创建型设计模式的讨论,“工厂”在一般程序设计讨论和设计模式中指的是相关但不同的概念。
在一般编程意义上,“工厂”是一个旨在创建其他对象的实体。这一点从其名称中可以体现出来,该名称指的是一个将物品从一个形状加工成另一个形状(或从一种类型的物品加工成另一种类型的物品)的设施。这种工厂的命名意味着函数或方法的输出是一个新对象。在 JavaScript 中,这意味着像返回一个对象字面量的函数这样简单的东西也是一个工厂函数:
const simpleFactoryFunction = () => ({}); // returns an object, therefore it's a factory.
这种工厂的定义很有用,但本章的这一节是关于工厂设计模式的,它确实符合这个整体的“工厂”定义。
工厂或工厂方法设计模式解决了一个类继承问题。一个基类或超类被扩展(扩展的类是一个子类)。基类的角色是为子类实现的方法提供协调,因为我们希望子类控制用哪些其他对象填充实例。
实现
一个工厂示例如下。我们有一个实现generateBuilding()方法的Building基类。目前,它将使用makeTopFloor实例方法创建顶层。在基类(Building)中,makeTopFloor被实现,主要是因为 JavaScript 没有提供定义抽象方法的方式。makeTopFloor的实现会抛出错误,因为子类应该重写它;在这种情况下,makeTopFloor是“工厂方法”。这是基类如何将对象的实例化推迟到子类的方法:
class Building {
generateBuilding() {
this.topFloor = this.makeTopFloor();
}
makeTopFloor() {
throw new Error('not implemented, left for subclasses
to implement');
}
}
如果我们想要实现一个单层房屋,我们会扩展Building并重写makeTopFloor;在这种情况下,topFloor将具有level: 1。
class House extends Building {
makeTopFloor() {
return {
level: 1,
};
}
}
当我们实例化House,它是Building的子类时,我们可以访问generateBuilding方法;当调用它时,它会正确设置topFloor(为{ level: 1 })。
const house = new House();
house.generateBuilding();
console.assert(house.topFloor.level === 1, 'topFloor works
in House');
现在,如果我们想要创建一个具有非常不同顶层的新型建筑,我们仍然可以扩展Building;我们只需重写makeTopFloor以返回不同的楼层。在摩天大楼的情况下,我们希望顶层非常高,所以我们将会这样做:
class SkyScraper extends Building {
makeTopFloor() {
return {
level: 125,
};
}
}
定义了我们的SkyScraper,它是Building的子类后,我们可以实例化它并调用generateBuilding。正如前面的House案例一样,generateBuilding方法将使用SkyScraper的makeTopFloor方法来填充topFloor实例属性:
const skyScraper = new SkyScraper();
skyScraper.generateBuilding();
console.assert(skyScraper.topFloor.level > 100, 'topFloor
works in SkyScraper');
在这种情况下,“工厂方法”是makeTopFloor。在基类中,makeTopFloor方法是“未实现”的,也就是说,它以一种强制子类在希望使用generateBuilding时定义makeTopFloor重写的方式实现。
注意,在我们的示例中,makeTopFloor返回了对象字面量,如本章前面提到的;这是 JavaScript 中不是所有面向对象语言都有的特性(JavaScript 是多范式的)。我们将在本节后面看到实现工厂模式的不同方法。
用例
使用工厂方法的好处是,我们可以在不修改基类的情况下创建各种子类。这是“开/闭原则”在起作用——我们例子中的Building类是“开放”的,可以扩展(即可以无限地子类化以创建不同类型的建筑),但“封闭”的,不允许修改(即我们不需要为每个子类在Building中进行更改,只有在我们要添加新行为时才需要)。
现代 JavaScript 的改进
我们可以用 JavaScript 实现的关键改进是由其对函数的第一级支持以及使用字面量定义对象的能力(而不是类实例化)所启用的。
JavaScript 有“第一级函数”意味着函数就像任何其他类型一样——它们可以作为参数传递,设置为变量值,并从其他函数返回。
这种模式的更自然实现可能涉及一个 generateBuilding 独立函数,而不是 Building 类。generateBuilding 将 makeTopFloor 作为参数,或者接受一个包含 makeTopFloor 键的对象参数。generateBuilding 的输出将是一个使用对象字面量创建的对象,它将 makeTopFloor() 的输出设置为 topFloor 键的值:
function generateBuilding({ makeTopFloor }) {
return {
topFloor: makeTopFloor(),
};
}
为了创建我们的房屋和摩天大楼,我们将使用相关的 makeTopFloor 函数调用 generateBuilding。在房屋的情况下,我们想要一个位于第 1 层的顶层;在摩天大楼的情况下,我们想要一个位于第 125 层的顶层。
const house = generateBuilding({
makeTopFloor() {
return {
level: 1,
};
},
});
console.assert(house.topFloor.level === 1, 'topFloor works
in house');
const skyScraper = generateBuilding({
makeTopFloor() {
return {
level: 125,
};
},
});
console.assert(skyScraper.topFloor.level > 100, 'topFloor works in skyScraper');
使用函数直接在 JavaScript 中工作得更好的一个原因是,我们不必实现一个“抛出错误以提醒消费者覆盖我”的 makeFloor 方法,就像我们在 Building 类中做的那样。
在支持抽象方法的除 JavaScript 之外的语言中,这种模式比在具有一等函数的 JavaScript 中更有用和自然实现。
你还必须记住,JavaScript/ECMAScript 的原始版本并没有包含 class 构造。
在本章的最后部分,我们学习了工厂方法模式是什么,以及它与工厂编程概念的对比。然后我们实现了一个基于类的工厂模式场景,以及一个更自然的 JavaScript 版本。在这一节中,我们涵盖了工厂方法模式在 JavaScript 中的用例、优点和缺点。
摘要
在本章中,我们讨论了如何使用创建型设计模式在 JavaScript 中构建更可扩展和可维护的系统。
原型设计模式在创建包含相同值的多个对象实例时特别出色。这种设计模式允许我们更改原型的初始值,并影响所有克隆实例。
单例设计模式对于完全隐藏应该只实例化一次的类的初始化细节非常有用。我们看到了 JavaScript 的模块系统如何生成单例,以及如何利用这一点来简化单例实现。
工厂方法设计模式允许基类将一些对象创建的实现推迟到子类。我们看到了哪些特性会使这种模式在 JavaScript 中更有用,以及一个使用工厂函数的替代的 JavaScript 习惯用法。
我们现在可以利用创建型设计模式来构建可组合的类,这些类可以根据需要进化以覆盖不同的用例。
现在我们已经了解了如何使用创建型设计模式高效地创建对象,在下一章中,我们将介绍如何使用结构型设计模式来组织不同对象和类之间的关系。
第二章:实现结构型设计模式
结构型设计模式为我们提供了处理连接不同对象的方法;换句话说,管理对象之间的关系。这包括减少内存使用和在不修改现有类的情况下开发功能的技术。此外,JavaScript 特性使我们能够更有效地应用这些模式。现代 JavaScript 包括一些内置功能,允许我们以更有效的方式实现结构型设计模式。
在本章中,我们将涵盖以下主题:
-
定义结构型设计模式整体,以及代理、装饰、享元和适配器具体实现
-
使用基于类的代理模式实现以及使用 Proxy 和 Reflect 的替代实现
-
利用 JavaScript 对函数的一级支持实现的装饰模式多种实现
-
一种迭代方法,用于在 JavaScript 中实现享元模式,包括使用现代 JavaScript 特性进行的人体工程学改进
-
基于类和函数的适配器实现
在本章结束时,你将能够就何时以及如何在使用 JavaScript 时应用结构型设计模式做出明智的决定。
技术要求
你可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/Javascript-Design-Patterns
什么是结构型设计模式?
在构建软件时,我们希望能够连接不同的代码片段(例如,类和函数),并改变参与这些连接和关系的各方如何交互,而无需跳转到代码库的多个碎片化部分。
结构型设计模式允许我们在模块和类中安全地添加、删除和更改功能。这些模式的“结构”方面是由于我们可以围绕实现进行操作,如果暴露的接口是稳定的。
结构型设计模式是维护关注点分离和不同类和模块的松散耦合,同时保持高开发速度的好方法。
在下一节中,我们将探讨在 JavaScript 中实现代理模式的多种方法。
使用 Proxy 和 Reflect 实现代理模式
代理模式涉及提供一个对象(subject,或real对象),该对象满足一定的接口。proxy(一个placeholder或wrapper对象)控制对subject的访问。这允许我们在不改变消费者与subject交互的情况下,在subject之上提供额外的功能。
这意味着代理需要提供一个与subject匹配的接口。
通过使用代理模式,我们可以拦截对原始对象的全部操作,要么将它们传递通过,要么改变它们的实现。这遵循了开闭原则,其中subject和consumer都封闭于修改,但代理为我们提供了一个钩子来extend,这意味着设计是开放的,可以扩展。
红色代理实现
我们将从以下实现类开始,它有几个输出字符串的方法:
class Implementation {
someFn() {
return 'some-output';
}
sensitiveFn() {
return 'sensitive-output';
}
}
让我们想象输出中的sensitive字符串应该被编辑。
下面是一个RedactionProxy类可能的样子:
class RedactionProxy {
constructor() {
this.impl = new Implementation();
}
someFn() {
return this.impl.someFn();
}
sensitiveFn() {
return this.impl.sensitiveFn().replace('sensitive',
'[REDACTED]');
}
}
在这种情况下,RedactionProxy执行我们所说的someFn()调用。换句话说,RedactionProxy#someFn只是将someFn调用转发到Implementation。以下是一个说明:
const redactionProxy = new RedactionProxy();
console.assert(
redactionProxy.someFn() === newImplementation().someFn(),
'Proxy implementation calls through to original'
);
当涉及到sensitiveFn时,RedactionProxy实现了与Implementation相同的接口,除了它覆盖了输出,将sensitive替换为[REDACTED]。
这意味着RedactionProxy和Implementation的接口是相同的,但RedactionProxy可以控制哪些方法调用和字段可用,以及它们的实现。以下是一个此行为的示例:
console.assert(
redactionProxy.sensitiveFn() !== new
Implementation().sensitiveFn()&&
redactionProxy.sensitiveFn() === '[REDACTED]-output',
'Proxy implementation adds new behaviour'
);
用例
代理模式允许我们拦截对对象的调用(实现或主题),并通过操纵输出或添加副作用来增强它们。
我们对编辑的例子是一个很好的用例,但任何其他类型的工具也是很好的用例。工具可能涉及测量关于函数/字段访问的某些内容(例如,所需的时间)或确保对属性的访问触发某种效果。例如,Vue.js 和 Alpine.js 的响应性系统基于代理,其中使用 JavaScript 代理对象来包装响应式数据对象。这允许库(Vue 或 Alpine)检测属性何时被更改,并运行诸如观察者、效果和重新渲染等操作。
使用Proxy和Reflect全局对象改进 JavaScript 中的代理模式
回到我们的例子,当我们需要编辑更多函数时会发生什么?
让我们以一个有三个方法(someFn、sensitiveFn和otherSensitiveFn)的Implementation类为例:
class Implementation {
someFn() {
return 'sensitive-some-output';
}
sensitiveFn() {
return 'sensitive-output';
}
otherSensitiveFn() {
return 'sensitive-other-output';
}
}
扩展代理的一个简单实现如下,其中每个方法都调用实现的方法,然后在输出中替换sensitive:
class RedactionProxyNaive {
constructor() {
this.impl = new Implementation();
}
someFn() {
return this.impl.someFn().replace
('sensitive', '[REDACTED]');
}
sensitiveFn() {
return this.impl.sensitiveFn().replace('sensitive',
'[REDACTED]');
}
otherSensitiveFn() {
return this.impl.otherSensitiveFn().
replace('sensitive', '[REDACTED]');
}
}
这个Proxy的实现是有效的,我们可以通过以下代码确保:
console.assert(
!new RedactionProxyNaive().someFn().includes('sensitive')
&&
!new RedactionProxyNaive().sensitiveFn().includes
('sensitive') &&
!new RedactionProxyNaive().otherSensitiveFn().includes
('sensitive'),
'naive proxy redacts correctly'
);
我们可以在这里进行的一个改进是提取一个#redact私有方法来处理sensitive的替换:
class RedactionProxyNaiveRefactored {
constructor() {
this.impl = new Implementation();
}
#redact(str) {
return str.replace('sensitive', '[REDACTED]');
}
someFn() {
return this.#redact(this.impl.someFn());
}
sensitiveFn() {
return this.#redact(this.impl.sensitiveFn());
}
otherSensitiveFn() {
return this.#redact(this.impl.otherSensitiveFn());
}
}
console.assert(
!new RedactionProxyNaiveRefactored().someFn().includes
('sensitive') &&
!new RedactionProxyNaiveRefactored().sensitiveFn().
includes('sensitive') &&
!new RedactionProxyNaiveRefactored()
.otherSensitiveFn()
.includes('sensitive'),
'refactored naive proxy redacts correctly'
);
这种方法的缺点是,Implementation对象(主题)上的每个方法都需要更改我们的代理实现。
幸运的是,JavaScript 有一个内置的类可以编程管理这些情况。这个 JavaScript 类恰当地被称为Proxy。
让我们以以下普通的 JavaScript 对象(这也适用于类实例)为例,它既有字段又有函数:
const obj = {
someFn() {
return 'sensitive-some-output';
},
sensitiveFn() {
return 'sensitive-output';
},
otherSensitiveFn() {
return 'sensitive-other-output';
},
field: 'sensitive-data',
sensitiveField: 'redact-everything',
};
我们希望能够完全编辑(即保留原始输出的全部内容)那些在字段或方法名称中包含sensitive的字段。我们还希望在输出包含字符串sensitive时具有值编辑功能,其中将sensitive替换为[REDACTED]。
为了实现这一点,我们定义了一个将包装我们的obj对象的代理。我们使用一个“get trap”实例化代理,这允许我们拦截所有属性访问(包括函数访问)。
get函数接收一个target和一个property。target是被包装的对象(obj),property是被访问的属性。
根据target[property]是否是函数,我们将用收集所有参数的包装函数替换它,使用这些参数调用target[property],拦截输出,并将sensitive替换为[REDACTED]。如果属性名包含sensitive(在我们的情况下,使用sensitiveFn),我们还将返回[REDACTED]。
在target[property]不是一个函数的情况下,如果属性名包含sensitive,我们将进行完整的编辑,并且替换所有其他属性中的sensitive:
const redactedObjProxy = new Proxy(obj, {
get(target, property, _receiver) {
if (target[property] instanceof Function) {
return (...args) => {
if (property.includes('sensitive')) {
return '[REDACTED]';
}
const output = targetproperty;
if (typeof output === 'string') {
return output.replace('sensitive', '[REDACTED]');
}
return output;
};
}
if (property.includes('sensitive')) {
return '[REDACTED]';
}
return target[property].replace('sensitive',
'[REDACTED]');
},
});
以下代码确保我们的代理实现按预期工作。sensitive不在任何函数输出或field值中:
console.assert(
!redactedObjProxy.someFn().includes('sensitive') &&
!redactedObjProxy.sensitiveFn().includes('sensitive') &&
!redactedObjProxy.otherSensitiveFn().includes
('sensitive'),
'JavaScript Proxy redacts correctly for all functions'
);
console.assert(
!redactedObjProxy.field.includes('sensitive'),
'JavaScript Proxy redacts field values by value
correctly'
);
console.assert(
redactedObjProxy.sensitiveField === '[REDACTED]',
'JavaScript Proxy redacts field values by property name
correctly'
);
其中一个关键好处是设置的简单性;所有的编辑逻辑都包含在get函数中,这使得它更加集中。
由于逻辑集中,我们能够除了编辑值之外,还能通过属性名添加编辑。
由于我们在函数中丢失了this上下文,我们当前的基于代理的方法还存在一些轻微的问题。我们调用targetproperty,只要我们的对象不访问this,这是可以的。我们将进一步重构我们的实现,以便更容易地进行扩展,同时利用Reflect全局内置对象来简化我们的代码。
Reflect提供了与Proxy陷阱具有相同名称和参数的函数;例如,Reflect.get(target, property, receiver)。
我们将提取一个redact函数,它接受一个propertyName和一个redactionValue。它将通过将其抽象为单独的函数来使我们的编辑逻辑更加同步:
const redact = (propertyName, redactionValue) => {
if (propertyName.includes('sensitive')) {
return '[REDACTED]';
}
if (typeof redactionValue === 'string') {
return redactionValue.replace('sensitive','[REDACTED]'
);
}
// Could implement redaction of objects/Arrays and so on
return redactionValue;
};
我们可以在必要时使用redact,使用Reflect.get()作为target[property]的快捷方式,并使用Reflect.apply来保持this上下文:
const redactedObjProxyImproved = new Proxy(obj, {
get(target, property, receiver) {
const targetPropertyValue = Reflect.get(target,
property, receiver);
if (targetPropertyValue instanceof Function) {
return (...args) => {
const output = Reflect.apply(
targetPropertyValue,
this === receiver ? this : target,
args
);
return redact(property, output);
};
}
return redact(property, targetPropertyValue);
},
});
我们的编辑在值、函数输出、属性和函数名称上仍然按相同的方式工作:
console.assert(
!redactedObjProxyImproved.someFn().includes
('sensitive') &&
!redactedObjProxyImproved.sensitiveFn().includes
('sensitive') &&
!redactedObjProxyImproved.otherSensitiveFn().includes
('sensitive'),
'JavaScript Proxy with Reflect redacts correctly for all
functions'
);
console.assert(
!redactedObjProxyImproved.field.includes('sensitive'),
'JavaScript Proxy with Reflect redacts field values
correctly'
);
console.assert(
redactedObjProxyImproved.sensitiveField === '[REDACTED]',
'JavaScript Proxy with Reflect redacts field values
correctly'
);
现在我们已经深入探讨了如何实现代理模式,我们将将其与装饰器模式进行对比,并讨论我们可以使用哪些 JavaScript 工具来实现它。
JavaScript 中的装饰器
装饰器模式与代理模式类似,因为它涉及到“包装”一个对象。然而,装饰器模式是在运行时向对象添加功能。不同的装饰器可以应用于对象,以向其添加不同的功能。
实现
给定以下基于fetch API 的HttpClient类,我们想要测量通过此客户端发出的请求。HttpClient实现了getJson,如果fetch请求成功则返回 JSON 输出:
class HttpClient {
async getJson(url) {
const response = await fetch(url);
if (response.ok) {
return response.json();
}
throw new Error(`Error loading ${url}`);
}
}
InstrumentedHttpClient,这是一个装饰器,可能看起来如下,其中我们暴露了相同的getJson方法,但在实例上增加了requestTimings字段。
当调用getJson时,我们跟踪HttpClient#getJson方法调用的开始和结束时间,并将其添加到实例的requestTimings中:
class InstrumentedHttpClient {
constructor(client) {
this.client = client;
this.requestTimings = {};
}
async getJson(url) {
const start = performance.now();
const output = await this.client.getJson(url);
const end = performance.now();
if (!Array.isArray(this.requestTimings[url])) {
this.requestTimings[url] = [];
}
this.requestTimings[url].push(end - start);
return output;
}
}
我们可以使用以下代码确保InstrumentedHttpClient按描述工作:
const httpClient = new HttpClient();
const instrumentedClient = new InstrumentedHttpClient
(httpClient);
await instrumentedClient.getJson
('https://ifconfig.io/all.json');
console.assert(
Object.keys(instrumentedClient.requestTimings).length >0,
'Tracks request timings'
);
await instrumentedClient.getJson
('https://ifconfig.io/all.json');
console.assert(
instrumentedClient.requestTimings
['https://ifconfig.io/all.json'].length === 2,
'Tracks per URL timings'
);
用例
装饰器模式,就像代理模式一样,可以用来测量或拦截“主题”上的操作。
一个关键的区别是,装饰器是关于向类添加“新成员”,而不仅仅是保持接口一对一。这就是为什么我们通常保存一个额外的requestTimings字段,并从“装饰”类InstrumentedHttpClient中访问它。
这意味着多个装饰器可以“堆叠”在彼此之上。例如,我们可以拥有我们的InstrumentedHttpClient,它有requestTimings,然后创建另一个装饰器类,使用时对时间信息进行有用的处理。这里的一个例子是发送一个“client-time”启发式头,允许服务器在知道客户端将在某个时间点终止连接后停止处理请求。
改进/限制
由于 JavaScript 对函数的第一类支持,我们可以使用函数作为装饰的基础,而不是类。
我们的getJson函数可能如下所示,与HttpClient.getJson方法的逻辑相似:
async function getJson(url) {
const response = await fetch(url);
if (response.ok) {
return response.json();
}
throw new Error(`Error loading ${url}`);
}
我们可以创建一个addTiming方法,该方法将请求时间存储在allOperationTimings Map实例中。
我们在这里使用了第一类函数的两个方面——我们传递一个函数作为参数(getJson)并返回一个函数:
const allOperationTimings = new Map();
function addTiming(getJson) {
return async (url) => {
const start = performance.now();
const output = await getJson(url);
const end = performance.now();
const previousOperationTimings =
allOperationTimings.get(url) || [];
allOperationTimings.set(url,
previousOperationTimings.concat(end - start));
return output;
};
}
使用我们的装饰器函数的方式如下:
const getJsonWithTiming = addTiming(getJson);
我们可以调用我们的仪器化函数并检查它是否向我们的allOperationTimings Map添加了时间:
await getJsonWithTiming('https://ifconfig.io/all.json');
await getJsonWithTiming('https://ifconfig.io/all.json');
console.assert(
allOperationTimings.size === 1,
'operation timings tracks by url'
);
console.assert(
allOperationTimings.get('https://ifconfig.io/all.json').
length === 2,
'operation timings tracks number of calls by url'
);
你可能注意到的一件事是,我们的addTiming 仍然知道getJson接口(它知道传递 URL 参数,并且getJson返回一个 Promise 对象)。我们将把它留给读者作为练习来实现,但将addTiming转换成一个可以测量任何 JavaScript 函数操作时间的函数是可能的;困难的部分是找到我们操作映射的一个好键。
在本章的下一部分,我们将探讨享元模式。
JavaScript 中的享元模式
Flyweight 模式是指具有相同值的对象属性子集存储在共享的“轻量级”对象中。
当生成大量具有相同值子集的对象时,Flyweight 模式很有用。
实现
来自 Eric Evans 的领域驱动设计中的一个概念是“值对象”。这些值对象具有其内容比其身份更重要的属性。让我们以一个“硬币”作为值对象的例子,其中,在支付的目的上,两枚 50 分硬币是可互换的。
值对象是可互换的且不可变的(50 分硬币不能变成 10 分硬币)。因此,这些类型的对象非常适合 Flyweight 模式。
并非所有“硬币”的特性都是由“价值”驱动的,例如,某些硬币是由某些材料制成的,并且硬币往往在特定年份发行。这两个特性(材料发行年份)可能对收藏家来说很有趣,从这个角度来看,现实中的硬币不仅作为价值对象,两枚 1993 年的硬币在硬币收藏的背景下可能以不同的方式有趣。
因此,我们将我们的 Wallet 模型化为包含一个硬币列表,将 Coin 模型化为包含金额(以分或其他“小额货币”计)、货币、发行年份和材料列表。

图 2.1:包含关联硬币及其操作方法的类图
我们的 CoinFlyweight 将是我们的“值对象”,包含 amount 和 currency,如下所示:
class CoinFlyweight {
/**
* @param {Number} amount – amount in minor currency
* @param {String} currency
*/
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
}
Flyweight 模式的关键优势在于我们可以重用我们的轻量级对象。为了做到这一点,我们需要通过工厂(如*第一章**,使用创建型设计模式,JavaScript 中的工厂模式)来控制轻量级的实例化。因此,我们定义了 CoinFlyweightFactory,它具有一个静态的 get 方法,该方法接受轻量级的初始化参数,但只有当内存中不存在具有正确金额和货币的 CoinFlyweight 时,才会实例化一个新的 CoinFlyweight。它还提供了一个 getCount 方法来返回当前实例化的轻量级的数量:
class CoinFlyweightFactory {
static flyweights = {};
static get(amount, currency) {
const flyWeightKey = `${amount}-${currency}`;
if (this.flyweights[flyWeightKey]) {
return this.flyweights[flyWeightKey];
}
const instance = new CoinFlyweight(amount, currency);
this.flyweights[flyWeightKey] = instance;
return instance;
}
static getCount() {
return Object.keys(this.flyweights).length;
}
}
使用 Flyweight 模式的另一个机会是与材料。我们可以类似地创建一个 MaterialFlyweight 并通过 MaterialFlyweightFactory 重复使用其值:
class MaterialFlyweight {
constructor(materialName) {
this.name = materialName;
}
}
class MaterialFlyweightFactory {
static flyweights = {};
static get(materialName) {
if (this.flyweights[materialName]) {
return this.flyweights[materialName];
}
const instance = new MaterialFlyweight(materialName);
this.flyweights[materialName] = instance;
return instance;
}
static getCount() {
return Object.keys(this.flyweights).length;
}
}
最后,我们可以实现 Coin 和 Wallet 类。我们的 Coin 实例有一个 flyweight 字段,该字段使用 CoinFlyweightFactory 进行填充。Coin#materials 字段使用常规数组进行填充,但数组的元素是 MaterialFlyweight,使用 MaterialFlyweightFactory 加载:
class Coin {
constructor(amount, currency, yearOfIssue, materials) {
this.flyweight = CoinFlyweightFactory.get
(amount, currency);
this.yearOfIssue = yearOfIssue;
this.materials = materials.map((material) =>
MaterialFlyweightFactory.get(material)
);
}
}
Wallet 是一个普通的 JavaScript 对象。它的 add 方法创建一个新的 Coin 实例并将其推入 Wallet 的 coins 字段。getTotalValueForCurrency 对给定货币的硬币价值进行求和:
class Wallet {
constructor() {
this.coins = [];
}
add(amount, currency, yearOfIssue, materials) {
const coin = new Coin(amount, currency, yearOfIssue,
materials);
this.coins.push(coin);
}
getCount() {
return this.coins.length;
}
getTotalValueForCurrency(currency) {
return this.coins
.filter((coin) => coin.flyweight.currency ===
currency)
.reduce((acc, curr) => acc + curr.flyweight.amount, 0);
}
}
钱包可以使用以下方式使用,添加不同面额的 GBP 和 USD:
const wallet = new Wallet();
wallet.add(100, 'GBP', '2023', ['nickel-brass',
'nickel-plated alloy']);
wallet.add(100, 'GBP', '2022', ['nickel-brass',
'nickel-plated alloy']);
wallet.add(100, 'GBP', '2021', ['nickel-brass',
'nickel-plated alloy']);
wallet.add(100, 'GBP', '2021', ['nickel-brass',
'nickel-plated alloy']);
wallet.add(200, 'GBP', '2021', ['nickel-brass',
'cupro-nickel']);
wallet.add(100, 'USD', '1990', ['copper', 'nickel']);
wallet.add(5, 'USD', '1990', ['copper', 'nickel']);
wallet.add(1, 'USD', '2010', ['copper', 'zinc']);
注意,尽管钱包实例包含八个硬币,但我们创建了六个 CoinFlyweight 和五个 MaterialFlyweight 实例:
console.assert(
wallet.getCount() === 8,
'wallet.add adds coin instances are created once given
the same cache key'
);
console.assert(
CoinFlyweightFactory.getCount() === 5,
'CoinFlyweights are created once given the same
cache key'
);
console.assert(
MaterialFlyweightFactory.getCount() === 6,
'MaterialFlyweights are created once given the same
cache key'
);
console.assert(
wallet.getTotalValueForCurrency('GBP') === 600,
'Summing GBP works'
);
console.assert(
wallet.getTotalValueForCurrency('USD') === 106,
'Summing USD works'
);
用例
Flyweight 模式是一种规范化技术,它以在访问和运行使用此模式的对象的计算时的认知开销为代价,减少了内存占用。当处理大量对象时,Flyweight 可以作为性能优化的手段。
它非常适合作为我们之前章节中展示的价值对象进行建模。唯一的缺点是 getTotalValueForCurrency,在那里我们必须读取 coin.flyweight.currency 和 coin.flyweight.amount。
改进/限制
我们可以对我们的 flyweight 钱包/硬币设置进行一些改进。其中一些改进集中在“工厂”上。flyweights 不应该从 get 函数外部访问,因此我们可以使用 #flyweights 使其成为私有字段。我们还可以利用 Map 对象,仍然使用相同的缓存键,尽管 Map 在可用的键方面具有更大的灵活性,并且具有不同的属性访问接口(.get(key) 而不是 [key] 访问)。使用 Map 意味着我们需要在 getCount 中使用 this.#flyweights.size:
class CoinFlyweightFactory {
static #flyweights = new Map();
static get(amount, currency) {
const flyWeightKey = `${amount}-${currency}`;
if (this.#flyweights.get(flyWeightKey)) {
return this.#flyweights.get(flyWeightKey);
}
const instance = new CoinFlyweight(amount, currency);
this.#flyweights.set(flyWeightKey, instance);
return instance;
}
static getCount() {
return this.#flyweights.size;
}
}
我们还将做出另一个改变,鉴于将 materials 作为 flyweight 并没有带来任何实质性的收益,因此我们将将其恢复为每个 Coin 实例存储字符串列表。
同样,我们希望将 #flyweight 设置为私有,这将改变 Coin 的接口,因为消费者将无法访问 coin.#flyweight(它是一个私有字段)。
我们将解决必须读取 coin.flyweight.amount 和 coin.flyweight.currency 的不匹配问题。我们将提供两个获取器,get amount() 和 get currency(),分别返回 this.#flyweight.amount 和 this.#flyweight.currency:
class Coin {
#flyweight;
constructor(amount, currency, yearOfIssue, materials) {
this.#flyweight = CoinFlyweightFactory.get
(amount, currency);
this.yearOfIssue = yearOfIssue;
this.materials = materials;
}
get amount() {
return this.#flyweight.amount;
}
get currency() {
return this.#flyweight.currency;
}
}
如前所述,Coin 的接口没有 flyweight 属性,因此 getTotalValueForCurrency 将会从 Coin#currency 和 Coin#amount 中读取。至于 Wallet,currency 和 amount 是 Coin 实例的字段,尽管它们是获取器:
class Wallet {
constructor() {
this.coins = [];
}
add(amount, currency, yearOfIssue, materials) {
const coin = new Coin(amount, currency, yearOfIssue,
materials);
this.coins.push(coin);
}
getCount() {
return this.coins.length;
}
getTotalValueForCurrency(currency) {
return this.coins
.filter((coin) => coin.currency === currency)
.reduce((acc, curr) => acc + curr.amount, 0);
}
}
我们可以通过使用与代码早期迭代中相同的测试来检查我们的新 Wallet 和 Coin 实现是否按预期工作:
const wallet = new Wallet();
wallet.add(100, 'GBP', '2023', ['nickel-brass',
'nickel-plated alloy']);
wallet.add(100, 'GBP', '2022', ['nickel-brass',
'nickel-plated alloy']);
wallet.add(100, 'GBP', '2021', ['nickel-brass',
'nickel-plated alloy']);
wallet.add(100, 'GBP', '2021', ['nickel-brass',
'nickel-plated alloy']);
Wallet.add(200, 'GBP', '2021', ['nickel-brass',
'cupro-nickel']);
wallet.add(100, 'USD', '1990', ['copper', 'nickel']);
wallet.add(5, 'USD', '1990', ['copper', 'nickel']);
wallet.add(1, 'USD', '2010', ['copper', 'zinc']);
console.assert(
wallet.getCount() === 8,
'wallet.add adds coin instances are created once
given the same cache key'
);
console.assert(
CoinFlyweightFactory.getCount() === 5,
'CoinFlyweights are created once given the same
cache key'
);
console.assert(
wallet.getTotalValueForCurrency('GBP') === 600,
'Summing GBP works'
);
console.assert(
wallet.getTotalValueForCurrency('USD') === 106,
'Summing USD works'
);
我们已经看到如何使用 flyweight 模式通过使用共享值对象来优化内存使用。
在本章的下一部分,我们将探讨本书中涵盖的最后一个结构化设计模式,即 JavaScript 中的适配器模式。
JavaScript 中的适配器
与其他结构化设计模式类似,适配器模式关注于接口。
在适配器模式的情况下,它涉及到能够在不更改消费者或实现接口的情况下使用新的实现。适配器“适配”接口以匹配消费者的期望。
我们没有改变实现或消费者;相反,我们构建了一个适配器来包装实现并将其插入消费者,而不改变任何一方。
实现
让我们从使用简单的内存数据库开始,该数据库使用一个简单的 IdGenerator 通过将对象编码为字符串来生成数据库条目的键:
Database 有一个 createEntry 方法,它使用 IdGenerator 生成键来存储给定数据。Database 还有一个 get 方法,可以通过 ID 回忆条目:
class IdGenerator {
get(entry) {
return JSON.stringify(entry);
}
}
class Database {
constructor(idGenerator) {
this.idGenerator = idGenerator;
this.entries = {};
}
createEntry(entryData) {
const id = this.idGenerator.get(entryData);
this.entries[id] = entryData;
return id;
}
get(id) {
return this.entries[id];
}
}
通过将 Database 与 IdGenerator 实例组合,我们得到一个键值查找数据库实例,其键等于值的 JSON 表示形式:
const naiveIdDatabase = new Database(new IdGenerator());
naiveIdDatabase.createEntry({
name: 'pear',
});
console.assert(
naiveIdDatabase.get('{"name":"pear"}').name === 'pear',
'stringIdDatabase recalls entries by stringified entry'
);
现在,将整个条目值编码在键中的简单 ID 生成方法并不理想。一个替代方案是使用 UUID。以下是一个使用 uuid npm 模块的 UuidFactory。它公开的关键操作是 generateUuid:
import { v4 as uuidv4 } from 'uuid';
class UuidFactory {
generateUuid() {
return uuidv4();
}
}
要使用 UuidFactory 与我们的 Database,我们需要一个 get 方法而不是 generateUuid 方法。这就是我们的适配器发挥作用的地方——我们可以将 UuidFactory 包装在一个类中,该类公开 get(entry) 但在 UuidFactor 实例上调用 generateUuid:
class UuidIdGeneratorAdapter {
constructor() {
this.uuidFactory = new UuidFactory();
}
get(_entry) {
return this.uuidFactory.generateUuid();
}
}
UuidIdGeneratorAdapter 可以作为 idGenerator 传递给 Database。一切如预期般工作,其中数据库的条目 ID 是 UUID:
const uuidIdDatabase = new Database(new UuidIdGeneratorAdapter());
const uuidEntryId = uuidIdDatabase.createEntry({
name: 'pear',
});
console.assert(
uuidIdDatabase.get(uuidEntryId).name === 'pear',
'uuidIdDatabase recalls entries by uuid'
);
import { validate as isUuid } from 'uuid';
console.assert(isUuid(uuidEntryId), 'uuidIdDatabase generated uuid ids');
另一个利用 entry 被传递给 idGenerator.get() 的事实来生成基于 entry 内容的前缀自增 ID 的例子。在这里,name 将用作前缀。我们有一个 Counter 类,它实现了 getAndIncrement(prefix),它根据前缀(或没有前缀)生成递增 ID:
class Counter {
constructor(startValue = 1) {
this.startValue = startValue;
this.nextId = startValue;
this.nextIdByPrefix = {};
}
getAndIncrement(prefix) {
if (prefix) {
if (!this.nextIdByPrefix[prefix]) {
this.nextIdByPrefix[prefix] = this.startValue;
}
const nextId = this.nextIdByPrefix[prefix]++;
return `${prefix}:${nextId}`;
}
return String(this.nextId++);
}
}
再次强调,getAndIncrement(prefix) 不符合 IdGenerator 接口(没有 get 方法)。我们可以将 Counter 包装在 PrefixedAutoIncrementIdGeneratorAdapter 中,以暴露 IdGenerator 接口,但使用 Counter 的实现:
class PrefixedAutoIncrementIdGeneratorAdapter {
constructor() {
this.counter = new Counter();
}
get(entry) {
return this.counter.getAndIncrement(entry.name);
}
}
我们可以确保前缀逻辑对 Database 的工作是正确的,因为它创建的条目键是带有前缀的自增 ID:
const prefixAutoIncrementDatabase = new Database(
new PrefixedAutoIncrementIdGeneratorAdapter()
);
我们可以检查没有设置 name 字段的情况是否按预期工作:
const noPrefixIncrementingEntryId1 =
prefixAutoIncrementDatabase.createEntry({
type: 'no-prefix',
});
const noPrefixIncrementingEntryId2 =
prefixAutoIncrementDatabase.createEntry({
type: 'no-prefix',
});
console.assert(
noPrefixIncrementingEntryId1 === '1' &&
noPrefixIncrementingEntryId2 === '2',
'prefixAutoIncrementDatabase generates autoincrementing
ids with no prefix if no name property is set'
);
console.assert(
prefixAutoIncrementDatabase.get
(noPrefixIncrementingEntryId1).type ===
'no-prefix' &&
prefixAutoIncrementDatabase.get
(noPrefixIncrementingEntryId2).type ===
'no-prefix',
'prefixAutoIncrementDatabase recalls entries by
autoincrementing id'
);
并且带有前缀的场景也按照以下示例正确地工作:
const prefixIncrementingEntryIdPear1 =
prefixAutoIncrementDatabase.createEntry({
name: 'pear',
});
const prefixIncrementingEntryIdPear2 =
prefixAutoIncrementDatabase.createEntry({
name: 'pear',
});
const prefixIncrementingEntryIdApple1 =
prefixAutoIncrementDatabase.createEntry(
{
name: 'apple',
}
);
console.assert(
prefixIncrementingEntryIdPear1 === 'pear:1' &&
prefixIncrementingEntryIdPear2 === 'pear:2' &&
prefixIncrementingEntryIdApple1 === 'apple:1',
'prefixAutoIncrementDatabase generates prefixed
autoincrementing ids'
);
console.assert(
prefixAutoIncrementDatabase.get
(prefixIncrementingEntryIdPear1).name ===
'pear',
prefixAutoIncrementDatabase.get
(prefixIncrementingEntryIdPear2).name ===
'pear',
prefixAutoIncrementDatabase.get
(prefixIncrementingEntryIdApple1).name ===
'apple',
'prefixAutoIncrementDatabase recalls entries by prefixed
id'
);
用例
当你需要使用两个没有特别设计来一起工作的类时,适配器模式非常有用。例如,考虑一个第三方库或模块,它公开了一个函数(例如 uuid 模块或甚至是我们场景中的 UuidFactory)。我们希望将实现抽象化,在我们的例子中是 IdGenerator 接口,它只是一个 get 方法,这样任何实现都可以使用。
我们的例子展示了适配器模式的价值。我们能够创建行为非常不同的数据库,而无需更改 UuidFactory、Counter 或 Database。这在需要连接两个第三方模块或自包含且不应更改的模块时非常重要。
因此,使用适配器模式意味着我们可以避免更改难以理解的代码,同时提供所需的功能。
改进/限制
类似于JavaScript 中的装饰器 - 改进/限制部分,JavaScript 中的一项特性可以帮助实现结构化设计模式,那就是对函数的一级支持。
而不是IdGenerator类,我们可以有一个defaultIdGenerator函数,它接受一个条目并返回一个字符串:
function defaultIdGenerator(entry) {
return JSON.stringify(entry);
}
Database类现在看起来可能如下所示,其中this.idGenerator(entryData)被直接调用:
class Database {
constructor(idGenerator) {
this.idGenerator = idGenerator;
this.entries = {};
}
createEntry(entryData) {
const id = this.idGenerator(entryData);
this.entries[id] = entryData;
return id;
}
get(id) {
return this.entries[id];
}
}
我们可以通过将其传递的内容序列化为 JSON 来验证原始实现仍然有效:
const naiveIdDatabase = new Database(defaultIdGenerator);
naiveIdDatabase.createEntry({
name: 'pear',
});
console.assert(
naiveIdDatabase.get('{"name":"pear"}').name === 'pear',
'stringIdDatabase recalls entries by stringified entry'
);
当我们需要插入 UUID 和前缀生成器时,这种方法特别有效。
uuidGenerator函数可以调用uuidv4()。我们可以验证uuidIdDatabase使用 UUID 作为键来检索条目:
function uuidGenerator() {
return uuidv4();
}
const uuidIdDatabase = new Database(uuidGenerator);
const uuidEntryId = uuidIdDatabase.createEntry({
name: 'pear',
});
console.assert(
uuidIdDatabase.get(uuidEntryId).name === 'pear',
'uuidIdDatabase recalls entries by uuid'
);
console.assert(isUuid(uuidEntryId), 'uuidIdDatabase
generated uuid ids');
最后,一个prefixAutoIncrementIdGenerator可能如下所示。我们正在使用模块作用域变量,这是 JavaScript 的另一个特性:
const startValue = 1;
let nextId = startValue;
let nextIdByPrefix = {};
function prefixAutoIncrementIdGenerator(entry) {
const prefix = entry.name;
if (prefix) {
if (!nextIdByPrefix[prefix]) {
nextIdByPrefix[prefix] = startValue;
}
const nextId = nextIdByPrefix[prefix]++;
return `${prefix}:${nextId}`;
}
return String(nextId++);
}
这段代码将位于与其消费者不同的模块中,因此它将是export function prefixAutoIncrementIdGenerator,其消费者将导入{prefixAutoIncrementIdGenerator}从'./path-to-module.js'。
prefixAutoIncrementIdGenerator函数与PrefixedAutoIncrementIdGeneratorAdapter类的作用类似,生成自增 ID,并在可能的情况下通过entry.name进行前缀:
const prefixAutoIncrementDatabase = new Database(
prefixAutoIncrementIdGenerator
);
const noPrefixIncrementingEntryId1 =
prefixAutoIncrementDatabase.createEntry({
type: 'no-prefix',
});
const noPrefixIncrementingEntryId2 =
prefixAutoIncrementDatabase.createEntry({
type: 'no-prefix',
});
console.assert(
noPrefixIncrementingEntryId1 === '1' &&
noPrefixIncrementingEntryId2 === '2',
'prefixAutoIncrementDatabase generates autoincrementing
ids with no prefix if no name property is set'
);
console.assert(
prefixAutoIncrementDatabase.get
(noPrefixIncrementingEntryId1).type ===
'no-prefix' &&
prefixAutoIncrementDatabase.get
(noPrefixIncrementingEntryId2).type ===
'no-prefix',
'prefixAutoIncrementDatabase recalls entries by
autoincrementing id'
);
const prefixIncrementingEntryIdPear1 =
prefixAutoIncrementDatabase.createEntry({
name: 'pear',
});
const prefixIncrementingEntryIdPear2 =
prefixAutoIncrementDatabase.createEntry({
name: 'pear',
});
const prefixIncrementingEntryIdApple1 =
prefixAutoIncrementDatabase.createEntry(
{
name: 'apple',
}
);
console.assert(
prefixIncrementingEntryIdPear1 === 'pear:1' &&
prefixIncrementingEntryIdPear2 === 'pear:2' &&
prefixIncrementingEntryIdApple1 === 'apple:1',
'prefixAutoIncrementDatabase generates prefixed
autoincrementing ids'
);
console.assert(
prefixAutoIncrementDatabase.get
(prefixIncrementingEntryIdPear1).name ===
'pear',
prefixAutoIncrementDatabase.get
(prefixIncrementingEntryIdPear2).name ===
'pear',
prefixAutoIncrementDatabase.get
(prefixIncrementingEntryIdApple1).name ===
'apple',
'prefixAutoIncrementDatabase recalls entries by prefixed
id'
);
在本章的最后部分,我们讨论了适配器模式以及如何在 JavaScript 中,当消费者期望一个类和一个函数时使用它。
摘要
在本章中,我们探讨了结构化设计模式如何使 JavaScript 中功能的扩展成为可能,而无需重新工作接口。
代理设计模式在想要拦截对象调用而不改变接口时非常有用。
相比之下,装饰器设计模式关注的是通过新的实例成员动态添加功能。
轻量级模式可以有效地用于管理大量对象,这对于值对象尤其有用。JavaScript 中存在一些解决方案来解决其一些用户体验上的缺点。
适配器模式允许我们集成具有不同意见和接口的多个类、模块或函数,而无需修改它们。适配器的形状由我们试图连接的现有模块和类决定。
现在我们已经知道了如何使用结构化设计模式来组织不同对象和类之间的关系,在下一章中,我们将介绍如何使用行为设计模式来组织对象之间的通信。
第三章:利用行为设计模式
行为设计模式有助于组织对象之间的通信。这包括在不修改这些现有类的情况下扩展功能的能力。通过实现本章中涵盖的行为设计模式以及它们在 JavaScript 生态系统中的应用,我们将学习如何构建可以扩展而不触及现有功能的 JavaScript 应用程序。
本章将涵盖以下主题:
-
对行为设计模式分类的理解
-
观察者模式的实现以及常见的 Web EventTarget API 如何暴露它
-
状态模式和策略模式的实现,包括基于类的方法和基于函数的方法
-
一个简化的访问者示例,以及访问者模式在 JavaScript 生态系统中的常见用法
到本章结束时,您将能够利用 JavaScript 中的行为设计模式来扩展您的代码库并公开功能扩展点。
技术要求
您可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/Javascript-Design-Patterns
什么是行为设计模式?
对象之间的通信是构建软件的关键。行为设计模式帮助我们组织这种通信,并且通常将可能的实现与其他对象解耦。这使得我们更有能力扩展我们的代码库。
行为设计模式帮助我们遵循开闭原则,我们可以扩展功能而不修改现有的实现模块。
我们将要涵盖的所有模式都允许我们“添加功能”而不修改现有的消费者/具体实现。在大型软件代码库中,这很有用,因为它意味着我们可以限制更改的范围并降低破坏现有功能的风险。我们能够有效地将“添加功能”与“更改其他无关功能的现有代码”解耦,并且可以添加新功能和行为,而无需对现有消费者进行修改。
使用行为设计模式,新的行为可以是纯粹的增加性的。观察者模式允许多个解耦的消费者(也称为监听器)。通过状态、策略和访问者模式,可以添加新的实现和转换,而不会干扰现有的实现。
在下一节中,我们将探讨我们的第一个行为设计模式,即 JavaScript 中的观察者模式。
JavaScript 中的观察者模式
观察者模式允许一个对象(可观察的或主题)维护一个依赖于它的其他对象列表(观察者)。当主题的状态更新发生时,例如实体对象被创建或更新,它会通知观察者。
实现
观察者模式的示例用例是一个内存队列。Queue实例将具有subscribe、unsubscribe和notify方法。
subscribe将添加一个额外的“处理”函数,unsubscribe将删除已注册的特定“处理”函数,最后,notify将使用“消息”有效负载调用每个处理函数。这是“观察者的通知”部分,其中可观察对象或主题确保每个已注册的观察者都被通知。
subscribe和unsubscribe分别用于开启和关闭“观察者”功能。subscribe必须使用来成为“观察者”,而unsubscribe在不再需要观察某些情况时(例如,我们已经达到一个结束状态)非常有用。同时,notify方法确保每个“已订阅”的观察者都能收到更新。
“处理”函数,正如其名所示,是将传递给另一个模块以供其自行执行的功能,通常是对“事件”的响应:
class Queue {
constructor() {
this.handlers = [];
}
subscribe(handlerFn) {
this.handlers.push(handlerFn);
}
unsubscribe(handlerFn) {
this.handlers = this.handlers.filter((handler) =>
handler !== handlerFn);
}
notify(message) {
this.handlers.forEach((handler) => {
handler(message);
});
}
}
我们可以实现三个简单的“订阅者”,分别只记录'CREATE'消息、只记录'UPDATE'消息和记录所有消息:
const queue = new Queue();
const createMessages = [];
queue.subscribe((message) => {
if (message.type === 'CREATE') {
createMessages.push(message);
}
});
const updateMessages = [];
queue.subscribe((message) => {
if (message.type === 'UPDATE') {
updateMessages.push(message);
}
});
const allMessages = [];
queue.subscribe((message) => {
allMessages.push(message);
});
当我们通过调用notify来触发通知时,我们可以通过检查它们存储消息的数组来确保订阅者按预期工作:
queue.notify({ type: 'CREATE', data: { user: { id: 1 } }
});
queue.notify({ type: 'CREATE', data: { user: { id: 2 } } });
queue.notify({ type: 'CREATE', data: { user: { id: 3 } } });
queue.notify({ type: 'UPDATE', data: { user: { id: 1, role:
'ADMIN' } } });
queue.notify({
type: 'UPDATE',
data: { user: { id: 3, role: 'DEVELOPER' } },
});
queue.notify({ type: 'UPDATE', data: { user: { id: 3, role:
'ADMIN' } } });
console.assert(
createMessages.length === 3,
'%o collects CREATE messages',
allMessages
);
console.assert(
updateMessages.length === 3,
'%o collects UPDATE messages',
allMessages
);
console.assert(
allMessages.length === 6,
'%o collects all message',
allMessages
);
注意,我们的观察者实现利用了 JavaScript 对函数的一等支持,这意味着我们可以将回调函数传递给subscribe方法,而不是notify必须在一个实例上调用一个方法。
在不支持或几乎没有一等函数支持的编程语言中,例如较老的 Java 和 PHP 版本,这种方法将需要传递一个观察者给subscribe和notify,并在每个观察者实例上调用一个方法。在 JavaScript 中,如果我们不使用“处理”函数,我们将创建一个observer对象,该对象被实例化并具有一个handle函数,该函数接受一个消息并围绕它实现一些逻辑;在这种情况下,它只是将其存储在一个实例变量上:
class UpdateMessageObserver {
constructor() {
this.updateMessages = [];
}
handle(message) {
if (message.type === 'UPDATE') {
this.updateMessages.push(message);
}
}
}
这将需要修改Queue类以正确工作:
class QueueObserverObjects {
constructor() {
this.observers = [];
}
subscribe(observerObj) {
this.observers.push(observerObj);
}
unsubscribe(observerObj) {
this.observers = this.observers.filter(
(observer) => observer !== observerObj,
);
}
notify(message) {
this.observers.forEach((observer) => {
observer.handle(message);
});
}
}
我们可以通过调用notify方法并检查UpdateMessageObserver().updateMessages的内容来确保它按预期工作,如下面的代码示例所示:
const queueObserverObjects = new QueueObserverObjects();
const updateMessageObserver = new UpdateMessageObserver();
queueObserverObjects.subscribe(updateMessageObserver);
queueObserverObjects.notify({
type: 'CREATE',
data: { user: { id: 1 } },
});
queueObserverObjects.notify({
type: 'UPDATE',
data: { user: { id: 1, role: 'ADMIN' } },
});
queueObserverObjects.notify({
type: 'UPDATE',
data: { user: { id: 3, role: 'DEVELOPER' } },
});
console.assert(
updateMessageObserver.updateMessages.length === 2,
'%o collects update messages',
updateMessageObserver.updateMessages,
);
我们现在已经看到了如何使用“处理”函数和Observer对象实例实现观察者模式,使用Queue可观察对象。接下来,我们将探讨观察者模式在 JavaScript 中的使用情况。
观察者模式的用例
观察者模式非常适合处理松散耦合的事件或消息。在 Web 应用程序的上下文中,这可能是 DOM 事件。EventTarget.addEventListener()和EventTarget.removeEventListener(),这些方法在Window、Document和Element对象上可用,是观察者模式的一种广泛实现。它们被客户端 JavaScript 应用程序用于注册用户交互的处理程序(例如,点击、表单提交、悬停和鼠标悬停)。
局限性和改进
在我们的队列实现中,处理程序可以从实例外部读取。处理程序是队列的实现细节,我们应该能够在不影响消费模块的情况下更改它。这意味着我们想要封装处理程序,使它们对于Queue类之外的代码不可用。如果我们保持handlers数组可用,那么Queue类之外的代码就可以访问并修改它,这意味着Queue抽象就会崩溃,因为消费者针对实现细节进行集成。这意味着消费者与Queue的内部实现紧密耦合。
因此,我们可以使用私有字段;在现代 JavaScript 中,这可以通过使用#语法来实现。对于处理程序,它将涉及在类中声明#handlers,然后访问this.#handlers:
class Queue {
#handlers;
constructor() {
this.#handlers = [];
}
subscribe(handlerFn) {
this.#handlers.push(handlerFn);
}
unsubscribe(handlerFn) {
this.#handlers = this.#handlers.filter((handler) =>
handler !== handlerFn);
}
notify(message) {
this.#handlers.forEach((handler) => {
handler(message);
});
}
}
我们可以对我们的队列进行另一项改进,那就是提供一个流畅的接口,以便我们可以“链式”调用。为此,我们只需从每个subscribe、unsubscribe和notify处理程序中返回this即可。这允许我们在单个“链”中调用实例方法;而不是使用queue.subscribe()后跟queue.notify(),我们可以将其写为单个语句 - queue.subscribe().notify():
class Queue {
#handlers;
constructor() {
this.#handlers = [];
}
subscribe(handlerFn) {
this.#handlers.push(handlerFn);
return this;
}
unsubscribe(handlerFn) {
this.#handlers = this.#handlers.filter((handler) =>
handler !== handlerFn);
return this;
}
notify(message) {
this.#handlers.forEach((handler) => {
handler(message);
});
return this;
}
}
我们可以通过验证队列在通知观察者方面的功能以及使用流畅(“链式”)接口的可用性来验证队列是否按预期工作:
const queue = new Queue();
const createMessages = [];
const updateMessages = [];
const allMessages = [];
queue
.subscribe((message) => {
if (message.type === 'CREATE') {
createMessages.push(message);
}
})
.subscribe((message) => {
if (message.type === 'UPDATE') {
updateMessages.push(message);
}
})
.subscribe((message) => {
allMessages.push(message);
});
queue
.notify({ type: 'CREATE', data: { user: { id: 1 } } })
.notify({ type: 'CREATE', data: { user: { id: 2 } } })
.notify({ type: 'CREATE', data: { user: { id: 3 } } })
.notify({ type: 'UPDATE', data: { user: { id: 1, role:
'ADMIN' } } })
.notify({
type: 'UPDATE',
data: { user: { id: 3, role: 'DEVELOPER' } },
})
.notify({ type: 'UPDATE', data: { user: { id: 3, role:
'ADMIN' } } });
console.assert(
createMessages.length === 3,
'%o collects CREATE messages',
allMessages
);
console.assert(
updateMessages.length === 3,
'%o collects UPDATE messages',
allMessages
);
console.assert(
allMessages.length === 6,
'%o collects all message',
allMessages
);
我们现在已经看到了如何在 JavaScript 中实现观察者模式,以及如何使用私有字段和流畅接口来改进我们的实现。
在下一节中,我们将实现状态模式和策略模式。
JavaScript 中的状态和策略以及简化方法
状态模式和策略模式密切相关,因为它们允许通过更改解耦的实现对象来扩展软件系统的功能,而不是更改核心主题对象。
状态允许对象根据其所在的状态显示不同的行为。这对于建模状态机非常有用。每个状态都提供相同的接口,核心对象会在不同的状态上调用方法。
策略模式同样允许对象在运行时动态选择一个实现。为了做到这一点,实现被注入到对象中并使用。
我们可以将状态模式归类为策略模式的一个子集,其中实现是通过状态实例动态改变的。
接下来,我们将看到如何使用状态模式在 JavaScript 中实现状态机,以及使用策略模式实现对象合并的抽象。
实现
对于我们实现的状态模式,我们将使用简化的拉取请求/合并请求/变更请求示例。
拉取请求从草稿或打开状态开始。从那里,它可以在打开和草稿之间转换,然后转换为关闭或合并状态。合并状态是一个最终状态;关闭可以通过重新打开拉取请求来撤销,因此它不是最终状态。
为了可视化所有状态之间的转换,我们可以使用表示拉取请求状态和允许转换的状态图。在 图 3.1 中,初始状态是草稿或打开。这两种状态都可以相互转换。打开可以变为合并或关闭,其中合并是一个有效的结束状态。草稿也可以变为关闭。

图 3.1:拉取请求状态图
首先绘制我们的 PullRequest 类很有用。在 PullRequest 上可能执行的操作有 open、markDraft、markReadyForReview、close 和 merge。
为了实现该状态模式,我们还公开了一个 setState 方法。每个状态都将 PullRequest 实例作为构造函数参数,PullRequest 的初始状态是 DraftState 或 OpenState,基于一个 isDraft 布尔参数:
class PullRequest {
constructor(isDraft = false) {
this.state = isDraft ? new DraftState(this) : new
OpenState(this);
}
setState(state) {
this.state = state;
}
open() {
this.state.open();
}
markDraft() {
this.state.markDraft();
}
markReadyForReview() {
this.state.markReadyForReview();
}
close() {
this.state.close();
}
merge() {
this.state.merge();
}
}
我们将实现状态机,从初始状态和最终状态开始。对于初始状态,我们有 DraftState 和 OpenState;对于最终状态,我们有 MergedState。
DraftState 只实现了 markReadyForReview 和 close,分别将 pullRequest 转换为 OpenState 或 ClosedState:
class DraftState {
constructor(pullRequest) {
this.pullRequest = pullRequest;
}
markReadyForReview() {
this.pullRequest.setState(new OpenState
(this.pullRequest));
}
close() {
this.pullRequest.setState(new ClosedState
(this.pullRequest));
}
}
OpenState 实现了 markDraft、close 和 merge,分别将 pullRequest 转换为 DraftState、ClosedState 和 MergedState:
class OpenState {
constructor(pullRequest) {
this.pullRequest = pullRequest;
}
markDraft() {
this.pullRequest.setState(new DraftState
(this.pullRequest));
}
close() {
this.pullRequest.setState(new ClosedState
(this.pullRequest));
}
merge() {
this.pullRequest.setState(new MergedState
(this.pullRequest));
}
}
作为最终状态,MergedState 不实现任何方法:
class MergedState {
constructor(pullRequest) {
this.pullRequest = pullRequest;
}
}
最后,ClosedState 实现了 open 方法,它将 pullRequest 转换为 OpenState:
class ClosedState {
constructor(pullRequest) {
this.pullRequest = pullRequest;
}
open() {
this.pullRequest.setState(new OpenState
(this.pullRequest));
}
}
我们可以检查我们的拉取请求和状态是否按预期工作。
使用 isDraft 设置为 true 实例化的 PullRequest 将从 DraftState 开始。调用 markReadyForReview 将将其转换为 OpenState:
const pullRequest1 = new PullRequest(true);
console.assert(pullRequest1.state instanceof DraftState,
pullRequest1.state);
pullRequest1.markReadyForReview();
console.assert(pullRequest1.state instanceof OpenState,
pullRequest1.state);
一旦使用 pullRequest.merge() 合并了拉取请求,就没有任何方法可用(它们都会抛出错误):
pullRequest1.merge();
console.assert(
captureError(() => pullRequest1.open()) instanceof Error,
pullRequest1.state
);
console.assert(
captureError(() => pullRequest1.markReadyForReview())
instanceof Error,
pullRequest1.state
);
console.assert(
captureError(() => pullRequest1.close()) instanceof
Error,
pullRequest1.state
);
从打开状态开始的拉取请求可以被关闭。一旦进入 ClosedState,除了在它上面执行 open() 方法之外,无法执行任何其他操作——例如,markDraft 将会因错误而失败:
const pullRequest2 = new PullRequest(false);
console.assert(pullRequest2.state instanceof OpenState,
pullRequest2.state);
pullRequest2.close();
console.assert(pullRequest2.state instanceof ClosedState,
pullRequest2.state);
console.assert(
captureError(() => pullRequest2.markDraft())
instanceof Error,
pullRequest2.state
);
pullRequest2.open();
console.assert(pullRequest2.state instanceof OpenState,
pullRequest2.state);
我们现在已经看到了如何使用状态模式实现拉取请求状态机。
接下来,我们将查看如何实现策略。
我们的例子是一个ObjectMerger类,它合并 JavaScript 对象。在 JavaScript 中有多种实现方式,因此我们构建ObjectMerger以接受一个strategy对象,并允许通过setStrategy方法对其进行更新。最后,我们公开一个combinedObjects方法,该方法调用实例的策略的combineObjects方法,并传入两个对象:
class ObjectMerger {
constructor(defaultStrategy) {
this.strategy = defaultStrategy;
}
setStrategy(newStrategy) {
this.strategy = newStrategy;
}
combineObjects(obj1, obj2) {
return this.strategy.combineObjects(obj1, obj2);
}
}
在这种情况下,一个示例策略是使用Object.assign与{}(一个新对象字面量)作为赋值的目标。这有一个好处,就是不会修改obj1和obj2参数:
class PureObjectAssignStrategy {
constructor() {}
combineObjects(obj1, obj2) {
return Object.assign({}, obj1, obj2);
}
}
我们的ObjectMerger可以使用PureObjectAssignStrategy进行实例化,如下所示:
const objectMerger = new ObjectMerger
(new PureObjectAssignStrategy());
然后,它可以用来合并对象,而不会修改obj1或obj2:
const obj1 = {
keys: '123',
};
const obj2 = {
keys: '456',
};
const defaultMergeStrategyOutput =
objectMerger.combineObjects(obj1, obj2);
console.assert(defaultMergeStrategyOutput.keys === '456',
'%o has keys = 456');
console.assert(obj1.keys === '123' && obj2.keys === '456',
obj1, obj2);
一个使用Object.assign的简单实现示例,它不使用新对象作为赋值目标(因此,会修改obj1),如下所示:
class MutatingObjectAssignStrategy {
constructor() {}
combineObjects(obj1, obj2) {
return Object.assign(obj1, obj2);
}
}
它可以这样使用,并且确实会修改obj1:
objectMerger.setStrategy(new
MutatingObjectAssignStrategy());
const mutatingMergedStrategyOutput =
objectMerger.combineObjects(obj1, obj2);
console.assert(
mutatingMergedStrategyOutput.keys === '456',
'%o has keys = 456',
mutatingMergedStrategyOutput
);
console.assert(
obj1.keys === '456' && obj2.keys === '456',
'Mutates the original object obj1 %o, obj2 %o',
obj1,
obj2
);
与我们的初始Object.assign({}, obj1, obj2)策略等效的策略是使用扩展语法:
class ObjectSpreadStrategy {
constructor() {}
combineObjects(obj1, obj2) {
return { ...obj1, ...obj2 };
}
}
我们可以验证将obj1和obj2展开会产生与我们的早期PureObjectAssignStrategy相同的策略特征:
objectMerger.setStrategy(new ObjectSpreadStrategy());
const newObj1 = { keys: '123' };
const newObj2 = { keys: '456', obj1: newObj1 };
const objectSpreadStrategyOutput =
objectMerger.combineObjects(
newObj1,
newObj2
);
console.assert(
objectSpreadStrategyOutput.keys === '456',
'%o has keys = 456',
objectSpreadStrategyOutput
);
console.assert(
newObj1.keys === '123' && newObj2.keys === '456',
'Does not mutate the original object newObj1 %o,
newObj2 %o',
newObj1,
newObj2
);
一个有趣的方面是,这种方法只创建了一个浅拷贝;对象内部的对象引用被复制,但目标对象的内容是相同的:
console.assert(
objectSpreadStrategyOutput.obj1 === newObj1,
'Does a shallow clone so objectSpreadStrategyOutput.obj1
references newObj1'
);
我们可以通过实现基于structuredClone的深度克隆策略来解决这个问题:
class DeepCloneObjectAssignStrategy {
constructor() {}
combineObjects(obj1, obj2) {
return Object.assign(structuredClone(obj1),
structuredClone(obj2));
}
}
DeepCloneObjectAssignStrategy具有PureObjectAssignStrategy和ObjectSpreadStrategy的所有属性,并且增加了深度复制的功能,递归地复制嵌套对象的内 容,而不是复制这些对象的引用:
objectMerger.setStrategy(new DeepCloneObjectAssignStrategy());
const deepCloneStrategyOutput = objectMerger.
combineObjects(newObj1, newObj2);
console.assert(
deepCloneStrategyOutput.keys === '456',
'%o has keys = 456',
deepCloneStrategyOutput
);
console.assert(
newObj1.keys === '123' && newObj2.keys === '456',
'Does not mutate the original object newObj1 %o,
newObj2 %o',
newObj1,
newObj2
);
console.assert(
deepCloneStrategyOutput.obj1 !== newObj1 &&
deepCloneStrategyOutput.obj1.keys === newObj1.keys,
'Does a shallow clone so deepCloneStrategyOutput.obj1
references newObj1'
);
我们现在已经看到了如何实现状态和策略模式。接下来,我们将探讨状态和策略模式在 JavaScript 中最常被使用的地方。
状态模式和策略模式的使用案例
如本章前面所述,状态模式对于实现状态机是有用的。
状态和策略之间的一个关键区别是,在状态模式中,通常不同状态之间是相互了解的——例如,ClosedState创建一个OpenState的新实例以过渡到它。同样,OpenState了解它可以过渡到的所有潜在状态(DraftState、ClosedState和MergedState)。相比之下,在实现策略模式时,不同的策略是自包含的,并且不了解彼此。例如,PureObjectAssignStrategy和MutatingObjectAssignStrategy不会相互引用。
策略模式有助于提供具有不同内部实现的统一接口。当不同的实现算法应该可互换,而集成消费者不需要了解它时,这是一个有用的抽象。
局限性和改进
在我们的国家示例中,请注意我们代码中有多少是重复的类构造函数,它接受一个pullRequest实例。我们可以通过提供一个PullRequestBaseState类来重构我们的代码,该类为每个状态方法抛出IllegalOperationError:
class IllegalOperationError extends Error {
constructor(stateInstance) {
this.stateInstance = stateInstance;
throw new Error('Illegal operation for State');
}
}
class PullRequestBaseState {
constructor(pullRequest) {
this.pullRequest = pullRequest;
}
markDraft() {
throw new IllegalOperationError(this);
}
markReadyForReview() {
throw new IllegalOperationError(this);
}
open() {
throw new IllegalOperationError(this);
}
close() {
throw new IllegalOperationError(this);
}
merge() {
throw new IllegalOperationError(this);
}
}
这意味着我们可以通过扩展PullRequestBaseState来定义我们的不同状态:
class ClosedState extends PullRequestBaseState {
open() {
this.pullRequest.setState(new OpenState
(this.pullRequest));
}
}
class DraftState extends PullRequestBaseState {
markReadyForReview() {
this.pullRequest.setState(new OpenState
(this.pullRequest));
}
close() {
this.pullRequest.setState(new ClosedState
(this.pullRequest));
}
}
class OpenState extends PullRequestBaseState {
markDraft() {
this.pullRequest.setState(new DraftState
(this.pullRequest));
}
close() {
this.pullRequest.setState(new ClosedState
(this.pullRequest));
}
merge() {
this.pullRequest.setState(new MergedState
(this.pullRequest));
}
}
class MergedState extends PullRequestBaseState {}
PullRequest类没有改变,这些新的状态实现与我们的先前实现工作相同:
const pullRequest1 = new PullRequest(true);
console.assert(pullRequest1.state instanceof DraftState,
pullRequest1.state);
pullRequest1.markReadyForReview();
console.assert(pullRequest1.state instanceof OpenState,
pullRequest1.state);
pullRequest1.merge();
console.assert(
captureError(() => pullRequest1.open()) instanceof Error,
pullRequest1.state
);
console.assert(
captureError(() => pullRequest1.markReadyForReview())
instanceof Error,
pullRequest1.state
);
console.assert(
captureError(() => pullRequest1.close()) instanceof
Error,
pullRequest1.state
);
const pullRequest2 = new PullRequest(false);
console.assert(pullRequest2.state instanceof OpenState,
pullRequest2.state);
pullRequest2.close();
console.assert(pullRequest2.state instanceof ClosedState,
pullRequest2.state);
console.assert(
captureError(() => pullRequest2.markDraft())
instanceof Error,
pullRequest2.state
);
pullRequest2.open();
console.assert(pullRequest2.state instanceof OpenState,
pullRequest2.state);
对于策略,我们可以利用的是 JavaScript 的一等函数支持。我们不需要将每个策略实现为一个对象,而是可以将其作为函数。
我们的ObjectMerger实现如下:
class ObjectMerger {
constructor(defaultStrategy) {
this.strategy = defaultStrategy;
}
setStrategy(newStrategy) {
this.strategy = newStrategy;
}
combineObjects(obj1, obj2) {
return this.strategy(obj1, obj2);
}
}
我们可以将所有策略重新实现为函数:
function pureObjectAssignStrategy(obj1, obj2) {
return Object.assign({}, obj1, obj2);
}
function mutatingObjectAssignStrategy(obj1, obj2) {
return Object.assign(obj1, obj2);
}
function objectSpreadStrategy(obj1, obj2) {
return { ...obj1, ...obj2 };
}
function deepCloneObjectAssignStrategy(obj1, obj2) {
return Object.assign(structuredClone(obj1),
structuredClone(obj2));
}
基于函数的策略ObjectMerger类具有与之前实现的基于类的相同属性。构造函数接受一个“策略函数”,并将其设置在实例上;每个实例公开一个setStrategy方法,该方法覆盖策略函数,以及一个combineObjects方法,我们可以调用它来合并对象。
这意味着我们可以使用我们的ObjectMerger与所有四个基于函数的策略(pureObjectAssignStrategy、mutatingObjectAssignStrategy、objectSpreadStrategy和deepCloneObjectAssignStrategy),如下所示:
const objectMerger = new ObjectMerger
(pureObjectAssignStrategy);
const obj1 = {
keys: '123',
};
const obj2 = {
keys: '456',
};
const defaultMergeStrategyOutput =
objectMerger.combineObjects(obj1, obj2);
console.assert(defaultMergeStrategyOutput.keys === '456',
'%o has keys = 456');
console.assert(obj1.keys === '123' && obj2.keys === '456',
obj1, obj2);
objectMerger.setStrategy(mutatingObjectAssignStrategy);
const mutatingMergedStrategyOutput =
objectMerger.combineObjects(obj1, obj2);
console.assert(
mutatingMergedStrategyOutput.keys === '456',
'%o has keys = 456',
mutatingMergedStrategyOutput
);
console.assert(
obj1.keys === '456' && obj2.keys === '456',
'Mutates the original object obj1 %o, obj2 %o',
obj1,
obj2
);
objectMerger.setStrategy(objectSpreadStrategy);
const newObj1 = { keys: '123' };
const newObj2 = { keys: '456', obj1: newObj1 };
const objectSpreadStrategyOutput =
objectMerger.combineObjects(
newObj1,
newObj2
);
console.assert(
objectSpreadStrategyOutput.keys === '456',
'%o has keys = 456',
objectSpreadStrategyOutput
);
console.assert(
newObj1.keys === '123' && newObj2.keys === '456',
'Does not mutate the original object newObj1 %o,
newObj2 %o',
newObj1,
newObj2
);
console.assert(
objectSpreadStrategyOutput.obj1 === newObj1,
'Does a shallow clone so objectSpreadStrategyOutput.obj1
references newObj1'
);
objectMerger.setStrategy(deepCloneObjectAssignStrategy);
const deepCloneStrategyOutput = objectMerger.combineObjects
(newObj1, newObj2);
console.assert(
deepCloneStrategyOutput.keys === '456',
'%o has keys = 456',
deepCloneStrategyOutput
);
console.assert(
newObj1.keys === '123' && newObj2.keys === '456',
'Does not mutate the original object newObj1 %o,
newObj2 %o',
newObj1,
newObj2
);
console.assert(
deepCloneStrategyOutput.obj1 !== newObj1 &&
deepCloneStrategyOutput.obj1.keys === newObj1.keys,
'Does a shallow clone so deepCloneStrategyOutput.
obj1 references newObj1'
);
我们已经展示了如何在 JavaScript 中实现状态和策略模式,以及它们的局限性和改进,这些都可以使用现代 JavaScript 特性来完成。
在下一节中,我们将介绍访问者模式及其在 JavaScript 生态系统中的应用。
JavaScript 中的访问者
访问者设计模式关注的是在不修改对象结构的情况下向对象添加功能。
在经典继承中,我们经常遇到一个“基类”,它不是直接使用的;它被用作一个“抽象类”,从该“抽象类”继承出我们的“基类”。例如,对于BankAccount和BankAccountWithInterest,我们的类图如下所示,其中BankAccountWithInterest扩展了BankAccount并覆盖了setBalance。

图 3.2:继承自 BankAccount 的 BankAccountWithInterest 类图
我们可以使用访问者模式定义BankAccount,它接受一个访问者和InterestRateVisitor访问者类。作为一个类图,它看起来如下。BankAccount和InterestRateVisitor不是通过继承链接的;它们将在InterestRateVisitor被BankAccount().accept方法调用时在运行时链接。这意味着InterestRateVisitor了解BankAccount的结构,但反之则不然。此外,访问者可能不需要了解它访问的完整结构,只需要了解实现访问者功能的相关部分。

图 3.3:BankAccount 类和独立的 InterestRateVisitor 类的类图
现在,我们将看到如何实现BankAccount和InterestRateVisitor场景。
实现
要实现一个访问者,让我们从一个简单的BankAccount类开始。构造函数设置账户类型(要么是活期账户,要么是储蓄账户)、货币和初始余额。BankAccount有一个setBalance方法,可以设置账户余额的值。accept方法将允许我们接受访问者并在实例上调用他们的visit方法:
class BankAccount {
/**
*
* @param {'CURRENT' | 'SAVINGS'} accountType
* @param {String} currency
* @param {Number} balance - balance in minor currency
unit
*/
constructor(accountType = 'CURRENT', currency = 'USD',
balance = 0) {
this.accountType = accountType;
this.currency = currency;
this.balance = balance;
}
setBalance(balance) {
this.balance = balance;
}
accept(visitor) {
visitor.visit(this);
}
}
一种结构化InterestVisitor的方式是使用利率和货币初始化它。visit方法接受bankAccount,如果账户匹配货币并且是储蓄账户,则根据利率和当前余额应用新的余额:
class InterestVisitor {
constructor(interestRate, currency) {
this.interestRate = interestRate;
this.currency = currency;
}
/**
* @param {BankAccount} bankAccount
*/
visit(bankAccount) {
if (
bankAccount.currency === this.currency &&
bankAccount.accountType === 'SAVINGS'
) {
bankAccount.setBalance((bankAccount.balance *
this.interestRate) / 100);
}
}
}
给定一组账户,我们可以创建 USD 和 GBP 的InterestVisitor实例:
const accounts = [
new BankAccount('SAVINGS', 'GBP', 500),
new BankAccount('SAVINGS', 'USD', 500),
new BankAccount('CURRENT', 'USD', 10000),
];
const usdInterestVisitor = new InterestVisitor(105, 'USD');
const gbpInterestVisitor = new InterestVisitor(110, 'GBP');
然后,我们可以遍历账户并使用相关访问者调用accept方法:
accounts.forEach((account) => {
account.accept(usdInterestVisitor);
account.accept(gbpInterestVisitor);
});
console.assert(
accounts[0].balance === 550 &&
accounts[1].balance === 525 &&
accounts[2].balance === 10000,
'%o',
accounts
);
我们现在已经看到了如何在银行账户场景中实现访问者模式。接下来,我们将探讨 JavaScript 中访问者模式的流行用例。
访问者模式的用例
访问者模式为库作者提供了一个简单的接口,允许消费者扩展库的功能。这在处理树或其他“节点集”的库中特别有效。这也解释了为什么访问者模式在自定义解析系统(如 GraphQL 实现)或编译器(如 Babel)的插件中很受欢迎。
例如,在 Apollo Server v2 中编写自定义指令的方式是扩展SchemaDirectiveVisitor:
import { SchemaDirectiveVisitor } from 'apollo-server';
class CustomDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
// we can replace/augment the field's resolver
implementation here
}
}
'@babel/parser' package:
import * as parser from '@babel/parser';
import traverse from '@babel/traverse';
const ast = parser.parse(`function triple(n) {
return n * 3;
}`);
const CustomVisitor = {
FunctionDeclaration(path) {
console.assert(path.node.id.name === 'triple');
},
};
traverse(ast, CustomVisitor);
我们现在已经看到了访问者模式是如何在操作树数据结构的库中使用的。接下来,我们将回顾本章所学的内容。
概述
在本章中,我们看到了行为设计模式如何通过支持不同的实现和解耦代码库的各个部分来扩展功能。
观察者模式对于支持松散耦合的可观察/观察者对之间的通信很有用。状态模式和策略模式可以用来实现状态机和有效地交换实现。访问者模式是暴露一个与操作对象的结构解耦的扩展机制的好方法。
现在我们知道了如何使用行为设计模式来组织不同对象和类之间的通信,在下一章中,我们将介绍 React 中的响应式视图库模式。
第二部分:架构和 UI 模式
在本部分,您将了解 JavaScript 中的架构和 UI 模式概述。您将学习 React 中常见的响应式视图库模式以及 React 和 Next.js 的渲染策略。最后,您将了解通过区域和岛屿架构两种方法来扩展应用程序的两种方法。
本部分包含以下章节:
-
第四章, 探索响应式视图库模式
-
第五章, 渲染策略和页面活化
-
第六章**, 微前端、区域和岛屿架构
第四章:探索响应式视图库模式
响应式视图库模式为我们提供了在需要跳出组件原语时以可扩展和可维护的方式构建应用程序的工具。使用 React 视图库,我们将介绍超越基于组件的组合以向我们的组件注入功能的不同技术——渲染属性、高阶组件、hooks 和提供者模式。
本章我们将涵盖以下主要主题:
-
介绍响应式视图库模式及其使用它们可以带来最大益处的领域
-
渲染属性模式的示例和实现方法
-
实现和使用高阶组件模式
-
使用 hooks 构建 React 函数组件
-
实现提供者模式的多种方式
到本章结束时,您将能够辨别何时以及如何使用响应式视图库模式来构建 React 应用程序。
技术要求
要跟随本章,您需要以下内容:
-
Node.js 20+:
nodejs.org/en -
Npm 8+:大多数 Node.js 安装都包含
-
在一些示例中使用了
parceljs.org/,它具有与 Node.js 类似的平台支持 -
React:通过 npm 安装 React DOM 和 Formik;在 Web 环境中需要了解
react.dev/的基础知识
您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/Javascript-Design-Patterns
什么是响应式视图库模式?
响应式视图库在 JavaScript 和 Web 前端开发中被广泛使用。一些非常流行的选项包括React、Angular和Vue。
响应式视图库提供了一种以更可扩展的方式编写应用程序的方法,允许用户界面(通常是浏览器)对数据的变化做出反应。因此,应用程序开发得到了简化,因为视图库或框架负责处理所有必要的直接操作,以保持底层数据和浏览器之间的同步。
这些库和框架之间一个关键的共同点概念是组件,它包含业务逻辑和/或渲染逻辑。组件是应用程序的关键构建块。它可以被重用或不被重用,但它通常封装了一组责任,并在其周围强制执行接口。
组件的一个特性是开发者应该能够将它们用作构建块,并且在不显著改变组件内部的情况下,可以显著改变应用程序的行为。
因此,响应式视图库模式帮助我们以可重用的方式构建组件,但它们也涵盖了处理组件抽象存在不足的情况的技术。
在接下来的章节中,我们将介绍 React 中的渲染属性、高阶组件、钩子和提供者模式。我们将重点关注 React,但这些模式在 Vue 中也有等效的模式。
渲染属性模式
当一个组件允许其消费者通过一个函数属性定义该组件的一部分如何渲染时,就会出现渲染属性模式。这些可以是作为函数的子元素或另一个属性,该属性是一个接受一些参数并返回 JSX 的函数。
渲染属性允许一定程度上的控制反转。尽管一个组件可以完全封装渲染和业务逻辑,但它反而将渲染逻辑的一些部分的控制权交给了其消费者。
这种控制反转对于在不共享视觉或实际渲染 UI 的情况下共享逻辑非常有用。因此,这种模式在库中很常见。一个典型的例子是Formik,它为消费者提供了如何渲染表单的灵活性,同时提供了一个对表单状态管理逻辑的抽象。
用例
让我们从构建一个CoupledSelect组件的场景开始,这是一个select原生元素的包装器。我们将以数据与渲染紧密耦合的方式构建此组件,这是一个渲染属性何时有用的简单示例。
消费者对CoupledSelect的期望是它主要像select原生元素那样表现,但有几点需要注意。
我们的CoupledSelect组件接受以下属性:
-
selectedOption:这设置了选定的选项;它类似于option原生元素上的
selected属性 -
options:这是一个字符串数组,它们被渲染为option元素
-
onChange:这是一个可选的回调,用于使渲染CoupledSelect的组件对选项选择做出反应
我们可以如下实现它。CoupledSelect将围绕onChange,因为它是不必要的:
import React from 'react';
export function CoupledSelect({ selectedOption, options,
onChange }) {
const onChangeHandler = (event) => {
if (onChange) onChange(event.target.value);
};
}
让我们继续讨论渲染逻辑。我们将返回一个具有onChange={onChangeHandler}和value={selectedOption}的select元素,这样选择将同步于selectedOption并传播更改:
import React from 'react';
export function CoupledSelect({ selectedOption, options,
onChange }) {
const onChangeHandler = (event) => {
if (onChange) onChange(event.target.value);
};
return <select onChange={onChangeHandler}
value={selectedOption}></select>;
}
最后,我们将使用.map渲染props.options,这将返回一个<option>元素,其值和键属性设置为option,其内容将是option值:
export function CoupledSelect({ selectedOption, options,
onChange }) {
// no change to onChangeHandler
return (
<select onChange={onChangeHandler}
value={selectedOption}>
{options.map((option) => (
<option value={option} key={option}>
{option}
</option>
))}
</select>
);
}
使用我们的CoupledSelect可能看起来像以下这样。
我们定义了一个选项数组。在这里,我们将它们结构化为一个具有value键的对象列表,该键是一个字符串:
const options = [
{ value: 'apple' },
{ value: 'pear' },
{ value: 'orange' },
{ value: 'grape' },
{ value: 'banana' },
];
我们可以通过确保props.options是一个字符串数组来使用CoupledSelect:
function App() {
return (
<>
<CoupledSelect
options={options.map((option) => option.value)}
/>
<>
);
}
接下来,我们可以使用useState钩子来保存selectedOption。我们将这个特定的状态命名为selectedOption,其更新函数为setSelectedOption。这将使我们能够使CoupledSelect具有交互性:
function App() {
const [selectedOption, setSelectedOption] = useState();
return (
<>
<p>Selected Option: {selectedOption}</p>
<CoupledSelect
selectedOption={selectedOption}
onChange={(selectedOption) => setSelectedOption
(selectedOption)}
options={options.map((option) => option.value)}
/>
<>
);
}
最后,我们将为selectedOption设置一个初始值,以展示CoupledSelect组件的功能是如何工作的:
function App() {
const [selectedOption, setSelectedOption] = useState
(options[3].value);
// no change to the returned JSX
}
从初始的 selectedOption 功能开始,我们可以看到选项数组索引 3 的项 { value: 'grape' } 是初始选中的选项,如图 图 4**.1 所示:

图 4.1:CoupledSelect 初始状态,选中葡萄
当打开 select 时,select 处于正确的状态。

图 4.2:CoupledSelect 选择打开状态,悬停在橙子选项上
最后,当我们选择不同的选项时,onChange 处理器也按预期工作。

图 4.3:在 CoupledSelect 选择后的状态中,现在选中了橙子
由于渲染函数中的 options.map() 调用,CoupledSelect 组件的灵活性有限。由于我们使用选项变量作为选项元素的值,它必须是字符串或数字。该值也等于选项元素的渲染文本内容,但通常情况下,我们希望显示与存储的值不同的内容。这是一个展示与持久性关注的问题。例如,我们无法在不更改 onChange 中存储的内容的情况下更改渲染的值。
如果我们想在 select 中添加一个 Fruit: 前缀,一个简单的方法是实现如下:
<CoupledSelect
{/* other props don't change */}
options={options.map((option) => `Fruit:
${option.value}`)}
/>
不幸的是,这并没有按预期工作,因为初始选择不再起作用:

图 4.4:CoupledSelect 初始状态,初始选择不正确
当打开 select 时,似乎一切正常;我们可以看到所有选项的 Fruit: 前缀,如图 图 4**.5 所示。

图 4.5:CoupledSelect 打开状态,带有 Fruit: 前缀
在选择新选项时,我们可以看到存储在 selectedOption 中的是 Fruit: pear 而不是 pear。

图 4.6:CoupledSelect 选择后的状态 – 注意到选中的选项 Fruit: pear 包含前缀 Fruit:
因此,由于渲染逻辑和数据逻辑的耦合,CoupledSelect 组件不能灵活使用。
现在我们将看到如何通过解耦数据和渲染逻辑来缓解这个问题。
实现/示例
在我们的 CoupledSelect 示例中,我们看到了存储的数据和显示给用户的内容是如何紧密耦合的。现在我们将看到如何使用渲染属性来打破这种耦合。
通过使用渲染属性解耦数据逻辑和展示逻辑
使用渲染属性编写 CoupledSelect 组件的另一种方法是以下所示。我们传递的附加属性是 renderOption,这是一个渲染属性。其余的大部分组件与之前相似,但包括为了完整性:
export function SelectRenderProps({
selectedOption,
options,
renderOption,
onChange,
}) {
const onChangeHandler = (event) => {
if (onChange) onChange(event.target.value);
};
return (
<select onChange={onChangeHandler} value=
{selectedOption}>
{options.map((option) => renderOption(option))}
</select>
);
}
SelectRenderProps 组件的使用与 CoupledSelect 非常相似。我们需要的唯一附加属性是实现 renderOption 属性,我们通过一个返回 option 元素的函数来实现它:
function App() {
return (
<SelectRenderProps
selectedOption={selectedOption}
onChange={(selectedOption) => setSelectedOption
(selectedOption)}
options={options.map((option) => option.value)}
renderOption={(option) => (
<option value={option} key={option}>
{option}
</option>
)}
/>
);
}
到目前为止,实现与 CoupledSelect 非常相似,除了 SelectRenderProps 的父组件现在决定如何渲染一个选项。
给定相同的将选项前缀为 Fruit: 的要求,我们现在可以按以下方式实现:
<SelectRenderProps
{/* rest of the props remain unchanged */}
renderOption={(option) => (
<option value={option} key={option}>
Fruit: {option}
</option>
)}
/>
注意,与我们在 CoupledSelect 中所做的方法相反,我们甚至没有触及选项属性。我们唯一的更改是 renderOption 属性。我们现在将测试这个示例,并展示将渲染逻辑(使用渲染属性)与数据逻辑解耦对于扩展性来说效果更好。
SelectRenderProps 初始状态渲染正确,父组件中包含 select 和 grape:

图 4.7:SelectRenderProps 初始状态 – 选项和初始选择正确显示
当我们打开 select 时,我们可以看到 Fruit: 前缀被渲染。

图 4.8:SelectRenderProps 打开状态 – 选项包括 Fruit: 前缀
最后,在选项被选择后,状态更新正确,父组件存储的 select 已选择 Fruit: banana:

图 4.9:SelectRenderProps 选择后的状态 – 选择的项目不包括 Fruit: 前缀
我们现在已经看到了渲染属性如何允许在制作渲染更改时分别编辑渲染逻辑和数据逻辑。
现在我们已经实现了一个基本的渲染属性模式示例,我们将看到库如何利用它为消费者提供灵活性。
提供具有灵活呈现的组件时的附加渲染属性模式
React 表单管理库 Formik 使用渲染属性将表单状态返回给消费者。渲染属性是 Formik 组件的子属性。换句话说,在 <Formik> 标签打开和 </Formik> 标签关闭之间是一个函数,它提供了如值、isSubmitting 和 handleChange 等属性。
请看以下示例,这是一个单输入表单,它接受一个名称,验证它至少有两个字符长,并允许表单提交。
首先,我们将渲染表单和输入,这些表单将在 Formik 中存储 fields 值:
import { Formik } from 'formik';
export function FormikIntegrationExample() {
return (
<Formik
initialValues={{ name: '' }}
>
{({
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
isSubmitting,
}) => (
<form onSubmit={handleSubmit}>
<fieldset>
<input
type="text"
id="name"
name="name"
onChange={handleChange}
onBlur={handleBlur}
value={values.name}
aria-required="true"
/>
</fieldset>
</form>
)}
</Formik>
);
}
接下来,我们可以添加提交处理和内联验证错误显示:
import { Formik } from 'formik';
export function FormikIntegrationExample() {
return (
<Formik
initialValues={{ name: '' }}
validate={(values) => {
const errors = {};
if (!values.name) {
errors.name = 'Required';
} else if (values.name.length < 2) {
errors.name = 'Name too short';
}
return errors;
}}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
alert(JSON.stringify(values, null, 2));
setSubmitting(false);
}, 400);
}}
>
{({
/* no change to props in render prop */
}) => (
<form onSubmit={handleSubmit}>
<fieldset>
<div>
<label htmlFor="name">
Name (Required)
<br />
{errors.name && touched.name ? (
<>Error: {errors.name}</>
) : (
<> </>
)}
</label>
</div>
{/* no change to the input */}
</fieldset>
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</form>
)}
</Formik>
);
}
在初始状态中,我们看到一个包含单个输入和一个提交按钮的表单:

图 4.10:Formik 单个字段和提交按钮在其初始状态,包括名称(必填)标签
当我们点击(或以其他方式聚焦)名称输入然后失焦(blur 网络事件)时,验证会触发,让我们知道该字段是必填的。

图 4.11:名称输入失焦验证 - 错误:必填验证错误
如果我们只输入一个字符并失焦,我们会得到一个验证错误,名称****太短。

图 4.12:在名称输入中输入 H 触发验证错误 - 错误:名称太短
当输入满足验证标准的名称时,验证错误会被清除。

图 4.13:有效的 Formik 字段清除验证错误
最后,当我们点击提交时,我们会收到一个浏览器警报,显示{ “name”: “****Hugo” }。

图 4.14:提交时的警报,显示 { “name”: “Hugo” }
现在,让我们看看渲染属性模式的一些限制。
限制
渲染属性模式的一个关键限制是它提供的是函数而不是组件的复用和集成单元。很多逻辑最终可能都集中在渲染属性函数本身,而这些逻辑本可以通过创建一个新的组件来更好地服务。
当使用浅渲染器(如 Enzyme 的 shallow)时,渲染属性可能会使代码更难测试,因为浅渲染器不会渲染完整的组件树。大量使用渲染属性的组件可能应该使用完整的“挂载”渲染方法,以便渲染组件的所有子组件(包括渲染属性)。
在本节中,我们向您介绍了渲染属性模式,并描述了其用例、示例和限制。
在下一节中,我们将了解另一个响应式视图库模式——高阶组件。
高阶组件模式
高阶组件是一个函数,它接受一个组件并返回一个组件。高阶组件的定义与高阶函数类似,JavaScript 支持。高阶函数是接收函数作为参数或返回函数的函数。
高阶组件模式允许我们向组件传递额外的属性。
实现/示例
以下是一个简单的渲染属性,withLocation,它将window.location.href和window.location.origin作为属性注入到组件中:
const location = {
href: window.location.href,
origin: window.location.origin,
};
export function withLocation(Component) {
return (props) => {
return <Component location={location} {...props} />;
};
}
使用高阶组件时使用的模式是将带有本地组件的高阶组件作为默认导出——在本例中,withLocation(Location)。Location组件是一个简单的组件,它接受由withLocation提供的位置并渲染它:
// in `location.jsx` file
function Location({ location }) {
return (
<>
location.href: {location.href}, location.origin:
{location.origin}
</>
);
}
export default withLocation(Location);
在Location的消费者中,我们导入的Location是默认导出——即withLocation(Location):
import Location from './location';
function App() {
return <Location>;
}
Location组件根据withLocation提供的内容渲染location.href和location.origin。

图 4.15:Location组件根据withlocation提供的内容渲染 href 和 origin
我们现在看到了高阶组件的一个简单示例,即其关键好处之一,即执行渲染的组件不需要直接知道信息来源;它可以读取属性。
用例
withLocation示例已经展示了我们可以使用高阶组件的一个简单原因——为了保持关注点的分离。
在我们的Location组件示例中,Location直接访问window.location是完全可能的。然而,这意味着Location组件会意识到全局对象,这可能是不可取的。例如,它可能会使Location的单元测试更加困难,因为它正在访问超出其属性的内容。
局限性
与所有抽象一样,高阶组件是一层间接层。这意味着追踪一个属性从哪里来可能比从父组件显式传递属性更困难。
当高阶组件来自第三方库(因此更难检查)时,追踪属性变得更加困难。
高阶组件在浏览器渲染方面可能会有成本,因为我们如果在多个高阶组件上堆叠我们的组件,就会将我们的组件包裹在另一个组件中。
例如,以下ConnectedComponent使用了三个高级组件:
const ConnectedComponent = withRouter(
withHttpClient(withAnotherDependency
(ComponentWithDependencies))
);
作为ConnectedComponent的消费者,我们可能会渲染四个组件——withRouter、withHttpClient、withAnotherDependency和ComponentWithDependencies提供的组件。如果我们有另一种方法注入路由器、HTTP 客户端和另一个依赖项,我们可以将组件数量减少到只有一个,只需要ComponentWithDependencies。
这种缺点使我们转向本章的下一个主题——钩子。钩子为我们提供了一种在类似高阶组件的场景中访问数据和逻辑的方法,而不需要渲染额外的组件。钩子是逻辑密集型高阶组件的绝佳替代品。
钩子模式
我们现在已经涵盖了在 React 中可能被认为是遗留模式的主题——渲染属性和高阶组件。
你会注意到关于高阶组件的 React 文档页面有如下免责声明:“高阶组件在现代 React 代码中不常用。”
附加阅读
useState 和 useEffect hooks 的 React 文档:
useState:react.dev/reference/react/useState
useEffect:react.dev/reference/react/useEffect
因此,到目前为止,我们知道 hooks 允许我们做我们用 render props 做过的事情,而且不再推荐使用高阶组件。这是因为 hooks 提供了一种访问所有 React 原语的方法,包括状态和组件生命周期。
React 提供了内置的 hooks。我们将关注其中的两个:useState 和 useEffect。hooks 的一个关键特性是我们可以编写自定义 hooks,这些 hooks 是建立在 React 内置 hooks 和其他自定义 hooks 之上的,这意味着我们在 React 中有了分享逻辑的新方法。
实现/示例
我们将使用类 React 组件实现简单的数据获取,然后使用 hooks。这将展示在这两种情况下如何处理状态和生命周期事件。
我们将从类组件开始。实现数据获取的常规方式是使用生命周期 hooks;最初的一个通常是 componentDidMount。
我们的 BasketItemsClassical 组件接受 httpClient 和 basketId。
组件的构造函数将一个 state.basketSession 变量初始化为一个空对象,{}:
import React from 'react';
export class BasketItemsClassical extends React.Component {
constructor(props) {
super(props);
this.state = {
basketSession: {},
};
}
接下来,我们将添加一个 setBasketSession 方法,该方法将调用 this.setState 来设置 basketSession 为传递的参数。
我们还将添加 componentDidMount,它使用 httpClient.get() 和 fakestoreapi.com URL 来加载购物车,使用 basketId prop:
export class BasketItemsClassical extends React.Component {
// no change to the constructor
componentDidMount() {
this.props.httpClient
.get(`https://fakestoreapi.com/carts/${this.props.basketId}`)
.then((session) => this.setBasketSession(session));
}
setBasketSession(session) {
this.setState({ basketSession: session });
}
}
这现在意味着我们应该能够在组件的 render() 方法中渲染出 this.state.basketSession 的内容:
export class BasketItemsClassical extends React.Component {
// no change to the constructor, componentDidMount or
setBasketSession
render() {
return <pre>{JSON.stringify(this.state.basketSession,
null, 2)}</pre>;
}
}
我们的 BasketItemsClassical 可以通过传递 httpClient 和 basketId 作为 props 来使用:
export function BasketClassical({ basketId, httpClient }) {
return (
<form>
<fieldset>
<label>Class</label>
<BasketItemsClassical basketId={basketId}
httpClient={httpClient} />
</fieldset>
</form>
);
}
然后,我们可以在 App 中使用 BasketClassical,如下所示:
const httpClient = {
async get(url) {
const response = await fetch(url);
return await response.json();
},
};
function App() {
return (
<>
<BasketClassical basketId="5" httpClient={httpClient} />
</>
);
}
在浏览器中,它显示如下:

图 4.16:加载 JSON 数据的 Basket 类组件
下面是使用 hooks 的相同示例;我们不是使用 componentDidMount,而是使用 useEffect hook,并且不是在构造函数中使用 this.state 和 this.setState,而是使用 useState hook。为了使用 hooks,我们使用一个 React 函数组件(React 类组件不支持 hooks):
export function BasketItemsHooks({ basketId, httpClient }) {
const [basketSession, setBasketSession] = useState({});
useEffect(() => {
httpClient
.get(`https://fakestoreapi.com/carts/${basketId}`)
.then((session) => setBasketSession(session));
}, []);
return <pre>{JSON.stringify(basketSession, null, 2)}</pre>;
}
我们的 BasketItemsHooks 可以像 BasketItemsClassical 一样使用,通过传递 httpClient 和 basketId 作为 props:
export function BasketHooks({ basketId, httpClient }) {
return (
<form>
<fieldset>
<label>Hooks</label>
<BasketItemsHooks basketId={basketId}
httpClient={httpClient} />
</fieldset>
</form>
);
}
我们还需要修改 App 以渲染 BasketHooks 以及 BasketClassical:
// no change to httpClient
function App() {
return (
<>
<BasketClassical basketId="5" httpClient={httpClient} />
<BasketHooks basketId="5" httpClient={httpClient} />
</>
);
}
在 HTTP 请求完成后,BasketHooks(图 4**.17)和 BasketClassical(图 4**.16)都产生了相同的 JSON 输出。

图 4.17:钩子篮子加载 JSON 数据
钩子方法稍微紧凑一些;每个功能部分都感觉更独立。例如,初始状态在定义钩子版本中状态更新函数的地方处理。在类示例中,initialisation状态在构造函数中,状态更新函数是一个方法。在BasketClassical示例中,有简化组件的选项,通过移除状态更新方法并使用直接的this.setState({ bookingSession: session })调用。
用例
关于钩子和类或函数组件的简单思考方式如下:
-
共享逻辑的钩子
-
与渲染相关的逻辑组件
高阶组件和渲染属性模式,这些模式用于分离表现层和业务逻辑,可能不再需要,可以用自定义钩子来替代。
React 钩子和函数组件是开发现代 React 应用的推荐方式。
局限性
如 React 文档中详细说明:react.dev/reference/react/Component#defining-a-class-component。请注意,函数组件是构建 React 组件的推荐方式。
在大量使用类组件的代码库中,应该继续使用高阶组件,而不是将组件迁移到函数中以使用钩子。
React 组件的最后一部分是如何绕过属性钻取问题,并在不改变 React 组件树中每个组件的情况下传递数据。我们用于此的模式是提供者模式,我们将在下一节中介绍。
提供者模式
React 中的提供者模式是指树中的某个组件使其数据对所有后代组件可用。这通常是通过使用 React 上下文原语来实现的。
用例 - 属性钻取问题
提供者模式的关键用途是避免属性****钻取问题。
大多数情况下,组件的主要输入是从其父组件接收的属性。在 React 中,要在组件之间共享状态,可以使用提升状态的模式。提升状态意味着将相关状态存储在需要共享状态的组件的共同祖先中。
如 React.js 文档所述(react.dev/learn/sharing-state-between-components)
当你想协调两个组件时,将它们的状态移动到它们的共同父组件中。然后通过它们的共同父组件从上往下传递信息
这可能导致正钻,当普通父组件与需要支撑的组件之间有多个组件时。这意味着所有中间组件都将接收支撑,但他们只会使用它们将它们传递给下一层组件。
如 React.js 文档(https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props)所述
传递属性是明确将数据通过 UI 树传递到使用它的组件的绝佳方式。但是,当您需要将某些属性深度传递到树中,或者许多组件需要相同的属性时,传递属性可能会变得冗长且不方便。最近的共同祖先可能离需要数据的组件很远,将状态提升到那么高的位置可能导致称为“属性钻取”的情况。
提供者模式是解决属性钻取问题的解决方案,因为提供者组件的每个后代都将有权访问它提供的数据。
一个实现/示例
让我们回顾一下钩子模式部分中的示例,其中 App 渲染BasketClassical和BasketHooks,分别渲染BasketItemsClassical和BasketItemsHooks。

图 4.18:包含 BasketClassical、BasketHooks 及其后代的 React 应用树
这说明了属性钻取问题,因为BasketClassical和BasketHooks在将basketId或httpClient传递给BasketItemsClassical和BasketItemsHooks之外没有使用它们。
在 React 中,有多种方式来消费上下文,但一切始于创建一个上下文:
import React, { createContext } from 'react';
const HttpClientContext = createContext(null);
export function HttpClientProvider({ httpClient, children
}) {
return (
<HttpClientContext.Provider value={httpClient}>
{children}
</HttpClientContext.Provider>
);
}
HttpClientContext是一个初始化为 null 值的上下文。HttpClientProvider是一个组件,它接受一个httpClient值,将其设置为HttpClientContext.Provider将传递给组件树中后代的值。
为了使用HttpClientContext,我们可以使用HttpClientContext.Consumer:
export const HttpClientConsumer = HttpClientContext.Consumer;
HttpClientContext.Consumer有一个子渲染属性(函数),它接受上下文的值(在这种情况下,httpClient)并返回一些 JSX 进行渲染:
// no change to httpClient
function App() {
return (
<HttpClientProvider httpClient={httpClient}>
{/* what's below could be however deep in the
component tree */}
<HttpClientConsumer>
{(httpClient) => (
<BasketItemsClassical basketId="5" httpClient=
{httpClient} />
)}
</HttpClientConsumer>
</HttpClientProvider>
);
}
这在浏览器中产生以下输出:

图 4.19:来自 fakestoreapi.com 的 basketId=5 的 JSON 内容
使用HttpClientContext.Consumer直接的方法有点难以操作。相反,我们可以将其包裹在一个高阶组件withHttpClient中,该组件消费HttpClientConsumer。这里的优点是我们只有一个使用HttpClientConsumer的地方:
export function withHttpClient(Component) {
return (props) => (
<HttpClientConsumer>
{(httpClient) => <Component {...props} httpClient=
{httpClient} />}
</HttpClientConsumer>
);
}
与我们示例中的高阶组件略有不同,我们将导出 const Connected
BasketItemsClassical,其值为 withHttpClient(BasketItemsClassical)。连接 命名法是对大型 React Redux 代码库的回溯,其中组件通常分为 展示 和 容器 组件。Redux 高阶组件称为 connect,所有容器都是连接的:
export const ConnectedBasketItemsClassical =
withHttpClient(BasketItemsClassical);
然后,我们可以如下使用 ConnectedBasketItemsClassical。请注意,我们没有传递一个 httpClient 属性:
function App() {
return (
<HttpClientProvider httpClient={httpClient}>
{/* what's below could be however deep in the
component tree */}
<ConnectedBasketItemsClassical basketId="5" />
</HttpClientProvider>
);
}
使用 withHttpClient 的高阶组件版本输出的值与直接的 HttpClientConsumer 实现相同。

图 4.20:来自 fakestoreapi.com 的 basketId=5 的 JSON 内容
使用上下文和提供者模式的最终方法是利用 React 的 useContext 钩子。类似于 HttpClientContext.Consumer 允许我们访问上下文提供者的值,这个钩子也扮演着同样的角色。因此,useContext(context) 的输出是基于钩子在组件树中渲染的位置的当前值。
通常,我们会将 useContext 钩子包裹在一个更具描述性的名称中(就像我们对 HttpClientContext.Consumer 所做的那样):
import React, { createContext, useContext } from 'react';
// no changes to HttpClientContext definition or
HttpClientContextConsumer
export function useHttpClient() {
const httpClient = useContext(HttpClientContext);
return httpClient;
}
这次,使用 HttpClientContext 中的 httpClient 需要组件级别的更改。因此,我们将编写以下 BasketItemsHooksUseContext 的实现:
export function BasketItemsHooksUseContext({ basketId }) {
const httpClient = useHttpClient();
const [basketSession, setBasketSession] = useState({});
useEffect(() => {
// @ts-ignore
httpClient
.get(`https://fakestoreapi.com/carts/${basketId}`)
.then((session) => setBasketSession(session));
}, []);
return <pre>{JSON.stringify(basketSession,
null, 2)}</pre>;
}
BasketItemsHooksUseContext 可以如下使用。请注意,我们并没有传递 BasketItemsHooksUseContext,一个 httpClient 属性:
function App() {
return (
<HttpClientProvider httpClient={httpClient}>
{/* what's below could be however deep in the
component tree */}
<BasketItemsHooksUseContext basketId="5" />
</HttpClientProvider>
);
}
这种实现再次等同于我们之前使用 HttpClientConsumer 和 HttpClient 所做的实现。

图 4.21:来自 fakestoreapi.com 的 basketId=5 的 JSON 内容
我们看到了如何使用提供者模式来解决 React 应用中的属性钻探问题。现在,让我们在下一节中看看这种模式的局限性。
局限性
提供者模式是一种间接层。有时可能并不明显知道上下文值来自何处,或者有时可能需要更改提供者/上下文形状以在组件级别进行一些更改。例如,当使用钩子与上下文一起时,钩子显示了消费组件和上下文之间的直接链接,但它并不一定显示提供者或上下文内部值的定义方式。
有时,通过大量使用子组件并在单个大的 JSX 返回中组合组件,也可以解决属性钻探问题,如下所示:
function MyComponent() {
return <ContainerComponent requiredProp={'value'}>
<OtherComponent prop="other-value"/>
<FinalComponent prop="final-value"/>
</ContainerComponent>
}
在MyComponent中,我们直接将 props 从MyComponent传递给OtherComponent和FinalComponent。如果我们有一个封装OtherComponent和FinalComponent的ContainerComponent,props 将通过ContainerComponent传递(它不使用 props,但接收它们,以便将其传递给其子组件)。
摘要
在本章中,我们探讨了如何通过响应式视图库模式,在组件范式开始崩溃时,更有效地构建 React 应用程序。
渲染属性模式允许我们通过将渲染控制权交还给组件的消费者来解耦数据逻辑和渲染逻辑。
高阶组件模式允许组件在其 props 上实现逻辑(数据或渲染),而无需关心信息来源。
钩子模式意味着以前仅在类组件中可用的 React 原语现在可以作为自包含的逻辑块提供给函数组件。钩子可以独立于组件组合,这使得钩子成为一个强大的原语,并且可以部分替代渲染属性和高级组件模式。
提供者模式允许 React 组件不仅将数据传递给其子组件,还可以传递给任何子组件。
现在我们已经熟悉了响应式视图库模式,在下一章中,我们将探讨渲染和页面激活策略,以改善 Web 应用程序的性能。
第五章:渲染策略和页面激活
渲染策略和页面激活方法允许我们利用 JavaScript 客户端和服务器生态系统来提供高性能和可扩展的 Web 应用程序,这取决于我们最终用户的需求。本章中涵盖的 React 和 JavaScript 技术是增强 第四章 的另一套工具。我们将利用客户端(浏览器)和服务器(特别是 Node.js)运行时的优势,为用户提供快速和可扩展的 React 网站。
在本章中,我们将涵盖以下主题:
-
通过实现纯客户端和服务器端渲染应用程序,了解 React 应用程序客户端和服务器渲染之间的权衡
-
框架如 Next.js 通过静态站点生成功能和服务器端渲染功能可以带来的优势类型
-
使用 React 页面激活示例及其注意事项来弥合客户端-服务器渲染差距
-
React 中的流式服务器端渲染
到本章结束时,你将能够使用 React 选择合适的渲染和页面激活策略,并能够实现框架级别的功能,从而让你做出更好的技术选择。
技术要求
你可以在 GitHub 上找到本章的代码文件,网址为 github.com/PacktPublishing/Javascript-Design-Patterns
使用 React 的客户端和服务器渲染
在网络环境中,客户端渲染是通过在用户的浏览器中使用 JavaScript 生成或更新页面内容的过程。一个完全客户端渲染的应用程序只有在相关的 JavaScript 代码完成下载、解析和运行后才会显示有意义的内容。
在下面的序列图中,我们使用“origin”而不是“服务器”等术语,因为全客户端渲染的一个好处是,为我们内容“提供服务”的资源可以是所谓的 静态托管。这包括 AWS 简单存储服务(S3)、Netlify、Cloudflare Pages 和 GitHub Pages 等服务。在这些服务中没有动态的服务器端组件。

图 5.1:客户端渲染序列图
相比之下,服务器端渲染指的是当浏览器请求时,服务器生成一个完整的 HTML 文档并返回的过程。

图 5.2:服务器端渲染序列图
React 中的客户端渲染
在 React 中,客户端渲染是默认的渲染方法。让我们从头开始构建一个从客户端渲染的应用程序:
-
我们从一个渲染一些文本及其 type 属性的 App 组件开始:
export function App({ type = '' }) { return ( <div> <p>Hello from the {type + ' '}app</p> </div> ); } -
然后,我们创建一个入口点文件,client.jsx,它导入应用程序并使用 ReactDOM 来渲染它,将 type 属性设置为 "****client render"。
import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './src/app'; ReactDOM.createRoot(document.querySelector ('#app')).render( <App type={`"client render"`} /> ); -
为了使此示例运行,我们需要一个允许 ReactDOM.createRoot 成功运行的 HTML 文档。换句话说,我们需要一个具有 id=app 元素并引用我们的入口点的 HTML 文档:
<div id="app"></div> <script src="img/client.js"></script> -
注意,入口点是 dist/client.js 而不是 client.jsx。这是因为 React 的 JSX 语法不能在浏览器中本地运行。相反,我们通过使用 esbuild 进行编译和打包步骤来运行我们的入口点文件,client.jsx。我们的构建命令看起来像这样:
npx esbuild client.jsx --bundle --outdir=dist
现在,如果我们加载浏览器中的 index.html 文件,我们会看到以下内容:

图 5.3:来自“客户端渲染”应用,在浏览器中渲染
React 的服务器端渲染
Node.js,在其网站上介绍为“一个开源、跨平台的 JavaScript 运行时环境”,使我们能够在服务器上运行 JavaScript。在 Node.js 中构建服务器的常用包是 Express。
在本节中,我们将了解如何使用 Node.js 和 Express 来服务器端渲染一个 React 应用程序。
一个简单的 Express 服务器,当加载根路径时返回 'Server-rendered hello',如下所示:
import express from 'express';
const app = express();
app.get('/', (_req, res) => {
res.send('Server-rendered hello');
});
const { PORT = 3000 } = process.env;
app.listen(PORT, () => {
console.log(`Server started on
http://localhost:${PORT}`);
});
再次,我们将使用 esbuild 来打包和编译 JSX 到 JavaScript:
npx esbuild server.js --bundle --platform=node --outdir=dist
然后,我们可以使用以下命令启动服务器:
node dist/server.js
默认情况下,它运行在端口 3000,但可以通过环境变量来覆盖。
当我们加载 localhost:3000 时,在浏览器中会看到以下消息。

图 5.4:在浏览器中渲染的服务器端渲染的“hello”
这是一个使用 Node.js 和 Express 进行服务器端渲染的非常简单的示例。
接下来,我们将了解如何利用 ReactDOM 包将 React 组件服务器端渲染:
-
ReactDOM 包提供了两个入口点:react-dom/client(我们在上一节中使用过)和 react-dom/server。正如名称所暗示的,客户端入口点旨在在客户端(在浏览器中,“客户端”JavaScript)使用,而服务器入口点则旨在在服务器(通过 Node.js 或其他服务器端 JavaScript 运行时)使用。
-
我们将使用两种方法:ReactDOMServer.renderToStaticMarkup 和 ReactDOMServer.renderToString;这两个方法将允许我们将 React 应用程序服务器端渲染为 HTML。
-
在一个 src/server-render.jsx 文件中,我们有以下 renderNav 和 serverRenderApp 函数,它们分别使用 ReactDOMServer.renderToStaticMarkup 和 ReactDOMServer.renderToString 来渲染 Nav 和 App:
import React from 'react'; import ReactDOMServer from 'react-dom/server'; import { App } from './app'; import { Nav } from './nav'; export function renderNav() { return ReactDOMServer.renderToStaticMarkup(<Nav />); } export function serverRenderApp() { return ReactDOMServer.renderToString(<App type={`"server render"`} />); }这里,
app.jsx与上一节中的相同,而nav.jsx如下所示:import React from 'react'; export function Nav() { return ( <ul> <li> <a href="/">Server-render only</a> </li> </ul> ); } -
然后,我们可以在server.js中使用renderNav和serverRenderApp。我们修改app.get('/')处理程序以渲染导航和应用程序:
// no other changes app.get('/', (_req, res) => { res.send(` <!DOCTYPE html> ${renderNav()} <h1>Server-render only</h1> <div id="app">${serverRenderApp()}</div> `); }); -
当我们重新构建服务器时,我们运行node dist/server.js并打开localhost:3000以查看以下内容:

图 5.5:来自“服务器渲染”应用,与标题和导航一起渲染
ReactDOMServer.renderToStaticMarkup和ReactDOMServer.renderToString之间的区别是什么?简短的回答是renderToStaticMarkup不能在客户端重新激活;换句话说,它不能用作初始 HTML,然后相同的 React 应用程序代码可以在客户端运行以提供完全交互式的体验。我们将在本章的后续部分重新讨论这个问题。
客户端和服务器渲染之间的权衡
那么,客户端和服务器渲染有什么优点和缺点?
客户端渲染的主要优势在于应用程序的“工作”完全在用户的浏览器中完成,这使得它具有高度的可扩展性,因为使用该系统的用户数量不会对原始服务器造成压力。客户端渲染的主要缺点与仅在服务器端可用的功能相关——例如,仅服务器端 cookie 或设置社交媒体预览的meta标签。
服务器渲染的主要缺点是工作必须在服务器上完成。如前所述,服务器作为一个“受控”环境有一些好处,即其与其他协同定位系统的延迟通常低于完整的浏览器-服务器往返,因为服务器的网络是已知的,并且不太可能像最终用户网络那样有太多的性能差异。通过不等待完整页面加载,然后是资产加载,然后是 JavaScript“解析和执行”级联,服务器渲染可以提高“核心 Web 指标”,如最大内容绘制(LCP)和累积布局偏移(CLS)。
最终,客户端渲染的功能是我们使用 JavaScript 的关键原因,这意味着在受限的使用案例中移除这种能力才有意义,例如内容网站(例如博客、新闻网站和文档网站)。
我们现在已经看到了客户端和服务器渲染之间的区别,以及如何使用 React 和 Node.js 实现它们。在接下来的部分,我们将探讨 Next.js 框架为 React 提供的渲染方法。
使用 Next.js 进行静态渲染
Next.js 是一个用于创建全栈 Web 应用的 React 框架。这意味着它提供了工具和观点,将帮助开发者短期和长期内提高生产力。
Next.js 包括用于“页面”的文件系统路由器、一组 React 路由原语、客户端和服务器渲染支持以及数据获取原语等。
我们将关注的 Next.js 功能是 静态站点生成(SSG)。这种渲染方法类似于服务器端渲染,但通过在构建时而不是在请求时进行渲染,缓解了一些缺点。

图 5.6:预渲染/静态站点生成用例的序列图
现在我们已经了解了当用户请求一个网站时,静态站点生成如何改变数据流,接下来我们将探讨 Next.js 的自动静态生成。
自动静态生成
在 Next.js 中,基于文件系统的路由意味着您的 Web 应用程序中的每个路径都对应于应用程序 pages 目录中的一个文件。例如,/ 对应于 pages/index.js。
当给定页面没有使用 Next.js 数据获取方法时,Next.js 默认使用静态生成。您可以从 Next.js 文档中找到更多信息 – 自动静态优化 (nextjs.org/docs/pages/building-your-application/rendering/automatic-static-optimization)。
如果一个页面没有阻塞数据需求(即,可以被预渲染),Next.js 会自动确定该页面是静态的。这种判断是通过页面上不存在 getServerSideProps 和 getInitialProps 来实现的。
例如,在 Next.js 应用程序中的以下页面将会被静态生成,因为它只导出了一个页面组件(Index 的默认导出);没有导出 getServerSideProps 或 getInitialProps 函数:
import React from 'react';
import Head from 'next/head';
import Link from 'next/link';
export default function Index() {
return (
<>
<Head>
<title>Next Static Rendering - Automatic Static
Generation</title>
<meta name="viewport" content="width=device-width,
initial-scale=1" />
</Head>
<main>
<ul>
<li>
<Link href="/products">Products Page (SSG)
</Link>
</li>
<li>
<Link href="/cart">Cart Page (SSR)</Link>
</li>
</ul>
</main>
</>
);
}
我们可以在以下屏幕截图中的 next build 过程中看到这一点;输出中的 / route (page) 被标记为 Static:
npx next build
info - Linting and checking validity of types
info - Creating an optimized production build
info - Compiled successfully
info - Collecting page data
info - Generating static pages (3/3)
info - Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 2.73 kB 75.8 kB
└ ○ /404 182 B 73.2 kB
+ First Load JS shared by all 73.1 kB
├ chunks/framework-fcfa81c6fe8caa42.js 45.2 kB
├ chunks/main-7039e34bfb6f1a68.js 26.9 kB
├ chunks/pages/_app-c7a111f3ee9d686c.js 195 B
└ chunks/webpack-8fa1640cc84ba8fe.js 750 B
○ (Static) automatically rendered as static HTML (uses no initial props)
当我们使用 next start 运行构建的 Next.js 输出时,页面会按预期行为。

图 5.7:产品页和购物车页面的链接渲染
由于我们没有动态数据获取需求,这个例子是一个相对受限的用例。它仍然展示了如果页面没有使用任何排除静态生成的功能,Next.js 默认会进行静态渲染。对于更高级的用例,Next.js 还允许使用“构建时”动态数据,这意味着我们可以使用第三方数据源来生成页面内容,等等。
我们已经看到了 Next.js 默认使用自动静态生成。接下来,我们将看到如何配置 Next.js 页面以加载数据以将页面作为静态内容渲染。
使用第三方数据源的静态生成
Next.js 有一个 getStaticProps 数据获取方法,允许我们在构建时加载数据,这些数据将被传递给页面。
以下序列图展示了这涉及的内容:

图 5.8:使用 getStaticProps 的 Next.js 预渲染序列图
例如,如果我们想基于 fakestoreapi.com 的数据构建一个“产品列表”页面,我们可以在 pages/products/index.js 页面中编写以下 getStaticProps 方法:
export async function getStaticProps() {
const products = await fetch
('https://fakestoreapi.com/products').then(
(res) => res.json()
);
return {
props: {
products,
},
};
}
这里是一个 product 示例,以说明数据结构:
{
id: 1,
title: 'Fjallraven - Foldsack No. 1 Backpack, Fits 15
Laptops',
price: 109.95,
description: 'Your perfect pack for everyday use and
walks in the forest. Stash your laptop (up to 15 inches)
in the padded sleeve, your everyday',
category: "men's clothing",
image: 'https://fakestoreapi.com/img/
81fPKd-2AYL._AC_SL1500_.jpg',
rating: { rate: 3.9, count: 120 }
}
根据 getStaticProps 提供的数据,我们可以构建一个 ProductIndexPage 组件。我们将遍历 props.products 中的每个产品,并将它们渲染在一个无序列表中。每个条目将包括一个链接到 /products/[id] 页面(该页面尚不存在):
import React from 'react';
import Link from 'next/link';
import Head from 'next/head';
export default function ProductIndexPage({ products }) {
return (
<>
<Head>
<title>Products</title>
</Head>
<div>
<h2>Products</h2>
<ul>
{products.map((product) => {
return (
<li key={product.id}>
<Link
href={{
pathname: '/products/[id]',
query: { id: product.id },
}}
>
{product.title}
</Link>
</li>
);
})}
</ul>
</div>
</>
);
}
// no change to getStaticProps
当运行 next build 时,此页面现在将被构建。正如我们从输出中可以看到的,/products 页面被标记为 SSG(静态站点生成):
info - Linting and checking validity of types...
info - Creating an optimized production build...
info - Compiled successfully
info - Collecting page data
info - Generating static pages (4/4)
info - Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 464 B 75.9 kB
├ ○ /404 182 B 73.2 kB
└ ● /products 426 B 75.9 kB
+ First Load JS shared by all 73.1 kB
├ chunks/framework-fcfa81c6fe8caa42.js 45.2 kB
├ chunks/main-7039e34bfb6f1a68.js 26.9 kB
├ chunks/pages/_app-c7a111f3ee9d686c.js 195 B
└ chunks/webpack-8fa1640cc84ba8fe.js 750 B
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
当我们使用 next start 启动 Next.js 服务器并导航到 /products 时,我们会看到以下内容。请注意,除非我们重新构建应用程序,否则页面上的产品不会改变。

图 5.9:使用 fakestoreapi.com 的产品静态预渲染的产品列表页面
我们已经看到如何使用 getStaticProps 根据第三方 API 生成页面,但在请求之前如何生成 /products/[id] 页面呢?为了做到这一点,我们需要能够提供 Next.js 需要生成所需的“必需路径”(或 URL)。这就是我们将在下一节中要查看的内容。
基于动态路径的静态生成
预先生成具有动态路径和内容的页面可能很有用。
我们可以使用 getServerSideProps 并按需渲染页面。在我们工作的上下文中,这对于“购物车”页面是有效的。
getServerSideProps 是服务器端渲染,正如我们之前所看到的。一个购物车页面可能应该使用服务器端渲染的原因是它可以根据最终用户的交互快速变化。一个动态但不会根据最终用户操作快速变化的页面示例是“查看单个产品”页面。我们将在购物车页面示例之后看到如何静态生成它。
我们创建一个 pages/cart.js 文件,其中我们提供了以下 getServerSideProps,它加载购物车,确定相关的产品 ID(按购物车内容),并加载它们(以便显示有关它们的信息):
export async function getServerSideProps({ query }) {
const { cartId = 1 } = query;
const cart = await fetch(`https://fakestoreapi.com/carts/${cartId}`).then(
(res) => res.json()
);
const productsById = (
await Promise.all(
cart.products.map(async (product) => {
return await fetch(
`https://fakestoreapi.com/products/
${product.productId}`
).then((res) => res.json());
})
)
).reduce((acc, curr) => {
acc[curr.id] = curr;
return acc;
}, {});
return {
props: {
cart,
productsById,
},
};
}
然后,我们可以构建一个页面组件并将其作为默认导出。在组件中,我们遍历购物车产品,根据 props.productsById 渲染一些计数信息和一些产品信息:
import Head from 'next/head';
import React from 'react';
export default function CartPage({ cart, productsById }) {
return (
<>
<Head>
<title>Cart Page</title>
</Head>
<div>
<ul>
{cart.products.map((product) => {
return (
<li key={product.productId}>
{product.quantity} x {productsById
[product.productId]?.title}
</li>
);
})}
</ul>
</div>
</>
);
}
我们知道这是一个服务器端渲染的页面,因为当我们运行 next build 时,它会将其标记为这样的(并且不会增加“生成静态”页面计数):
npx next build
info - Linting and checking validity of types
info - Creating an optimized production build
info - Compiled successfully
info - Collecting page data
info - Generating static pages (4/4)
info - Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 464 B 75.9 kB
├ ○ /404 182 B 73.2 kB
├ λ /cart 445 B 73.5 kB
└ ● /products 426 B 75.9 kB
+ First Load JS shared by all 73.1 kB
├ chunks/framework-fcfa81c6fe8caa42.js 45.2 kB
├ chunks/main-7039e34bfb6f1a68.js 26.9 kB
├ chunks/pages/_app-c7a111f3ee9d686c.js 195 B
└ chunks/webpack-8fa1640cc84ba8fe.js 750 B
λ (Server) server-side renders at runtime (uses
getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no
initial props)
● (SSG) automatically generated as static HTML + JSON
(uses getStaticProps)
我们可以通过带有 ?cartId=1 查询参数加载 /carts 页面并查看购物车 1。

图 5.10:已加载购物车 1 并显示内容的购物车页面
我们还可以通过 cartId=3 查询参数加载 /carts 页面,并查看购物车 3。

图 5.11:已加载购物车 3 并显示内容的购物车页面
我们现在已经看到了如何按需渲染购物车页面;我们提到的一个非常适合构建时预渲染(即静态站点生成)的页面是 products/[id] 页面。为了渲染此页面,我们需要提供 Next.js 需要尝试预渲染的“路径”,因为 [id] 是动态的。
以下图表展示了 getStaticPaths 和 getStaticProps 之间的交互方式。简而言之,getStaticPaths 返回一个“路径”列表;然后对路径列表中的每个项目调用 getStaticProps,并可以执行相关的 I/O 调用来提供页面的 props。

图 5.12:使用 getStaticPaths 和 getStaticProps 的 Next.js 预渲染序列图

图 5.13:对预渲染的 Next.js 应用程序请求的序列
在我们的示例 Next.js 应用程序中,我们可以创建一个 pages/products/[id].js 文件,并包含以下 getStaticPaths 和 getStaticProps 函数:
export async function getStaticPaths() {
const products = await fetch('https://fakestoreapi.com/
products')
.then((res) => res.json())
.then((json) => json);
const paths = products.map((product) => ({
params: { id: String(product.id) },
}));
return { paths, fallback: false };
}
paths 生成的一个特点是,我们将 product.id 从数字转换为字符串,因为 [id] 路径参数需要是字符串。否则,Next.js 会报错 Error: A required parameter (id) was not provided as a string received number in getStaticPaths for /``products/[id]。
getStaticProps 接收包含在 getStaticPaths 返回的对象中的 params 对象,并进一步进行 fetch 调用来通过 ID 加载产品。最后,它为 Page 组件返回 product:
export async function getStaticProps({ params }) {
const product = await fetch(
`https://fakestoreapi.com/products/${params.id}`
).then((res) => res.json());
return {
props: {
product,
},
};
}
我们的 ProductPage 组件可以如下所示,其中我们使用 product.title 既是页面的标题也是页面 h2 元素的内容。从这里,我们可以显示产品响应中包含的任何内容,包括价格和库存信息以及图片:
import React from 'react';
import Link from 'next/link';
import Head from 'next/head';
export default function ProductPage({ product }) {
return (
<>
<Head>
<title>{product.title}</title>
</Head>
<div>
<Link href={'/products'}>Back</Link>
<h2>{product.title}</h2>
</div>
</>
);
}
当我们运行 next build 时,构建将花费更长的时间,因为每个 products/[id] 页面都需要向 fakestoreapi.com 发送请求。请注意,products/[id] 页面被标记为 SSG。我们还看到生成的静态页面数量增加到 24,以及 products/[id] 页面的截断子集:
npx next build
info - Linting and checking validity of types
info - Creating an optimized production build
info - Compiled successfully
info - Collecting page data
info - Generating static pages (24/24)
info - Finalizing page optimization
Route (pages) Size First Load JS
┌ ○ / 464 B 75.9 kB
├ ○ /404 182 B 73.2 kB
├ λ /cart 445 B 73.5 kB
├ ● /products 426 B 75.9 kB
└ ● /products/[id] 383 B 75.9 kB
├ /products/1
├ /products/2
├ /products/3
└ [+17 more paths]
+ First Load JS shared by all 73.1 kB
├ chunks/framework-fcfa81c6fe8caa42.js 45.2 kB
├ chunks/main-7039e34bfb6f1a68.js 26.9 kB
├ chunks/pages/_app-c7a111f3ee9d686c.js 195 B
└ chunks/webpack-8fa1640cc84ba8fe.js 750 B
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
在使用 next start 构建并启动服务器后,当我们加载 /products/1 路径时,我们看到产品 1 的名称。

图 5.14:/products/1 内容
当我们加载 /products/8 路径时,我们看到产品 8 的名称。

图 5.15:/products/8 内容
我们现在已经看到了如何利用 Next.js 的功能,自动静态渲染页面而不进行数据获取,getStaticProps 和 getStaticPaths 在构建时渲染具有动态内容和动态路径的页面,以及这些方法与 getServerSideProps 的对比。
接下来,我们将深入探讨如何在客户端重新激活服务器端渲染的 React 页面。
页面激活策略
如我们在本章第一部分所见,React 提供了在服务器和客户端渲染应用程序的原语。然而,我们只看了只进行客户端或服务器端渲染的示例。React 框架(如 Next.js)的一个关键特性是它们允许您无缝地在静态、客户端和服务器端渲染之间切换。我们将探讨如何使用 React 原语实现这一点。

图 5.16:一个服务器端渲染并在客户端随后重新激活的页面的序列图
我们将首先通过添加一个 ClientCounter 组件来扩展我们的 React 客户端/服务器端渲染的 app.jsx。事件处理器是观察交互原语的最简单方法之一。我们的 ClientCounter 组件显示一个计数器,其初始值为 0,并且每当点击 src/client-counter.jsx 文件时:
import React, { useState } from 'react';
export function ClientCounter() {
const [count, setCount] = useState(0);
return (
<div>
Dynamic Counter, count: {count}
<br />
<button onClick={() => setCount(count + 1)}>
Add</button>
</div>
);
}
我们可以在 app.jsx 组件中渲染它,如下所示:
import React from 'react';
import { ClientCounter } from './client-counter';
export function App({ type = '' }) {
return (
<>
<div>
<p>Hello from the {type + ' '}app</p>
<ClientCounter />
</div>
</>
);
}
如果我们构建客户端入口点并在浏览器中加载它,它将正常工作,每次点击 添加 都会增加:
npx esbuild client.jsx --bundle --outdir=dist
如果我们打开 index.html 文件(它没有改变),我们将能够看到计数器并增加它,如下面的截图所示。

图 5.17:显示增加 7 的 React 客户端渲染计数器
然而,如果我们构建并运行我们的服务器端入口点,组件仍然保持在 0:
npx esbuild server.js --bundle --platform=node --outdir=dist
然后我们可以使用以下命令启动服务器:
node dist/server.js
如以下截图所示,无论我们点击 添加 多少次,组件只显示 0。

图 5.18:React 服务器端渲染不允许交互式计数组件,尽管多次点击添加按钮,计数仍然显示为 0
为了“激活”我们的服务器端渲染页面,我们可以创建一个新的入口点,rehydrate.jsx。它使用 react-dom/client 的 hydrateRoot 函数在包含我们的应用程序的元素上:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './src/app';
ReactDOM.hydrateRoot(
document.querySelector('#app'),
<App type={`"server render"`} />
);
我们将使用 esbuild 打包重新激活的入口点,类似于之前的入口点:
npx esbuild rehydrate.jsx --bundle --outdir=dist
一旦我们的新 dist/rehydrate.js 文件构建完成,我们需要在我们的服务器端渲染应用程序中使用它。我们修改 server.js 以静态服务 dist,这意味着 dist/rehydrate.js 可用作为 rehydrate.js。然后我们创建一个新的 GET 路由,/rehydrate。此路由返回之前看到的导航元素,但现在应用程序还有一个将加载 rehydrate.js 的脚本:
// no changes to other routes
app.use(express.static('./dist'));
app.get('/rehydrate', (_req, res) => {
res.send(`
<!DOCTYPE html>
${renderNav()}
<h1>Server-render with client-side rehydration</h1>
<div id="app">${serverRenderApp()}</div>
<script src="img/rehydrate.js"></script>
`);
});
// no changes to server startup
我们还在 nav.jsx 中包含了 /rehydrate,现在看起来如下:
import React from 'react';
export function Nav() {
return (
<ul>
<li>
<a href="/">Server-render only</a>
</li>
<li>
<a href="/rehydrate">Server-render with client-side
rehydration</a>
</li>
</ul>
);
}
然后,我们可以重新构建我们的入口点并启动服务器。当我们导航到 /rehydrate 时,计数器是交互式的,我们看到导航和 h1 是在服务器端渲染的。

图 5.19:重新激活的服务器端渲染应用程序允许客户端计数器的交互式使用,此处显示计数为 5
我们现在已经看到了如何重新激活服务器端渲染的 React 应用程序,接下来我们将深入了解常见的 React 重新激活问题。
常见的 React 重新激活问题
重新激活有一些关键陷阱。
在应用程序中看到以下运行时环境检测代码是很常见的。
export const isServer = () => typeof window ===
'undefined';
假设我们将 isServer 放在 src/rendering-utils.js 文件中;我们可以如下使用它来条件性地渲染内容,例如 'from client' 或 'not from client',或者在服务器端渲染时完全避免渲染 ClientCounter:
import React from 'react';
import { ClientCounter } from './client-counter';
import { isServer } from './rendering-utils';
export function App({ type = '' }) {
return (
<>
<div>
<p>Hello from the {type + ' '}app</p>
<p>Rendering: {isServer() ? 'not from client' :
'from client'}</p>
{!isServer() && <ClientCounter />}
</div>
</>
);
}
在纯粹的服务器端渲染用例中,这运行得很好,我们显示 'not from client' 并隐藏 ClientCounter。

图 5.20:isServer 检测成功用于仅服务器端渲染
初看起来,它似乎适用于服务器端渲染后跟客户端侧重新激活的使用案例。它显示 from client 并显示客户端计数器组件。

图 5.21:isServer 检测是否适用于服务器端渲染后重新激活
然而,如果我们查看控制台,我们可以看到我们有一些错误。

图 5.22:重新激活期间的控制台错误
问题在于客户端渲染与服务器端渲染不匹配 - 例如,ReactDOM.rehydrateRoot 预期应用程序在服务器和客户端以相同的方式渲染。在这种情况下,React 会回退到完全的客户端渲染(在激活过程中发生错误。服务器端 HTML 被客户端内容替换在
为了解决这个问题,需要更好地检测服务器与客户端。简单的检测将涉及使用useEffect的钩子。useClientRenderingOnly函数将在应用程序运行我们的useEffect之前始终为false,而useEffect只在客户端运行:
export function useClientRenderingOnly() {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
});
return hasMounted;
}
它可以在src/client-counter.jsx中使用,而不是在app.jsx中使用isServer:
import React, { useState } from 'react';
import { useClientRenderingOnly } from './rendering-utils';
export function ClientCounter() {
const isClientRendering = useClientRenderingOnly();
const [count, setCount] = useState(0);
if (!isClientRendering) return null;
// no change to JSX return
}
app.jsx可以变成以下形式,利用isClientRendering来显示'from client'和'not from client':
import React from 'react';
import { ClientCounter } from './client-counter';
import { isClientRendering } from './rendering-utils';
export function App({ type = '' }) {
return (
<>
<div>
<p>Hello from the {type + ' '}app</p>
<p>
Rendering: {isClientRendering ? 'from client' :
'not from client'}
</p>
<ClientCounter />
</div>
</>
);
}
在仅服务器端渲染的情况下,这可以工作,在重新激活的情况下,我们现在知道是否在服务器或客户端显示某些内容,而不会出现重新激活问题。
其他导致重新激活错误的常见问题包括无效的标记(某些 HTML 标签不应该放在其他 HTML 标签内)。
React 提供了一种额外的渲染方法,允许服务器通过流式传输更早地开始向客户端返回数据。
React 流式服务器端渲染
React 流式服务器端渲染利用流式传输,使得服务器可以更早地开始向浏览器返回数据(流中的数据块而不是一次性响应)。这也意味着浏览器可以更早地开始渲染工作。
流式传输的一个主要缺点是,它相对于非流式服务器端渲染的一个关键优势是它支持新的 suspense 原语。这个原语由特定的库和框架支持,并且很难使用 React 原语来展示。
根据 React 关于 suspense 使用的文档(react.dev/reference/react/Suspense#usage):
不使用有偏见的框架进行启用 Suspense 的数据获取尚不支持。实现启用 Suspense 的数据源的要求不稳定且未记录。将在 React 的下一个版本中发布一个官方 API,用于将数据源与 Suspense 集成。
在重新激活 React 流式服务器渲染的页面时,我们需要替换整个文档,因此我们将创建一个新的<Page>组件,它将是一个完整的页面。我们还将创建一个用于客户端的streaming-rehydrate.jsx入口点。
以下是一个新的src/page.jsx文件的内容。为了进行流式服务器端渲染,需要包括html和head在内的完整页面:
import React from 'react';
import { App } from './app';
import { Nav } from './nav';
export default function Page() {
return (
<html>
<head>
<title>Streaming</title>
</head>
<body>
<Nav />
<h1>Server-render with streaming</h1>
<div id="app">
<App type={`"streaming server render"`} />
</div>
</body>
</html>
);
}
我们的streaming-rehydrate.jsx入口点与我们的rehydrate.jsx入口点非常相似,唯一的区别是它激活document,而不是具有app ID 的元素。这是由于上述流式服务器端渲染的限制——整个文档必须由 React 控制:
import React from 'react';
import ReactDOM from 'react-dom/client';
import Page from './src/page';
ReactDOM.hydrateRoot(document, <Page />);;
我们将使用以下方式构建 JavaScript 的入口点:
npx esbuild streaming-rehydrate.jsx.jsx --bundle --outdir=dist
我们现在可以开始在src/server-rendering.jsx中处理服务器端渲染。我们创建了一个新的serverRenderAppStream函数,它接受一个 Express/Node.js 的res对象作为参数。它调用ReactDOMServer.renderToPipeableStream函数,使用Page组件,并将bootstrapScripts设置为包含我们的streaming-rehydrate.js入口点:
import React from 'react';
import ReactDOMServer from 'react-dom/server';
// no changes to other imports
import Page from './page';
export function serverRenderAppStream(res) {
const { pipe } = ReactDOMServer.renderToPipeableStream
(<Page />, {
bootstrapScripts: ['./streaming-rehydrate.js'],
});
pipe(res);
}
在server.js中,我们可以为/streaming路径创建一个新的GET路由,该路由简单地调用serverRenderAppStream函数,并按照 Express 路由处理器的定义使用res对象:
// no change to other imports
import {
// no change to other imports
serverRenderAppStream,
} from './src/server-render';
// no change to other routes
app.get('/streaming', (_req, res) => {
serverRenderAppStream(res);
});
// no change to startup logic
我们还将/streaming路由添加到src/nav.jsx中:
import React from 'react';
export function Nav() {
return (
<ul>
{/* no change to the other li elements */}
<li>
<a href="/streaming">Server-render with streaming
</a>
</li>
</ul>
);
}
我们现在可以加载/streaming页面并看到它的实际效果。

图 5.23:带有重新加湿的 React 流式服务器端渲染
我们现在已经看到了如何实现带有重新加湿的 React 流式服务器端渲染。
摘要
在本章中,我们介绍了如何通过更深入地理解渲染和页面加湿策略,可以帮助我们使用 React 提供最优和可扩展的 Web 用户界面。
客户端和服务器渲染各有优缺点,它们是互补的。客户端渲染启动时间较长,但提供了更多的交互性,并且不需要太多的服务器端计算能力;服务器渲染可以更快地返回内容,但需要基础设施,并且不提供相同的交互性水平。
Next.js 的静态站点生成功能可以与经典服务器端渲染结合使用,根据访问模式和内容更改的频率,明智地决定一组页面的渲染策略。
最后,页面加湿和重新加湿与流式服务器端渲染结合,弥合了服务器和客户端渲染之间的差距,使得两者的优点都能包含在一个页面中。
现在我们已经熟悉了渲染和页面加湿策略,我们可以在下一章中探讨使用“区域”和“岛屿”架构实现微前端。
第六章:微前端、区域和岛屿架构
微前端架构,特别是“区域”和“岛屿”模式,与后端系统的微服务架构相呼应。有了合适的工具,它们允许多个团队在单一产品上进行高速开发。本章介绍的技术探讨了系统级交互和集成模式。每个系统都可以利用创建、结构、行为和反应视图库模式,分别如第一章、第二章、第三章和第四章所述。微前端架构有助于将系统连接起来,而不是在它们内部更好地组织代码。
本章将涵盖以下主题:
-
微前端解决的问题空间,包括一些常见方法和它们的缺点
-
利用 Next.js 功能构建“区域”微前端设置
-
使用is-land包在 Preact 和 Vue.js 中实现“岛屿”微前端设置
到本章结束时,你将能够讨论权衡并交付现代 JavaScript 微前端方法。
技术要求
你可以在 GitHub 上找到本章的代码文件:github.com/PacktPublishing/Javascript-Design-Patterns
微前端概述
微前端设置是指多个前端应用程序或组件的组合。这类似于微服务,其中微前端将封装功能子集,或“边界上下文”。
例如,在电子商务环境中,我们可能会有“搜索”微前端和“购物车”或“结账”微前端。

图 6.1:简化的微前端图
我们现在已经介绍了微前端架构;接下来,我们将看到微前端提供的关键优势。
关键优势
微前端模式的好处与微服务的好处相似。它们通常出现在开发的社会技术方面。
每个微前端可以使用不同的技术集,这意味着可以选择适合工作的正确工具。一个非常关注页面加载性能的页面可能使用与管理员界面或高流量 SVG 可视化页面不同的堆栈。
可提供增量升级,并且可以在将更改部署到所有组件之前在一个组件中进行测试。
不同微前端的发布不是锁定在一起的。这有助于在扩展时,每个团队可能都在一个或多个微前端上工作。它们可以独立于其他团队发布,这意味着节奏可以加快;这与我们将讨论的最后一个优势相关。
每个微前端都可以有自己的代码库,并且可以严格实施“边界上下文”。
“经典”微前端模式
我们将涵盖创建微前端设置的五种不同的“经典”方法。
第一种方法是使用服务器端包含的“容器应用”。这利用了一个服务器,它会从不同的微前端获取内容并将它们拼接在一起。以下图示说明了这一点,其中容器应用加载了一个“购物车”HTML 部分和一个“搜索”HTML 部分,并将它们注入到自己的模板中,然后再返回给客户端。

图 6.2:“容器应用”序列
服务器端包含或“容器应用”的好处是,每个微前端的部署是解耦的(例如,我们可以部署购物车的更改,而不必部署搜索部分或容器的更改);此外,它完全不受技术限制,因为微前端甚至不需要使用 JavaScript。
接下来我们将看到的是另一种“经典”方法,它使用“构建时”组合,其中每个微前端都是一个包,通常是 npm 包(Node.js/JavaScript 工具链的一部分)。然后,每个包在需要的地方导入,并在“构建时”进行组合(即每个应用程序打包用于部署时)。
“构建时”组合的关键缺点是,现在发布需要部署级联。为了使所有应用程序都能接收到“购物车”的更新,我们需要更新版本并发布所有应用程序。
最后三种“经典”方法在概念上相似,尽管使用了不同的技术。它们都是“运行时集成”;技术包括 iframe、JavaScript 和 Web 组件。运行时集成意味着微前端从浏览器请求微前端资源。
在 iframe 的情况下,这涉及到使用 iframe 的src属性。这种做法的主要缺点是,每个微前端都需要防范所有公开网络攻击。更重要的是,如果操作不当,允许应用内容的 iframe 可能导致点击劫持漏洞,因此存在安全影响。

图 6.3:使用 iframe 的运行时集成
在 JavaScript 或 Web 组件“运行时”集成的情况下,组合是通过加载 JavaScript 文件来管理的。这比使用 iframe 更理想,因为将 JavaScript 提供给浏览器比允许内容被框架化有更少的潜在安全影响。在 Web 组件的情况下,你会在 HTML 的主体中引用 Web 组件,并且还需要引用运行 Web 组件所需的脚本。

图 6.4:使用 JavaScript 或 Web 组件的运行时集成
运行时集成对用户体验有性能影响,如图表之间“服务器端包含”和“运行时集成”的差异所示。在“服务器端包含”的情况下,服务器在返回给客户之前会组合一个完整的应用程序。在“运行时集成”中,服务器返回给浏览器的是本质上是对资源的引用,然后浏览器必须加载这些资源。
当我们探索微前端的“区域”和“岛屿”现代实现时,我们将遇到这些技术共同使用的几个实例。
微前端世界中的其他关注点
类似于微服务架构,允许不同团队以自己的方式构建的微前端可以是一种优势,也可能是一种劣势。
最后,大多数前端系统最终都需要与后端服务进行通信。如何做这仍然是一个待定的问题——每个团队是否应该部署自己的前端后端(BFF),是否应该部署一个单一的网关来暴露相关的服务端点,或者它应该是一个将服务封装在不同查询系统(如 GraphQL)中的网关?
微前端也给测试带来了挑战。当每个微前端都有自己的测试套件时,我们如何可靠地在“用户旅程”级别进行测试,这可能涉及多个微前端?
与关于使用哪种后端集成的问题类似,还有一个与共享样式和潜在组件库相关的挑战。进行微前端开发的团队可能会在某个技术(如 React、Vue 等)上实现标准化,以获得组件库的好处。在多种技术中维护组件库更为困难,但公司有时会选择这种方式来支持工程师选择合适的工具。
微前端的一个大挑战是如何保持它们的性能。即使在所有团队都使用相同技术的案例中,同一依赖项也可能在微前端之间重复,这会影响性能。当技术和构建和部署流程出现分歧(微前端是可能的)时,这个问题会加剧。
例如,与“服务器端包含”相关的另一个性能问题是,页面将只以页面中最慢的组件的速度加载。在运行时集成中,这不太成问题,但每个微前端可能会影响整个页面的性能,这在使用微前端构建系统时是一个相关的问题。
最后,正如我们关于测试微前端所暗示的,它会导致操作和治理复杂性。例如,环境不匹配问题更难检测。在多个微前端的情况下,运行或部署用于开发或测试的完整环境比单体应用程序更复杂。
现在我们已经定义并对比了微前端的一般优势和劣势以及特定的微前端方法,我们可以看看微前端在现代的实现。在下一节中,我们将探讨利用 Next.js 和“zones”来构建灵活的微前端。
使用 Next.js“zones”组合应用程序
Next.js “zones”是一种基于 URL“基本路径”的方法,用于组合 Next.js 应用程序。这允许我们使用 Next.js 构建一个微前端设置。
如下图中所示,这意味着一个电子商务用例,其中用户可能请求四组 URL(GET /, GET /careers, GET /search, 和 GET /cart/{id}),{id}表示购物车有一个动态段,即请求的购物车 ID。对于GET /和GET /careers请求,请求首先发送到root前端,它直接处理渲染。对于GET /search请求,请求发送到root前端,它将请求转发到搜索前端。同样,对于GET /cart/{id}请求,请求最初发送到root前端,它将请求代理到结账前端。

图 6.5:三个应用 Next.js 区域设置的概述流程图
我们现在已经介绍了 Next.js“zones”和我们的实现概述,接下来我们将实现“root app”。
根应用程序
根应用程序包含两个页面,/(pages/index.js)和/careers(pages/careers.js)。这两个页面都是静态渲染的,index.js通过自动静态生成(因为它没有getServerSideProps或getInitialProps),而careers.js通过静态站点生成(因为它有getStaticProps)。
index.js包含一个标题以及Head内容。
import React from 'react';
import Head from 'next/head';
export default function Home() {
return (
<>
<Head>
<title>Homepage (Root zone)</title>
</Head>
<main>
<h1>Root</h1>
</main>
</>
);
}
当我们加载GET /路径时,我们的根应用程序渲染我们放置其中的h1元素,内容为'Root'。

图 6.6:根页面的渲染效果
/careers页面使用getStaticProps从 API 加载角色,并以列表形式显示它们。
我们可以从pages/careers.js中的getStaticProps函数开始。这个函数从一个“虚假工作”API 加载数据,并返回一个roles属性,它包括 API 返回的数据:
export async function getStaticProps() {
const jobs = await fetch(
'https://apis.camillerakoto.fr/fakejobs/
jobs?fulltime=true').then((res) => res.json());
return {
props: { roles: jobs },
};
}
接下来,我们将添加一个CareersPages组件。它包括带有title和h1的页面框架。它还会遍历roles属性,将其渲染为列表,使用ul和li:
import React from 'react';
import Head from 'next/head';
export default function CareersPage({ roles }) {
return (
<>
<Head>
<title>Careers (Root zone)</title>
</Head>
<main>
<h1>Careers</h1>
<ul>
{roles.map((role) => {
return (
<li key={role.id}>
{role.title} ({role.country})
</li>
);
})}
</ul>
</main>
</>
);
}
它显示如下。

图 6.7:根区域中的职业页面
next build输出显示index.js确实被静态渲染,而/careers使用静态站点生成:
Route (pages) Size First Load JS
┌ ○ / 430 B 77.7 kB
├ ○ /404 182 B 77.5 kB
├ λ /api/health 0 B 77.3 kB
└ ● /careers 498 B 77.8 kB
+ First Load JS shared by all 77.3 kB
├ chunks/framework-4725d5bb117f1d8e.js 45.2 kB
├ chunks/main-7a398668474d4dd1.js 31.1 kB
├ chunks/pages/_app-ecd5712b2c05cb6a.js 195 B
└ chunks/webpack-8fa1640cc84ba8fe.js 750 B
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
我们现在已经开始了根应用程序的实现,接下来我们将转向我们的第二个区域,“搜索”区域。
添加/search 应用程序
接下来,我们将构建和挂载一个/search页面。
search/pages/index.js 显示一个输入并在更改时调用 /search/api/search 路由:
import React, { useState } from 'react';
import Head from 'next/head';
export default function Home() {
const [searchResult, setSearchResult] = useState({
count: 0,
matches: [],
});
return (
<>
<Head>
<title>Search Page (Search zone)</title>
</Head>
<main>
<h1>Search</h1>
<input
type="search"
onChange={async (event) => {
const data = await fetch(
`/search/api/search?q=${event.target.value}`
).then((res) => res.json());
setSearchResult(data);
}}
/>
<div>
<h2>Results ({searchResult.count})</h2>
{searchResult.matches.map((product) => {
return <div key={product.id}>
{product.title}</div>;
})}
</div>
</main>
</>
);
}
要实现 search/pages/api/search API 路由,我们创建 pages/api/search,它从 fakestoreapi 加载产品,并在标题、描述和类别之间找到匹配项:
export default async function handler(req, res) {
const allProducts = await fetch
('https://fakestoreapi.com/products').then(
(res) => res.json()
);
const { q } = req.query;
const searchQuery = Array.isArray(q) ? q[0] : q;
const matches = allProducts.filter(
(product) =>
product.title.includes(searchQuery) ||
product.description.includes(searchQuery) ||
product.category.includes(searchQuery)
);
return res.status(200).json({ matches,
count: matches.length });
}
为了使 search-app/ 在 search-app/search 下挂载,我们将在搜索应用程序的 next.config.js 中使用 basePath:
module.exports = {
basePath: '/search',
};
我们将通过根应用程序的 next.config.js 暴露 search:
module.exports = {
async rewrites() {
return [
{
source: '/search/:path*',
destination: 'http://localhost:3001/search/:path*',
},
];
},
};
然后,我们可以加载 搜索 页面,其显示如下:

图 6.8:加载时的搜索页面
搜索功能正常工作——例如,使用 jacket 搜索词。

图 6.9:包含夹克搜索词的搜索页面
/search 通过自动静态渲染进行静态渲染:
Route (pages) Size First Load JS
┌ ○ / 607 B 73.7 kB
├ └ css/776983a5dfcef528.css 271 B
├ ○ /404 182 B 73.2 kB
├ λ /api/health 0 B 73.1 kB
└ λ /api/search 0 B 73.1 kB
+ First Load JS shared by all 73.1 kB
├ chunks/framework-4725d5bb117f1d8e.js 45.2 kB
├ chunks/main-ee0b7fc0f7162449.js 26.9 kB
├ chunks/pages/_app-ecd5712b2c05cb6a.js 195 B
└ chunks/webpack-ab5c478f511867a3.js 756 B
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
我们现在已经实现了搜索“区域”,接下来我们将实现结账“区域”。
添加 /checkout 应用
我们将在新的 Next.js 应用程序 pages/cart/[id].js 下添加一个“查看购物车”页面。
购物车页面从 fakestoreapi 加载购物车及其包含的产品,并通过 CartContents 组件显示它们。
首先,我们将定义一个 CartContents 组件,该组件接受 cart 和 productsById 属性。然后它遍历 cart.products,提取购物车中的产品标题和请求的数量,然后使用 .toLocaleString 计算并格式化欧元价格。
我们需要 cart 和 productsById 的原因是购物车以规范化的数据格式返回,这意味着它只包含购物车特定的信息,不包含任何相关产品的信息,除了产品 ID。因此,我们需要根据产品 ID 进行查找。
我们使用的渲染逻辑是一个无序列表容器(ul HTML 元素)和列表项元素(li HTML 元素)。我们将标题渲染为 h3 标题,其余信息使用 span 元素:
import React from 'react';
function CartContents(props) {
const { cart, productsById } = props;
return (
<ul>
{cart.products.map((product) => {
const fullProductInformation = productsById
[product.productId];
return (
<li key={product.productId} className=
"cart-item-product">
<h3 className="cart-item-product-name">
{fullProductInformation?.title}
</h3>
<span className="cart-item-product-quantity">
{' '}
x {product.quantity}
</span>
<span className="cart-item-product-price">
Price:
{(
product.quantity *
fullProductInformation?.price)
.toLocaleString('en', {
style: 'currency',
currency: 'EUR',
})}
</span>
</li>
);
})}
</ul>
);
}
现在我们正在渲染购物车的内容,我们将添加到 CartContents 的附加功能是显示购物车的总价。
这是通过添加另一个 li 实现的,它显示“总计:”并计算总价,使用 reduce 对 cart.products 进行操作。记住,从之前的代码块中,cart.products 是规范化的,这意味着它不包含任何关于产品(例如,其价格)的信息,除了产品的 ID。这意味着我们的 reduce 处理器在 productsById[product.productId] 上进行查找,以便访问产品的价格。
一旦我们有了购物车中给定产品的数量和产品的价格,我们只需将它们相乘,然后将数量乘以价格的结果加到累加器上,我们将其初始化为 0。
与购物车项类似,我们使用 toLocaleString 将总价格式化为欧元作为 en 本地化的字符串:
// no change to imports
function CartContents(props) {
// no change to the function body
return (
<ul>
{/* no change to `cart.products` mapping */}
<li className="cart-item-product">
<strong className="cart-item-product-price">
Total:
{cart.products
.reduce((acc, curr) => {
const fullProductInformation = productsById
[curr.productId];
return acc + curr.quantity *
fullProductInformation.price;
}, 0)
.toLocaleString('en', {
style: 'currency',
currency: 'EUR',
})}
</strong>
</li>
</ul>
);
}
我们将利用 getServerSideProps 来加载购物车,然后从 fakestoreapi 加载相关产品。如前述代码块中所述,fakestoreapi 的购物车响应是规范化的,因此不包含我们需要的所有产品数据,这就是为什么我们通过 ID 加载产品的原因。
一旦我们有了购物车响应和所有相关的产品响应,我们将处理产品以允许通过 ID 查找它们。最后,getServerSideProps 在一个对象的 props 属性中返回 id(Next.js 上下文中的购物车 ID)、productsById 和 cart,以便 Next.js 可以将它们传递给我们的页面组件:
// no changes to imports
// no changes to CartContents definition
export async function getServerSideProps(ctx) {
const { params } = ctx;
const cartId = params.id;
const cart = await fetch(`https://fakestoreapi.com/carts
/${cartId}`).then(
(res) => res.json()
);
if (!cart?.products) {
return {
props: {
id: cartId,
},
};
}
const productsById = (
await Promise.all(
cart.products.map(async (product) => {
return await fetch(
`https://fakestoreapi.com/products/$
{product.productId}`
).then((res) => res.json());
})
)
).reduce((acc, curr) => {
acc[curr.id] = curr;
return acc;
}, {});
return {
props: {
id: cartId,
cart,
productsById,
},
};
}
最后,我们将添加我们的 GetCartPage 组件,该组件将接受 Next.js 传递的属性(基于 getServerSideProps 的输出),并使用它们来渲染 CartContents,以及一个标题和标题:
import Head from 'next/head';
import React from 'react';
// no changes to CartContents definition
export default function GetCartPage({ id, cart, productsById }) {
return (
<>
<Head>
<title>GetCartPage (Checkout zone)</title>
</Head>
<main>
<h1>GetCartPage (Checkout zone)</h1>
<CartContents cart={cart} productsById=
{productsById} />
</main>
</>
);
}
// no changes to getServerSideProps definition
为了让结账应用在正确的路径下挂载,我们在其 next.config.js 中设置 basePath:
module.exports = {
basePath: '/checkout',
};
我们还需要修改根应用的 next.config.js,以便相关请求被代理到结账应用:
module.exports = {
async rewrites() {
return [
// no change to other entries
{
source: '/checkout/:path*',
destination:'http://localhost:3002/checkout/:path*',
},
];
},
};
我们可以加载 /checkout/cart/2,以下内容将显示:
![图 6.10:结账区域中加载了购物车 2 的购物车/[id] 页面](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-dsnptn/img/B19109_06_10.jpg)
图 6.10:结账区域中加载了购物车 2 的购物车/[id] 页面
在构建输出中,我们可以看到 /cart/[id] 是服务器端渲染的,因为它使用了 getServerSideProps:
Route (pages) Size First Load JS
┌ ○ / 445 B 73.5 kB
├ ○ /404 182 B 73.3 kB
└ λ /cart/[id] 3.95 kB 77 kB
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
我们现在已经看到了如何将结账“区域”添加到我们的微前端设置中,接下来我们将介绍微前端“区域”架构的益处,特别是关于在增长团队中工作的益处。
优势/支持团队扩展
使用带有 basePath 的“区域”意味着 Next.js 的功能可以无缝工作。例如,客户端过渡和 getServerSideProps 重新获取功能正常工作(Next.js 加载 {basePath}/_next/...),以及我们在搜索示例中使用的 API 路由。
添加新页面也“只需如此”;在 /cart/[id]/checkout 的新页面不需要对根应用进行任何更改即可对用户可用。
我们只有在需要添加一个全新的应用(顶级路径)时才会更改根应用配置——例如,如果我们想要一个管理应用,我们就需要创建它并配置根 next.config.js。
在流量很大且我们想要更有效地使用资源的情况下,我们不需要使用 root 应用将所有请求转发到其他微前端;我们可以利用任何反向代理(如 NGINX 和 Caddy)或甚至基础设施提供商的 CDN(例如 Fastly、Akamai、Cloudflare 和 AWS)来配置,以便将所有来自 domain.tld/{path}/*(以 {path} 开头的 domain.tld 的所有请求)转发到特定的源。
通过拥有一系列都使用 Next.js 的应用,可以在根应用中进行实验性页面构建,然后将其扩展为完整的 Next.js 应用。
为了调试和沟通目的,在 URL 中包含应用程序名称可以帮助在与技术团队和非技术团队成员讨论应用程序和页面时。例如,即使是非技术团队成员也会理解“URL 的这个第一部分是应用程序名称。”
使用区域的一个其他好处是,在反向代理传递期间请求不会被重写。例如,在某些配置中,反向代理会接收到/search,但在搜索应用程序中加载/。这意味着在本地运行搜索应用程序与代理时存在细微的不匹配。
在这里使用了 Next.js 的所有系统,但并非必需;大多数工具都可以配置为从“子路径”或 Next.js 所说的basePath提供服务。
Next.js 区域的缺点
在我们展示的设置中,主要缺点是“框架”包在应用程序之间没有共享。这意味着当用户从一个区域移动到另一个区域时,他们会加载不同版本的 Next.js、React 和 React DOM。这不太理想,但可能适用于许多用例。当这不适用时,可以部署模块联邦或其前身,供应商包。
另一个缺点是,在本地开发时,使用next dev并使用根应用程序代理请求,我们会失去诸如快速刷新/实时重新加载等特性。这可以通过在本地开发期间直接访问微前端来解决这个问题。
现在我们已经了解了如何使用 Next.js 基于路径的路由、代理和基本 URL 功能来提供“区域”实现,其中微前端各自服务于 URL 的不同子集,我们现在将探讨如何使用is-land包中的“岛屿”架构来提供微前端应用程序,使得所有微前端都显示在单个页面上。微前端将使用 Preact 和 Vue.js 构建。
使用“岛屿”架构扩展性能敏感页面的规模
根据 is-land 库文档(github.com/11ty/is-land),is-land 是“一种新的以性能为导向的方法,用于向您的网站添加交互式客户端组件。或者,更技术性地:一个框架无关的局部水合岛屿架构实现。”
让我们先看看“岛屿架构”是什么。岛屿架构是一种模式,其中页面主要是由服务器渲染的,并且只在必要时添加交互性。这减少了页面加载时间,以及传递的 JavaScript 数量(JavaScript 仅用于特定的客户端交互)。这与 JavaScript 应用程序“接管”整个页面的情况形成对比——例如,在 Next.js 应用程序中,客户端 JavaScript 将重新挂载已由服务器渲染的内容,这意味着默认情况下在客户端运行的 JavaScript 的最小量是 Next.js 客户端代码 + React + React DOM。
以下图显示了如何利用岛屿架构来提供微前端体验。
每个岛屿都负责从服务器获取自己的数据。

图 6.11:由岛屿组成的 app 页面
岛屿架构中的一个额外元素是在用户交互时加载 JavaScript – 例如,在点击、悬停或元素滚动到视图中时。is-land包提供了创建具有这些类型的水合策略的岛屿的原始方法。
使用 is-land 设置的岛屿
我们将探讨如何实现包含立即初始化的产品岛屿、交互时初始化的购物车岛屿以及滚动到视图中时初始化的相关产品岛屿的三岛屿页面。
我们的示例将演示在没有打包器的情况下使用所有工具的用法。我们将使用 Preact 与htm(因为我们没有 JSX 编译管道)以及 Vue 与 DOM 模板。
为了在我们的脚本中启用简单的导入,我们将利用导入映射,从unpkg.com CDN 加载:
<script type="importmap">
{
"imports": {"@11ty/is-land/is-land.js":
"https://unpkg.com/@11ty/is-land@4.0.0/is-land.js",
"htm/preact": "https://unpkg.com/htm@3.1.1/
preact/index.module.js",
"htm": "https://unpkg.com/htm@3.1.1/dist/htm.mjs",
"preact": "https://unpkg.com/preact@10.15.1/
dist/preact.mjs",
"vue": "https://unpkg.com/vue@3.2.36/dist
/vue.esm-browser.prod.js"
}
}
</script>
为了初始化岛屿,我们将在页面末尾包含is-land包:
<script type="module">
import '@11ty/is-land/is-land.js';
</script>
我们现在已经介绍了将要构建的页面,并配置了is-land在页面加载时初始化,接下来我们将实现产品岛屿。
产品岛屿
我们将使用 Vue 构建我们的产品岛屿。
第一步是创建一个<is-land>元素和脚本。
我们设置on:visible,以便当元素在视口中时,岛屿的内容由is-land初始化;由于我们的 HTML 中只包含产品岛屿,这将发生在页面加载时。
我们将创建一个 Vue 应用,在mount时向fakestoreapi.com发起 API 调用以根据查询参数获取产品。在fetch API 调用周围,我们将设置this.loading = true(在 API 调用开始之前)和this.loading = false(当 API 调用完成时)。
Vue 应用的数据方法将从 URL 查询字符串中读取productId,将加载设置为true,并将product设置为空对象字面量({}):
<is-land on:visible>
<div id="vue-product-island"></div>
<template data-island>
<script type="module">
import { createApp } from 'vue';
createApp({
async mounted() {
this.loading = true;
const product = await fetch(
`https://fakestoreapi.com/
products/${this.productId}`
).then((res) => res.json());
this.product = product;
this.loading = false;
},
data: () => ({
productId:
new URLSearchParams(window.location.search).
get('productId') || '1',
loading: true,
product: {},
}),
}).mount('#vue-product-island');
</script>
</template>
</is-land>
现在数据已加载,我们可以专注于模板;我们将渲染标题、描述和其他产品信息:
<is-land on:visible>
<div id="vue-product-island" class="product-container">
<h2 v-text="product.title"></h2>
<p v-text="product.description"></p>
<p v-cloak>
<span
v-text="product.price?.toLocaleString('en', {
style: 'currency', currency: 'EUR'})"
></span>
<br /><span v-text="product?.rating?.rate">
</span>/5.0 (<span
v-text="product?.rating?.count"
></span
>)
</p>
<img v-bind:src="img/product.image" width="320px"
class="product-image" />
</div>
<style>
.product-container {
min-height: 100vh;
border-bottom: solid 1px black;
}
[v-cloak] {
display: none;
}
</style>
<template data-island>
<style>
.product-image {
min-width: 320px;
display: block;
margin: auto;
}
</style>
<!-- no change to the script -->
</template>
</is-land>
当我们使用productId=1或没有productId(因为它已默认)加载此页面时,我们看到以下输出:

图 6.12:在产品岛屿中显示 ID 为 1 的产品
我们现在已经看到了如何使用is-land和 Vue 实现产品岛屿。接下来,我们将构建购物车岛屿。
购物车岛屿
再次,我们将从一个is-land元素开始,这次是on:interaction,这意味着岛屿只有在用户点击它时才会初始化(我们将显示一个按钮供他们点击):
<is-land on:interaction>
<div id="preact-cart-island">
<button>My Cart</button>
</div>
</is-land>
接下来,我们将构建一个CartContainer组件,它将使用 Preact 挂载。
CartContainer 从 fakestoreapi.com 加载购物车和产品信息,并将其存储在状态中,以便 CartContents 组件进行渲染:
<is-land on:interaction>
<div id="preact-cart-island">
<button>My Cart</button>
</div>
<template data-island>
<script type="module">
import { html, render } from 'htm/preact';
import { useState, useEffect } from 'preact/hooks';
function CartContents() {
// empty for now
return null;
}
function CartContainer(props) {
const cartId = props.id ?? 1;
const [open, setOpen] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [cartContents, setCartContents] = useState({
cart: null,
productsById: null,
});
useEffect(async () => {
setIsLoading(true);
const cart = await fetch(
`https://fakestoreapi.com/carts/${cartId}`
).then((res) => res.json());
if (!cart?.products) {
return {
props: {
id: cartId,
},
};
}
const productsById = (
await Promise.all(
cart.products.map(async (product) => {
return await fetch(
`https://fakestoreapi.com/
products/${product.productId}`
).then((res) => res.json());
})
)
).reduce((acc, curr) => {
acc[curr.id] = curr;
return acc;
}, {});
setCartContents({
cart,
productsById,
});
setIsLoading(false);
}, [cartId]);
const cartItemCount = cartContents?.
cart?.products?.length;
return html`<div>
<button onClick=${() => setOpen(!open)}>
My Cart ${cartItemCount !== undefined ? `
(${cartItemCount})` : ''}
</button>
${open && isLoading && html`<div>
Loading...</div>`} ${open &&
!isLoading &&
cartContents.cart &&
cartContents.productsById &&
html`<${CartContents}
cart=${cartContents.cart}
productsById=${cartContents.productsById}
/>`}
</div>`;
}
const appContainer = document.querySelector
('#preact-cart-island');
render(
html`<${CartContainer}
id=${new URLSearchParams(window.location.search)
.get('cartId')}
/>`,
appContainer,
appContainer
);
</script>
</template>
</is-land>
最后,我们将实现 CartContents,在其中遍历购物车并渲染定价信息:
<template data-island>
<script type="module">
import { html, render } from 'htm/preact';
// no changes to imports
function CartContents(props) {
const { cart, productsById } = props;
return html`<ul>
${cart.products.map((product) => {
const lineItemQueryParams = new URLSearchParams([
['productId', product.productId],
['cartId', cart.id],
]);
const fullProductInformation = productsById
[product.productId];
return html`<li class="cart-item-product"
key=${product.productId}>
${html`<a href=${'?' +
lineItemQueryParams.toString()}
>${fullProductInformation?.title}</a
>`}
<span class="cart-item-product-quantity"
>x ${product.quantity}</span
>
<span class="cart-item-product-price">
Price:${' '}${(
product.quantity * fullProductInformation
?.price).toLocaleString(navigator.language, {
style: 'currency',
currency: 'EUR',
})}
</span>
</li>`;
})}
<li class="cart-item-product">
<strong class="cart-item-product-price">
Total:${' '} ${cart.products
.reduce((acc, curr) => {
const fullProductInformation =
productsById[curr.productId];
return acc + curr.quantity *
fullProductInformation.price;
}, 0)
.toLocaleString(navigator.language, {
style: 'currency',
currency: 'EUR',
})}
</strong>
</li>
</ul> `;
}
// no changes to CartContainer component
</script>
</template>
当我们使用 cartId 1 和 productId 1 加载我们的页面并打开购物车内容时,我们可以看到它渲染了 ID 为 1 的购物车,包括三个项目,它们的数量,每个项目的小计,以及购物车总额。

图 6.13:购物车岛上的购物车 1 渲染
我们现在已经使用 Preact 实现了购物车岛,接下来我们将实现一个相关产品岛,它仅在可见时初始化。
一个相关产品岛
最后,我们将构建我们的相关产品岛。岛屿本身相当直接,但传达展示的产品及其类别则更为复杂。
我们将构建一个岛屿,等待可见性来初始化自己,再次使用 on:visible 但也使用 on:idle。这意味着岛屿将在可见或其他处理完成时加载。
如果岛屿接收到 product-category-load 自定义事件,它将挂载。
我们将首先构建 RelatedProducts 组件,该组件将接收三个属性——selectedProductId、category 和 from。from 值将在我们渲染的 h3 元素中显示,以说明岛屿如何接收其数据:
<is-land on:visible on:idle id="related-products-island-wrapper">
<template data-island="">
<script type="module">
import { html } from 'htm/preact';
function RelatedProducts({ selectedProductId,
category, from }) {
return html`<div>
<h3>Related Products (from ${from})</h3>
</div>`;
}
</script>
</template>
</is-land>
接下来,根据类别,我们希望从 fakestoreapi.com 加载所有可能的产品。我们将使用 useState() 钩子存储值,并且加载相关产品将在组件挂载时完成,使用 useEffect() 钩子。
数据获取逻辑如下。我们将使用提供的类别调用 fakestoreapi.com 的 API。为了满足“相关产品”的“相关”要求,我们将排除当前显示的产品——即从产品列表中移除 ID 等于 selectedProductId 的产品。最后,我们按评分对相关产品进行排序,并使用 setRelatedProducts 将前三个项目持久化到状态中:
<is-land on:visible on:idle id="related-products-island-wrapper">
<template data-island="">
<script type="module">
// no changes to other imports
import { useState, useEffect } from 'preact/hooks';
function RelatedProducts({ selectedProductId,
category, from }) {
const [relatedProducts, setRelatedProducts] =
useState([]);
useEffect(async () => {
const productsInCategory = await fetch(
`https://fakestoreapi.com/products/category/$
{encodeURIComponent(
category
)}`
).then((res) => res.json());
const topRelatedProductsByRating =
productsInCategory
.filter((el) => {
return el.id !== parseInt(selectedProductId,
10);
})
.sort((a, b) => b.rating.rate – a.rating.rate);
setRelatedProducts
(topRelatedProductsByRating.slice(0, 3));
}, [selectedProductId, category]);
// no change to returned template
}
</script>
</template>
</is-land>
将数据持久化到 relatedProducts 后,我们可以现在使用 .map 函数渲染它们,该函数返回一个列表。对于每个产品,我们希望显示一个标题,该标题也是一个查看产品的链接,其价格,一张图片,以及评分信息:
<is-land on:visible on:idle id="related-products-island-wrapper">
<template data-island="">
<script type="module">
// no changes to imports
function RelatedProducts({ selectedProductId,
category, from }) {
const [relatedProducts, setRelatedProducts] =
useState([]);
// no change to useEffect
return html`<div>
<h3>Related Products (from ${from})</h3>
<ul class="related-product-card-row">
${relatedProducts.map((product) => {
const productSearchParams = new
URLSearchParams([
['productId', product.id],
]);
const currentCartId = new URLSearchParams(
window.location.search
).get('cartId');
if (currentCartId) {
productSearchParams.set('cartId',
currentCartId);
}
return html`<li class="related-product-card">
<a href=${'?' + productSearchParams
.toString()}>
<h4>${product.title}</h4>
<p>
${product.price.toLocaleString
(navigator.language, {
style: 'currency',
currency: 'EUR',
})}
</p>
<img height="100px" src=${product.image} />
<p>${product.rating.rate}/5.0
(${product.rating.count})</p>
</a>
</li>`;
})}
</ul>
</div>`;
}
</script>
</template>
</is-land>
最后,我们将添加逻辑来挂载 RelatedProducts,基于对 product-category-load 自定义事件的监听器:
<is-land on:visible on:idle id="related-products-island-wrapper">
<div id="preact-related-products-island">
<h3>Related Products</h3>
<div class="related-product-card-row">Loading...</div>
</div>
<template data-island="">
<script type="module">
import { html, render } from 'htm/preact';
// no change to preact/hooks import or
RelatedProducts
const relatedProductsIslandContainer =
document.querySelector(
'#preact-related-products-island'
);
function mountRelatedProductsIsland(
relatedProductsIslandContainer,
category,
selectedProductId,
from
) {
if (category && selectedProductId) {
render(
html`<${RelatedProducts}
category=${category}
selectedProductId=${selectedProductId}
from=${from}
/>`,
relatedProductsIslandContainer,
relatedProductsIslandContainer
);
}
}
document.addEventListener('product-category-load',
(event) => {
const category = event.detail.category;
const selectedProductId = event.detail.
selectedProductId;
mountRelatedProductsIsland(
relatedProductsIslandContainer,
category,
selectedProductId,
'custom-event'
);
});
</script>
</template>
</is-land>
现在,我们需要确保从产品岛发出 product-category-load 事件。我们需要对 Vue.js 产品岛脚本的“mounted”生命周期钩子进行以下更改:
<script type="module">
import { createApp } from 'vue';
createApp({
async mounted() {
// no changes
document.dispatchEvent(
new CustomEvent('product-category-load', {
detail: {
category: this.product.category,
selectedProductId: this.product.id,
},
})
);
},
// no changes to other properties
});
</script>
还有一个条件,即在初始化相关产品岛之前会发出 product-category-load 事件;为了解决这个问题,我们将在 #related-products-island-wrapper 元素的 dataset 属性中存储信息:
<script>
document.addEventListener('product-category-load',
(event) => {
const category = event.detail.category;
const selectedProductId = event.detail.
selectedProductId;
Object.assign(
document.querySelector('#related-products-island-
wrapper').dataset,
{ category, selectedProductId }
);
});
</script>
然后,我们可以将此信息用作挂载条件:
<is-land on:visible on:idle id="related-products-island-wrapper">
<!-- no changes to template -->
<script type="module">
// no changes to the rest of the code
const { selectedProductId, category } =
document.querySelector(
'#related-products-island-wrapper'
).dataset;
mountRelatedProductsIsland(
relatedProductsIslandContainer,
category,
selectedProductId,
'data-*'
);
</script>
</is-land>
我们使用 from 来说明基于 dataset 的方法和基于事件的方法都在不同的场景下工作。
如果我们加载页面并向下滚动到相关产品(最初位于视口外),我们会看到以下内容:

图 6.14:带有来自数据属性的类别信息的相关产品岛屿
如果我们重新加载页面,滚动位置将使得相关产品岛屿可见并立即初始化,这意味着数据直接来自自定义事件。

图 6.15:带有来自自定义事件的类别信息的相关产品岛屿
我们现在已经实现了使用 Preact 的相关产品岛屿,并采用了两种读取产品类别的方案。接下来,我们将看到如何结合岛屿架构使用捆绑。
与团队一起扩展 - 捆绑岛屿
我们可以将特定岛屿的大部分代码移动到外部文件,然后使用 esbuild 等工具将其捆绑在一起。以下使用 .jsx 文件为 Preact,但使用 htm 复制粘贴现有文件也会有效:
npx esbuild ./src/preact-cart-island.jsx --jsx-import-source=preact --jsx=automatic --bundle --outdir=dist --format=esm --minify
npx esbuild ./src/vue-product-island.js --alias:vue=vue/dist/vue.esm-bundler.js --bundle --outdir=dist --format=esm --minify
npx esbuild ./src/preact-related-products-island.jsx --jsx-import-source=preact --jsx=automatic --bundle --outdir=dist --format=esm --minify
输出的文件可以按以下方式使用:
<script type="module" src="./dist/
preact-cart-island.js"></script>
<script type="module" src="./dist/
vue-product-island.js"></script>
<script type="module">
import { mountRelatedProductsIsland } from './dist/
preact-related-products-island.js';
// use mountRelatedProductsIsland
</script>
每个团队可以通过提供 JavaScript 包和/或模板(模板需要是服务器端包含)来拥有一个或多个岛屿。
缺点
在捆绑用例中,我们的两个 Preact 岛屿不共享 Preact 版本,这意味着这个依赖将在浏览器中加载两次。这可以通过供应商捆绑或模块联邦来解决,如前所述。此外,请注意,对于岛屿脚本位于页面本身的初始代码版本,这不是一个问题。
岛屿架构中的挑战主要与组件通信(正如我们通过相关产品岛屿所展示的)以及用于在统一页面上组合模板和脚本的机制有关。
摘要
在本章中,我们介绍了微前端、常见方法以及如何使用 Next.js 和 is-land 构建高开发速度的系统,同时不损害用户体验。
微前端允许团队在没有损害用户体验的情况下对前端生态系统的不同部分进行强有力的治理。微前端允许更多团队及其技能有效地发挥作用,从而提高整体交付速度。常见的方法包括具有“服务器端包含”的容器应用程序、通过共享包在构建时集成以及运行时集成(例如 iframe、JavaScript 和 Web 组件)。
推荐的 Next.js “zones” 方法允许不同的微前端在不同的“基础路径”上挂载。zones 方法是一种更灵活的服务端包含类型;应用通过反向代理和 URL 被包含。从概念上讲,能够提供多个页面和 API 路由的特定领域应用是大型团队可以利用的强大工具。
最后,我们讨论了通过 is-land 包实现的“islands”架构,它展示了使用多个基于 JavaScript 的库为不同组件提供轻量级微前端方法。is-land 进行部分激活的能力对最终用户来说是一个明显的优势。岛屿间的通信,岛屿架构的常见挑战,通过包括 CustomEvent 和 HTML 数据属性的方法得到了解决。
现在我们已经涵盖了现代微前端方法和“zones”以及“islands”架构,我们将在下一章中探讨在 JavaScript 中进行高效异步编程的模式。
第三部分:性能和安全模式
在本部分,我们将深入探讨 JavaScript 中的性能和安全模式。您将学习如何优化您的异步和事件驱动的 JavaScript 代码,使其在性能和安全敏感的环境中表现更佳。此外,您还将了解并实现资产级别的优化,包括在 Next.js 应用程序中实现懒加载和代码拆分 JavaScript,如何优先加载资产,以及使用 Next.js 和 Partytown 在主线程之外执行 JavaScript。
本部分包含以下章节:
-
第七章,异步编程性能模式
-
第八章,事件驱动编程模式
-
第九章,最大化性能 – 懒加载和代码拆分
-
第十章,资产加载策略和主线程之外执行代码
第七章:异步编程性能模式
JavaScript 运行时的一个关键优势是事件循环,它将单线程执行模型中的“非阻塞输入/输出”耦合在一起。这意味着只要不是计算密集型系统(即,它们是 I/O 密集型),JavaScript 就非常适合高并发系统。
使用异步和非阻塞 I/O,JavaScript 具有强大的内置功能来编排请求。在本章中,我们将涵盖以下主题:
-
JavaScript 中的顺序和并行异步操作模式,仅使用 Promise 和使用 async/await
-
使用 AbortController 取消和超时 fetch 请求
-
高级异步操作模式:节流、防抖和批处理
在本章结束时,您将能够识别并修复 JavaScript 中异步操作编排可能需要改进的情况。
技术要求
您可以在 GitHub 上找到本章的代码文件,网址为 github.com/PacktPublishing/Javascript-Design-Patterns
使用 async/await 和 Promise 控制顺序异步操作
Promise 在 ES2015(ES6)中引入,以及其他现代数据结构。
对于在 ES2015 之前熟悉 JavaScript 的人来说,异步行为是通过基于回调的接口来模拟的,例如,request(url, (error, response) => { /* do work with response */ })。Promise 解决的关键问题是异步请求的链式调用和管理并行请求的问题,我们将在本节中介绍。
ES2016 包含了 async/await 语法的基本规范。它建立在 Promise 对象之上,以便编写不涉及“Promise 链”的异步代码,其中不同的 Promise 使用 Promise().then 函数进行处理。Promise 功能和 async/await 之间很好地互操作。实际上,调用一个异步函数返回一个 Promise。
我们将首先展示如何使用 Promise 来管理顺序异步操作。我们将使用 Fetch API(它返回一个 Promise)来加载 fakestoreapi.com/auth/login。给定用户名和密码,并根据输出,我们将加载该用户的所有相关购物车。随后,我们将使用 fakestoreapi.com/carts/user/{userId} 端点来加载该用户的相关购物车。此请求流程在以下图中进行了可视化。

图 7.1:/auth/login 和 /carts/user/{userId} 请求的序列
我们将首先向 auth/login 端点发送 POST 请求。我们添加 .then((res) => res.json()),这将等待初始 fetch() 输出 Promise 解析为“响应”(因此命名为 res)。然后我们在响应上调用 .json() 方法,这同样是一个 Promise,它解析为 JSON 解码后的响应体:
function fetchAuthUserThenCartsPromiseThen(username,
password) {
return fetch('https://fakestoreapi.com/auth/login', {
method: 'POST',
body: JSON.stringify({
username,
password,
}),
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json());
}
从res.json()返回的 Promise 可以在另一个.then()回调中访问,在这个回调中我们解析token字段,它是一个jwt-decode包。
我们从解码的令牌中提取sub字段。这是“主题”声明,它告诉我们这个令牌是关于哪个用户的。在fakestoreapi令牌的情况下,userId被用作“主题”声明。因此,我们可以使用sub声明作为在后续 API 调用中加载购物车的用户 ID,即fakestoreapi.com/carts/user/{userId}:
import jwt_decode from 'https://esm.sh/jwt-decode';
function fetchAuthUserThenCartsPromiseThen(username,
password) {
return // no change to the fetch() call
.then((res) => res.json())
.then((responseData) => {
const parsedValues = jwt_decode(responseData.token);
const userId = parsedValues.sub;
return userId;
})
.then((userId) =>
fetch(`https://fakestoreapi.com/carts/user/${userId}
?sort=desc`)
)
.then((res) => res.json());
}
这个函数可以按以下方式使用。注意,密码不应该存储在生产应用的源代码中(如本例所示)。
当我们调用fetchAuthUserThenCartsPromiseThen函数时,它会执行/auth/login调用,然后是/carts/user/{userId}调用,这意味着我们收到一个包含请求用户相关购物车的数组(注意userId = 3,这是kevinryan用户的正确 ID)。
注意,我们在这里使用 async/await 来“扁平化”Promise 输出到userCartsDataPromiseThen,我们可以对其断言:
const username = 'kevinryan';
const password = 'kev02937@';
const userCartsDataPromiseThen = await
fetchAuthUserThenCartsPromiseThen(
username,
password
);
assert.deepEqual(userCartsDataPromiseThen, [
{
__v: 0,
date: '2020-01-01T00:00:00.000Z',
id: 4,
products: [
{
productId: 1,
quantity: 4,
},
],
userId: 3,
},
{
__v: 0,
date: '2020-03-01T00:00:00.000Z',
id: 5,
products: [
{
productId: 7,
quantity: 1,
},
{
productId: 8,
quantity: 1,
},
],
userId: 3,
},
]);
正如我们在调用fetchAuthUserThenCartsPromiseThen的代码中所看到的,async/await 相对于Promise().then()链的关键优势是代码结构更类似于同步代码。
在同步代码中,一个操作的输出可以是,例如,分配给一个常量:
const output = syncGetAuthUserCarts();
console.log(output);
而在Promise().then()中,输出只能在额外的.then()回调中访问:
promisifiedGetAuthUserCarts().then((output) => {
console.log(output);
});
await允许我们以以下方式结构化代码:
const output = await promisifiedGetAuthUserCarts();
console.log(output);
一种思考方式是await可以展开 Promise。Promise 的“已解决值”,通常只能在Promise().then()回调中访问,可以直接访问。
对于顺序操作,这非常有用,因为它使得代码结构化,每个异步操作都有一个变量赋值集。
await运算符在现代运行环境中的 ECMAScript 模块的顶层可用,作为 ES2022 规范的一部分。
然而,为了在函数内部使用await,我们需要将函数标记为async。这种在async函数中使用await的用法自 ES2016 以来一直可用。
代码编辑器和 IDE,如 Visual Studio Code,可以将链式Promise().then()调用重构为 async/await。在我们的情况下,我们可以构建一个fetchAuthUserThenCartsAsyncAwait函数,如下所示。
我们将首先使用await fetch()而不是使用fetch().then(res => res.json()),然后使用await authResponse.json():
async function fetchAuthUserThenCartsAsyncAwait
(username, password) {
const authResponse = await fetch('https://fakestoreapi.com/auth/login', {
method: 'POST',
body: JSON.stringify({
username,
password,
}),
headers: {
'Content-Type': 'application/json',
},
});
const authData = await authResponse.json();
}
我们现在可以访问authData。我们可以像之前一样使用jwt-decode包解码authData.token。这使我们能够访问sub(主题)声明,即用户 ID:
Import jwt_decode from 'https://esm.sh/jwt-decode';
async function fetchAuthUserThenCartsAsyncAwait
(username, password) {
// no change to /auth/login API call code
const parsedValues = jwt_decode(authData.token);
const userId = parsedValues.sub;
}
现在我们有了相关的用户 ID,我们可以调用/carts/user/{userId}端点来加载用户的购物车:
async function fetchAuthUserThenCartsAsyncAwait
(username, password) {
// no change to /auth/login call or token parsing logic
const userCartsResponse = await fetch(
`https://fakestoreapi.com/carts/user/${userId}?sort=desc`
);
const userCartsResponseData = await userCartsResponse.
json();
return userCartsResponseData;
}
给定与使用 Promise().then() 的方法相同的输入数据,加载的购物车是相同的。请注意,再次强调,密码和凭证不应存储在源代码文件中:
const username = 'kevinryan';
const password = 'kev02937@';
const userCartsDataAsyncAwait = await fetchAuthUserThenCartsAsyncAwait(
username,
password
);
assert.deepEqual(userCartsDataAsyncAwait, userCartsDataPromiseThen);
这两种方法之间有一个区别是,使用 async/await 时,所有变量都在单个函数作用域中定义,而 Promise().then() 方法使用多个函数作用域(对于传递给 .then() 的每个回调)。使用单个大函数作用域,变量名不会冲突,这使得代码更加冗长,因为例如,每个 response 对象都需要一个限定符来避免变量名冲突,例如 authResponse 和 userCartsResponse。
单个大函数作用域的好处是,前一个 API 调用的所有输出都可用于后续调用,而无需显式地将它们作为值传递给 .then() 中传递的回调函数。
最后,一个特定的 fetch() 示例是,由于在执行 fetch 和访问 JSON 响应时需要处理多个 Promise,因此 await 方法可能会有些“嘈杂”。
请看以下两个示例。首先,使用 async/await,我们为 fetch 的 response 值分配一个变量:
const response = await fetch(url);
const data = await response.json();
接下来,使用 .then(),我们只分配一个 data 变量,并使用箭头函数来处理 .json() 展开操作:
const data = await fetch(url).then((response) => response.json());
如您所见,我们的最终示例是 async/await 和 Promise().then() 的混合,这样代码中最“重要”的部分就显而易见了。我们从 fetch 中提取 JSON 输出的具体方法并不一定是我们的逻辑核心,因此可能更好地用 Promise().then() 来表达。
通常,这种风格上的细微差别不会发生,因为代码中“不那么重要”的部分,例如我们如何与 fetch API 交互以处理请求到 JSON,往往会抽象化——在这种情况下,在一个某种类型的 HTTP 客户端中。我们预计 HTTP 客户端可以处理检查 response.ok 和访问解析后的 JSON 响应体(使用 response.json())。
我们现在已经看到了如何使用仅 Promise 的方法、基于 async/await 的方法以及如何结合使用 async/await 和 Promise 技巧来提高代码的可读性和性能。
并行异步操作模式
性能不佳的一个常见原因是运行那些本可以并行完成的操作顺序执行。
例如,一个简单的加载购物车及其包含的产品实现可能如下所示:

图 7.2:从 fakestoreapi 加载购物车及其包含的三个产品
在这种情况下,操作完成时间由以下各项之和组成:
-
GET /carts/{cartId} 的请求-响应时间
-
GET /products/1 的请求-响应时间
-
GET /products/2 的请求-响应时间
-
GET /products/3 的请求-响应时间
需要 /products/{productId} 调用在 GET /carts/{cartId} 调用完成后进行,因为产品 ID 就来自那里。不需要每个产品调用等待前一个调用完成;调用只依赖于 GET /carts/{cartId} 调用的数据。这是一个优化机会。我们可以同时开始所有的 GET /products/{id} API 调用。我们得到以下序列:

图 7.3:并行加载购物车及其包含的三个产品
在这种情况下,操作完成时间由以下各项的总和组成:
-
GET /carts/{cartId} 的请求-响应时间
-
GET /products/1、GET /products/2 和 GET /products/3 之间的最长请求-响应时间
这意味着我们至少节省了两次 API 调用的请求-响应时间。
JavaScript 特别适合这些工作负载,因为它的并发模型基于事件循环。当 JavaScript 等待异步操作完成时,它可以完成其他同步操作。
用通俗易懂的话来说,在 JavaScript 中触发异步操作与在 Java 和 C++ 等流行编程语言中常见的基于线程的并发模型相比,“便宜且轻量”。
JavaScript 中有多种结构允许我们将 Promise 数组转换为解析为数组的 Promise。Promise.all 就是其中之一。
实现我们之前描述的场景,即加载购物车然后加载相关产品详情,使用 Promise.all 和 Promise().then 将如下所示:
首先,我们需要调用 API 来加载购物车并提取响应体中的 JSON:
function fetchCartPromiseThen(cartId = '1') {
return fetch(`https://fakestoreapi.com/carts/${cartId}`).
then((res) =>
res.json()
);
}
一旦加载了 /carts/{cartId} URL 的请求,我们就需要设置正确产品 URL 的获取。在获取完成后执行我们代码的模式使用返回的 promise 上的 .then():
function fetchCartPromiseThen(cartId = '1') {
// no change to previous operations
.then((cart) => {
const productUrls = cart.products.map(
(p) => `https://fakestoreapi.com/products/$
{p.productId}`
);
})
}
接下来,我们将使用 Promise.all 来使用 fetch 加载所有产品 URL。由于我们的目标是返回购物车和产品,我们将 { cart } 作为我们传递给 Promise.all() 的数组中的第一个项目。传递给 Promise.all 的数组的其余部分将是通过对每个产品 URL 调用 fetch().then((res) => res.json()) 生成的 Promise。为了做到这一点,我们在数组中的 ...productUrls.map(/* mapping function */) 上使用展开操作符 (...):
function fetchCartPromiseThen(cartId = '1') {
// no change to previous operations
.then((cart) => {
// no change to productUrls generation
return Promise.all([
{ cart },
...productUrls.map((url) => fetch(url).then
((res) => res.json())),
]);
})
}
最后,我们将创建一个对象,包含所有购物车字段和一个基于 /products/{id} 获取输出的新产品字段:
function fetchCartPromiseThen(cartId = '1') {
// no change to previous operations
.then(([prev, ...products]) => {
return {
...prev,
products,
};
});
}
我们可以通过加载购物车 ID 1 来测试函数的输出:
const cartDataFromPromiseThen = await fetchCartPromiseThen
('1');
购物车正如我们所期望的那样——它返回了三个产品:
assert.deepEqual(cartDataFromPromiseThen.cart, {
__v: 0,
date: '2020-03-02T00:00:00.000Z',
id: 1,
products: [
{
productId: 1,
quantity: 4,
},
{
productId: 2,
quantity: 1,
},
{
productId: 3,
quantity: 6,
},
],
userId: 1,
});
我们响应的 products 字段包含正确的项目,位于索引 0、1 和 2 的位置:
assert.deepEqual(cartDataFromPromiseThen.products[0], {
category: "men's clothing",
description:
'Your perfect pack for everyday use and walks in the
forest. Stash your laptop (up to 15 inches) in the
padded sleeve, your everyday',
id: 1,
image: 'https://fakestoreapi.com/img/
81fPKd-2AYL._AC_SL1500_.jpg',
price: 109.95,
rating: {
count: 120,
rate: 3.9,
},
title: 'Fjallraven - Foldsack No. 1 Backpack, Fits 15
Laptops',
});
assert.deepEqual(cartDataFromPromiseThen.
products[1], {
category: "men's clothing",
description:
'Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.',
id: 2,
image:
'https://fakestoreapi.com/img/
71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg',
price: 22.3,
rating: {
count: 259,
rate: 4.1,
},
title: 'Mens Casual Premium Slim Fit T-Shirts ',
});
assert.deepEqual(cartDataFromPromiseThen.products[2], {
category: "men's clothing",
description:
'great outerwear jackets for Spring/Autumn/Winter, suitable for many occasions, such as working, hiking, camping, mountain/rock climbing, cycling, traveling or other outdoors. Good gift choice for you or your family member. A warm hearted love to Father, husband or son in this thanksgiving or Christmas Day.',
id: 3,
image: 'https://fakestoreapi.com/img/
71li-ujtlUL._AC_UX679_.jpg',
price: 55.99,
rating: {
count: 500,
rate: 4.7,
},
title: 'Mens Cotton Jacket',
});
我们现在已经看到如何利用 Promise.all 并行运行多个承诺,并使用一个处理程序处理它们的输出。
你可能已经注意到了我们在 Promise.all 中传递 { cart } 对象并从中提取解析数组的第一个项作为上一个响应的“技巧”。正如在 使用 async/await 和 Promises 控制顺序异步操作 部分中提到的,这是 Promise().then() 链接的限制,每个 .then() 函数参数都有自己的作用域:
Promise.resolve({ id: 1 })
.then((cart) => {
const productUrls = [];
return Promise.all([{ cart }, ...productUrls.map(()
=> {})]);
})
.then(([prev, ...products]) => {});
写这个的另一种方法是存储购物车在函数作用域中:
function fetchCartFunctionVariable() {
let loadedCart = null;
return Promise.resolve({ id: 1 })
.then((cart) => {
loadedCart = cart;
const productUrls = [];
return Promise.all(productUrls.map(() => {}));
})
.then((products) => ({
cart: loadedCart,
products,
}));
}
这按预期工作。显然,我们已经从 API 中移除了实际的购物车和产品获取逻辑,但与 { id: 1 } 相关的购物车,我们在初始 Promise.resolve() 函数调用中解析,是通过 .then() 调用缓存的:
assert.deepEqual(await fetchCartFunctionVariable(), {
cart: { id: 1 },
products: [],
});
另一种在不使用可能难以跟踪的函数作用域变量的情况下改进我们的实现的方法是将它转换为使用 async/await。
我们的逻辑如下。我们首先加载购物车并将 JSON 响应体转换为:
async function fetchCartAsyncAwait(cartId = '1') {
const cart = await fetch(`https://fakestoreapi.com/carts/${cartId}`).then(
(res) => res.json()
);
}
一旦加载了购物车,我们就通过根据 cart.products 内容(主要是 productId 字段)生成 URL 来获取相关产品。我们也使用 Promise.all 获取这些 URL:
async function fetchCartAsyncAwait(cartId = '1') {
// no change to cart fetching
const productUrls = cart.products.map(
(p) => `https://fakestoreapi.com/products/${p.productId}`
);
const products = await Promise.all(
productUrls.map((url) => fetch(url).then((res)
=> res.json()))
);
}
最后,我们可以返回购物车和加载的产品:
async function fetchCartAsyncAwait(cartId = '1') {
// no changes to cart or products fetching
return {
cart,
products,
};
}
实现与我们的之前基于严格 Promise().then() 的实现等效,如下检查所证明:
const cartDataFromAsyncAwait = await fetchCartAsyncAwait
('1');
assert.deepEqual(cartDataFromPromiseThen.cart,
cartDataFromAsyncAwait.cart);
assert.deepEqual(
cartDataFromPromiseThen.products,
cartDataFromAsyncAwait.products
);
在这种情况下使用 async/await 的好处是,再次提高了可读性。语法比链式 .then() 调用更不阻碍,我们不必返回 Promise.all([{ cart }]) 中的第一个响应作为一个项,也不必添加一个存储购物车的函数作用域变量。
我们现在已经看到如何利用 Promise.all 来并行完成异步操作,无论是使用 Promise().then() 独家方法,还是通过审慎重构为 async/await 以简化代码。
接下来,我们将看到如何使用 JavaScript 中的 AbortController 取消和超时请求。
使用 AbortController 的异步取消和超时
应用程序中性能不佳的另一个原因是执行不必要的操作。在 JavaScript 网络应用程序的上下文中,可能是不必要的“工作”(因此会降低性能)之一是存在不再需要的 HTTP 请求。例如,在一个相册系统或任何分页系统中,当浏览照片时,上一个照片的请求可能还没有完成,下一个请求就已经开始。在这种情况下,之前的请求数据不再必要,因为我们实际上已经完全在不同的页面上。
在这些情况下,取消请求可能是有用的。
AbortController 是一个 Web/DOM API,允许我们取消网络请求。它是通过其构造函数 new AbortController 创建的,控制请求(可能取消它)是通过 AbortController().signal 值完成的,该值是一个 AbortSignal 对象。
我们使用 new AbortController() 构造函数调用实例化控制器。如果我们想使 fetch 调用可取消,我们将 abortController.signal 作为 signal 选项传递:
function fetchWithCancel(url) {
const abortController = new AbortController();
const response = fetch(url, { signal:
abortController.signal }).then((res) =>
res.json()
);
return {
response,
};
}
如果我们想取消 fetch 请求,我们可以调用 abortController.cancel。我们将将其作为 cancel 函数添加到 fetchWithCancel 返回的输出中:
function fetchWithCancel(url) {
// no changes to contents
return {
// no changes to other keys in the object
cancel: () => abortController.abort(),
};
}
最后,我们需要确保当我们看到 AbortError 时,我们能够处理它。在这种情况下,我们将使用 Promise().catch 处理器来处理它,当看到 AbortError 时,将返回 'Aborted',否则重新抛出错误。
AbortError 错误实例有一个名称属性等于 'AbortError',但还有一个如 DOMException [AbortError]: This operation was aborted 的消息,以及其堆栈跟踪:
function fetchWithCancel(url) {
// no change to abortController initiationisalition
const response = fetch(url, { signal: abortController.signal })
.then((res) => res.json())
.catch((err) => {
if (err.name === 'AbortError') return 'Aborted';
throw err;
});
// no change to return value
}
给定对 fakestoreapi 的两个 API 调用,/products/1 和 /products/2,我们可以取消其中一个而不影响另一个请求,如下所示,通过调用 fetchWithCancel 并将两个 URL 存储在两个变量中。注意,我们还没有使用 await。
然后,我们可以使用我们之前构建的 .cancel() 函数取消对 /products/1 的获取:
const fetchProduct1 = fetchWithCancel
('https://fakestoreapi.com/products/1');
const fetchProduct2 = fetchWithCancel('https://fakestoreapi.com/products/2');
fetchProduct1.cancel();
结果是,当我们等待 fetchProduct1.response 和 fetchProduct2.response 时,fetchProduct1.response 的输出是 'Aborted',这意味着在 fetchWithCancel 中处理了一个 AbortError 实例(即,我们的取消成功)。
fetchProduct2.response 的输出是产品对象:
assert.deepEqual(await fetchProduct1.response, 'Aborted');
assert.deepEqual(await fetchProduct2.response, {
category: "men's clothing",
description:
'Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.',
id: 2,
image:
'https://fakestoreapi.com/img
/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg',
price: 22.3,
rating: {
count: 259,
rate: 4.1,
},
title: 'Mens Casual Premium Slim Fit T-Shirts ',
});
手动取消请求是有用的,但更广泛的使用场景是在请求超过一定时间后超时。这对于确保客户有响应式的用户体验很有用。不同的情况需要更长或更短的超时延迟。
我们可以使用 fetch、AbortController 和 setTimeout 实现一个 fetchWithTimeout 函数。
我们的功能接受一个 URL 和一个可选的超时时间,我们将默认设置为 500(500 毫秒)。类似于我们的手动取消场景(见 fetchWithCancel),我们将创建一个 abortController 对象,并将它的 signal 属性作为选项传递给 fetch:
async function fetchWithTimeout(url, timeout = 500) {
const abortController = new AbortController();
return fetch(url, { signal: abortController.signal });
}
为了在一段时间后取消获取,我们将使用 setTimeout。setTimeout 处理器将简单地调用 abortController.abort(),我们将超时延迟设置为我们的 timeout 变量:
async function fetchWithTimeout(url, timeout = 500) {
// no change to abortController
setTimeout(() => {
abortController.abort();
}, timeout);
// no change to fetch call or return
}
当请求完成所需时间少于 fetch 请求所需时间时,我们收到响应数据:
const timedoutFetchShouldSucceedData = await fetchWithTimeout(
'https://fakestoreapi.com/products/1',500
)
.then((res) => res.json())
.catch((error) => {
if (error.name === 'AbortError') {
return 'Aborted';
}
throw error;
});
console.assert(
timedoutFetchShouldSucceedData.id === 1,
'fetchWithTimeout with 500ms timeout should have
succeeded'
);
当 fetch 请求超过配置的超时时,我们收到一个 AbortError 实例:
const timedoutFetchShouldAbort = await fetchWithTimeout(
'https://fakestoreapi.com/products/1',10
)
.then((res) => res.json())
.catch((error) => {
if (error.name === 'AbortError') {
return 'Aborted';
}
throw error;
});
console.assert(
timedoutFetchShouldAbort === 'Aborted',
'fetchWithTimeout with 10ms timeout should have
aborted but did not'
);
我们现在已经看到了如何使用 AbortController 手动控制 fetch 取消以及如何用它创建一个“带超时”的实用工具。我们可以使用 AbortController 来取消不再需要的操作,从而减少网络使用。
接下来,我们将探讨可以帮助优化高请求量情况的更多模式。
节流、防抖和批量处理异步操作
节流是一种操作,在达到一定时间之前会丢弃请求。例如,对于 10 毫秒的节流超时,一旦发起一个请求,在接下来的 10 毫秒内不会发送任何请求。如果在 0 毫秒到 10 毫秒之间发起多个请求,只有 10 毫秒超时到期后发送的最后一个请求会被发送。
在 JavaScript 中,这样的节流函数可以如下实现。
高阶函数 throttle 接受一个 fn 参数并返回一个具有与 fn 参数相同输入签名的可执行函数。
当“节流”的 fn 函数被调用时,我们设置 isThrottled = true 以便能够在第一次调用和配置的超时之间丢弃调用:
function throttle(fn, timeout) {
let isThrottled = false;
return (...args) => {
isThrottled = true;
return fn(...args);
};
}
现在,我们需要确保在 isThrottled 为 true 时 fn 不会被调用。我们通过从返回的“节流”fn 函数中提前返回来实现这一点。
我们保存了“节流”的 fn 函数被调用时的参数,以便在超时到期时使用:
function throttle(fn, timeout) {
// no change to existing variable definitions
let lastCallArgs = null;
return (...args) => {
if (isThrottled) {
lastCallArgs = args;
return;
}
// no change to "initial call" case
};
}
最后,我们配置 setTimeout 以触发节流状态的重置并执行最后的函数调用:
function throttle(fn, timeout) {
// no change to existing variable definitions
return (...args) => {
// no change to short-circuit logic
setTimeout(() => {
isThrottled = false;
return fn(...lastCallArgs);
}, timeout);
// no change to "initial call" case
};
}
使用的一个简单例子如下场景,其中在给定时间内可能会发送许多消息。相反,我们希望以每 1 毫秒间隔发送 1 条消息。
我们的 storeMessage 函数如下:
let messages = [];
const storeMessage = (message) => {
messages.push(message);
};
我们可以生成一个具有 1 毫秒超时的 throttledStoreMessage 函数如下。
当同步调用十次并随后等待计时器完成时,只有第一次('throttle-1')和最后一次('throttle-10')调用被记录:
const throttledStoreMessage = throttle(storeMessage, 1);
throttledStoreMessage('throttle-1');
throttledStoreMessage('throttle-2');
throttledStoreMessage('throttle-3');
throttledStoreMessage('throttle-4');
throttledStoreMessage('throttle-5');
throttledStoreMessage('throttle-6');
throttledStoreMessage('throttle-7');
throttledStoreMessage('throttle-8');
throttledStoreMessage('throttle-9');
throttledStoreMessage('throttle-10');
await timeout();
assert.deepEqual(messages, ['throttle-1', 'throttle-10']);
function timeout(ms = 0) {
return new Promise((r) => setTimeout(r, ms));
}
如果我们在 'throttle-5' 的调用后重置消息并等待计时器完成,我们将以 ['throttle-1', 'throttle-5', 'throttle-6'] 结束,即第一次调用,以及计时器清除前后的调用。
如果我们在完成所有调用后再次清除计时器,'throttle-10' 也会出现在我们的消息列表中,这意味着调用已完成:
messages = [];
throttledStoreMessage('throttle-1');
throttledStoreMessage('throttle-2');
throttledStoreMessage('throttle-3');
throttledStoreMessage('throttle-4');
throttledStoreMessage('throttle-5');
await timeout();
throttledStoreMessage('throttle-6');
throttledStoreMessage('throttle-7');
throttledStoreMessage('throttle-8');
throttledStoreMessage('throttle-9');
throttledStoreMessage('throttle-10');
assert.deepEqual(messages, ['throttle-1', 'throttle-5', 'throttle-6']);
await timeout();
assert.deepEqual(messages, [
'throttle-1',
'throttle-5',
'throttle-6',
'throttle-10',
]);
我们已经看到了如何节流一个函数。现在我们可以看看防抖。
JavaScript 中的 debounce 函数接受一个 fn 参数,它是一个函数。目标是使防抖的 fn 函数在经过一个 timeout 期间未被调用时,丢弃所有调用,除了最后的调用。
为了做到这一点,我们应该“延迟”函数调用直到超时完成之后。我们保存 timeoutId 引用,以便在防抖的 fn 函数再次被调用时取消调用。我们使用 setTimeout 并传递防抖的 fn 函数被调用的参数:
function debounce(fn, timeout) {
let timeoutId;
return (...args) => {
timeoutId = setTimeout(() => {
fn(...args);
}, timeout);
};
}
在当前debounce函数的状态下,对fn的调用次数将与对去抖动fn函数的调用次数一样多;它们只是根据超时时间被排队延迟执行。为了避免这种情况,我们可以使用clearTimeout(timeoutId)来取消之前的调用超时:
function debounce(fn, timeout) {
// no change to variable declarations
return (...args) => {
clearTimeout(timeoutId);
// no change to setTimeout logic
};
}
在这些更改到位后,如果我们创建一个具有 1 毫秒超时的debouncedStoredMessage函数并调用它 10 次,它将不会执行,直到我们等待计时器完成:
messages = [];
const debouncedStoredMessage = debounce(storeMessage, 1);
debouncedStoredMessage('debounce-1');
debouncedStoredMessage('debounce-2');
debouncedStoredMessage('debounce-3');
debouncedStoredMessage('debounce-4');
debouncedStoredMessage('debounce-5');
debouncedStoredMessage('debounce-6');
debouncedStoredMessage('debounce-7');
debouncedStoredMessage('debounce-8');
debouncedStoredMessage('debounce-9');
debouncedStoredMessage('debounce-10');
assert.deepEqual(messages, []);
await timeout();
assert.deepEqual(messages, ['debounce-10']);
我们可以通过等待第五次调用后的计时器完成来进一步展示这一点。在这种情况下,第五次调用将触发,并且,如果另一个超时窗口清除,第十次调用也将触发:
messages = [];
debouncedStoredMessage('debounce-1');
debouncedStoredMessage('debounce-2');
debouncedStoredMessage('debounce-3');
debouncedStoredMessage('debounce-4');
debouncedStoredMessage('debounce-5');
await timeout();
debouncedStoredMessage('debounce-6');
debouncedStoredMessage('debounce-7');
debouncedStoredMessage('debounce-8');
debouncedStoredMessage('debounce-9');
debouncedStoredMessage('debounce-10');
assert.deepEqual(messages, ['debounce-5']);
await timeout();
assert.deepEqual(messages, ['debounce-5', 'debounce-10']);
我们现在已经看到了如何节流和去抖动函数,这允许我们确保操作不会触发不必要的次数。
在一个“边打字边搜索”或“边打字边建议”的输入场景中(有时被称为“预测输入”),需要通过 API 请求获取搜索结果或建议,通常在使用debounce等待用户停止打字后再发送请求,或者节流请求,以便在窗口中而不是每次按键时发送 API 请求。
这也可以与其他启发式方法结合使用,以避免因不必要的请求而使 API 服务器过载。例如,通常在输入几个字符之前避免发送请求,因为只有 1 个或 2 个字符的搜索请求太宽泛。
我们已经看到了如何通过节流或去抖动减少请求数量来保护 API。在并行异步操作模式部分,我们使用了Promise.all来并行发送请求。这也可以是异步操作目标可能过载的另一个场景。为了避免过载情况,批量请求可能很有用。
“批量处理”是一种限制并发的方式,例如,我们不想同时发送 20 个请求(并行),而是一次发送 5 个。
batch函数接受一个数组和批量大小,并返回一个数组的数组。嵌套数组的最长长度为“批量大小”。
我们首先计算在batches数组中我们需要多少个batchItem列表项。为了做到这一点,我们将输入数组的长度除以批量大小,并应用ceil函数到该值。换句话说,我们将inputLength除以batchSize向上取整到下一个最大的整数值。
然后,我们可以使用正确的尺寸(batchCount,如计算所得)生成我们的batches数组:
function batch(inputArray, batchSize) {
const batchCount = Math.ceil(inputArray.length /
batchSize);
const batches = Array.from({ length: batchCount });
}
然后,我们使用Array.prototype.map()遍历每个批次。batches中的项最初是未定义的,但我们使用项的索引(我们将称之为batchNumber)。对于batches中的每个项,我们从batchNumber * batchSize到(batchNumber + 1) * batchSize取项,它们构成了我们的batches[batchNumber]数组项的内容:
function batch(inputArray, batchSize) {
// no change to existing size computations
return batches.map((_, batchNumber) => {
return inputArray.slice(
batchNumber * batchSize,
(batchNumber + 1) * batchSize
);
});
}
你会注意到我们使用 Array.from 生成数组,然后使用 Array.prototype.map() 来填充它,然而,Array.from() 支持第二个参数,即映射函数。因此,我们的代码可以是以下这样:
function batch(inputArray, batchSize) {
const batchCount = Math.ceil(inputArray.length /
batchSize);
return Array.from({ length: batchCount }, (_,
batchNumber) => {
return inputArray.slice(
batchNumber * batchSize,
(batchNumber + 1) * batchSize
);
});
}
无论如何,我们的 batch 函数适用于任何数组,例如,一个 10 个元素的数组可以通过我们的函数正确地分批为 4 或 3 个块:
assert.deepEqual(batch([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 4), [
[1, 2, 3, 4],
[5, 6, 7, 8],
[9, 10, 11],
]);
assert.deepEqual(batch([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 3), [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[10, 11],
]);
之前的示例展示了同步示例。对于我们的用例——提高异步操作的性能,我们需要处理 Promise。好消息是 Promise 可以像数组一样存储:
const numberResolverBatches = batch(
[Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)],
2
);
console.assert(numberResolverBatches.length === 2);
console.assert(numberResolverBatches[0].length === 2);
console.assert(numberResolverBatches[1].length === 1);
然而,要获取 Promise 的批处理输出,我们需要编写一个函数,该函数等待每个批次中的所有 Promise 以顺序解决它们。
这可以通过使用 for ... of 循环和 Promise.all 来实现,如下所示。我们将解析的值展开:
async function resolveBatches(batchedPromises) {
const flattenedBatchOutput = [];
for (const batch of batchedPromises) {
const resolved = await Promise.all(batch);
flattenedBatchOutput.push(...resolved);
}
return flattenedBatchOutput;
}
const batchOutput = await resolveBatches(numberResolverBatches);
assert.deepEqual(batchOutput, [1, 2, 3]);
在我们的示例中,使用 Promise.resolve() 调用 1、2 和 3 的确可以批量处理并解决。
我们现在已经看到了如何构建和使用节流、去抖和批处理来提高 JavaScript 中异步操作的性能。
摘要
在本章中,我们介绍了使用 Promise 和 async/await 的异步操作编排模式,以管理顺序和并行操作。我们还介绍了高级模式,如请求取消、实现超时、节流和去抖之间的区别,以及如何在异步操作上下文中使用批处理。
为了管理顺序的异步操作,我们可以使用基于 Promise 的方法 Promise().then()、async/await,或者混合这两种方法。这有助于保持我们的代码简单,易于推理。对于并行执行,我们可以利用 Promise.all() 与 Promise.then() 或 async/await。我们还有多种方法来维护异步操作之间的响应数据。
我们可以利用 AbortController 来取消请求。我们使用 AbortController 和 setTimeout 实现了对 fetch 响应时间的超时处理。停止正在进行的请求是一个有用的清理步骤,可以通过减少对 API 原始服务的不必要负载来提高性能。
最后,高级异步编程模式通过节流和去抖减少请求的发生。我们还可以通过批处理和解决批次来控制并行请求的并发性。这些方法可以减少不必要的网络流量和对 API 服务器的负载。
现在我们已经涵盖了异步编程性能模式,包括 Promise、async/await 和高级模式,我们可以看看 JavaScript 事件驱动编程的模式。
第八章:事件驱动编程模式
JavaScript 中的事件驱动编程非常普遍,并且是处理某些场景的唯一方式。在事件监听器周围维护性能和安全至关重要。管理不善的事件监听器一直是历史性的错误来源和关键性能问题;我们将通过事件委托模式解决这个问题。在支付环境中,帧和上下文之间的安全消息传递始终至关重要。最近,Web 平台和 JavaScript 正在添加新的原语,这些原语暴露了一个事件/消息接口,用于在上下文之间保持隔离。
在本章中,我们将涵盖以下主题:
-
实现事件委托
-
使用postMessage接口在上下文之间进行通信,以下是一个支付 iframe 的示例
-
常见的事件监听器反模式及其修复方法
在本章结束时,您将学会如何使用 JavaScript 中高级的事件驱动编程概念来保持代码的性能和安全性。
技术要求
您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/Javascript-Design-Patterns
通过事件委托优化事件监听器
事件委托是一种常见的事件监听器模式,用于从“许多元素,许多事件监听器”转换为“许多元素,单个事件监听器”。在其核心,事件委托将一个事件监听器附加到页面的Document上,并在该监听器内部检查事件的target,以确定如何处理该事件。
事件委托意味着附加的监听器更少。每个根节点只有一个;如果我们是在文档级别进行事件委托,那么意味着只有一个监听器。另一个好处是,可以在不担心添加或删除相关事件监听器的情况下添加和删除 DOM 节点。
以下序列图详细说明了监听两个按钮点击的实现。

图 8.1:没有事件委托的事件处理
没有事件委托的事件处理可以与事件委托序列进行对比,后者不是为每个事件/元素附加一个处理程序,而是附加一个处理程序并在单个监听器中计算相关操作。

图 8.2:使用事件委托的事件处理
我们将实现一个简单的邮件订阅表单的事件委托,该表单通过客户端 JavaScript 使用fetch提交。首先,我们将开始布局一个表单。我们有一个带有data-newsletter-form属性的表单,我们将在 JavaScript 中获取它,一个标题,一个标签,一个电子邮件输入框和一个提交按钮:
<form data-newsletter-form>
<h3>Subscribe to the newsletter!</h3>
<div>
<label for="email">Email</label>
<input
id="email"
type="email"
name="email"
placeholder="test@example.com"
/>
</div>
<button type="submit">Submit</button>
</form>
要从事件委托的事件部分开始,我们向文档添加一个点击监听器。这个监听器根据event.target.tagName进行切换;tagName取大写值,如P、BUTTON和DIV。为了使代码更容易理解,我们将切换到tagName的小写版本:
<script>
document.addEventListener('click', (event) => {
switch (event.target.tagName?.toLowerCase()) {
}
});
</script>
当我们检测到按钮元素的点击时,然后检查event.target是否在表单内,event.target是否为submit类型,以及包含事件目标元素的表单是否在其dataset中包含newsletterForm,换句话说,它是否有data-newsletter-form。在这种情况下,我们调用event.preventDefault。我们将使用 JavaScript 处理表单提交。
我们通过更改事件目标按钮的内容(到Submitting)并设置disabled属性来向用户提供反馈,表明表单正在提交,这样在处理程序执行完成之前,表单就不能再次提交:
<script>
document.addEventListener('click', (event) => {
switch (event.target.tagName?.toLowerCase()) {
case 'button': {
const form = event.target.closest('form');
if (
form &&
event.target.type === 'submit' &&
'newsletterForm' in form.dataset
) {
event.preventDefault();
const formValues = new FormData(form);
event.target.innerText = 'Submitting';
event.target.setAttribute('disabled',
'disabled');
const email = formValues.get('email');
return;
}
}
}
});
</script>
当我们点击提交按钮时,它现在被禁用,其内容设置为提交中。

图 8.3:当点击提交按钮时,它被禁用,文本变为提交中
现在,我们将处理提交新闻通讯表单。为了做到这一点,我们需要一个基于fetch的函数,该函数将POST给定的email参数到jsonplaceholder.typicode.com/users。然后我们等待fetch承诺,并使用res.json()提取 JSON 响应:
<script>
async function submitNewsletterSubscription(email) {
const res = await fetch
('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
body: JSON.stringify({
email,
}),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});
return res.json();
}
// no change to the document "click" event listener
</script>
现在,我们将扩展button的type=submit处理程序,以调用submitNewsletterSubscription。email值来自formValues.get('email')(表单的电子邮件字段)。当submitNewsletterSubscription成功完成(即 Promise 解析)时,我们将重置submit按钮以具有disabled属性:
<script>
document.addEventListener('click', (event) => {
switch (event.target.tagName?.toLowerCase()) {
case 'button': {
const form = event.target.closest('form');
if (
form &&
event.target.type === 'submit' &&
'newsletterForm' in form.dataset
) {
// no change to existing logic
const email = formValues.get('email');
submitNewsletterSubscription(email).then((result) => {
event.target.innerText = 'Submit';
event.target.removeAttribute('disabled');
});
}
return;
}
}
});
</script>
为了突出显示请求/响应,我们将在我们的页面上添加一个storeLogEvent函数和一个 API 请求/响应日志:
<div style="height: 300px; overflow: scroll">
<h3>API Request/Response Log</h3>
<pre><code></code></pre>
</div>
<script>
// no change to other functionality
function storeLogEvent(value) {
$requestLog = document.querySelector('pre code');
$requestLog.innerText += value;
$logParent = $requestLog.closest('div');
$logParent.scrollTo({ top: $logParent.scrollTopMax,
behavior: 'smooth' });
}
</script>
我们可以在调用submitNewsletterSubscription之前和之后使用storeLogEvent:
// -> inside the listener
// -> switch
// -> case 'button'
// -> if (form in ancestors && button type === submit &&
form has data-newsletter-form)
const email = formValues.get('email');
storeLogEvent(`Request: ${email}`);
submitNewsletterSubscription(email).then((result) => {
storeLogEvent(`\nResponse: ${JSON.stringify(result,
null, 2)}\n\n`);
event.target.innerText = 'Submit';
event.target.removeAttribute('disabled');
});
现在,当我们点击POST到jsonplaceholder并收到响应时,正如我们可以在以下屏幕截图中所见:

图 8.4:当我们输入电子邮件并点击提交时,API 响应被显示
为了展示在 DOM 元素可以动态添加的情况下事件委托的优势,我们将创建一个添加表单按钮,该按钮将向文档中附加一个额外的新闻通讯表单。
首先,我们添加一个带有data-add-form的button,我们将使用数据属性来检测和处理按钮的点击。
data-add-form attribute by adding if ('addForm' in event.target.dataset). For now, we’ll return early to prevent any further handling code from executing:
We want to implement the add form functionality, and we want to find a `data-newsletter-form` element and clone it using `.cloneNode(true)`.
We’ll append a random number inside the heading so we can identify when new forms are added and reset the email input. Finally, we append the new node to the `document.body` element using `.appendChild`:
With no changes to the handling of the newsletter form submission, the cloned form functions just as the initial one does.

Figure 8.5: The effect of submit on multiple forms, one of which was added with an Add a form! button
We’ve now seen how to implement event delegation to prevent having to add event listeners manually to dynamically added DOM nodes. Next, we’ll look at patterns that use the `postMessage` interface between iframes.
Patterns for secure frame/native WebView bridge messaging
Gaining a deep understanding of messaging patterns with `postMessage` in JavaScript is crucial for working in a variety of contexts. `postMessage` is defined on the following Web API objects: `Window`, `MessagePort`, `Worker`, `Client`, `ServiceWorker`, and `BroadcastChannel`.
In other words, `postMessage`-based messaging is useful for document-to-iframe, iframe-to-iframe, document-to-worker, and service worker-to-document communication and that’s only the Web APIs. Due to how widespread the `postMessage` API is, it’s also adopted in non-standard APIs for handling multiple JavaScript contexts. For example, web extensions for Chrome and Firefox contain multiple JavaScript contexts: the devtools panel, proxy, backend, and background script. The `postMessage` API is also used for Android and iOS communication between the native code and WebViews.
The scenario that we’ll go through is about iframes and how they communicate. A common e-commerce use-case is integrating a third-party payment service provider’s hosted card capture form into their e-commerce website. By using a payment service provider and not knowing the customer’s card payment details, the e-commerce vendor can meet **Payment Card Industry Data Security Standard** (**PCI DSS**) compliance more easily.
The container or parent document will be a checkout form, inside of which we’ll iframe a hosted card capture document. The two documents will communicate with `postMessage`. The container document will not read the card details in cleartext. Instead, it will receive a public-key encrypted payload (which can only be decrypted via the paired private key).
Without being careful, it’s possible for `iframe` initialization to cause race conditions. To work around this, we’ll implement the following initialization scheme.
Initially, we’ll load a container document with an `iframe` that has no `src`. Only after we’ve added important event listeners to the `iframe` element, will we add `src`. This means that the `iframe` can’t load before our listeners are attached.

Figure 8.6: Sequence diagram of initialization messaging
We need two files, one at `frame-parent.html` (which will be our application shell) and one at `frame-content.html` (which will represent our iframe’s contents).
Some payment service provider integrations won’t require a fully custom `iframe` (sometimes, a JavaScript SDK is provided that helps manage the `iframe` part of it), but the important thing is that the `iframe` is loaded from an origin (server) that is owned by the payment service provider. We won’t be able to represent this since we’re working locally.
Our `frame-parent.html` HTML looks as follows: a few headings, a `form`, an `input type=email`, an `iframe`, and a submit button. Note that the `iframe` element doesn’t have a `src` attribute. We’ll add that via JavaScript to prevent race conditions:
To prevent race conditions when loading the `iframe`, we haven’t set the `src` in the HTML. We want to prevent situations where the `iframe` could load before we’ve attached a `load` event handler to it.
We start by adding a `message` event listener to the container window:
Next, we’ll select the payment capture `iframe` and add a `load` event listener to the iframe element. Our handler will send an `init` message with some data to the `iframe` element’s `contentWindow`:
Finally, we can set the `iframe` element’s `src` attribute so that it loads:
We now need to implement the `frame-content.html` file to receive the message we sent. Our `iframe`, again is mostly a heading and a form with multiple fields. We have `type=hidden` inputs for the price and currency, as well as text inputs for the card number, expiry date, and `Messages` section to illustrate which messages are being sent and received by the iframe:
支付 iframe
消息
In order to handle messages from the parent frame, we’ll add a `message` event listener. It stores all received messages in the `pre code` element we defined earlier.
If the `event.data.type` is `init`, we set the value of our `price` and `currency` inputs:
Finally, we send an `init` message when our script finished running. We use `window.parent.postMessage` to achieve this:
With this code in place, when we load the `frame-parent.html` file in a browser, we see the following. The `iframe` has sent an `init` message and received one as well.

Figure 8.7: Container and iframe contents in their initial state
When we submit the container, we’ll want to ensure the card details are retrieved by the `iframe` and passed back to the container. These details will be encrypted by the `iframe` (which, in our scenario, will be served from a domain from the payment service provider) before being sent to the parent document.
The following diagram details the expected interactions.

Figure 8.8: Container and iframe communication sequence diagram during user interaction
The key change we have to make to the container is to listen for a submit event on the `form` element. We then send a message with `type="submit"` to the iframe:
The `iframe` receives the message and we’ll need to extend our `message` event handler to react to the `submit` message:
Now that we’ve implemented a new `iframe` to container “validation error” message, we need to handle that message type in `frame-parent.html`. In this case, we’ve already done everything that’s necessary in the `submit` form event handler (which calls `preventDefault()`), so we’ll simply log out the message contents:
We can now use the `encryptToBase64` function in our `type=submit` message-handling code. Once the validation passes, we’ll serialize the data using `FormData`, `FormData().entries()`, and `Object.fromEntries`. We stringify it before encrypting it to a base64 ciphertext.
Finally, we send a `type=submit-reponse` message to the container document with the encrypted string as the payload:
We now need to handle the `type=submit-response` message in `iframe-parent.html`. Again, we’re just extending our `switch(type)` statement with an additional case for `submit-response`. We’ll log some messages, including the `event.data` and extract the values from the container `form` element using `FormData().entries()` and `Object.fromEntries()`. At this point, we could send the `event.data` and the container form data to a backend endpoint to complete the transaction:
We’ve now seen how to implement secure messaging between an iframe and the page that contains it. Next, we’ll recap on event listener performance anti-patterns.
Event listener performance antipatterns
Event listener performance antipatterns change over time. For example, when Internet Explorer support was broadly required due to its market share, adding event listeners to DOM nodes and subsequently deleting the nodes would not clean up the event listeners, causing memory leaks. This doesn’t occur anymore in modern browsers.
An event listener antipattern that is often caught by the Lighthouse page performance auditing tool is `scroll` event listeners that aren’t set to be passive. Passive event listeners are more performant because `event.preventDefault()` doesn’t intercept and stop the event’s default behavior. This allows browsers to set the event listener to be non-blocking since the listener can’t act on the event.
Making an event listener passive simply involves passing `{ passive: true }` as the third parameter to `addEventListener()`:
document.addEventListener(
'scroll',
(event) => {},
{ passive: true }
);
Another antipattern is to forgo using debounce or throttle on the event listener handler for high-volume events (scroll is a good example). We covered how to implement debounce and throttle in *Chapter 7**, Asynchronous Programming Performance Patterns*, in the *Throttling, debouncing and batching asynchronous* *operations* section.
The final antipattern is solved by event delegation. At some amount of DOM nodes and event listeners, adding one event listener per potential target starts causing performance implications. Luckily, event delegation solves this problem. It allows us to attach one event listener per event type while maintaining the ability to handle each target differently.
We’ve now covered event listener performance antipatterns to keep an eye out for and how to remediate them.
Summary
In this chapter, we’ve covered advanced event-driven programming patterns to keep a JavaScript code base performant and secure when handling large numbers of events and event listeners.
Event delegation is useful to ensure that the number of event listeners doesn’t grow with the number of DOM nodes in a client-side application where elements are inserted and removed dynamically.
Patterns for secure frame messaging mean we’re able to orchestrate `iframe` initialization and bidirectional communication between an `iframe` and its parent document.
Finally, we covered common event listener performance antipatterns to avoid the common pitfalls of event listener-heavy code bases.
Now that we’re familiar with advanced event-driven programming patterns in JavaScript, in the next chapter, we’ll cover lazy-loading and code-splitting to maximize the performance of JavaScript applications.
第九章:最大化性能——懒加载和代码拆分
为了最大化 JavaScript 应用程序的性能,减少加载和解释的未使用 JavaScript 的数量是关键。可以用来解决这个问题的方法被称为懒加载和代码拆分。懒加载和代码拆分允许 JavaScript 的部分按需加载。这与页面加载时下载形成对比,可以大大减少加载和解释的未使用 JavaScript 的数量。
本章我们将涵盖以下主题:
-
动态导入语法以及 Vite 如何根据语法自动进行代码拆分
-
使用 Next.js 进行基于路由的代码拆分以及如何阅读 Bundle Analyzer 报告
-
如何使用next/dynamic和react-intersection-observer在不同的用户交互中加载 JavaScript 和 React 组件
到本章结束时,你将能够识别并利用各种场景和应用中的懒加载和代码拆分。
技术要求
你可以在 GitHub 上找到本章的代码文件,网址为github.com/PacktPublishing/Javascript-Design-Patterns
使用 Vite 进行动态导入和代码拆分
JavaScript 中的动态导入指的是使用import()语法导入一个模块。与import Something from './my-module.js'这种声明性语法不同,import()更像是一个返回 promise 的函数。例如,我们可以将原始导入重写为const Something = await import('./my-module.js')。
“动态”部分是指导入不需要在模块评估时完成;它是代码执行的一部分。当与代码拆分结合使用时,这很有用——我们现在将定义它——因为它意味着我们可以避免在需要之前加载和评估某些 JavaScript 代码。
代码拆分是一种技术,将代码构建成多个文件(也称为“块”或“包”),而不是单个文件。代码拆分有助于避免一次性加载所有代码。相反,当与动态导入结合使用时,代码会被拆分成多个文件,只有在必要时才加载其不同部分。这意味着 JavaScript 的加载、解析和编译周期的前期成本更低。
Vite 构建工具支持在动态导入边界处进行代码拆分。
给定以下简单文档,它有一个id="app"的div并引用一个main.js文件,只要main.js存在,Vite 就可以运行构建:
<div id="app"></div>
<script src="img/main.js" type="module"></script>
现在我们将有两个模块:main.js,这是 Vite 将引用的入口点,我们的代码将导入dynamic.js模块。
main.js将'Hello from main.js'注入我们的app div。然后它将继续动态加载dynamic.js模块,并将app div 的内容设置为dynamic.js导出的hello函数的输出:
document.querySelector('#app').textContent = 'Hello from main.js';
const { hello } = await import('./dynamic.js');
dynamic.js implementation of the hello function:
export function hello() {
return 'Hello from dynamic.js';
}
When running the Vite dev server using `npx vite`, we can see that the dynamically imported `hello` function contents are displayed on the page. Notice that `dynamic.js` is loaded as a separate request to `main.js`; that is code splitting at play.

Figure 9.1: “Hello from dynamic.js” on the page with network requests, including a request specifically for dynamic.js
This pattern can be useful to defer loading JavaScript until it’s required – for example, if we want to add client-side tracking of button clicks using `fetch` requests.
We have the following HTML, with two buttons that have a `data-track` property:
We’ll add a `trackInteraction.js` module with a `trackInteraction` function, which will use `fetch` and the `POST` HTTP method to send interaction data to `jsonplaceholder`. If this were a live implementation, we could realistically replace `jsonplaceholder` with Google Analytics or another equivalent service that exposes a client-side JavaScript accessible endpoint:
export function trackInteraction(page, type = 'click') {
return fetch
('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify({
type,
page,
}),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
}).then((response) => response.json());
}
Now, the `trackInteraction` module has nothing to do with the page functionality so we want to avoid loading it until it’s needed.
In this case, we’ll attach a click event listener to each element that has a `data-track` attribute. Only when the listener is triggered does the `import('./trackInteraction.js')` statement run:
// no change to rest of main.js
document.querySelectorAll('[data-track]').forEach((el) => {
el.addEventListener('click', async (event) => {
const page = window.location.pathname;
const type = event.target.dataset?.track;
const { trackInteraction } = await import
('./trackInteraction.js');
const interactionResponse = await trackInteraction
(page, type);
console.assert(
interactionResponse.type === type &&
interactionResponse.page === page,
'interaction response does not match sent data',
);
});
});
If we load the Vite dev server and click the **With tracked click** button and the **Other tracked click** button once and then the **With tracked click** button once again, we’ll get the following network requests:

Figure 9.2: Network requests after clicking the “With tracked click,” “Other tracked click,” and “With tracked click” buttons in sequence
On the first click of either button, the `trackInteraction.js` file is loaded and then a `fetch` request is triggered. On subsequent clicks, `trackInteraction.js` is already loaded so the `fetch` requests to `jsonplaceholder` are the only network requests we see.
Note that each `POST` request to `jsonplaceholder` is preceded by an `OPTIONS` request due to browser `OPTIONS` response includes `Access-Control-Allow-…` headers that allow our origin and method.
We’ve now seen what dynamic imports in JavaScript look like and how Vite automatically code splits dynamic imports, which allows us to only load modules that are required “just in time,” thereby allowing us to reduce the upfront JavaScript load/parse/evaluation cost.
Next, we’ll cover route-based code splitting in Next.js and how to inspect generated chunks with the Next.js Bundle Analyzer plugin.
Route-based code splitting and bundling
Let’s begin by defining a **route** in a general web application context and then in a Next.js context.
In a web application, a route comes from the **router** concept; in simple terms, it’s an entry in the router. An entry in the router mechanism can take multiple shapes – for example, in an nginx/Apache/Caddy web server setup, we can have a path to file forwarding or a wildcard forwarding approach. In backend MVC frameworks such as Ruby on Rails, Laravel (PHP), and Django (Python), a route associates a request path to the specific code to be run. The *request path to code to be run* concept also applies to Node.js backend applications using libraries such as Express, Koa, Fastify, and Adonis.js.
Let’s now see how the *route* concept is used in the Next.js filesystem router.
A minimal Next.js project as initialized with `create-next-app` is laid out as follows. Each file in the `pages` directory corresponds to a route. For example, `index.js` is used to render the `/` path of the application. If we had an `about.js` or `about/index.js` file, that would be used to render the `/about` path of the application:
.
├── components
├── next.config.js
├── package.json
├── pages
│ └── index.js
└── public
We defined code splitting in the previous section, *Dynamic imports and code splitting with Vite*. Since a core Next.js feature is the router, it can do what’s called **route-based code splitting**, which is automatic code splitting based on a route or page contents.
A naive route-based code-splitting approach would be to create completely separate sets of bundles for each route. In the context of a React or Next.js application, this is inefficient since we would end up with shared libraries (for example, React and Next.js) in each of the per-page bundles.
What Next.js can do in this case is identify shared code and classify it as `First Load JS shared` `by all`.
This is the sample build output:
- First Load JS shared by all 79.9 kB
├ chunks/framework-cc1b0d6c55d15cb9.js 45.3 kB
├ chunks/main-7c6ad51e94ec3ff5.js 32.8 kB
├ chunks/pages/_app-db3a4be757903450.js 205 B
└ chunks/webpack-8850afd7843acaaa.js 1.55 kB
We can add the Next.js Bundle Analyzer to check the contents of each chunk:
npm install --save @next/bundle-analyzer
Then, we can configure `next.config.js` to use it. In our case, it looks as follows:
const nextConfig = {
// no changes to this config
};
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer(nextConfig);
To use the bundle analyzer, we can add an `analyze` script to our `package.json file`:
{
"//": "// no change to other properties",
"scripts": {
"//": "// no change to other scripts",
"analyze": "cross-env ANALYZE=true next build"
}
}
This can be run with `npm run analyze`. Its shell output is the same as `npm run build` but it opens a browser window with the bundle analysis file – for example, `.next/analyze/client.html`:
npm run analyze
next-route-based-splitting@0.1.0 analyze
cross-env ANALYZE=true next build
✓ Linting and checking validity of types
Webpack Bundle Analyzer saved report to /next-route-based-splitting/.next/analyze/nodejs.html
No bundles were parsed. Analyzer will show only original module sizes from stats file.
Webpack Bundle Analyzer saved report to /next-route-based-splitting/.next/analyze/edge.html
Webpack Bundle Analyzer saved report to /next-route-based-splitting/.next/analyze/client.html
✓ Creating an optimized production build
✓ Compiled successfully
✓ Collecting page data
✓ Generating static pages (3/3)
✓ Collecting build traces
✓ Finalizing page optimization
We can use this to inspect the contents of the `shared` JavaScript in the `framework`, `main`, `pages/_app`, and `webpack` chunks as well as page-specific chunks:

Figure 9.3: The client.html Bundle Analyzer output in the browser
The `framework` bundle includes the following packages from `node_modules`: `react`, `react-dom`, and `scheduler`. Meanwhile, the `main` bundle includes `next` and its submodules such as `shared/lib`, which includes a large `router` chunk, or `next/client`, which is the client-side section of Next.js. Also, it is harder to see in the preceding screenshot, but `main` includes `@swc/helpers/esm`, which is probably an artifact of the Next.js build using the SWC compiler.
We’ve now seen how Next.js supports route-based code splitting and how to inspect the contents of the Next.js-generated bundles using the Next.js Bundle Analyzer report. Next, we’ll see dynamic import patterns to load additional JavaScript under different element visibility and interaction conditions.
Loading JavaScript on element visibility and interaction
In this section, we’ll look at four different scenarios where dynamic or lazy loading of React components and JavaScript modules can be applied in the context of a Next.js application.
The first instance will be whether the component is in the component tree or not – in other words, whether it’s considered to be rendered or not. Next, we’ll look at dynamic imports based on user interaction. We’ll also cover how to handle an interaction that potentially requires a dynamic import of a JavaScript resource. Finally, we’ll show how to dynamically load a React component when an element is visible in the viewport.
Next.js provides a `dynamic` utility (see the documentation at [`nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading`](https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading)) that allows us to lazily and dynamically load a React component.
In our case, we have a `components/Hello.jsx` component with a `Hello` component that is a named export:
import React from 'react';
export function Hello() {
return <>Hello</>;
}
We can dynamically load it using `dynamic()` and `import()`. Due to `Hello` being a named export, we need to extract the `Hello` property of the `import()` promise using `.then()`. We set `ssr: false` to showcase how `next/dynamic` allows us to control whether a dynamically loaded component is included in the server-rendered output or not:
import React from 'react';
import dynamic from 'next/dynamic';
const DynamicClientSideHello = dynamic(
() => import('../components/Hello.jsx').then(({ Hello })
=> Hello),
{ ssr: false },
);
export default function Index() {
return (
<>
Next.js route-based splitting and component lazy
loading
</>
);
}
By using `npm run analyze` as configured in the *Route-based code splitting and bundling* section (using the `@next/bundle-analyzer` module), we can inspect the contents of the `chunks/pages/index` chunk; you’ll note that `Hello.jsx` is in a different chunk.

Figure 9.4: Bundle analyzer filtered to “chunks/pages/index” and the chunk containing Hello.jsx
When we run the Next.js dev server using `next dev` and load up the `/` path, we see the following page and network requests. `_next/static/chunks/components_Hello_jsx.js` is loaded last and separately to `_next/static/chunks/pages/index.js`, which means that we are in fact doing a dynamic load of the `Hello.jsx` component.

Figure 9.5: Dynamic loading of the Hello.jsx page contents and Network tab
We’ll now showcase using `next/dynamic` inside of the `Index` component based on the component state.
Our example is a *Terms and Conditions* selector that allows the user to select between three options: `NoRender` component (which simply returns `null`), and **Short** and **Long** will dynamically load a component to display.
We’ll start by adding a `components/TermsAndConditionsShort.jsx` component, which contains an `h3` element and a single paragraph of content:
import React from 'react';
export function TermsAndConditions() {
return (
<>
Terms and Conditions Short
{/* 条款和条件内容 */}
</>
);
}
We’ll also add a `components/TermsAndConditionsLong.jsx` component, which contains the same `h3` and content but has five paragraphs of content instead of one:
import React from 'react';
export function TermsAndConditions() {
return (
<>
条款和条件 - 长版
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
</>
);
}
Finally, we’ll add a `select` field with relevant `option` values (`None`, `Short`, and `Long`) to `pages/index.js`. We’ll use `useState` to keep track of the currently selected option:
import React, { useState } from 'react';
export default function Index() {
const [selectedTermsAndConditions,
setSelectedTermsAndConditions] =
useState('None');
return (
<>
{/* 返回的 JSX 中其余部分无更改 */}
<select
id="termsAndConditionsType"
onChange={(e) => setSelectedTermsAndConditions
(e.target.value)}
</>
);
}
Finally, we’ll add a `NoRender` component and, based on `selectedTermsAndConditions`, either render `NoRender` or the dynamically loaded `TermsAndConditions` component:
import React, { useState } from 'react';
const NoRender = () => null;
export default function Index() {
// useState 无更改
const TermsAndConditions = ['Short', 'Long'].includes(
selectedTermsAndConditions,
)
? dynamic(() =>
import(
`../components/TermsAndConditions$
{selectedTermsAndConditions}.jsx`
).then(({ TermsAndConditions }) =>
TermsAndConditions),
- )
- NoRender;
return (
<>
{/* 返回的 JSX 中其余部分无更改 */}
{/* 标签或选择器无更改 */}
</>
);
}
When we run the next dev server and load the index page, we initially see the `Hello.jsx` one from the previous example.

Figure 9.6: Terms and conditions selector initial state with None selected; therefore, no dynamic imports apart from the existing Hello.jsx one
On selection of `_next/static/chunks/components_TermsAndConditionsShort_jsx.js`.

Figure 9.7: Terms and conditions selector when Short is selected; TermsAndConditionsShort.jsx has been dynamically loaded and is displayed
When we select `/_next/static/chunks/components_TermsAndConditionsLong_jsx.js`.

Figure 9.8: Terms and conditions selector when Long is selected; TermsAndConditionsLong.jsx has been dynamically loaded and is displayed
We can also look at the Bundle Analyzer’s `client.html` output using `npm run analyze`; the following has been filtered to the relevant chunks to illustrate how `TermsAndConditionsShort` and `TermsAndConditionsLong` are not included in `chunks/pages/index.js`. There are three “dynamic” chunks (which correlates with our findings from the network requests we observe in the browser): one for `components/Hello.jsx`, one for `components/TermsAndConditionsShort.jsx`, and one for `components/TermsAndConditionsLong.jsx`.

Figure 9.9: Bundle Analyzer output for the page chunk as well as the dynamic chunks (which include the TermsAndConditionsShort and TermsAndConditionsLong components)
We’ve now seen how `dynamic` can be used in response to a user action to dynamically load content based on user-provided data. Next, we’ll revisit dynamic imports of a JavaScript resource (as opposed to React components) while handling a user action in the context of a Next.js application.
We’ll start with a new component, `TermsAndConditionsLongScroll.jsx`, which is functionally the same as `TermsAndCondtionsShort.jsx` or `TermsAndCondtionsLong.jsx` but with 10 paragraphs:
import React from 'react';
export function TermsAndConditions() {
return (
<>
条款和条件 - 长滚动版
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
{/* 条款和条件内容 */}
</>
);
}
We’ll now add a form at the bottom of the page to accept the terms and conditions. We have a long form so it’s nice to be able to go directly to the bottom. To this end, we add a button that, on click, scrolls us to the input checkbox element using a ref.
In our *scroll-to-bottom* handler, we ensure that smooth scrolling is available (some older Safari versions don’t natively support it) by conditionally importing the `scroll-behavior-polyfill` package if `scrollBehavior` is not detected.
Finally, we scroll using the `scrollTargetRef.current.scrollIntoView()` function. `scrollTargetRef` is attached to the checkbox input using the `ref` property:
import React, { useRef } from 'react';
export function TermsAndConditions() {
const scrollTargetRef = useRef();
async function handleScroll() {
if (!('scrollBehavior' in document.
documentElement.style)) {
await import('scroll-behavior-polyfill');
}
if (scrollTargetRef.current) {
scrollTargetRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}
}
return (
<>
{/* 无标题更改 */}
{/* 段落无更改 */}
</>
);
}
Back in `pages/index.js`, we’ll allow `option`) and to be dynamically imported:
// Index 外部的导入和定义无更改
export default function Index() {
// 状态值无更改以保持选择状态
const TermsAndConditions = ['Short', 'Long',
'LongScroll'].includes(
selectedTermsAndConditions,
)
? dynamic(() =>
import(
`../components/TermsAndConditions$
{selectedTermsAndConditions}.jsx`
).then(({ TermsAndConditions }) =>
TermsAndConditions),
- )
- NoRender;
return (
<>
{/* select 选项外部的内容没有变化 */}
{/* 标签没有变化 */}
<select
id="termsAndConditionsType"
onChange={(e) => setSelectedTermsAndConditions
(e.target.value)}
{/* 现有的选项没有变化 */}
</>
);
}
When we run the next dev server, load the index page, and select `TermsAndConditionsLongScroll.jsx`.

Figure 9.10: TermsAndConditionsLongScroll.jsx selection with dynamic import
In browsers where `behavior: 'smooth'` is supported, when the **Scroll to bottom** button is clicked, no additional JavaScript chunks are loaded and we’re scrolled to the checkbox input after the multiple paragraphs.

Figure 9.11: TermsAndConditionsLongScroll.jsx selection with dynamic import
On browsers that don’t support `behavior: 'smooth'` for scrolling, `scroll-behavior-polyfill` will be loaded allowing for smooth scrolling to the checkbox.

Figure 9.12: TermsAndConditionsLongScroll.jsx selection with dynamic import of the component and of the scroll-behavior-polyfill module
Based on the Bundle Analyzer output (using `npm run analyze` and the `@next/bundle-analyzer` plugin), we can see that there is a chunk that contains `scroll-behavior-polyfill`, along with chunks for `pages/index.js` and one each for `TermsAndConditionsShort.jsx`, `TermsAndConditionsLong.jsx`, and `TermsAndConditionsLongScroll.jsx`.

Figure 9.13: Bundle Analyzer output for the pages/index.js chunk as well as relevant dynamic chunks (TermsAndConditionsShort, TermsAndConditionsLong, TermsAndConditionsLongScroll, and scroll-behavior-polyfill)
We’ve now seen that Next.js code splits effectively on native `import()` as well as the provided `dynamic()` utility.
Finally, we’ll see how to use `dynamic()` and the `react-intersection-observer` package to dynamically load content when it is visible.
One other variant of a Terms and Conditions form or similar would be to include additional fields that should be captured when the customer accepts the terms.
In this example, we’ll add a `components/TermsForm.jsx` component with an input for the user’s name and a label for it:
从 'react' 模块导入 React。
导出默认函数 TermsForm() {
return (
);
}
Next, we’ll want to include it in `components/TermsAndConditionsLongScrollAcceptForm.jsx`. We’ll use `dynamic()` to load the `TermsForm` component.
The rest of our code is similar to the end state of the `TermsAndConditionsLongScroll` components, with a heading, 10 paragraphs, and an `accept` input.
The key exception is the import and usage of the `InView` component from `react-intersection-observer`.
The `InView` component has a children render property that receives, among other properties, the `ref` property, which we can attach to elements whose visibility we’re interested in. Another property of interest to us is the `inView` Boolean, which tells us whether the element on which we put the `ref` prop is in the viewport.
As the rendered output of the `InView` children function, we return a `div` element to which we attach the `ref` property. Inside of the `div`, we render `TermsForm` but only if `inView` is `true`:
导入 React from 'react';
导入 dynamic from 'next/dynamic';
导入 { InView } from 'react-intersection-observer';
const TermsForm = dynamic(() => import('./TermsForm.jsx'));
导出函数 TermsAndConditions() {
return (
<>
长滚动接受条款和条件表单
{/* 10 段内容 */}
{({ inView, ref }) =>
</>
);
}
Finally, we need to add `TermsAndConditionsLongScrollAcceptForm` as a selectable option and a dynamically loaded component:
// Index 模块外部没有导入和定义的变化
导出默认函数 Index() {
// useState 没有变化,以保持选择状态
const TermsAndConditions = [
'Short',
'Long',
'LongScroll',
'LongScrollAcceptForm',
].includes(selectedTermsAndConditions)
? dynamic(() =>
导入(
`../components/TermsAndConditions$
{selectedTermsAndConditions}.jsx`
).then(({ TermsAndConditions }) =>
TermsAndConditions),
- )
- NoRender;
return (
<>
{/* select 选项外部的内容没有变化 */}
{/* 标签没有变化 */}
<select
id="termsAndConditionsType"
onChange={(e) => setSelectedTermsAndConditions
(e.target.value)}
{/* 现有的选项没有变化 */}
</>
);
}
Now, when we run the next dev server and load the index page, `LongScrollAcceptForm` is available. When we select it, the `TermsAndConditionsLongScrollAcceptForm.jsx` component is loaded.

Figure 9.14: LongScrollAcceptForm selected and TermsAndConditionsLongScrollAcceptForm.jsx dynamically loaded
When `TermsAndConditionsLongScrollAcceptForm` is scrolled to the bottom (to the point where the checkbox is visible), the `TermsForm.jsx` component is dynamically loaded and is shown on the page.

Figure 9.15: TermsAndConditionsLongScrollAcceptForm scrolled to the bottom and TermsForm.jsx dynamically loaded
We’ve now seen how to load JavaScript and React components on component visibility and interaction with Next.js.
Summary
In this chapter, we’ve covered various approaches for maximizing the performance of your JavaScript, React, and Next.js applications with lazy loading approaches and code splitting.
First, we showcased how to use the dynamic import syntax in a Vite-powered setup to cause code splitting and illustrated it by importing additional code only when it’s required (during an interaction handler).
Next, we saw how Next.js provides out-of-the-box route-based code splitting while also ensuring modules shared across pages don’t get loaded or output more than once. We also delved into how to validate this using the Next.js Bundle Analyzer plugin.
Finally, we covered how to implement different lazy loading scenarios in Next.js: on presence in the component tree, on change caused by user interaction, importing a JavaScript module during an event handler, and lazy loading on an element entering the viewport.
We now know how to leverage lazy loading and code splitting to maximize application load performance. In the next chapter, we’ll cover asset-loading strategies and how to execute code off the main thread.
第十章:资产加载策略和主线程之外的代码执行
在应用程序的生命周期中,有些情况下不可避免地需要加载更多 JavaScript。本章详细介绍了减轻此类情况影响的技术。你将了解资产加载优化,如脚本元素的 async、defer 属性的影响、type="module" 的影响以及链接元素的 rel(关系)属性的 preconnect、preload 和 prefetch 值。接下来,你将使用 Next.js 的 Script 组件及其不同选项进一步优化脚本加载。本章最后将探讨执行 JavaScript 代码离开主线程的原因以及实现此目标的方法。
在本章中,我们将涵盖以下主题:
-
如何通过脚本的自定义
async和defer属性以及preconnect、preload和prefetch链接来更细致地控制资产加载 -
在 Next.js 中使用 Script 组件及其 strategy 属性的进一步优化机会
-
如何以及何时通过 Next.js 和 Partytown 在主线程之外运行代码
到本章结束时,你将具备更多控制资产加载和 Web 环境中 JavaScript 加载与执行技能。
技术要求
你可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/Javascript-Design-Patterns
资产加载优化 – 异步、延迟、预连接、预加载和预取
当使用 script 来加载和执行 JavaScript 时,我们可以使用 script 的 HTML 属性来控制加载和执行。
我们可以依赖外部脚本和内联脚本之间的差异;我们还可以使用 async、defer 和 type="module" 属性。
我们将首先定义外部和内联脚本,然后是 async 和 defer 属性。最后,我们将探讨 type="module" 属性。
外部脚本使用 src 属性指向一个单独的 JavaScript 文件;例如,以下是一个外部脚本,当遇到时会加载并评估 ./script.js:
与内联脚本进行对比,内联脚本没有 src 属性;相反,JavaScript 代码位于 script 标签的内容中:
<script>
console.log('inline script');
</script>
脚本的默认加载/执行周期就是我们所说的 script 标签的 JavaScript 完成执行。
script HTML 标签上的 async 和 defer 属性可以改变脚本加载和执行的行为。
将 async 添加到脚本意味着它在解析其余 HTML 文档的同时被获取。一旦加载,async 脚本就会被评估。这是对 script 的默认文档解析行为的重大改变。
假设我们有一个 async.js 文件,它插入一个包含文本 async.js: async script executed 的段落:
(() => {
const node = document.createElement('p');
node.innerText = 'async.js: async script executed';
document.body.appendChild(node);
})();
假设我们还有一个 script.js 文件,它也会插入一个包含 script.js: blocking script executed 的段落:
(() => {
const node = document.createElement('p');
node.innerText = 'script.js: blocking script executed';
document.body.appendChild(node);
})();
最后,假设我们有一个文档,其中包含内联脚本片段,这些片段在两个额外的 script 标签之前和之后添加段落以跟踪它们的执行。一个脚本使用具有 async 属性的脚本加载 async.js,而第二个脚本使用默认的渲染阻塞加载方式加载 script.js 元素:
<script>
(() => {
const node = document.createElement('p');
node.innerText = 'inline: script 1 executed';
document.body.appendChild(node);
})();
</script>
<script src="img/async.js" async></script>
<script src="img/script.js"></script>
<script>
(() => {
const node = document.createElement('p');
node.innerText = 'inline: script 2 executed';
document.body.appendChild(node);
})();
</script>
当在空缓存中加载时,以下是在浏览器中显示的:内联脚本 1 首先执行,然后是 script.js,然后是内联脚本 2,最后是 async.js。注意 async.js 在文档中位于 script.js 之前,但执行在后;这是 async 属性的效果:

图 10.1:内联脚本、外部脚本和具有 async 执行顺序的外部脚本
接下来,我们将看到 defer 如何影响脚本的加载。
defer 告诉浏览器脚本应该在文档解析后才能加载。然而,DOMContentLoaded 事件将在所有具有 defer 属性的脚本加载并执行后才触发。
假设我们添加一个 defer.js 文件,该文件将插入一个包含 defer.js: defer script executed 的段落,如下面的代码块所示:
(() => {
const node = document.createElement('p');
node.innerText = 'defer.js: defer script executed';
document.body.appendChild(node);
})();
接下来,我们通过在 <script src="img/defer.js" defer></script> 之前添加 <script src="img/async.js" async></script> 来扩展上一个 async 示例中的 HTML 文档。这将如下所示:
<!-- no change to inline script 1 -->
<script src="img/defer.js" defer></script>
<script src="img/async.js" async></script>
<script src="img/script.js"></script>
<!-- no change to inline script 2 -->
当我们在浏览器中加载此文档时,我们看到以下输出,其中延迟执行的脚本在所有其他脚本之后添加其段落,尽管它在文档的解析顺序中位于 async.js、script.js 和内联脚本 2 之前。

图 10.2:内联脚本、外部脚本、具有 async 的外部脚本和具有 defer 执行顺序的外部脚本
接下来,我们将看到“模块”脚本和“经典”脚本如何受到 async 和 defer 的不同影响。
当一个脚本接收到具有 module 值的属性类型时,该脚本将被解释为 JavaScript 模块。我们将这些称为“模块”脚本,与没有类型属性的“经典”脚本相对。
type="module" 延迟脚本的执行。这意味着“模块”脚本不受 defer 属性的影响(因为默认情况下该行为应用于它们的执行)。
async 属性总体上对“模块”脚本和“经典”脚本的影响相似,即脚本将在文档解析的同时并行加载,并在加载完成后执行。
async 属性对“模块”脚本的一个额外影响是,由于 JavaScript 模块具有表示依赖项加载的语法,模块脚本本身以及一旦加载,通过 import 语法加载的所有依赖项都将与文档解析并行加载。
假设我们有一个名为 module.js 的模块,它在运行时插入 module.js: type="module" executed:
const node = document.createElement('p');
node.innerText = 'module.js: type="module" executed';
document.body.appendChild(node);
假设我们还有一个名为 module-async.js 的模块,它在运行时插入 module-async.js: type="module" async executed:
const node = document.createElement('p');
node.innerText = 'module-async.js: type="module"
async executed';
document.body.appendChild(node);
我们添加了带有 type="module" 的脚本标签,其中包含一个内联模块,该模块在运行时插入 inline: type="module" executed,以及引用 module.js 和 module-async.js 的模块脚本:
<!-- no change to inline scripts -->
<script type="module">
const node = document.createElement('p');
node.innerText = 'inline: type="module" executed';
document.body.appendChild(node);
</script>
<script src="img/module-async.js" type="module" async>
</script>
<script src="img/module.js" type="module"></script>
<!-- no change to existing external scripts -->
当我们在浏览器中加载此文档时,我们会看到以下内容。这表明 type="module" 的默认加载/执行是延迟的,因为即使内联模块脚本也在 async 脚本之后执行。一个有趣的观点是,模块脚本的 async 可以使其比没有 async 的脚本执行得更早。这是有道理的,因为 async 表示并行加载,执行是“一旦可用”,而与模块脚本的默认执行方法相反,其默认方法是 defer:

图 10.3:内联脚本、外部脚本、带有 async 的外部脚本、带有 defer 执行顺序的外部脚本、内联模块脚本以及带有 async 和不带 async 的外部模块脚本
我们现在已经对比了脚本加载/执行的不同特性:内联与外部,async 和 defer 属性的影响,以及经典与模块。以下图表总结了执行顺序:

图 10.4:脚本加载/执行顺序与浏览器文档解析
我们现在已经看到了如何通过调整 JavaScript 的加载和执行方式来提高页面性能。接下来,我们将学习如何使用资源提示来提高页面性能。
根据 HTML 规范,资源提示允许消费者预先完成一个操作。它们用作链接元素上的 rel 值。与我们用例相关的值是 preconnect、prefetch 和 preload。
根据 HTML 标准,preconnect 的定义如下:
“preconnect:指定用户代理应预先连接到目标资源的源”,HTML 标准 – 4.6.7 链接类型:html.spec.whatwg.org/#linkTypes
总结来说,preconnect 允许开发者“告诉”浏览器创建到源的服务器连接,从而使得后续对该源的服务器请求能够更快地发生,尤其是在 HTTP/2 上下文中,可以并行执行更多请求(通过多路复用)并且连接能够被高效重用。
link element:
<head>
<link rel="preconnect" href="https://example.com" />
</head>
接下来,根据 HTML 规范,preload的定义如下:
“preload:指定用户代理必须根据由 as 属性给出的潜在目的地(以及与相应目的地关联的优先级)预先获取并缓存目标资源,用于当前导航。” HTML 标准 – 4.6.7 链接类型:html.spec.whatwg.org/#linkTypes
preload可以在检测到页面上的资源之前加载资源。这在单页应用或其他高度动态的 JavaScript 驱动环境中特别有用,在这些环境中,资源可能不在初始返回的 HTML 有效负载中,但我们知道哪些资源可能是必需的。
注意,preload需要一个完全限定的资源路径(例如,example.com/assets/resource-1.js),这与仅使用源地址的preconnect不同。此外,请注意,preload不是为模块脚本设计的;为此,我们需要rel="modulepreload",这在 HTML 标准规范中定义如下:
“modulepreload:指定用户代理必须预先获取模块脚本并将其存储在文档的模块映射中以便稍后评估。可选地,还可以获取模块的依赖项。” HTML 标准 – 4.6.7 链接类型:html.spec.whatwg.org/#linkTypes
在我们当前的示例中,我们可以在浏览器在 HTML 中“看到”它们之前(提前)请求预加载一些async资源,我们的资源加载默认如下。加载顺序由 HTML 元素中脚本标签的顺序和所有资源的优先级定义,所有资源的优先级默认为Normal:

图 10.5:页面加载包括网络标签,但没有预加载
为了说明preload,我们可以在 HTML 的head元素内添加一个针对async.js的preload链接和一个针对module-async.js的modulepreload链接,如下面的代码片段所示:
<head>
<link rel="preload" href="async.js" as="script" />
<link rel="modulepreload" href="module-async.js"
as="script" />
</head>
如果我们重新加载我们的示例页面,我们会看到async.js和module-async.js现在以最高优先级加载,并且在页面上的其他脚本之前。此外,由于async属性加载较早,脚本执行也较早。

图 10.6:页面加载包括网络标签,async.js 有预加载,module-async.js 有 modulepreload
最后,在 HTML 规范中,prefetch被定义为如下:
“prefetch:指定用户代理应预先获取并缓存目标资源,因为它可能对于后续导航是必需的” HTML 标准 – 4.6.7 链接类型:html.spec.whatwg.org/#linkTypes
这意味着 prefetch 不仅会连接(就像 preconnect 所做的那样),还会进行完整的加载和缓存周期。prefetch 在资源将在下一次加载时而不是在当前页面上(在这种情况下应使用 preload 和 modulepreload)需要时很有用。
我们已经看到了如何通过 script 元素的 async 和 defer 属性以及通过 link 元素的 preconnect、preload 和 prefetch 来优化资产加载。接下来,我们将探讨如何使用 Next.js Script 组件的 strategy 在 Next.js 应用程序中实现类似的结果。
使用 Next.js 脚本策略选项优化资产加载
Next.js 的 Script 组件让我们对脚本加载行为有更多的控制,从而可以改善页面加载性能。
strategy 属性允许我们控制加载策略;它默认为 afterInteractive,这意味着在 Next.js 代码运行之后开始加载。它可以设置为 beforeInteractive,在这种情况下,脚本将在所有 Next.js 代码之前加载和执行。lazyOnLoad 可以用于低优先级的脚本,以延迟加载直到浏览器空闲时间。
最后一个选项是实验性的;它是 worker 策略,它将在 Web Worker 中加载和运行脚本。
根据 Next.js 文档中关于 Script#strategy 选项的说明,以下列表包含了脚本的加载策略(请参阅文档:nextjs.org/docs/pages/api-reference/components/script#strategy)。
可以使用四种不同的策略:
-
beforeInteractive:在任何 Next.js 代码和任何页面激活之前加载
-
afterInteractive(默认):在页面某些激活之后早期加载
-
lazyOnload:在浏览器空闲时加载
-
worker(实验性):在 Web Worker 中加载
Script 组件相对于原生的 script 元素的一个优点是,即使在内联脚本上也可以使用加载策略。例如,假设我们在 Next.js 应用程序中有一个 pages/index.js 页面;我们添加了一些 Script 组件,并采用两种方法添加内联脚本。我们将后者 Script 设置为使用 beforeInteractive,记住默认策略是 afterInteractive:
import React from 'react';
import Script from 'next/script';
export default function Index() {
return (
<>
<h1>Next.js Script Strategy</h1>
<Script>{`console.log('inline script 1');`}</Script>
<Script
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `console.log('inline script 2');`,
}}
></Script>
</>
);
}
当我们使用 npx next dev 或 npx next build && npx next start 运行 Next.js 服务器时,我们看到控制台中打印的是 inline script 2 在 inline script 1 之前;这是 Script 策略正在应用:

图 10.7:第二个内联脚本由于每个脚本的策略在第一个脚本之前记录到控制台
现在,我们将展示如何使用加载策略与外部脚本一起使用。
假设我们有一个 public/afterInteractive.js,其中包含以下内容:
console.log('afterInteractive.js: loaded');
同样,public/beforeInteractive.js 和 public/lazyOnload.js 包含一个带有相关内容的 console.log 函数调用,分别是 beforeInteractive.js: loaded 和 lazyOnload.js: loaded。
我们可以使用以下对 pages/index.js 的更改来加载它们;请注意,我们将它们按大致的“反向”顺序加载,以展示 strategy 的影响:
import React from 'react';
import Script from 'next/script';
export default function Index() {
return (
<>
{/* no change to h1 or inline script 1 */}
<Script src="img/lazyOnload.js" strategy="lazyOnload" />
<Script src="img/afterInteractive.js" strategy=\
"afterInteractive" />
<Script src="img/beforeInteractive.js" strategy=
"beforeInteractive" />
{/* no change to inline script 2 */}
</>
);
}
当我们使用 npx next dev 或 npx next build && npx next start 运行 Next.js 服务器时,我们会看到在控制台打印出 beforeInteractive 之前,会先打印出 afterInteractive,而 lazyOnLoad 在 afterInteractive 之前打印:

图 10.8:基于策略的脚本登录顺序
我们现在已经看到了 Next.js Script 和其 strategy 属性如何让我们在 Next.js 环境中控制脚本资产加载,以实现额外的页面加载性能。接下来,我们将介绍如何在工作线程中运行脚本。
在工作线程中加载和运行脚本
Next.js Script 策略选项之一是 worker,它会在一个 Web Worker 中加载和运行脚本。在当前的 Next.js 版本中,这是通过一个名为 Partytown 的库实现的 (partytown.builder.io/)。以下内容来自 Partytown 文档:
“Partytown 是一个懒加载库,用于将资源密集型脚本重新定位到 Web Worker 中,并从主线程上移除。它的目标是帮助通过将主线程专用于你的代码,并将第三方脚本卸载到 Web Worker 中来加速网站。” Partytown 主页 – partytown.builder.io/
为了扩展这个定义,JavaScript 在浏览器中运行在一个单线程的环境中。“单线程”意味着我们只有一个实体能够执行计算操作;非异步工作不能并行执行。在这个上下文中,主线程是浏览器的 JavaScript 执行线程。当加载和执行计算密集型脚本时,它们可能会剥夺其他脚本的执行环境。通过在工作线程中运行这些计算密集型脚本,它获得了一个不同的 JavaScript 环境或执行线程,这意味着主线程被释放出来以服务其余的 JavaScript 执行。
由于 Next.js Script 的 strategy="worker" 是实验性的,为了使用它,我们需要在 next.config.js 中启用它,如下所示:
const nextConfig = {
// no change necessary to other config fields
experimental: {
nextScriptWorkers: true,
},
};
module.exports = nextConfig;
当运行 npx run dev 时,你会在运行命令的终端中看到一个关于 nextScriptWorkers 实验性功能的警告:
▲ Next.js 13.5.4
- Local: http://localhost:3000
- Experiments (use at your own risk):
· nextScriptWorkers
✓ Ready in 2.4s
为了说明我们如何使用由 Partytown 提供的 strategy="worker",我们可以编写一个 analytics.js 脚本,该脚本将记录登录、加载,并对 jsonplaceholder 进行一些关于页面的 API 调用。我们将 analytics.js 存储在 public/analytics.js 中,以模拟加载第三方脚本(或更普遍地,无法打包的依赖项,即我们无法将其导入到我们的应用程序代码中):
console.log('analytics.js: loaded');
async function trackPageLoad() {
const responseJson = await fetch(
'https://jsonplaceholder.typicode.com/posts',
{
method: 'POST',
body: JSON.stringify({
page: window.location.pathname,
origin: window.location.origin,
}),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
},
).then((response) => response.json());
console.log('analytics.js: page load fetch response',
responseJson);
}
trackPageLoad();
然后,我们可以在 Next.js 应用程序中创建一个新的pages/worker.js文件,该文件渲染一个标题和一些 Next.js 脚本,包括/analytics.js。其他脚本是为了说明worker策略与替代策略值的加载顺序:
import React from 'react';
import Script from 'next/script';
export default function Worker() {
return (
<>
<h1>Next.js Script "worker" experimental
Strategy</h1>
<Script src="img/analytics.js" strategy="worker" />
<Script src="img/lazyOnload.js" strategy="lazyOnload" />
<Script src="img/afterInteractive.js" strategy=
"afterInteractive" />
<Script src="img/beforeInteractive.js" strategy=
"beforeInteractive" />
</>
);
}
当我们加载npx next build && npx next start时,生产服务器启动,并且使用strategy="worker"在所有其他策略之后加载。我们还看到对jsonplaceholder的fetch()调用成功完成:

图 10.9:在其它策略和 fetch 调用响应日志之后加载 worker 策略
通过worker策略加载的另一个方面是analytics.js不是作为脚本加载的;它是通过fetch加载的。这可以通过检查XMLHttpRequest(fetch的前身)以及检查jsonplaceholder请求在这里出现(作为两个请求,一个OPTIONS请求以确保我们可以进行跨源请求,随后是POST请求)来看到。

图 10.10:analytics.js 通过 fetch 加载,以及 jsonplaceholder 的请求
如果我们进一步挖掘analytics.js请求,我们会看到Referer头部的值(它帮助我们跟踪请求的来源)是_next/static/~partytown/partytown-sandbox-sw.html,这是一个 Parytown 生成的文档。

图 10.11:analytics.js 的 Referer 是 Parytown 服务工作者生成的 HTML 文件
简而言之,使用strategy="worker"在不同的 JavaScript 上下文中加载和执行我们的脚本,尽管 Parytown 被设计成应该与原始窗口有高度的相似性。
我们已经看到了如何使用strategy="worker"和 Parytown 在 web worker 环境中执行主线程之外的脚本。
摘要
在本章中,我们介绍了控制资产和 JavaScript 加载的更细粒度技术。
为了使用浏览器内置功能控制脚本加载,我们可以使用async和defer属性;我们讨论了它们对模块脚本与经典脚本的影响。我们还探讨了在link元素上使用rel属性进行资源提示,以及preconnect、preload、modulepreload和prefetch对资源加载的影响。
我们可以利用 Next.js Script组件的strategy属性来控制 Next.js 应用程序上下文中的脚本加载和执行,而不仅仅是async和defer。
最后,我们探讨了使用 Next.js Script worker策略,由 Parytown 库提供支持,在主 JavaScript 线程之外运行某些脚本的可能性。
在本章的最后,我们讨论了资产加载策略和优化,例如在主线程之外执行代码。
这就带我们来到了本书的结尾。希望您已经对 JavaScript 中的设计模式及其实现有了更深入的理解。您将能够讨论和对比实现方式,以及属于创建型、结构型和行为型设计模式类别的语言无关模式的实用性。此外,您应该对有助于您扩展应用的 JavaScript 特定模式充满信心,例如响应式视图库模式、渲染策略、以及 JavaScript 中的异步和事件驱动编程模式。此外,您现在对与 JavaScript 相关的性能和架构模式也应该很熟悉,例如微前端、懒加载、代码拆分以及进一步的资产加载优化。
当然,所有这些模式都是为了使用而设计的,您将发现新的组合它们的方法,甚至可能在您意想不到的地方注意到它们。JavaScript 和 Web 平台空间是不断演变的,我希望这本书能帮助您更好地利用其出色的功能。


浙公网安备 33010602011771号