Node-现代前端开发-全-

Node 现代前端开发(全)

原文:zh.annas-archive.org/md5/7ff6184289c9b97579cd1c50c2bf5608

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书涵盖了使你充分利用 Node.js 的力量、其概念和其生态系统所需的一切。这包括你需要了解的所有关于模块系统、包、辅助库、CLI 工具、WebAssembly 以及一系列可用的工具,如打包器(Webpack(v5)、Parcel(v2)、Vite 和 esbuild)、测试运行器(AVA、Jest 和 Mocha)、转译器(Babel 和 TypeScript)以及许多其他工具(Flow、Prettier、eslint 和 Stylelint)的内容。

本书面向对象

本书面向初级和中级前端 Web 开发者,他们希望利用 Node.js 生态系统来构建前端解决方案。本书需要具备 JavaScript、HTML 和 CSS 的入门级知识。在标准 shell(sh)中使用的前期经验将有所帮助。

本书涵盖内容

第一章, 了解 Node.js 的内部机制,描述了 Node.js 的内部工作原理、其原则和基本思想。本章还使你熟悉基本的 Node.js 命令行工具。

第二章, 将代码划分为模块和包,介绍了不同的模块格式、它们的优缺点以及它们在 Node.js 中的支持情况。本章还介绍了重要的package.json文件,用于定义 Node.js 包。

第三章, 选择包管理器,描述并比较了用于在 Node.js 包中安装和管理第三方依赖的不同已建立命令行工具。

第四章, 使用不同版本的 JavaScript,涵盖了使用 Node.js 的不同 JavaScript 版本的主要概念和思想。这些版本包括 Flow 和 TypeScript,但也包括比当前可用的 Node.js 版本支持的 ECMAScript 标准更新的规范。

第五章, 使用代码检查器和格式化工具提高代码质量,涵盖了用于提高 JavaScript 项目代码质量的可用工具。本章提供了有关如何安装这些代码质量助手、配置它们以及将它们集成到标准工作流程和开发过程中的信息。

第六章, 使用打包器构建 Web 应用,讨论了关于被称为打包器的专用 Web 构建工具你需要了解的一切。在本章中,你将学习如何将最先进的 Web 项目从源代码编译成可以在服务器上发布的工件。涵盖的打包器包括 Webpack、esbuild、Parcel 和 Vite。

第七章, 使用测试工具提高可靠性,涵盖了您需要了解的所有关于使用 Node.js 进行测试的知识——从运行单元测试的工具到完整的端到端测试运行器。特别是,本章包括关于 Jest、Mocha、AVA、Playwright 和 Cypress 的基础知识。

第八章, 发布 npm 包,包含了从官方 npm 注册表或自定义私有注册表(如 Verdaccio)发布和消费包的有用信息。本章还涵盖了使用 Node.js 创建和发布 CLI 工具,以及关于编写同构库的信息。

第九章, 在单仓库中组织代码,涵盖了使用 Node.js 开发多个依赖包的一般策略。特别是,它深入探讨了在称为单仓库的单个仓库中工作多个包的细节。介绍了如 Nx、Lerna 或 Turbo 等可能的工具,并结合 npm、Yarn 和 pnpm 工作空间进行介绍。

第十章, 将本地代码与 WebAssembly 集成,讨论了运行编译为 WebAssembly 的本地代码的可能性。本章将指导您创建您的第一个 WebAssembly 模块,以及如何在浏览器和 Node.js 中运行创建的模块。

第十一章, 使用替代运行时,详细介绍了 Node.js 的两个替代方案:Deno 和 Bun。它们在兼容性、安全性、性能和稳定性方面进行了评估。

为了充分利用本书

本书中的所有示例都是本着简洁的原则创建的。它们的工作方式相似,只需要了解核心前端技术(如 JavaScript、HTML 和 CSS)的知识。此外,为了跟随所有示例,还需要具备使用终端的基本知识。本书讨论了使代码运行所需的工具。因此,如果您知道如何使用 JavaScript,并遵循本书中解释如何使用 Node.js 与 npm 结合使用的部分,您将没有问题运行书中提供的示例。

第十一章中,您还将运行 Deno 和 Bun。该章节本身包含了安装说明。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js。如果代码有更新,它将在 GitHub 仓库中更新。

我们还提供其他代码包,这些代码包来自我们丰富的书籍和视频目录,可在github.com/PacktPublishing/找到。查看它们!

代码在行动

本书的相关代码在行动视频可在bit.ly/3EgcKwM查看。

下载彩色图像

我们还提供包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/zqKz4

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块设置如下:

html, body, #map {
 height: 100%;
 margin: 0;
 padding: 0
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都按以下方式编写:

$ npm install

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要提示

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com并在邮件主题中提及书籍标题。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata并填写表格。

copyright@packt.com并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享你的想法

一旦您阅读了《使用 Node.js 的现代前端开发》,我们很乐意听听您的想法!请点击此处直接转到该书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

你喜欢在移动中阅读,但无法携带你的印刷书籍到处走吗?

你的电子书购买是否与您选择的设备不兼容?

不要担心,现在,随着每本 Packt 书籍的购买,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,你还可以获得独家折扣、时事通讯和每日免费内容的每日邮箱访问权限!

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接:

packt.link/free-ebook/9781804618295

  1. 提交你的购买证明

  2. 就这些!我们将直接将免费的 PDF 和其他优惠发送到你的邮箱

第一部分:Node.js 基础知识

在这部分,你将通过学习 Node.js 的工作原理以及如何使用它来深入了解 Node.js。你还将接触 Node.js 生态系统。特别是,你将了解 Node.js 项目的结构。本部分的一个重要主题是如何处理以包形式存在的依赖项。

本书本部分包括以下章节:

  • 第一章, 了解 Node.js 的内部结构

  • 第二章, 将代码划分为模块和包

  • 第三章, 选择包管理器

第一章:了解 Node.js 的内部机制

多年来,成为一名前端开发者意味着写一些 HTML 代码,并在其上添加一些 CSS 样式。然而,自从上个十年以来,这种工作描述几乎已经不再适用。相反,现在大部分的前端工作都是使用 JavaScript 来完成的。

最初用于使网站(如元素的切换)的微调增强成为可能,前端开发现在已成为网络的粘合剂。网站不再仅仅是使用 HTML 和 CSS 编写的。相反,在许多情况下,网页是通过使用现代技术(如依赖管理和资源打包)用 JavaScript 编程的。Node.js 框架为这一运动提供了一个理想的基础。它使开发者能够在浏览器中运行的网站内部以及编写网页的工具中(在浏览器之外)使用 JavaScript。

当 Node.js 在 2009 年 5 月发布时,并没有引起太大的关注。JavaScript 也可以在服务器上运行。然而,Node.js 的跨平台特性和 JavaScript 社区的规模为计算机历史上最大的颠覆之一奠定了基础。人们如此迅速地采用这个框架,以至于许多现有的框架要么消失了,要么不得不进行重构以吸引开发者。很快,JavaScript 就被用于浏览器和服务器,并且也成为了每个前端开发者工具箱的一部分。

随着新的开发框架(如 AngularReact)的兴起,对有吸引力的前端工具的需求变得明显。这些新框架总是依赖于某些构建步骤——否则,使用这些框架的网站和应用对开发者来说将非常不便编写。由于庞大的 Node.js 生态系统似乎已经找到了一种适合重用的合适方法,这些新框架采用了这种方法,并将其作为其开发故事的一个组成部分。这样,使用 Node.js 成为了任何类型的前端项目的既定标准。

现在,几乎不可能开始一个前端开发项目而不安装 Node.js。在这本书中,我们将一起探索从内部学习 Node.js 的旅程。我们不会专注于编写服务器应用程序或探讨 Node.js 的集成功能。相反,我们将探讨作为前端开发者,我们如何利用 Node.js 带来的最佳功能。

在本章的第一部分,我们将讨论 Node.js 的内部机制。这将帮助你理解 Node.js 的工作原理以及你如何实际使用它。在本章之后,你将能够使用 Node.js 命令行应用程序运行和调试简单的脚本。

在本章中,我们将涵盖以下关键主题:

  • 详细了解 Node.js 架构

  • 理解事件循环

  • 使用命令行运行 Node.js

  • CommonJS

技术要求

要跟随本书中的代码示例,你需要了解 JavaScript 和如何使用命令行。你应该已经按照 nodejs.org 上的说明安装了 Node.js。

本章的完整源代码可在 github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter01 获取。

本章的 Code in Action (CiA) 视频可在 bit.ly/3fPPdtb 访问。

仔细查看 Node.js 架构

Node.js 的主要基础受到了一些事物的影响:

  • 浏览器中特有的单个工作线程在服务器空间中已经相当成功。在这里,流行的 nginx 网络服务器表明,事件循环模式(在本章后面解释)实际上对性能来说是一种祝福——消除了处理请求时需要使用专用线程池的需求。

  • 将所有内容打包在一个以文件为中心的结构中称为 模块 的想法。这使 Node.js 避免了许多其他语言和框架的陷阱——包括浏览器中的 JavaScript。

  • 避免创建一个庞大的框架,并让所有内容都可通过包管理器扩展和轻松获取的想法。

线程

现代计算机提供了大量的计算能力。然而,为了使应用程序真正使用可用的计算能力,我们需要让多件事情并行工作。现代操作系统通过所谓的线程了解不同独立运行的任务。线程是一组按顺序运行的运算,这意味着按照一定的顺序。然后操作系统安排线程何时运行以及它们在哪里(即在哪个 CPU 核心上)运行。

这些原则共同构成一个看似容易创建但难以复制的平台。毕竟,有大量的 JavaScript 引擎和有用的库可用。对于 Node.js 的原始创建者和维护者 Ryan Dahl 来说,框架的基础必须非常稳固。

Ryan Dahl 选择了一个现有的 JavaScript 引擎(V8)来接管解析和运行 JavaScript 代码的责任。选择 V8 引擎有两个很好的原因。一方面,该引擎作为一个开源项目,在宽松的许可证下可用——可以被 Node.js 等项目使用。另一方面,V8 也是 Google 浏览器 Chrome 使用的引擎。它非常快,非常可靠,并且正在积极开发中。

使用 V8 的一个缺点是它是用 C++ 编写的,使用了名为 GYP 的自定义构建工具。虽然 GYP 在 V8 几年后被取代,但对于 Node.js 来说,过渡并不容易。到目前为止,Node.js 仍然依赖于 GYP 作为构建系统。V8 是用 C++ 编写的这个事实最初可能像是一个旁注,但如果你想编写所谓的 原生模块,它可能非常重要。

原生模块允许您超越 JavaScript 和 Node.js – 充分利用可用的硬件和系统功能。原生模块的一个缺点是它们必须在每个平台上构建。这与 Node.js 的 跨平台 特性相矛盾。

让我们退一步,将到目前为止提到的部分整理成一个架构图。图 1.1 展示了 Node.js 内部是如何组成的:

图 1.1 – Node.js 的内部组成

图 1.1 – Node.js 的内部组成

在 Node.js 的架构中,除了 JavaScript 引擎之外,最重要的组件是 libuv 库。libuv 是一个多平台、低级库,它基于 事件循环 提供异步 输入/输出I/O)支持。I/O 以多种形式发生,例如写入文件或处理 HTTP 请求。通常,I/O 指的是在操作系统的专用区域中处理的所有内容。

任何运行在 Node.js 上的应用程序都是用 JavaScript 或其某种变体编写的。当 Node.js 启动应用程序时,JavaScript 会被 V8 解析和评估。所有标准对象,如 console,都暴露了一些作为 Node.js API 部分的绑定。这些底层函数(如 console.logfetch)使用了 libuv。因此,仅针对语言特性(如原始计算 2 + 3)的简单脚本不需要从 Node API 中获取任何内容,并将保持与 libuv 独立。相反,一旦使用了底层函数(例如,用于访问网络的函数),libuv 就可能是其背后的劳动力。

图 1.2 中,展示了各种 API 层的框图。这个图的优点在于它揭示了 Node.js 实际上是什么:一个允许从最先进的 C/C++ 库访问底层功能的 JavaScript 运行时。Node.js API 由包含的 Node.js 绑定和一些 C/C++ 插件组成:

图 1.2 – 以构建块为单位的 Node.js 组成

图 1.2 – 以构建块为单位的 Node.js 组成

在前面的图中需要解释的一点是事件循环是如何与所有块相关联实现的。在谈论 Node.js 的内部架构时,对事件循环是什么以及为什么它对 Node.js 重要的更广泛讨论是绝对必要的。所以让我们深入了解这些细节。

理解事件循环

事件循环是一种运行时模型,它使用户能够从单个线程运行所有操作 – 不论这些操作是否访问长时间运行的外部资源。为了实现这一点,事件循环需要向事件提供者发出请求,该提供者调用指定的事件处理器。在 Node.js 中,libuv 库用于事件循环的实现。

在图 1.1 中给 libuv 留出最多的空间是为了突出这个库的重要性。内部来说,libuv 用于处理所有与 I/O 相关的事情,这可以说是任何框架中最关键的部分。I/O 使框架能够与其他资源通信,例如文件、服务器或数据库。默认情况下,处理 I/O 是以阻塞方式进行的。这意味着我们应用程序的操作序列本质上被停止,等待 I/O 操作完成。

存在两种策略来减轻阻塞 I/O 的性能影响。

第一种策略是为实际执行这些阻塞 I/O 操作创建新的线程。由于线程包含一个独立的操作组,它可以并发运行,最终不会停止应用程序原始线程中的操作。

第二种策略是完全不使用阻塞 I/O。相反,使用一种替代变体,通常称为非阻塞 I/O 或异步 I/O。非阻塞 I/O与回调一起工作,即在某些条件下被调用的函数——例如,当 I/O 操作完成时。Node.js 使用 libuv 来充分利用第二种策略。这允许 Node.js 在单个线程中运行所有代码,同时 I/O 操作并发运行。

在图 1.3 中,展示了 libuv 的构建块。关键部分是 libuv 已经包含了很多处理网络 I/O 的功能。此外,文件和 DNS 操作也得到了很好的覆盖:

图 1.3 – libuv 的构建块

图 1.3 – libuv 的构建块

除了不同的 I/O 操作外,该库还提供了一套处理异步用户代码的不同选项。

事件循环本身遵循反应器设计模式。维基百科对这种模式描述如下:

反应器设计模式是一种事件处理模式,用于处理一个或多个输入并发地向服务处理程序提交的服务请求。然后,服务处理程序将传入的请求解复用,并将它们同步地调度到相关的请求处理程序。(en.wikipedia.org/wiki/Reactor_pattern)

重要的是,这个定义提到了同步调度。这意味着通过事件循环运行的代码保证不会遇到任何冲突。事件循环确保代码总是顺序运行。尽管 I/O 操作可能并发运行,但我们的回调永远不会并行调用。从我们的角度来看,尽管 Node.js 会通过 libuv 内部使用多个线程,但整个应用程序是单线程的。

以下是一个简单的脚本,展示了事件循环的基本行为——我们将在“从命令行使用 Node.js”部分讨论如何运行此脚本:

events.js

console.log('A [start]');
setTimeout(() => console.log('B [timeout]'), 0);
Promise.resolve().then(() => console.log('C [promise]'));
console.log('D [end]');

我们将在学习 Node.js 的命令行使用时运行此脚本。在此期间,请思考前面的代码,并写下您将看到的 console 输出的顺序。您认为它会按“A B C D”的顺序打印,还是其他顺序?

libuv 中事件循环实现的算法显示在 图 1.4 中:

图 1.4 – libuv 中事件循环的实现

图 1.4 – libuv 中事件循环的实现

虽然代码片段仅处理与 JavaScript 相关的结构(例如 consolePromisesetTimeout),但通常,回调函数与超出 Node.js 范围的资源相关联,例如文件系统更改或网络请求。其中一些资源可能有操作系统等效项;而另一些则仅以阻塞 I/O 的形式存在。

因此,事件循环实现始终考虑其线程池并轮询已完成的 I/O 操作。定时器(例如示例脚本中的 setTimeout)仅在开始时运行。要确定是否需要运行定时器,需要将其到期时间与当前时间进行比较。最初,当前时间与系统时间同步。如果没有其他事情要做(即没有活跃的定时器、没有等待完成的资源等),则循环退出。

让我们看看如何运行 Node.js 以巩固我们对事件循环的了解。

使用命令行运行 Node.js

仅使用 JavaScript 开发 Web 应用程序只需在浏览器中打开网站即可。浏览器将评估包含的 JavaScript 并运行它。当您想将 JavaScript 作为脚本语言使用时,需要找到运行 JavaScript 的新方法。Node.js 提供了这种方法 – 在计算机终端、服务器上运行 JavaScript。

当安装 Node.js 时,它附带了一套可在您选择的终端中使用的命令行工具。对于本书,您需要了解我们将贯穿章节使用三个不同的可执行文件:

  • node:运行 Node.js 脚本的主要应用程序

  • npm:默认的包管理器 – 关于这一点将在后面详细介绍

  • npx:一个非常方便的实用工具,用于运行 npm 二进制文件

目前,我们只需要了解 node。如果我们想从上一节中运行 events.js 脚本,我们需要在放置脚本(events.js)的目录中执行以下命令。您可以通过仅插入上一节 events.js 列表中的内容来放置它:

$ node events.js

A [start]

D [end]

C [promise]

B [timeout]

命令显示在传统的 $ 符号之后,表示命令提示符。运行脚本的输出显示在 node events.js 命令下方。

如您所见,顺序是“A D C B” – 即 Node.js 首先处理所有顺序操作,然后再处理 promise 的回调函数。最后,处理超时回调函数。

在事件循环中处理超时回调之前处理承诺回调的原因在于事件循环。在 JavaScript 中,承诺产生所谓的微任务,这些微任务被放置在 libuv 事件循环的挂起回调部分,如图 1.4* 所示。然而,超时回调被当作一个完整任务处理。它们之间的区别在于事件循环中。微任务被放置在一个优化的队列中,实际上在每个事件循环迭代中都会被多次查看。

根据 libuv,超时回调只能在定时器到期时运行。由于我们只在事件循环的空闲处理(即主部分)中放置了它,我们需要等待事件循环的下一迭代。

node 命令行应用程序也可以接收额外的参数。官方文档详细介绍了所有细节(nodejs.org/api/cli.html)。其中一个有用的参数是 -e--eval 的简写),可以直接从命令行输入评估脚本,而无需文件运行:

$ node -e "console.log(new Date())"

2022-04-29T09:20:44.401

另一个非常有用的命令行标志是 --inspect。这打开了标准端口以进行图形检查,例如,通过 Chrome 网络浏览器。

让我们运行一个带有一些连续逻辑的应用程序,以证明检查会话的合理性。在您的机器上的终端中运行以下命令:

$ node -e "setInterval(() => console.log(Math.random()), 60 * 1000)" --inspect

Debugger listening on ws://127.0.0.1:9229/64c26b8a-0ba9-484f-902d-759135ad76a2

For help, see: https://nodejs.org/en/docs/inspector

现在我们可以运行一个图形应用程序。让我们使用 Chrome 网络浏览器。打开它并转到 chrome://inspect。这是一个特殊的 Chrome 内部 URL,允许我们查看可用的目标。

以下图(图 1.5)显示了在 Chrome 网络浏览器中检查 Node.js 应用程序可能的样子:

图 1.5 – 在 Chrome 网络浏览器中检查 Node.js 应用程序

图 1.5 – 在 Chrome 网络浏览器中检查 Node.js 应用程序

在这种情况下,Chrome 识别到我们的应用程序正在以进程 ID 3420 运行。在你的机器上,进程 ID 很可能不同。没有给出文件名,因为我们是以 -e 命令行选项开始的。

当你在命令行中点击看到的 console 输出时。

当你从 DevTools 控制台跟随到评估脚本的链接时,你将能够放置 断点 或暂停执行。暂停执行可能不会立即工作,因为这需要一个活动的 JavaScript 操作。

图 1.6 中,你可以看到在 Chrome DevTools 中调试 Node.js 脚本的样子:

图 1.6 – 在 Chrome DevTools 中调试 Node.js 脚本

图 1.6 – 在 Chrome DevTools 中调试 Node.js 脚本

在前面的例子中,JavaScript 只每分钟运行一次。当发生暂停时,你应该最终进入 Node.js 本身的 internal/timers.js 部分。这是一个不同的 JavaScript 文件,但它属于整个 Node.js 框架的一部分。该文件可以集成,因为它遵循某些称为 CommonJS 的约定和规则。

CommonJS

Node.js 从一开始就做得正确的一件事是引入了一种明确获取和使用功能的方式。浏览器中的 JavaScript 患有 全局作用域 问题,这给开发者带来了许多麻烦。

全局作用域

在 JavaScript 中,全局作用域指的是可以从同一应用程序中运行的任何脚本访问的功能。在一个网站上,全局作用域通常与 window 变量相同。将变量附加到全局作用域可能很方便,有时甚至必要,但它也可能导致冲突。例如,两个独立的函数都可能尝试从同一变量中写入和读取。结果的行为可能很难调试,并且非常难以解决。标准建议是尽可能避免使用全局作用域。

当 Node.js 介绍时,其他功能明确导入的想法当然并不新鲜。虽然其他编程语言或框架中存在导入机制已有很长时间,但类似选项也已在浏览器中的 JavaScript 中可用——通过第三方库如 RequireJS

Node.js 以 CommonJS 的名称引入了其 模块系统。Node.js 实现的基础实际上是一个在 Mozilla 开发的项目。在那个项目中,Mozilla 一直在研究一系列提案,最初是针对非浏览器使用,但后来扩展到了一个通用的 JavaScript 模块系统规范集。

CommonJS 实现

除了在 Node.js 中的实现,许多其他运行时或框架也使用 CommonJS。例如,可以在 MongoDB 数据库中使用的 JavaScript 利用的是基于 CommonJS 规范的模块系统。Node.js 中的实现实际上只是部分满足完整规范。

模块系统对于以非常透明和明确的方式包含更多功能至关重要。除了更多高级功能之外,模块系统还给我们以下功能:

  • 一种包含更多功能的方式(在 CommonJS 中,通过全局 require 函数)

  • 一种暴露功能的方式,然后可以在其他地方包含它(在 CommonJS 中,通过模块特定的 moduleexports 变量)

CommonJS 的工作方式在本质上非常简单。想象你有一个名为 a.js 的文件,其中包含以下代码:

const b = require('./b.js');
console.log('The value of b is:', b.myValue);

现在 Node.js 的任务就是真正使这个工作得以实现,也就是说,给 b 变量赋予一个代表模块所谓导出的值。目前,脚本会报错说缺少 b.js 文件。

b.js 文件,它应该与 a.js 邻近,内容如下:

exports.myValue = 42;

当 Node.js 评估文件时,它会记住定义的导出。在这种情况下,Node.js 会知道 b.js 实际上只是一个具有 myValue 键,值为 42 的对象。

a.js 的角度来看,代码可以读作如下:

const b = {
  myValue: 42,
};
console.log('The value of b is:', b.myValue);

使用模块系统的优势是不需要再次编写模块的输出。require调用为我们做了这件事。

副作用

用模块的输出替换require调用只是为了说明目的。通常情况下,这是不可能的,因为模块评估可能会有一些所谓的副作用。如果我们用导入模块的导出进行require调用,我们不会运行副作用,这会错过模块的一个关键方面。

在给定的例子中,我们直接使用了文件名,但导入模块可能比这更微妙。让我们看看代码的改进版本:

a.js

const b = require('./b');
console.log('The value of b is:', b.myValue);

./b.js的调用已被替换为./b。这仍然会工作,因为 Node.js 会尝试给定的导入的各种组合。它不仅会附加某些已知的扩展名(如.js),还会检查b是否实际上是一个包含index.js文件的目录。

因此,根据前面的代码,我们实际上可以将b.jsa.js相邻的文件移动到相邻目录b中的index.js文件。

然而,最大的优势是这种语法还允许我们从第三方包中导入功能。正如我们将在第二章中探讨的,将代码划分为模块和包,我们的代码必须划分为不同的模块和包。一个包含一组可重用的模块。

Node.js 自带了一组不需要安装的包。让我们看看一个简单的例子:

host.js

const os = require('os');
console.log('The current hostname is:', os.hostname());

在前面的例子中,我们使用了集成的os包来获取当前计算机的网络名称。

我们可以在命令行中使用node运行这个脚本:

$ node host.js

The current hostname is: DESKTOP-3JMIDHE

这个脚本在安装了 Node.js 的任何计算机上都能工作。

摘要

在本章中,我们首次了解了 Node.js。你现在应该对 Node.js 构建的核心原则(如事件循环、线程、模块和包)有一个很好的了解。你已经了解了一些关于 Node.js 的历史以及为什么选择 V8 作为 JavaScript 引擎的原因。

从本章中我们可以学到的一个重要内容是如何事件循环工作。请注意,这部分知识并不局限于 Node.js。微任务和任务之间的区别是 JavaScript 引擎(甚至是你浏览器的 JavaScript 引擎)工作方式的一个基本组成部分。

最后,你现在已经准备好使用node命令行应用程序了,例如,运行或调试简单的脚本,这些脚本可以使用 CommonJS 模块系统导出和导入功能。你学习了如何使用 Chrome 网络浏览器检查 Node.js 脚本,就像检查网站一样。

在下一章中,我们将通过学习如何高效地将代码划分为模块和包来增加我们对 CommonJS 的了解。

第二章:将代码划分为模块和包

在编写专业软件时,考虑最重要的方面之一是复用性。复用性意味着我们的代码库的某些部分可以在多个地方或不同情况下使用。这意味着我们实际上可以非常容易地使用现有的功能。

正如我们所学的,Node.js 成功故事的关键部分在于它自带了一个模块系统。到目前为止,我们只触及了 CommonJS 的基本概念,这是从模块中导入和导出功能的方式。

在本章中,我们将有机会熟悉更多模块格式,包括它们的历史、用例和开发模型。我们将学习如何高效地将我们的代码划分为模块和包。除了学习 CommonJS,我们还将了解什么是包以及我们如何定义自己的包。总的来说,这将帮助我们实现高度的复用性——不仅适用于 Node.js 中的工具,也适用于在浏览器中运行的应用程序。

本章我们将涵盖以下关键主题:

  • 使用 ESM 标准

  • 学习 AMD 规范

  • 兼容 UMD

  • 理解 SystemJS 和导入映射

  • 了解 package.json 的基础知识

技术要求

本章的完整源代码可以在 github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter02 找到。

本章的 CiA 视频可以通过 bit.ly/3FZ6ivk 访问。

使用 ESM 标准

CommonJS 对于 Node.js 来说是一个不错的解决方案,但并不是作为语言而言的期望解决方案。例如,在浏览器中,CommonJS 不起作用。在 URL 上进行同步导入是不可能的。CommonJS 的模块解析在添加扩展和尝试目录方面也过于灵活。

为了在 JavaScript 中标准化模块,require 整个模块系统依赖于使用保留字的语言结构。这样,模块系统也可以被带到浏览器中。

ECMAScript 标准为此指定了两个关键字:

  • import:用于从其他模块导入功能

  • export:用于声明可以被其他模块导入的功能

import 关键字必须出现在文件的开头——在所有其他代码之前。这种选择的原因在于 ESM 文件的需求,不仅要在 Node.js 中使用,还要在浏览器中使用。通过将 import 语句放在顶部,每个 ESM 文件可以安全地等待所有导入都已解析。

将上一章的示例重写,我们得到以下 a.js

import * as b from './b.js'; // get all things from b.js
// use imports
console.log('The value of b is:', b.myValue);

根据 ESM 标准将 b.js 文件重写为有效格式如下:

export const myValue = 42;

import 关键字有多种可能性。我们可以使用以下方法:

  • 由开发者选定的通配符(使用*)导入

  • 命名导入,例如myValue

  • 由开发者选定的具有名称的默认导入

  • 一个空的导入,不获取任何内容,但确保运行模块

使用命名导入,我们可以得到a.js的一个更干净的版本:

// get only selected things
import { myValue } from './b.js';
console.log('The value of b is:', myValue); // use imports

上述代码与解构赋值非常相似,它使用赋值运算符(=)将对象分解为其字段。然而,存在一些关键的区别。其中之一是如何创建别名。

例如,当使用解构赋值时,我们可以使用冒号(:)来重命名变量,这些变量默认会有相应字段的名称。如果我们想给变量一个不同于其原始字段(例如,myValue)的名称(例如,otherValue),我们必须编写以下内容:

// gets all the things, but only uses myValue
const { myValue: otherValue } = require('./b.js');

使用import语句时,你需要使用as关键字来实现这一点:

// gets only myValue – but renames it
import { myValue as otherValue } from './b.js';

一个很快就会变得相关的主题是默认导出的概念。特别是在处理未知模块的导出时,定义导出名称的需求非常强烈。因此,在 CommonJS 中,开发者选择了整个模块;然而,在 ESM 中这不再可能。每个导出都需要命名。

幸运的是,标准化委员会考虑了默认导出的主题。如果一个导出使用了default关键字,它就被认为是默认导出。例如,将b.js中的导出更改为使用默认值可能看起来如下所示:

export default 42;

导入默认导出也非常方便。在这里,我们可以在我们的模块内部自由选择一个名称来引用默认导出。而不是能够重命名导入,我们被迫给它一个名称:

import otherValue from './b.js'; // gets only default
console.log('The value of b is:', otherValue);

整个想法是尽可能多地使用默认导出。最终,那些有效编写且围绕导出单个功能构建的模块通常被认为是目标。

我们已经了解到 CommonJS 在浏览器中不起作用。相比之下,现代 ESM 规范应该可以工作,因为导入在开始时就已经声明。这种修改允许浏览器在导入完全处理之前安全地挂起模块评估。这种等待依赖项加载完成的挂起实际上是从另一个名为异步模块定义AMD)的模块系统尝试中借鉴的。

学习 AMD 规范

在 ESM 建立之前,人们也尝试让模块在浏览器中工作。最早的尝试之一是一个名为文档<head>的小型库。然后脚本会加载并运行一个定义的根模块,该模块会处理更多的模块。

使用 RequireJS 的一个示例网站如下所示:

<!DOCTYPE html>
<html>
  <head>
    <title>My Sample Project</title>
    <!--
      data-main attribute tells RequireJS to load
      ./main.js after ./require.js has been loaded
    -->
    <script data-main="./main" src="img/require.js"></script>
  </head>
  <body></body>
</html>

RequireJS 诞生于 JavaScript 世界尚未确立 promises(承诺)的时期。因此,模块加载器基于下一个最佳选择:回调函数。因此,通过调用由 RequireJS 定义的requirejs函数来加载模块。整个过程可以像图 2.1所示的那样异步加载模块。

图 2.1 – 按顺序加载模块与异步加载模块

图 2.1 – 按顺序加载模块与异步加载模块

为了实现异步模块加载,requirejs函数接受两个参数。第一个参数是一个包含所有依赖项的数组。第二个参数是一个回调函数,它接收依赖项的导出并返回当前模块的导出。

RequireJS 背后的整个理念与今天的 ESM(模块化 JavaScript)非常相似,它将两个部分(加载依赖项和使用依赖项的代码)合并到同一个模块中——但仍然区分import语句和其他所有语句。在这里,ESM 利用了它实际上是一个语言构造的事实。

简而言之,使用 RequireJS 的模块看起来如下:

requirejs(['./helper/util'], (util) => {
  // This is called when ./helper/util.js. has been processed
});

这些模块的形状并非任意决定。相反,RequireJS 库只是异步模块系统规范的一个实现。这个规范被称为 AMD。

使用 AMD(异步模块定义),之前的 RequireJS 特定示例可以重写如下:

define(['./helper/util'], (util) => {
  // This is called when ./helper/util.js. has been processed
});

除了define函数的双参数版本之外,还有一个三参数版本,其中第一个参数有助于命名定义的模块。

这里展示了define函数的三参数调用示例:

define('myModule', ['dep1', 'dep2'], (dep1, dep2) => {
  // Define the module exports by returning a value.
  return {};
});

现在,在我们能够普遍使用 AMD 之前,只剩下学习如何将其集成到 Node.js 中。首先,我们需要从官方下载页面获取r.jsrequirejs.org/docs/download.html#rjs。通过图 2.2中显示的下载按钮下载它:

图 2.2 – RequireJS 网站上的 r.js 下载按钮

图 2.2 – RequireJS 网站上的 r.js 下载按钮

将下载的文件存储在您通过node运行脚本的目录中。在同一个目录中创建一个新的a.js脚本:

a.js

const define = require('./r.js'); // gets the loader
define.config({
  // Will also correctly resolve other Node.js dependencies
  nodeRequire: require
});
define(['./b'], (b) => {
  console.log('The value of b is:', b.myValue);
});

这里的代码与 CommonJS 示例看起来并没有太大区别。毕竟,只是添加了 RequireJS 加载器的初始化。现在,模块的实际内容现在是回调函数的一部分。

让我们看看转换后的b.js是什么样子:

b.js

const define = require('./r.js'); // gets the loader
define.config({
  // Will also correctly resolve other Node.js dependencies
  nodeRequire: require
});
define([], () => {
  return {
    myValue: 42,
  };
});

b.js的前面代码中,我们又添加了与a.js相同的包装,记住每个模块都需要被视为独立的代码。虽然这样做可能看起来有些冗余,但一旦与未知数量的其他模块一起使用,真正的优势就变得明显。在这种情况下,我们永远不知道已经加载或使用了什么。独立意味着在这些场景中是可预测的。

之前的方法的问题是,尽管它在 Node.js 中有效,但在浏览器中肯定不起作用。尽管我们选择了 AMD,但未能使其在浏览器中工作。问题在于对require的初始调用,它使用 CommonJS 来获取 AMD 加载器。

为了减轻这个问题,并在不同的 JavaScript 环境中使用 AMD,创建了通用模块定义UMD)规范。

使用 UMD 实现通用性

当 UMD 规范被提出时,社区中有很多炒作。毕竟,标签通用已经声称 UMD 是最终的模块系统——统治所有模块的系统。它试图通过支持本质上三种不同的 JavaScript 模块格式来实现这一点:

  • 没有模块系统做事的经典方式——也就是说,通过在浏览器中使用<script>标签运行 JavaScript

  • Node.js 使用的 CommonJS 格式

  • 之前讨论的来自 AMD 规范的异步加载模块

当你以 UMD 规范编写 JavaScript 文件时,你实际上确保了每个流行的 JavaScript 运行时都可以读取它。例如,UMD 在 Node.js 和浏览器中工作得非常好。

为了实现这种通用性,UMD 会做出一个关于可以使用哪种模块系统的合理猜测,并选择它。例如,如果检测到define函数,那么可能会使用 AMD。或者,检测到exportsmodule等,则指向 CommonJS。如果没有发现任何东西,那么假设该模块在没有 AMD 的情况下在浏览器中运行。在这种情况下,模块的导出将被存储在全局范围内。

UMD 的主要目标群体是库作者。当你构建一个库时,你希望它是有用的。因此,你还需要确保库可以被使用。通过以 UMD 格式提供你的库,你确保它可以在几乎所有平台上使用——在 Node.js 和浏览器中。

那么,如果我们选择 UMD 作为首选格式,之前的示例代码会是什么样子呢?让我们看看:

a.js

((root, factory) => { // context and export callback
  if (typeof define === 'function' && define.amd) {
    // there is a define function that follows AMD – use it
    define(['b'], factory);
  } else if (typeof exports === 'object' && typeof module
    !== 'undefined') {
    // there is module and exports: CommonJS
    factory(require('b'));
  } else {
    // we just take the global context
    factory(root.b);
  }
})(typeof self !== 'undefined' ? self : this, (b) => {
  // this is the body of the module, follows AMD
  console.log('The value of b is:', b.myValue);
});

与之前一样,前面的代码由两部分组成。第一部分建立模块系统并设置回调。第二部分将我们模块的实际内容放入回调中。

剩下的就是看看我们如何用 UMD 标记我们的导出。对于这部分,我们将查看 UMD 格式的b.js

b.js

((root, factory) => {
  if (typeof define === 'function' && define.amd) {
    // in AMD we depend on the special "exports" dependency
    define(['exports'], factory);
  } else if (typeof exports === 'object' && typeof module
    !== 'undefined') {
    // in CommonJS we'll forward the exports
    factory(exports);
  } else {
    // for scripts we define a new global and forward it
    factory(root.b = {});
  }
})(typeof self !== 'undefined' ? self : this, (exports) =>
{
  // use the CommonJS format in here
  exports.myValue = 42;
});

在所有样板代码就绪后,脚本就变得通用。定义的回调(在本节两个示例中命名为factory)要么由 AMD 运行时间接调用,要么在其他两种情况下直接调用。

通常,我们不会自己编写这里显示的整个样板代码。样板代码将由工具生成,我们将在第六章,“使用打包器构建 Web 应用”中探讨这一点。但在许多情况下,编写模块的理想选择是 ESM。由于它是基于语法的,我们遵循语言的标准。其他格式然后可以作为输出格式由我们的工具使用。

另一个需要更仔细查看的模块格式是 SystemJS。SystemJS 有趣的一个原因是它带来了对导入映射的支持,这可以简化处理模块系统。

理解 SystemJS 和导入映射

在本章的早期,我们了解到 ESM 可能是 JavaScript 最好的模块系统。毕竟,它是集成到 JavaScript 语言中的。其他格式今天仍然相关的一个原因之一是向后兼容性。

向后兼容性允许像 AMD 或 UMD 这样的格式在较老的 JavaScript 运行时中使用,例如较老的浏览器版本,如 Internet Explorer,即使我们不需要向后兼容性,这些替代格式仍然比 ESM 具有一个或多个优势。

ESM(模块系统)的一个核心问题在于它没有定义模块是如何被解析的。实际上,解析模块的唯一指定方式是通过文件系统显式进行。当我们使用 ESM 时,我们会显式地声明我们的模块导入,例如在./b.js中。正如提到的,我们不允许隐式地使用像./b或仅仅是b这样的东西。

在做前端开发时,依赖性的概念已经变得相当基础。从样板库到 UI 框架,前端开发者使用大量现成的代码。这些代码通常被打包成库,然后为了开发目的在本地安装,但应该如何使用这些依赖项呢?

事实上,Node.js 在其开发的早期阶段就已经解决了这个问题。我们已经看到,使用 CommonJS,我们可以编写如下代码:

host-cjs.js

const os = require('os');
console.log('The current hostname is:', os.hostname());

os的引用是通过 Node.js 通过 CommonJS 解析的。在这个特殊情况下,引用指向 Node.js 的一个框架库。然而,它也可能指向我们安装的第三方依赖。在第三章,“选择包管理器”中,我们将看到这是如何工作的。

让我们将前面的代码翻译成 ESM:

host-esm.js

import { hostname } from 'node:os';
console.log('The current hostname is:', hostname());

转换这个小片段并不复杂,除了模块名称。之前,我们使用os作为标识符。Node.js 选择为了向后兼容也允许这样做——至少目前是这样。然而,首选的方式是使用自定义协议。在 Node.js 框架库的情况下,已经选择了node:协议。

在浏览器中利用自定义协议解析依赖项是可能的。然而,这也相当繁琐。毕竟,整个解析现在需要我们来做。这也代表了一个经典的“先有鸡还是先有蛋”的问题。要定义自定义协议,我们需要运行一些 JavaScript;然而,如果这段 JavaScript 依赖于通过自定义协议实际解析的第三方依赖项,那么我们就无法成功实现依赖项的解析。

我们仍然可以使用像os这样的方便引用的一种方法是为所谓的导入映射定义。导入映射帮助浏览器将模块名称映射到实际的 URL。它使用imports字段。

以下是一个导入映射,用于查找os模块的实现:

{
  "imports": {
    "os": "https://example.com/js/os.min.js"
  }
}

URL 不需要完全限定。在相对 URL 的情况下,模块的 URL 是从导入映射的基本 URL 计算得出的。

将导入映射集成到网站中相对简单。我们只需要指定一个类型为importmap<script>标签:

<script type="importmap">
{
  "imports": {
    "os": "https://example.com/js/os.min.js"
  }
}
</script>

此外,导入映射也可以从外部文件加载。无论如何,指定的模块名称到 URL 的映射仅适用于import语句。在其他需要 URL 的地方它将不起作用。例如,以下示例不起作用:

fail.html

<script type="importmap">
{
  "imports": {
    "/app.mjs": "/app.8e0d62a03.mjs"
  }
}
</script>
<script type="module" src="img/app.mjs"></script>

在之前的代码中,我们尝试直接加载/app.mjs,这将失败。我们需要使用一个import语句:

success.html

<script type="importmap">
{
  "imports": {
    "/app.mjs": "/app.8e0d62a03.mjs"
  }
}
</script>
<script type="module">import "/app.mjs";</script>

关于导入映射可以写很多内容;然而,目前最重要的细节是它们只部分工作——也就是说,在没有外部文件的情况下,在最新版本的Google Chrome89及以上)和Microsoft Edge89及以上)中。在大多数其他浏览器中,导入映射的支持要么不存在,或者必须显式启用。

另一种选择是使用 SystemJS。SystemJS 是一个类似于 RequireJS 的模块加载器。主要区别在于 SystemJS 提供了对多个模块系统和模块系统功能的支持,例如使用导入映射。

虽然 SystemJS 也支持各种格式,如 ESM,但它也有自己的格式。不深入细节的话,一个原生的 SystemJS 模块的形状如下:

System.register(['dependency'], (_export, _context) => {
  let dependency;
  return {
    setters: [(_dep) => {
      dependency = _dep;
    }],
    execute: () => {
      _export({
        myValue: 42,
      });
    },
  };
});

之前的代码在结构上与 AMD 模板非常相似,唯一的区别在于回调的结构。虽然 AMD 在回调中运行模块的主体,但 SystemJS 在回调中指定了更多的部分。这些部分将在需要时运行。模块的实际主体定义在返回的execute部分中。

如前所述,简短的片段已经很好地说明了 SystemJS 模块很少是手动编写的。相反,它们是由工具生成的。因此,一旦我们有了更强大的工具来自动化创建有效 SystemJS 模块的任务,我们就会再次回到 SystemJS。

现在我们已经足够了解库和包了,我们还需要知道如何定义我们自己的包。为了指示一个包,必须使用 package.json 文件。

了解 package.json 基础知识

多个模块的聚合形成一个包。一个包由目录中的 package.json 文件定义。这标志着目录是包的根目录。表示包的最小有效 package.json 如下所示:

package.json

{
  "name": "my-package",
  "version": "1.0.0"
}

一些字段,例如 nameversion,具有特殊含义。例如,name 字段用于给包命名。Node.js 有一些规则来决定什么是一个有效的名称,什么不是。

目前,只需知道有效的名称可以用小写字母和破折号组成。由于包名称可能出现在 URL 中,因此包名称不允许包含任何非 URL 安全字符。

version 字段必须遵循 语义版本控制semver)的规范。GitHub 仓库 github.com/npm/node-semver 包含了 Node.js 的实现和许多有效版本的示例。更重要的是,semver 还允许你使用范围表示法选择匹配的版本,这对于依赖项非常有用。

Semver

除了版本标识符的规则和约束之外,semver 的概念用于在更新依赖项时明确地与包用户沟通更改的影响。根据 semver,版本的三个部分(X.Y.Z – 例如,1.2.3)各有不同的用途。

前导数字(X)是主版本,它表示兼容性级别。中间的数字(Y)是次要版本,它表示功能级别。最后,最后一个数字(Z)是补丁级别,它对于热修复很有用。通常,补丁级别的更改应该始终应用,而功能级别的更改是可选的。兼容性级别的更改绝不应该自动应用,因为它们通常涉及一些重构。

默认情况下,如果同一目录下包含一个 index.js 文件,那么这个文件被认为是包的 mainrootentry 模块。或者,我们可以使用 main 字段来指定包的主模块。

要将包的主模块位置更改为位于 lib 子目录中的 app.js 文件,我们可以编写以下内容:

package.json

{
  "name": "my-package",
  "version": "1.0.0",
  "main": "./lib/app.js"
}

此外,package.json可以用来包含有关包本身的元数据。这对包的用户非常有帮助。有时,这些元数据也用于工具中——例如,自动打开包的网站或问题跟踪器,或显示同一作者的其他包。

在最有用的元数据中,我们有以下内容:

  • description: 包的描述,它将在列出该包的网站上显示。

  • license: 使用有效的ISC OR GPL-3.0等许可协议也是可能的。这些将在列出该包的网站上显示。

  • author: 可以是一个简单的字符串,或者是一个包含作者信息(例如,nameemailurl)的对象。这些信息将在列出该包的网站上显示。

  • contributors: 实际上是一个作者或以某种方式为该包做出贡献的人的数组。

  • repository: 一个包含代码仓库urltype(例如,git)的对象——即包的源代码存储和维护的地方。

  • bugs: 一个用于报告问题和提出功能请求的问题跟踪器的 URL。

  • keywords: 一个可以用来对包进行分类的单词数组。这对于查找包非常有用,并且是搜索引擎的主要来源。

  • homepage: 包的网站 URL。

  • funding: 一个包含包财务支持平台urltype(例如,patreon)的对象。此对象也集成到显示包的工具和网站上。

在处理第三方包时,还有一些其他字段是必须指定的。当我们在详细讨论包管理器时,我们将在第三章中介绍这些内容,即选择包管理器

摘要

在本章中,你了解了一系列不同的模块格式,作为 CommonJS 模块格式的替代方案。你被介绍到了编写 ESMs 的当前标准方法,这直接将模块系统引入 JavaScript 语言。

你还看到了如何使用 AMD 或 UMD 等替代模块格式在较旧的 JavaScript 运行时上运行 JavaScript 模块。我们讨论了通过使用专门的模块加载器 SystemJS,你实际上可以充分利用作为当今网络标准的真正便捷和当前的功能。在讨论第三方依赖项时,导入映射的需求尤为明显。

你了解到大多数第三方依赖项实际上是以包的形式部署的。在本章中,你也看到了package.json文件如何定义包的根以及可能包含在package.json文件中的数据类型。

在下一章中,我们将学习如何使用被称为包管理器的特殊应用程序来安装和管理使用所讨论格式的包。我们将了解这些包管理器在底层是如何运作的,以及我们如何利用它们来提升我们的开发体验。

第三章:选择包管理器

到目前为止,我们已经对 Node.js 和其内部模块有了一些了解。我们还开始编写自己的模块,但要么避免使用第三方包,要么绕过使用第三方包。

Node.js 的一大优点是使用他人的代码实际上相当容易。这样做的方法直接引我们到包管理器。包管理器帮助我们处理包含可用于 Node.js 的模块的包的生命周期。

在本章中,我们将学习 Node.js 的实际标准包管理器 npm 的工作原理。然后我们将继续学习其他包管理器,例如 Yarnpnpm。它们在可用性、性能或可靠性方面都承诺提供一些优势。我们将更深入地研究它们,以了解这些优势以及谁可能从使用每个不同的包管理器中受益。最后,我们还将探讨替代方案。

本章将帮助您在代码中使用第三方库。第三方依赖将使您更高效、更专注,而包管理器将有助于安装和更新第三方依赖。到本章结束时,您将了解最重要的包管理器,以及您在项目背景下想选择哪一个。

我们在本章中将涵盖以下关键主题:

  • 使用 npm

  • 使用 Yarn

  • 使用 pnpm

  • 更多替代方案

技术要求

本章的一些代码示例可在 github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter03 找到。

本章的 CiA 视频可通过 bit.ly/3TmZr22 访问。

使用 npm

当您从官方来源安装 Node.js 时,您将获得比 Node.js 更多的东西。为了方便起见,Node.js 还会将一些额外的程序和设置添加到您的系统中。其中最重要的添加之一是一个名为 npm 的工具。最初,npm 的缩写是 Node.js Package Manager,但如今,它基本上是一个独立的名称。

npm 的目标是让开发者能够管理第三方依赖。这包括安装和更新包,以及处理它们的版本和传递依赖。当安装的依赖项也包括依赖项时,就会建立传递依赖,因此也需要安装这些依赖项。

为了让 npm 了解存在哪些依赖以及它们的依赖关系,创建了 npm 注册表。它是一个托管在文件服务器上的所有包的 Web 服务。

更改使用的 npm 注册表

现在,存在许多 npm 注册表——但默认情况下,只有位于registry.npmjs.org/的官方注册表被使用。要一致地更改注册表,需要创建一个特殊的文件,.npmrc。如果该文件创建在主目录中,那么更改将适用于所有使用情况。否则,此文件也可以创建在package.json旁边——仅适用于指定的项目。最后,要仅临时使用另一个注册表,可以使用--registry命令行标志。.npmrc文件的格式在docs.npmjs.com/cli/v8/configuring-npm/npmrc中概述。

要使用 npm 注册表中的包,我们需要使用npm命令行工具。实际上,当我们复制或克隆 Node.js 项目的源代码时,我们应该首先在项目package.json所在的目录中运行npm install

$ npm install

这将安装package.json中提到的所有运行时和开发依赖项的包。这些包将从配置的 npm 注册表中下载,然后存储在node_modules目录中。避免将node_modules目录添加到源控制中是一种良好的做法。例如,对于 Git,你应该将node_modules添加到你的仓库的.gitignore文件中。这样做有几个原因——例如,安装可能是平台特定的,或者安装可能无论如何都是可重复的。

npm命令行工具自带一系列集成命令——例如之前展示的install命令。要查看可用的命令,可以使用--help标志来使用该工具:

$ npm --help

Usage: npm <command>

where <command> is one of:

    access, adduser, audit, bin, bugs, c, cache, ci, cit,

    clean-install, [...], v, version, view, whoami

npm <command> -h  quick help on <command>

--help标志也可以与特定命令结合使用。如果你想了解install命令有哪些选项,只需输入以下内容:

$ npm install --help

npm install (with no args, in package dir)

[...]

npm install <github username>/<github project>

aliases: i, isntall, add

common options: [--save-prod|--save-dev|--save-optional] [--save-exact] [--no-save]

获取特定上下文帮助的原则对于许多命令行工具至关重要。在本章中我们将要探讨的所有包管理器都具备这种功能。最终,对我们这些用户来说,这带来了一些优势。我们无需每次都查找在线文档、其他书籍或教程来查看命令的语法,我们只需在针对我们使用的特定版本定制的命令行中直接获取所有所需信息。

一个非常有用的命令是init。虽然install对于现有项目来说很棒,但init可以用来创建一个新的项目。当你运行npm init时,你将引导通过一系列选项,就像进行一次调查。结果如下所示:

$ npm init

package name: (my-project)

version: (1.0.0)

description: This is my new project

git repository:

author: Florian Rappl

license: (ISC) MIT

About to write to /home/node/my-project/package.json:

{

  "name": "my-project",

  "version": "1.0.0",

  "description": "This is my new project",

  "keywords": [],

  "scripts": {

    "test": "echo \"Error: no test specified\" && exit 1"

  },

  "main": "index.js",

  "author": "Florian Rappl",

  "license": "MIT"

}

Is this OK? (yes) yes

另一种选择是指定-y标志。这样,所有默认值都将被采用——如果你只是想初始化一个新的项目,这是一个更快的选择。

npm 的初始化函数甚至可以被扩展。如果你在npm init之后提供了另一个名称,那么 npm 将尝试使用create-前缀查找一个包。例如,当你运行npm init react-app时,npm 将查找名为create-react-app的包并运行它。运行一个包指的是在包的package.json文件中查找bin字段,并使用给定的引用启动一个新的进程。

如果你想要向你的项目中添加依赖项,你也可以使用npm install。例如,将 React 作为依赖项添加是npm install react

依赖项的生命周期还要求我们了解依赖项何时过时。为此,npm 提供了 npm outdated 命令:

$ npm outdated

Package                  Current   Wanted   Latest  Location

@types/node              16.11.9  17.0.40  17.0.40  pilet-foo

react                     17.0.2   17.0.2   18.1.0  pilet-foo

typescript                 4.5.2    4.7.3    4.7.3  pilet-foo

命令仅显示比当前安装版本更新的软件包。在某些情况下,这是可以接受的——也就是说,当当前版本与所需版本相匹配时。在其他情况下,运行 npm update 实际上会更新安装的版本。

使用不同版本的 npm

npm 已经与 Node.js 打包在一起。因此,Node.js 的每个版本也会选择一个 npm 版本。例如,Node.js 14 捆绑了 npm 6。在 Node.js 15 中,包含了 npm 7。从 Node.js 16 开始,你将获得 npm 8。保持灵活的一种方法是使用nvm。nvm 是一个小工具,允许你选择要使用的 Node.js 版本。它还可以用来更改默认版本,快速更新和安装新的 Node.js 和 npm 版本。更多信息请访问github.com/nvm-sh/nvm

npm 还提供许多有用的便捷功能——例如,提高安全性。npm audit命令将当前安装的软件包与包含安全漏洞的在线数据库进行比对。通常,在易受攻击的软件包中的修复只需一个npm audit --fix标志的调用即可。此外,使用如npm view之类的命令——例如,在npm view react中——我们可以直接与包含大多数公开可用软件包的 npm 注册表进行交互。

虽然 npm 注册表是软件包的绝佳来源,但 npm 命令行工具并非使用它的唯一方式。实际上,该网络服务的 API 是公开的,任何人——或者任何程序——都可以使用。

首批使用 npm 注册表公开 API 的公司之一是 Facebook。他们在大型项目中遇到了安装时间慢的问题,并希望通过提供一个更好的算法来实际解决项目的依赖项——特别是传递依赖项。结果是出现了一个名为Yarn的新包管理器。

使用 Yarn

原始 npm 包解析算法的问题在于它是以一种弹性但天真的方式创建的。这并不意味着算法很简单。相反,这里我们指的是没有考虑过任何异国情调的技巧或经验优化。它不是试图优化(即降低)本地磁盘上可用的包的数量,而是设计成将包放入与它们声明时相同的层次结构中。这导致了一个如 图 3**.1 所示的文件系统视图:

图 3.1 – 使用 npm 安装包后的示例文件系统快照

图 3.1 – 使用 npm 安装包后的示例文件系统快照

处理包安装的简单方式当然是一种确保一切安装正确无误的绝佳方法,但在性能方面并不理想。查看 图 3**.1,可能有一些优化空间。

让我们在 图 3**.1 中添加一些示例包名和版本,以查看优化的机会。在 图 3**.2 中,显示了相同的快照——只是带有示例包名:

图 3.2 – 使用 npm 安装后带有示例包名的文件系统快照

图 3.2 – 使用 npm 安装后带有示例包名的文件系统快照

不同于重复使用 bar 依赖,它只需使用一次。另一方面,由于版本冲突,foo 依赖必须重复。其他传递依赖,如 abcdef,可以被提升到顶级。

结果图像显示在 图 3**.3 中。尽可能简化了结构。这种优化是 Yarn 第一版的关键。实际上,它非常成功,npm 也改进了其算法。今天,npm 以类似 图 3**.3 中所示草图的方式解决包:

图 3.3 – 使用 Yarn 安装后带有示例包名的文件系统快照

图 3.3 – 使用 Yarn 安装后带有示例包名的文件系统快照

对于 Yarn 团队来说,获得的优化并不足够。他们开始寻找不同的方法来进一步提高。然而,他们越寻找,就越确信需要一些全新的东西来做出任何进一步的改进。

结果是通过引入 node_modules 目录来实现的。相反,创建了一个名为 .pnp.cjs 的特殊文件,以提供有关如何解决依赖项的信息。有了 .pnp.cjs 文件,每个包都可以被解决——就像之前的 node_modules 一样。

包的具体位置取决于项目的设置。在 Yarn 2 中,引入了一个名为 zero-installs 的新概念。这样,每个依赖项都将可在项目内部使用——只是在 .yarn/cache 子文件夹中。要真正实现零安装,.yarn 文件夹应该被提交到源代码控制。现在,当项目被克隆时,不需要执行安装。依赖项已经是存储库的一部分。

虽然大多数命令非常相似,但 Yarn 在添加新依赖项方面采取了不同的方法。在这里,依赖项是通过 yarn add 添加的——例如,yarn add react。使用 yarn 命令行工具安装包与之前使用 npm 的用法相当,尽管如此:

$ yarn install

 YN0000: ┌ Resolution step

 YN0000: └ Completed in 0s 634ms

 YN0000: ┌ Fetch step

 YN0013: │ js-tokens@npm:4.0.0 can't be found in the cache and will be fetched from the remote registry

 YN0013: │ loose-envify@npm:1.4.0 can't be found in the cache and will be fetched from the remote registry

 YN0013: │ react-dom@npm:18.1.0 can't be found in the cache and will be fetched from the remote registry

 YN0013: │ react@npm:18.1.0 can't be found in the cache and will be fetched from the remote registry

 YN0013: │ scheduler@npm:0.22.0 can't be found in the cache and will be fetched from the remote registry

 YN0000: └ Completed

 YN0000: ┌ Link step

 YN0000: └ Completed

 YN0000: Done in 0s 731ms

图 3.4 中,使用前一个示例展示了新的 PnP 机制。通过使用由包名和版本组成的完全限定名称,创建了唯一标识符,允许在扁平结构中定位同一包的多个版本。

PnP 机制的一个缺点是自定义的解析方法,这需要在 Node.js 中进行一些修补。Node.js 的标准解析机制使用 node_modules 来实际查找包内的模块。自定义解析方法教会 Node.js 使用不同结构的不同目录来查找模块:

图 3.4 – 使用 Yarn PnP 安装后的文件系统快照,包含示例包名

图 3.4 – 使用 Yarn PnP 安装后的文件系统快照,包含示例包名

虽然对于许多包来说,使用自定义解析方法不是问题,但某些包可能依赖于涉及 node_modules 的经典结构,其中包仅解析为目录和文件。然而,在 PnP 中,结构是扁平的,每个包都是一个压缩存档。

到目前为止,许多插件和补丁可以使包与 PnP 兼容。许多——尤其是不太受欢迎的——包仍然不能与 PnP 一起使用。幸运的是,Yarn 3 解决了许多这些问题,为大多数有问题的包提供了一个兼容模式。最终,这仍然主要是一个试错的问题。幸运的是,Yarn PnP 不是唯一可以加快 npm 速度的解决方案。

在 Yarn 2 的 PnP 发布之前,其他开发者已经开始考虑替代策略来加快安装时间并节省网络带宽和存储容量。最著名的尝试是一个名为 pnpm 的实用工具。

使用 pnpm

pnpm 的方法有点像 npm 的原始包解析。在这里,每个包基本上是隔离的,并将自己的依赖项放入一个本地的 node_modules 子文件夹中。

然而,有一个关键的区别:不是每个依赖项都有一个硬拷贝,而是通过符号链接提供不同的依赖项。这种方法的优点是每个依赖项只需要在每个系统中解析一次。

另一个优点是,对于大多数包来说,一切如常。没有任何东西隐藏在存档后面或通过模块定义的某些自定义映射中,这些映射将在开始时运行。整个包解析过程正常工作。这个规则的例外是使用其路径来查找其他包或针对根目录工作的包。由于包的物理位置是全局的,因此与项目的位置不同,这些方法与 pnpm 不兼容。

使用 pnpm 命令行工具安装包的工作方式与 npm 非常相似:

$ pnpm install

Packages: +5

+++++

Packages are hard linked from the content-addressable store to the virtual store.

  Content-addressable store is at: /home/rapplf/.local/share/pnpm/store/v3

  Virtual store is at:             node_modules/.pnpm

dependencies:

+ react 18.1.0

+ react-dom 18.1.0

Progress: resolved 5, reused 2, downloaded 3, added 5, done

总体而言,pnpm 命令行工具的大多数命令要么与 npm 的对应命令同名,要么非常相似。

在安装过程中,pnpm 将不可用的包添加到本地存储中。本地存储只是 pnpm 的一个特殊目录,它并不绑定到你的项目,而是你的用户账户。实际上,pnpm 的包存储是其神奇性能的来源。之后,pnpm 创建所有符号链接来连接一切。结果看起来类似于 图 3.5

图 3.5 – 使用 pnpm 安装后的文件系统快照,包含示例包名

图 3.5 – 使用 pnpm 安装后的文件系统快照,包含示例包名

node_modules 文件夹中只列出直接依赖项。每个子文件夹的内容在原始的 node_modules 中不可用,而是在全局 .pnpm 缓存中。然后,相同的处理方式应用于所有子依赖项。

结果是性能的大幅提升。在干净安装的情况下,pnpm 已经比竞争对手快。然而,在其他场景中,相对差距可能更大。在 图 3.6 中,pnpm 的性能与其他包管理器进行了比较。较低的柱状图表示更好的性能:

图 3.6 – 与 npm、Yarn 和带有 PnP 的 Yarn 的性能基准比较(来源:https://pnpm.io/benchmarks)

图 3.6 – 与 npm、Yarn 和带有 PnP 的 Yarn 的性能基准比较(来源:https://pnpm.io/benchmarks)

只有在安装是最新的情况下,npm 才可以被认为是最快的选项。在其他情况下,pnpm 和有时 Yarn PnP 可以被认为是更快的。考虑到这一点,关键问题是是否还有其他替代方案可以考虑。让我们看看我们还能做些什么来简化依赖项管理。

更多替代方案

在使用包管理器时没有严格的要求。从理论上讲,代码的来源并不重要。例如,您可以直接下载包,提取它们,并通过它们的本地路径引用它们。

另一种选择是使用像Deno这样的系统。表面上,Deno 与 Node.js 非常相似。然而,在底层有一些关键的区别。最显著的区别是 Deno 没有包管理器。相反,包只是 URL,一旦需要才会解析。这样,包安装就只是下载——它恰好发生在需要的时候。

简而言之,Deno

Deno 是由 Node.js 的创造者 Ryan Dahl 创建的。因此,Deno 与 Node.js 共享许多功能,但在某些方面有所不同。Deno 旨在比 Node.js 更兼容在浏览器中运行的 JavaScript。Deno 还试图默认提供安全性。当使用 Deno 运行脚本时,必须由用户定义提供的安全上下文。否则,代码运行时可能无法访问网络或文件系统。您可以在deno.land/获取更多信息。

另一个选择是使用一个工具,它实际上在底层利用现有的包管理器,但以更高效或用户友好的方式。这个类别中的一个例子是Turborepo

Turborepo 可以与任何流行的包管理器协同工作,并声称为许多任务提供了改进的性能,包括包安装和更新。然而,最有效利用 Turborepo 的方式是将其用于所谓的单一代码库(monorepo),这一点将在第九章“在单一代码库中结构化代码”中更详细地讨论。

除了如何安装、更新和发布包的问题之外,包管理的另一部分是包注册库。在这个领域,你可以从许多商业产品中选择,例如Verdaccio。对于大型项目来说,拥有自己的包注册库可能非常好,因为缺少依赖项或公共 npm 注册库的宕机可能成为问题。

通常,与已建立的包管理器 npm、Yarn 和 pnpm 相比,替代品并不多。虽然优化包管理器的使用或使用缓存的注册库可能很有吸引力,但它们对于大多数项目来说肯定不值得付出努力。目前,npm 和 Yarn 似乎在广泛的场景中最具吸引力,而 pnpm 可能被认为是大型仓库的理想选择。

摘要

在本章中,你学习了如何使用包管理器来处理与包相关的所有事情。你利用了默认的npm命令行工具。你接触到了最重要的替代品,Yarn 和 pnpm。你应该知道 Yarn 带来了什么——毕竟,PnP 和零安装是很好的特性。此外,你还检查了一些替代品,并了解了自定义注册库和仓库任务运行器,如 Turborepo。

到目前为止,您已经拥有了克隆和运行现有 Node.js 项目所需的一切。您可以安装新的依赖项,检查过时的依赖项,并更新它们。这使得您能够整合 npm 注册表中多年来发布的超过一百万个包。

在下一章中,我们将讨论如何使用不同版本的 JavaScript,例如更现代的规范或以 JavaScript 作为编译目标的语言,在 Node.js 中使用。

第二部分:工具

在这部分,您将通过接触各种工具和实用程序来加强您对 Node.js 生态系统的了解。您将学习如何在 Node.js 中使用不同版本的 JavaScript。这里包括 TypeScript 和 Flow。您还将看到哪些代码验证和样式检查器存在以及如何使用它们。

本部分的主要重点是让您从头开始设置和维护一个新的 Web 开发项目。这还包括质量保证方面的知识。作为这些主题的一部分,讨论了诸如 Jest 或 Playwright 之类的实用工具。

本书这部分包含以下章节:

  • 第四章, 使用不同版本的 JavaScript

  • 第五章, 使用代码检查器和格式化工具提高代码质量

  • 第六章, 使用打包器构建 Web 应用

  • 第七章, 使用测试工具提高可靠性

第四章:使用不同版本的 JavaScript

在上一章中,您已经完成了在 Node.js 中进行项目的基本要素。看看现实中的项目,您会很快发现人们使用各种版本的 JavaScript 与 Node.js 一起使用。JavaScript 的一个 版本 可以看作是官方 JavaScript 语言标准的变体。大多数情况下,这些版本看起来非常像您所习惯的 JavaScript,但在关键部分有所不同。有时,它们添加新的语言结构以简化某些任务;有时,在发布任何代码之前,它们会带来改进以确保可靠性。

在本章中,我们将学习如何使用 Node.js 与不同的 JavaScript 版本。我们将介绍最重要的工具和版本。就工具部分而言,我们将介绍流行的开源包 Babel。这个工具可以帮助 Node.js 学习如何使用一个 JavaScript 版本。这些版本包括对语言的一些有趣补充,如 FlowTypeScript。两者都引入了类型系统,但后者还向语言添加了新的结构。

本章将帮助您使用可以用 Node.js 转换为 JavaScript 的语言。最终,这是关键——不仅能够独立于语法在任何版本的 Node.js 上运行 JavaScript 文件,而且还能在大型项目中引入额外的安全性和便利性。

本章将涵盖以下关键主题:

  • 集成 Babel

  • 使用 Flow

  • 使用 TypeScript

技术要求

本章的完整源代码可以在 github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter04 找到。

本章的 CiA 视频可以在 bit.ly/3UeL4Ot 访问。

集成 Babel

在过去十年中,JavaScript 从一种简单的脚本语言上升为全球使用最广泛的编程语言。随着流行度的增加,该语言也获得了许多有趣的功能。不幸的是,最新的功能通常需要一段时间才能在所有实现中可用。如果我们想在旧实现中使用最新的语言功能,这个问题会变得更糟。

这是个前端开发者多年来都知道的问题——毕竟,浏览器版本和种类不能由开发者预先确定。只有用户才能做出这个决定——而且一个较旧的浏览器可能无法理解开发者想要使用的某些现代特性。在 Node.js 中,我们并没有遇到完全相同的问题——因为我们理论上可以决定 Node.js 的版本——但如果 Node.js 没有最新的语言特性,或者如果我们创建了应该在他人机器上运行的工具,那么这可能会成为一个类似的问题。

一种摆脱语言特性锁定(即,仅使用由引擎支持的特性集的限制)的好方法是使用一个理解最新语言规范并能将其正确翻译成旧语言规范的工具。这种编程语言翻译的过程称为转译。这个工具被称为转译器

最著名的 JavaScript 转译器之一是 Babel。它的力量在于丰富的插件生态系统。实际上,使用 Babel 扩展 JavaScript 语言的结构非常容易,以至于许多功能最初都是在 Babel 中引入的,然后才成为官方标准或事实标准。前者是一个例子是async/await,这是一个相当复杂的功能。后者的例子是JSX,即 JavaScript 的XML-样结构的扩展。

以下代码使用了async/await,并且与版本7.6.0之前的 Node.js 不兼容:

function wait(time) {
  return new Promise(resolve => setTimeout(resolve, time));
}
async function example() {
  console.log('Starting...');
  await wait(1000);
  console.log('1s later...');
  await wait(1000);
  console.log('2s later...');
  await wait(3000);
  console.log('Done after 5s!');
}
example();

如果我们想使其与旧版本(或更一般地说,无法处理现代async/await语法的 JavaScript 引擎)兼容,那么我们可以使用 Babel。

使用 Babel 转译代码有三种方式:

  • 我们可以使用@babel/node包,它是对 Node.js 的一个薄包装。本质上,它将在执行期间转译模块——也就是说,当它们被需要时。

  • @babel/cli包可以用来预先转译模块并在转译后的模块上运行 Node.js。

  • 或者,可以使用@babel/core包来编程控制转译过程——也就是说,哪些模块正在被转译以及如何处理结果。

每种方法都有其自身的优缺点。例如,选择@babel/node可能最容易启动,但实际上会给我们带来轻微的性能损失和一些不确定性。如果某些较少使用的模块存在语法问题,那么我们只能在模块使用时才发现。

同样,@babel/cli在便利性和功能之间找到了一个完美的平衡点。是的,它只与文件一起工作,但在几乎所有情况下这正是我们想要的。

一种非常方便地查看 Babel 如何处理事情的方法是使用位于babeljs.io/repl的交互式网站。对于我们的上一个代码示例,它使用了一个带有awaitasync函数,我们得到如图4.1所示的视图:

图 4.1 – 通过 Babel 在线转译一些 JavaScript

图 4.1 – 通过 Babel 在线转译一些 JavaScript

对于图 4.1中显示的截图,我们指定了 Node.js 的版本为7.6。一旦我们将它改为更低的版本,例如,7.5,我们就会得到不同的输出。这一切都始于一些生成的代码:

"use strict";
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = genkey; var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

在生成的代码之后,我们的代码被输出。关键的区别在于,我们的代码现在使用了前面生成的代码中的辅助工具:

function wait(time) {
  return new Promise(resolve => setTimeout(resolve, time));
}
function example() {
  return _example.apply(this, arguments);
}
function _example() {
  _example = _asyncToGenerator(function* () {
    console.log('Starting...');
    yield wait(1000);
    console.log('1s later...');
    yield wait(1000);
    console.log('2s later...');
    yield wait(3000);
    console.log('Done after 5s!');
  });
  return _example.apply(this, arguments);
}
example();

如您所见,代码已被生成的函数修改。在我们的例子中,这些函数已被用来用yield替换标准的async/await机制,使用生成器函数。但在为版本6.0之前的 Node.js 进行转换时,这还可以进一步改变,因为6.0引入了对生成器函数的支持。

在任何情况下,Babel 实际上正在做艰苦的工作,确定我们代码中使用了哪些构造,以及根据 Node.js 的目标版本需要替换哪些构造。它还知道适当的替换,并可以生成一些模板代码来支持语言构造。

为了让 Babel 完成所有这些工作,它需要理解 JavaScript 语言。这是通过解析源代码来实现的。解析是一个涉及遍历所有字符、将它们分组为所谓的标记(如标识符、数字等),然后将这些标记放入一个称为抽象语法树AST)的树状结构中的过程。一个可以探索 Babel 看到的 AST 的工具可以在astexplorer.net/找到。

理解 ASTs

就像处理 HTML 会产生一个不同节点的树一样,任何编程语言实际上都会解析为一个表达式和语句的树。虽然for循环等语句形成一个封闭的指令块,但加法等表达式总是会返回一个值。AST 将这些全部联系起来,并为相应的节点类型整合所有提供的信息。例如,加法表达式由两个应该相加的表达式组成。这些可以是任何表达式,例如,一个简单的字面量表达式,如数字标记。

上述示例的 AST 片段可以在图 4.2中看到。AST 中的每个节点都有一个关联的类型(如AwaitExpression)和源文档中的位置:

图 4.2 – AST Explorer 显示了 Babel 解析的信息

图 4.2 – AST Explorer 显示了 Babel 解析的信息

带着这些知识,我们现在可以尝试使用@babel/node在本地做一些事情:

  1. 我们首先创建一个新的 Node.js 项目。在一个新目录中,运行以下命令:

    $ npm init -y
    
  2. 这将创建一个package.json文件并包含一些基本信息。之后,你可以将@babel/node@babel/core包作为开发依赖项安装:

    $ npm install @babel/core @babel/node --save-dev
    
  3. 您可以使用其他包管理器进行此操作。一旦安装完成,我们应该添加脚本。创建一个名为index.js的新文件,内容如下:

index.js

let x = 1;

let y = 2;

// use conditional assignment – ES2021 feature

x &&= y;

console.log('Conditional assignment', x);

代码使用了一个名为条件赋值的ES2021特性。只有当y为真值时,才会执行赋值。在这种情况下,我们期望条件赋值后x的值为2

  1. 要运行代码,我们需要修改package.json。在scripts部分,我们添加一个start字段。现在,scripts部分应该看起来像这样:

    "scripts": {
    
      "start": "node index.js",
    
      "test": "echo \"Error: no test specified\" && exit
    
        1"
    
    }
    
  2. 到目前为止,我们可以方便地使用 npm start 运行脚本。对于最近的 Node.js 版本(15 或更高版本),输出应该是这样的:

    $ npm start
    
    > example01@1.0.0 start
    
    > node index.js
    
    Conditional assignment 2
    
  3. 然而,如果我们尝试使用 Node.js 14 运行代码,我们会得到一个错误:

    $ npm start
    
    > example01@1.0.0 start /home/node/examples/example01
    
    > node index.js
    
    /home/node/examples/example01/index.js:4
    
    x &&= y;
    
      ^^^
    
    SyntaxError: Unexpected token '&&='
    

现在,如果你想让它运行,你可以将 package.jsonstart 脚本切换为使用 babel-node 而不是标准的 node。然而,尝试这样做是不会工作的。原因是,默认情况下,Babel 不理解当前环境,因此无法应用转换。

  1. 为了让 Babel 真正理解它,我们需要使用 @babel/preset-env 包。这是一个代表插件集合的预设。在这种情况下,env 预设是一个特殊的预设,它根据当前环境智能地查找正确的插件。让我们首先安装它:

    $ npm install @babel/preset-env --save-dev
    
  2. 之后,我们可以通过创建一个新文件来集成它:

.babelrc

{

  "presets": [["@babel/preset-env"]]

}
  1. 文件必须放置在 package.json 旁边。一旦放置在那里,Babel 将自动加载该文件并将其内容作为配置输入。现在输出符合我们的预期:

    $ npm start
    
    > example01@1.0.0 start
    
      /home/rapplf/Code/Articles/Node.js-for-Frontend-
    
      Developers/Chapter04/example01
    
    > babel-node index.js
    
    Conditional assignment 2
    

按照这些说明,你现在可以运行现代代码,即使在较旧的 Node.js 版本上。前面的例子最终在 Node.js 14 上运行了——即使这个版本不支持新的赋值运算符 &&=

有许多不同的包与 Babel 一起工作。为 Babel 编写了完整的语言或语言扩展。其中之一是 Flow。

使用 Flow

Flow 主要是一个 JavaScript 代码的 静态类型检查器。静态类型检查器的目的是确保在构建时一切都能按预期工作。因此,我们应该在运行时看到更少的错误。实际上,正确使用静态类型检查器将基本上消除所有简单的错误,并让我们专注于解决那些无论如何都会出现的算法和行为问题。

在 Flow 中,每个 JavaScript 文件都可以转换为 Flow 文件。需要做的只是引入 @flow 注释。一个简单的例子如下:

// @flow
function square(n: number): number {
  return n * n;
}
square("2"); // Error!

即使代码在标准 JavaScript 中运行得相当好,Flow 也会通过在最后一行引发错误来帮助我们。square 函数使用了类型注解来指定 n 输入参数和返回值。冒号表示法将标识符或函数头与指定的类型分开。

由于冒号表示法不是 JavaScript 标准的一部分,我们无法直接运行前面的代码。相反,我们可以使用 Babel 与 @babel/preset-flow 包一起使用,以去除 Flow 类型注释——只保留 Node.js 可以理解的 JavaScript。

让我们用一个新项目来测试一下:

  1. 我们通过初始化一个 npm 项目并安装必要的开发依赖来在新的目录中开始:

    $ npm init -y
    
    $ npm install @babel/core @babel/node @babel/preset-
    
      flow --save-dev
    
  2. 现在,我们配置 Babel 并更改 package.json 文件:

.babelrc

{

  "presets": ["@babel/preset-flow"]

}
  1. package.json 中,我们需要在 scripts 部分添加一个 start 字段:

package.json

{

  "name": "example02",

  "version": "1.0.0",

  "scripts": {

    "start": "babel-node index.js"

  },

  "devDependencies": {

    "@babel/core": "⁷.18.5",

    "@babel/node": "⁷.18.5",

    "@babel/preset-flow": "⁷.17.12"

  }

}

现在,运行npm start应该不会出现任何错误信息。然而,如果我们运行node index.js,那么我们实际上会面对一个错误。尽管如此,我们不应该在这种情况下也遇到错误吗?

  1. 好吧,正如提到的,Babel 部分只是为了运行。安装的预设只理解并移除类型注解。它并不进行实际的类型检查。为此,我们需要安装另一个名为flow-bin的包:

    $ npm install flow-bin --save-dev
    
  2. 我们可以使用npm中自带npx运行器来运行flow。首先,我们初始化项目:

    $ npx flow init
    
  3. 然后,我们可以对我们的解决方案进行类型检查:

    $ npx flow
    
    Launching Flow server for
    
      /home/node/examples/example02
    
    Spawned flow server (pid=13278)
    
    Logs will go to /tmp/flow/example02.log
    
    Monitor logs will go to
    
      /tmp/flow/example02.monitor_log
    
    Error ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/011.png) index.js:6:8
    
    Cannot call square with "2" bound to n because string
    
     [1] is incompatible with number [2]. [incompatible-
    
     call]
    
     [2] 2│ function square(n: number): number {
    
         3│   return n * n;
    
         4│ }
    
         5│
    
     [1] 6│ square("2"); // Error!
    
         7│
    
    Found 1 error
    

如预期的那样,调用不满足类型检查。这对我们自己的代码来说是个好消息,但对于使用第三方库来说更是如此。有了类型检查,我们可以确信我们正确地使用了提供的 API。不仅现在如此,而且在我们为第三方库安装更新时也是如此。

很遗憾,并不是每个包都包含 flow-类型的注解。然而,对于一个非常类似的工具 TypeScript 来说,情况看起来要乐观一些。

使用 TypeScript

TypeScript 是一种完整的编程语言,它被设计为 JavaScript 的超集。基本想法是从 JavaScript 开始,通过添加类型、类或枚举等缺失的部分来增强它,并将 JavaScript 作为语言的转译目标。多年来,TypeScript 语言中首次引入的许多功能也进入了 JavaScript 语言。

今天,TypeScript 是编写大型 JavaScript 项目的最流行方式。在官方npm注册表中,几乎每个包都提供了与 TypeScript 兼容的类型注解——要么在包内部,要么在专门的包中。例如,react包的类型注解可以在@types/react包中找到。

要使用 TypeScript,我们需要安装typescript包。这个包包含了tsc脚本,它赋予我们检查类型和将使用.ts.tsx扩展名编写的 TypeScript 文件进行转译的能力。

让我们继续创建一个新的项目,安装typescript,并添加一个源文件:

  1. 我们从项目创建开始。在一个新目录中,运行以下命令:

    $ npm init -y
    
    $ npm install typescript --save-dev
    
  2. 让我们添加一个与 Flow 示例内容相似的index.ts文件:

index.ts

function square(n: number): number {

  return n * n;

}

square("2"); // Error!

文件的内容与之前几乎相同,然而,缺少了@flow注释。

  1. 我们现在可以直接通过安装的typescript包中的tsc命令来运行它:

    $ npx tsc index.ts
    
    index.ts:5:8 - error TS2345: Argument of type 'string'
    
      is not assignable to parameter of type 'number'.
    
    5 square("2"); // Error!
    
             ~~~
    
    Found 1 error in index.ts:5
    

flow工具相比,tsc做得多一些。它不仅进行类型检查,还会生成输出文件。它不做的就是运行代码。@babel/node的即时评估功能可以在ts-node包中找到,它的工作方式与 Babel 类似。

  1. 默认情况下,tsc 尝试将 .ts.tsx 输入文件转换为一些新文件:一个 .js 文件和一个 .d.ts 文件。即使在类型检查失败的情况下,这些文件也可能被生成。.js 文件将默认写入,也就是说,每次使用 tsc 时都会写入,除非我们告诉 TypeScript 不要输出。.d.ts 文件只有在我们也启用了声明创建时才会写入。在运行了之前的示例之后查看目录将揭示两个新文件:

    $ ls -l
    
    -rw-r--r-- 1   64 index.js
    
    -rw-r--r-- 1   79 index.ts
    
    drwxr-xr-x 4 4096 node_modules
    
    -rw-r--r-- 1  387 package-lock.json
    
    -rw-r--r-- 1  278 package.json
    
  2. 实际运行代码需要额外的 JavaScript。这同样适用于为浏览器编写的 TypeScript。由于没有浏览器能理解 TypeScript 代码,我们需要在之前将其转换为 JavaScript。就像 Babel 一样,我们可以为不同的 JavaScript 标准版本进行转换。

  3. 为了保持你的代码库整洁,你不应该像之前展示的那样使用 TypeScript。相反,一个更好的方法是引入一个 tsconfig.json 文件,你应该将其放置在 package.json 旁边。这样,你不仅可以正确地定义目标 JavaScript 版本,还可以指定转换输出应该放置的目标目录。然后,你可以在这个目标目录中忽略版本控制系统:

tsconfig.json

{
  "compilerOptions": {
    "target": "es6",
    "outDir": "./dist"
  },
  "include": [
    "./src"
  ],
  "exclude": [
    "node_modules"
  ]
}

在配置中,我们指定了一个 src 目录作为转换的根目录。内部所有的 .ts.tsx 文件都将被转换。输出将可在 dist 目录中找到。

  1. 现在,你只需将 index.ts 移动到一个新的 src 子文件夹中,然后再次运行 tsc。同样的错误会再次出现,但与在 index.ts 文件旁边创建 index.js 不同,输出将出现在 dist 文件夹中:

    $ npx tsc
    
    src/index.ts:5:8 - error TS2345: Argument of type
    
      'string' is not assignable to parameter of type
    
      'number'.
    
    5 square("2"); // Error!
    
             ~~~
    
    Found 1 error in src/index.ts:5
    
    $ ls -l dist/
    
    -rw-r--r-- 1   64 index.js
    

现在,大多数发布在公共 npm 注册库上的库都将使用 TypeScript 创建。这不仅防止了一些不必要的错误,而且也使库的消费者体验变得更好。

摘要

在本章中,你学习了如何使用 Node.js 的不同 JavaScript 风格。你看到了如何安装、配置 Babel,以及如何使用它将你的代码转换为 Node.js 目标版本支持的 JavaScript 标准。

现在,你也应该了解最重要的 JavaScript 风格的基础:Flow 和 TypeScript。我们讨论了它们的安装和配置方法。当然,为了实际使用这些风格,你需要额外的材料来学习它们的语法并掌握这些语言背后的概念。学习 TypeScript 的好书是 Mastering TypeScript,作者为 Nathan Rozentals

在下一章中,我们将讨论一个非常重要的工具领域——能够为我们代码提供改进的一致性和验证的应用程序。

第五章:使用 Linters 和格式化工具提升代码质量

到本章为止,我们主要处理的是在热路径上的结构和代码——也就是说,直接必要的,以实际做某事。然而,在大多数项目中,有许多部分不是直接有用或可见的。这些部分往往在保持项目一定质量方面起着至关重要的作用。

软件项目质量增强领域的例子之一是用于确保遵循某些编码标准的工具。这些工具可以出现在许多类别中——最突出的类别是 lintersformatters。通常,这些工具可以归类为辅助工具。

在本章中,我们将学习存在哪些类型的辅助工具,以及我们为什么可能想要使用一些额外的工具来提升我们项目的代码质量。我们将介绍最重要的辅助工具,如 ESLintStylelintPrettier。我们还将探讨这些工具如何与标准文本编辑器,如 VS Code,集成或使用。

通过本章介绍的辅助工具,你将能够对任何你将贡献的基于 Node.js 的前端项目产生卓越的积极影响。

本章将涵盖以下关键主题:

  • 理解辅助工具

  • 使用 ESLint 和替代方案

  • 介绍 Stylelint

  • 设置 Prettier 和 EditorConfig

技术要求

本章的完整源代码可在 github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter05 找到。

本章节的 CiA 视频可在 bit.ly/3fLWnyP 访问。

理解辅助工具

当大多数人想到软件时,他们可能会想到微软 Word 这样的应用程序、Minecraft 这样的游戏,或者 Facebook 这样的网络应用程序。多亏了流行媒体,普遍的观点是这些应用程序是由个人天才编写的,他们将这些零和一黑客进一个晦涩的界面。现实情况可能完全相反。

如你所知,要创建任何类型的软件,需要大量的库、工具——在许多情况下——以及大型团队。然而,大多数人低估了仅仅保持项目进展的努力——也就是说,仍然能够向现有软件添加新功能。有几个问题导致了这种功能放缓。

一方面,软件内部的复杂性总是会增加。无论我们是否愿意,随着每个新功能的加入,项目都会变得更加具有挑战性。此外,较大的软件往往由更多的开发者编写——每个开发者都有略微不同的偏好和风格。这很快就会让新开发者甚至那些在项目中拥有经验但之前未曾接触过这些领域的开发者陷入混乱。

一种控制复杂性上升的方法是引入流程。例如,进行带有审查的拉取请求的过程已经提出,以传播关于新功能的知识,检测问题,并讨论发现。在良好的拉取请求审查结束时,代码应该处于新添加的功能在功能和技术上都能很好地融入整个项目状态。

现在,一切关于自动化。因此,尽管手动流程如代码审查可能很好且必要,我们通常更喜欢自动化流程。这正是所有辅助工具发挥作用的地方。以代码审查中关于代码格式的潜在讨论为例。假设代码的一部分如下所示:

export function div(a,b){ return (
 a/ b)
}

代码本身是好的——div函数应该执行除法,当然,它确实做到了。然而,格式却大相径庭。一个审阅者可能会抱怨函数的参数应该使用逗号后的空格进行适当的格式化。另一个审阅者可能不喜欢没有使用括号的返回语句。第三个审阅者可能会指出缺少可选的分号以及缩进仅有一个空格。

现在,一切设置和完成后,代码的新版本将被推送:

export function div(a, b){
  return a / b;
}

在这种情况下,第二个审阅者可能会提出关于分号引入的讨论——在这种情况下,分号是可选的,代码没有它也能工作。此时,一个新的审阅者加入并质疑引入函数的必要性:“为什么需要除法函数?这里没有什么新奇的或有趣的地方。”

因此,你会看到在各方面都浪费了大量的时间。与其首先讨论函数的业务需求,不如把时间花在讨论应该自动对齐和纠正的正式程序上。这正是 linters 和 formatters 发挥作用的地方。它们可以通过遵循为项目设定的标准来完成任务,使代码易于阅读。因此,团队只需要就制表符与空格的争论或分号的使用达成一致。工具负责实际应用决策。

JavaScript 中的分号

JavaScript 对语法的限制相对宽松。虽然其他语言有必须遵循的规则和结构,但 JavaScript 在其规范中包含了许多可选的结构。例如,分号在某种程度上是可选的。在某些情况下,你需要分号来避免诸如在 for 循环头部出现的令人不快的意外,但大多数情况下,你可以直接去掉它们,代码仍然可以正常工作。

在许多领域,辅助工具是有意义的。当然,代码本身的排列是好的,但即使是像在使用项目版本控制系统时的提交信息或检查是否提供了文档这样的事情也可以是有用的。

在检查实际语法——例如,空格和换行符的使用,这是一个常见用例——一个更加重要的用例是检查实际的代码结构以查找某些模式。对所使用模式的验证通常被称为 linting——一类被称为 linters 的工具。在这个领域表现出色的工具是 ESLint

使用 ESLint 和替代方案

ESLint 静态分析代码以识别常见模式并查找问题。它可以作为 Node.js 应用程序中的库、Node.js 脚本中的工具、CI/CD 管道中的工具,或者在代码编辑器中隐式使用。

通常建议在 Node.js 项目中本地安装 ESLint。可以使用您喜欢的包管理器,如 npm 来进行本地安装:

$ npm install eslint --save-dev

在大多数情况下,您会想要指定 --save-dev 标志。这将添加一个开发依赖项,这些依赖项不会在消费应用程序中安装,并且在生产安装中将被跳过。实际上,开发依赖项只在项目的实际开发期间才有意义。

或者,您也可以将 ESLint 设置为全局工具。这样,您就可以在尚未包含 ESLint 的项目和代码文件中运行 ESLint。要全局安装 ESLint,需要运行以下命令:

$ npm install eslint --global

可能需要提升 shell 访问权限(例如,使用 sudo)来全局安装 ESLint。一般建议避免使用提升的 shell 访问权限,这意味着避免全局安装。

全局安装与本地安装

npm 不仅是一种很好的分发包的方式,也是一种分发工具的方式。npm 的标准安装创建了一个专门用于此类工具的目录。这个专用目录被添加到您的系统 PATH 变量中,允许直接执行目录中的任何内容。通过使用全局安装,像 ESLint 这样的工具被添加到专用目录中,使我们能够通过在命令行中键入 eslint 来运行它。

另一方面,本地安装的工具不会放在专门的目录中。相反,它们在 node_modules/.bin 文件夹中可用。为了避免运行像 ./node_modules/.bin/eslint 这样冗长的命令,我们可以使用 npx 工具。

npx 是与 Node.js 和 npm 一起安装的任务运行器。它会智能地检查提供的脚本是否已本地或全局安装。如果没有找到任何内容,则从 npm 注册表临时下载一个包,从临时安装中执行脚本。因此,在已安装 ESLint 的项目中运行 npx eslint 将启动代码检查。

让我们初始化一个新的项目(npm init -y)并将 eslint 作为开发依赖项安装。现在你已经安装了 ESLint,你可以在一些示例代码上实际使用它:

  1. 对于这一点,我们可以利用上一节中的示例:

index.js

export function div(a,b){ return (

 a/ b)

}
  1. 在我们运行 eslint 之前,我们还需要创建一个配置。对于前端开发的大多数工具来说,拥有一个配置文件是必需的。在 ESLint 的情况下,配置文件应该命名为 .eslintrc

将以下 .eslintrc 文件放置在与 package.json 相同的目录中:

.eslintrc

{

    "root": true,

    "parserOptions": {

        "sourceType": "module",

        "ecmaVersion": 2020

    },

    "rules": {

        "semi": ["error", "always"]

    }

}

为 ESLint 编写配置方式有多种。在前面的代码片段中,我们使用了 JSON 格式,这对于有 JavaScript 或 Web 开发背景的人来说应该非常熟悉。另一种常见的方法是使用 YAML 格式。

  1. 在前面的配置中,我们指示 ESLint 停止查找父配置。因为这个确实是我们的项目配置,我们可以在这里停止。此外,我们配置 ESLint 的解析器实际按照非常新的规范解析 ESM。最后,我们配置了分号规则,如果缺少分号则抛出错误。

应用此规则集的结果可以在以下代码片段中看到。从当前目录(.)开始运行 npx eslint 的样子如下:

$ npx eslint .
/home/node/Chapter05/example01/index.js
  2:7  error  Missing semicolon  semi
![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png) 1 problem (1 error, 0 warnings)
  1 error and 0 warnings potentially fixable with the
  `--fix` option.

如预期的那样,检查器会抱怨。然而,这种抱怨肯定是在积极区域。相当建设性地,ESLint 还告诉我们有关自动修复问题的选项。

  1. 让我们使用建议的 --fix 选项运行相同的命令:

    $ npx eslint . --fix
    

这里没有输出。实际上,这是好事。缺失的分号已经被插入:

export function div(a,b){ return (

 a/ b);

}
  1. 其他规则怎么样?如果我们想强制代码使用匿名箭头函数而不是命名函数呢?虽然 ESLint 直接提供的规则可以覆盖许多内容,但系统可以通过第三方包的规则进行扩展。为 ESLint 带来额外功能的第三方包被称为 ESLint 插件。

要引入一个规则来强制使用匿名箭头函数,我们可以使用 ESLint 插件。这个插件的包名为 eslint-plugin-prefer-arrow。让我们首先安装它:

$ npm install eslint-plugin-prefer-arrow --save-dev
  1. 现在,我们可以更改配置。我们需要包含对插件的引用,并指定规则:

.eslintrc

{

    "root": true,

    "parserOptions": {

        "sourceType": "module",

        "ecmaVersion": 2020

    },

    "plugins": [

      "prefer-arrow"

    ],

    "rules": {

        "semi": ["error", "always"],

        "prefer-arrow/prefer-arrow-functions": ["error", {}]

    }

}
  1. 使用这种配置,我们现在可以测试函数声明是否确实被认定为错误:

    $ npx eslint .
    
    /home/node/Chapter05/example01/index.js
    
      1:8  error  Use const or class constructors instead
    
      of named functions  prefer-arrow/prefer-arrow-
    
      functions
    
    ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png) 1 problem (1 error, 0 warnings)
    

与之前的错误相比,我们在这里没有看到任何自动修复的迹象。在这种情况下,代码的作者必须手动进行所有更改以满足 linting 工具的要求。

ESLint 有很多替代方案。在过去,TypeScript 特定的变体 TSLint 相当受欢迎。然而,几年前,TSLint 背后的团队决定将他们的规则合并到 ESLint 中——这也使得 ESLint 成为了 linting TypeScript 文件的既定标准。如今,最受欢迎的替代方案是Romequick-lint-jsJSHint

Rome 是一个将多个实用工具结合成一个统一应用程序的全能工具。虽然 Rome 不是用 JavaScript 和 Node.js 编写的,但它仍然很好地集成到标准前端工具中。Rome 涵盖的一个方面是 linting。在撰写本文时,遗憾的是,Rome 尚未功能完善,仍在早期 alpha 版本中,但它的性能和便利性优势是明显的。

quick-lint-js包是一个小巧的工具,无需配置,并且针对执行时间优于 ESLint 进行了优化。缺点是 quick-lint-js 功能较少,设计上不太灵活。

最后,在 linting 领域的一个黄金经典是JSHint。最初,它被创建为一个更可配置的JSLint版本,可以被认为是 JavaScript 的第一个流行 linter。JSHint 的一个问题是它不支持ECMAScript标准的最新和最伟大的功能。如果你正在寻找 ES2020 支持,那么 JSHint 可以被舍弃。同样,JSHint 在可扩展性方面也更为严格。在 JSHint 中,你不能定义自定义规则。然而,如果你在 JSHint 中缺少某些功能,你将无法简单地添加它们。

然而,ESLint 最大的优势是它已经拥有了其他人可能缺少的生态系统。ESLint 在编辑器支持方面表现出色。图 5.1显示了 VS Code Marketplace 上官方 ESLint 扩展的入口。

图 5.1 – VS Code Marketplace 上官方 ESLint 扩展的入口

图 5.1 – VS Code Marketplace 上官方 ESLint 扩展的入口

对于其他编辑器也存在类似的插件。一些编辑器,如 Brackets,甚至预装了 ESLint 集成。

编辑器集成会在代码中直接显示 ESLint 问题。这在开发过程中非常有帮助。你不必等待代码质量检查结果,而是在问题出现时就能直接看到它们。这样,你就可以立即修复它们,而不是需要稍后返回之前已关闭的文件。

在几乎所有的编辑器集成中,当 ESLint 发现问题时,你不仅会得到一些波浪线或类似的视觉提示,还可以运行快速修复。运行快速修复将触发 ESLint 的修复功能。在前面的命令行使用中,我们通过使用 --fix 标志触发了这种行为。

图 5.2 展示了 VS Code 如何报告在给定示例文件 index.js 中由 ESLint 发现的问题:

图 5.2 – ESLint 在 VS Code 中集成报告问题

图 5.2 – ESLint 在 VS Code 中集成报告问题

通常来说,为代码检查规则定义一个坚实的基础是有意义的。然而,这个基础不应过大。过多的规则最终会产生相反的效果。与其通过寻找共同风格和避免问题模式来增强团队的能力,不如过于严格的约束——这本质上会减慢甚至阻碍新功能的进展。因此,建议从少量规则开始,当某些代码问题在拉取请求审查中频繁出现时再添加新规则。这样,代码检查规则集将随着项目的发展而演变。

虽然检查 JavaScript 源文件无疑是现代前端开发中最重要任务之一,但它绝不是你将遇到的唯一类型的源文件。可以说,第二重要的文件类型是样式表,如 CSSSCSS。对于这些,我们可以依赖另一个名为 Stylelint 的代码检查工具。

介绍 Stylelint

Stylelint 是一个用于 CSS 文件的代码检查工具,并且可以扩展以理解 CSS 方言,如 SCSS、SassLessSugarCSS。它拥有超过 170 个内置规则,但与 ESLint 类似,也提供了自定义规则的支持。

安装 Stylelint 时,我们可以遵循与 ESLint 相同的步骤:

  1. 在这里,通常有理由依赖 Stylelint 提供的标准配置。与 ESLint 不同,标准配置以单独的包发布,因此也需要安装。安装这两个包作为开发依赖项的命令如下:

    $ npm install stylelint stylelint-config-standard
    
      --save-dev
    
  2. 在任何情况下,我们仍然需要一个配置文件。目前,只需让 stylelint 知道我们想使用 stylelint-config-standard 包中的配置即可。在这里,我们可以在项目的 package.json 旁边编写另一个配置文件:

.stylelintrc

{

  "extends": "stylelint-config-standard"

}
  1. 接下来,让我们引入一些有问题的 CSS 文件来尝试 stylelint 工具:

style.css

div {

    padding-left: 20px;

    padding: 10px;

}

p {

    color: #44;

}

前面的代码片段存在几个问题。一方面,我们会在之后使用 padding 简写属性覆盖 padding-left 属性。另一方面,我们会使用一个无效的颜色十六进制代码。最后,我们可能希望在不同的声明块之间有一个新行。

  1. 我们可以使用 npx 任务运行器运行 stylelint 工具——就像我们触发 eslint 一样:

    $ npx stylelint style.css
    
    style.css
    
     2:5   ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Expected indentation of 2 spaces
    
       indentation
    
     3:5   ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Unexpected shorthand "padding" after
    
      "padding-left"  declaration-block-no-shorthand-
    
      property-overrides
    
     3:5   ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Expected indentation of 2 spaces
    
       indentation
    
     5:1   ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Expected empty line before rule
    
       rule-empty-line-before
    
     6:5![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Expected indentation of 2 spaces
    
       indentation
    
     6:12  ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Unexpected invalid hex color "#44"
    
       color-no-invalid-hex
    
     7:1   ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Unexpected missing end-of-source newline
    
       no-missing-end-of-source-newline
    
  2. 出现的问题列表相当长!幸运的是,就像 eslint 一样,我们可以使用 --fix 标志自动修复尽可能多的问题:

    $ npx stylelint style.css --fix
    
    style.css
    
     3:5   ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Unexpected shorthand "padding" after
    
      "padding-left"  declaration-block-no-shorthand-
    
      property-overrides
    
     6:12  ![](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/mdn-fe-dev-node/img/012.png)  Unexpected invalid hex color "#44"
    
       color-no-invalid-hex
    

虽然处理空格和换行符的视觉问题可以通过 Stylelint 自动解决,但剩余的两个问题(3:56:12)需要更多的思考才能修复。第一个问题需要我们决定是删除 padding-left 属性还是将其移动到 padding 简写使用之后。第二个问题要求我们实际考虑一个有效的颜色来使用。在这里,Stylelint 不可能知道我们在编写代码时心中所想的颜色。

Stylelint 不仅非常实用,而且相当独特。在 CSS 代码风格检查的世界里,选择并不多。大多数人倾向于依赖他们的工具——例如 Sass 或 Less,以提前给出一些错误和警告。Stylelint 则更进一步。除了丰富的内置规则和通过插件提供的灵活性外,Stylelint 还提供了一个丰富的生态系统。就像 ESLint 一样,许多编辑器都集成了 Stylelint。

在所有代码风格检查工具配置完成后,我们现在可以转向代码美学的更柔和部分——代码的视觉结构。在这里帮助我们的是 Prettier 工具。

设置 Prettier 和 EditorConfig

Prettier 是一款与众多不同源文件兼容的代码格式化工具。在支持的文件类型中,包括纯 JavaScript、Flow、TypeScript、HTML、CSS、SASS、Markdown 等多种格式。Prettier 还集成了许多不同的编辑器,例如 Atom、Emacs、Sublime Text、Vim、Visual Studio 或 VS Code。

让我们深入了解安装和配置 Prettier 格式化器:

  1. 与之前的工具一样,Prettier 可以本地或全局安装。将 Prettier 添加到现有项目可以通过从 npm 注册表安装 prettier 包来完成:

    $ npm install prettier --save-dev
    
  2. Prettier 可以在不进行任何配置的情况下格式化 JavaScript 代码。要在现有的代码文件上运行 Prettier,您可以使用 npx 运行 prettier 工具。例如,要将格式应用于您之前的代码文件,您可以运行:

    $ npx prettier index.js
    
    export function div(a, b) {
    
      return a / b;
    
    }
    

在这种情况下,Prettier 只在命令行中打印了格式化结果。它还在语句的末尾添加了分号。让我们配置 Prettier 以 在语句末尾添加分号。

  1. 要配置 Prettier,应在项目的根目录中添加一个 .prettierrc 文件——紧挨着 package.json。该文件可以用 JSON 编写。以下是一个示例:

.prettierrc

{
  "tabWidth": 4,
  "semi": false,
  "singleQuote": true
}

提供的示例将缩进设置为四个空格。它指示 Prettier 在可能的情况下始终使用单引号而不是双引号来表示字符串。最重要的是,我们禁用了分号的使用。

  1. 在上述配置到位后,我们可以再次运行 prettier

    $ npx prettier index.js
    
    export function div(a, b) {
    
        return a / b
    
    }
    

这种效果非常明显。现在,使用的是四个空格而不是两个。分号被省略了。配置已成功应用。然而,还有一件事是实际覆盖现有文件。毕竟,在命令行中获取格式化代码很好,但如果我们没有真正格式化原始文件,那么这并没有多少价值。

  1. 为了让 prettier 应用更改,需要使用 --write 标志。因此,步骤 4 中的命令将更改为以下内容:

    $ npx prettier index.js --write
    
    index.js 41ms
    

输出现在会打印出所有已更改和未更改的文件的摘要。使用前面的命令,只有 index.js 文件被格式化;然而,prettier 工具也会接受通配符,如 *,以指示匹配多个文件的占位符。

模式匹配

许多 Node.js 工具接受一种特殊的语法来匹配多个文件。这种语法通常直接来自或至少受到 glob 包的启发,该包从 Unix 中复制了符号。这种语法定义了所谓的 globs——即允许匹配文件的模式。在这种类似于正则表达式的语法中,* 匹配单个路径段中的 0 个或多个字符,而 ? 匹配恰好一个字符。另一个有用的结构是 **,它可以用来表示 0 个或多个目录。例如,**/*.js 的模式将匹配当前目录及其子目录中的任何 .js 文件。更多关于 glob 包及其语法的详细信息可以在 www.npmjs.com/package/glob 找到。

虽然 Prettier 对于许多类型的源文件来说很棒,但它肯定无法处理一般的文本文件。然而,我们经常希望为项目中的任何内容建立通用的格式化规则。这就是 EditorConfig 发挥作用的地方。

EditorConfig 是一个标准,用于帮助维护项目的一致编码风格。它由一个名为 .editorconfig 的文件建立。几乎每个编辑器都支持这个文件。

一个 .editorconfig 的示例如下:

.editorconfig

root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2

与 ESLint 一样,我们可以使用嵌套配置文件——也就是说,通过在它们中放置另一个 .editorconfig 文件来为子目录专门配置。root = true 配置告诉编辑器停止向上遍历文件系统以查找额外的配置文件。否则,此文件只有一个部分,[*],匹配所有文本文件。

上面的示例中的规则集实际上会告诉编辑器只使用行结束符(lf)来结束行。虽然这是基于 Unix 的系统的标准,但 Windows 用户通常会得到两个字符来结束行:行结束符(lf)和回车符(cr)——所谓的 lfcr 习惯。此外,规则集会在每个文件的末尾引入一个空行。根据定义,每个文本文件都会使用两个空格作为缩进级别。

虽然这样的配置很棒,但它可能与 Prettier 配置直接冲突。然而,Prettier 的另一个优点是它可以与 EditorConfig 协同工作。让我们重新配置之前的配置,使其也使用 EditorConfig:

.prettierrc

{
  "semi": false,
  "singleQuote": true
}

由于 Prettier 规则始终优先级最高,并覆盖.editorconfig文件中的规则,因此删除冲突规则是有意义的。否则,我们只剩下 JavaScript 特定的格式化规则——例如,在.prettierrc中关于分号和首选引号样式——。现在,通用文本格式化规则通过 EditorConfig 隐式指定。

考虑到所有这些,让我们回顾一下本章所学的内容。

摘要

在本章中,您学习了如何借助 linters 和 formatters 来提高代码质量。现在您可以使用诸如 EditorConfig、Prettier、Stylelint 或 ESLint 等常用工具。您现在可以在您喜欢的任何项目中添加、配置和运行这些工具。

到目前为止,您可以为几乎所有基于 Node.js 的前端项目做出贡献,这些项目在工具方面都基于 Node.js。此外,您还可以引入如 Prettier 这样的高质量增强器。一旦成功引入,这些工具将确保某些质量关卡始终得到满足。在 Prettier 的情况下,关于代码风格的讨论大多已成为过去式——帮助全球各地的团队真正专注于实际问题,而不是处理代码的表面问题。

需要记住的一个缺点是,这些工具中的大多数都对您的代码有一些假设。因此,如果您的代码使用了,例如,我们在第四章“使用 JavaScript 的不同版本”中讨论的版本之一,那么您很可能需要教您的工具关于这个版本的知识。通常情况下,这只需要安装一个插件,但在严重的情况下,您将面临放弃工具或停止在项目中使用该版本的选择。

在下一章中,我们将深入探讨对于前端开发者来说可能最重要的工具:打包器。

第六章:使用打包器构建 Web 应用

在上一章中,我们介绍了一组重要的辅助工具——代码检查器和格式化工具。虽然代码质量很重要,但每个项目无疑最重要的方面是客户所交付和使用的内容。这正是需要一种特殊工具的地方——称为打包器——的地方。

打包器是一种理解并处理源代码的工具,以生成可以放置在 Web 服务器上并由 Web 浏览器消费的文件。它考虑 HTML、CSS、JavaScript 和相关文件,使它们更高效和可读。在这个过程中,打包器会合并、拆分、压缩,甚至将代码从一种标准(如 ES2020)转换为另一种标准(如 ES5)。

现在,打包器不再是可选的,而是大多数项目直接或间接必需的。几乎每个 Web 框架都提供基于打包器的工具。通常,挑战在于配置打包器,使其理解我们的代码库并执行我们期望它执行的操作。由于 Web 代码库相当不同,打包器需要在多个方向上保持灵活性。

在本章中,你将建立起对打包器做什么以及如何控制其内部过程的理解。我们还将介绍目前最重要的打包器,并了解它们如何被使用和配置以高效地为我们工作。这将帮助你将你的 Web 项目从原始源代码转换为生产就绪的工件。

本章将涵盖以下关键主题:

  • 理解打包器

  • 比较可用的打包器

  • 使用 Webpack

  • 使用 esbuild

  • 使用 Parcel

  • 使用 Vite

技术要求

本章的完整源代码可在github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter06找到。

本章的 CiA 视频可通过bit.ly/3G0NiMX访问。

理解打包器

编写现代 Web 应用相当困难。导致这种难度水平的一个原因是需要涉及过程中的各种不同技术。让我们简单提一下:

  • 用于编写文档的 HTML

  • 使用 CSS 来设计这些文档

  • 使用 DOM API 的 JavaScript 以增加一些交互性

  • 一个用于创建交互式组件的 JavaScript UI 框架

  • 一个用于 CSS 的预处理器,以使用变量、嵌套等更多功能

  • 可能使用 TypeScript 或其他一些类型系统来提高某些源代码区域的可靠性

  • 需要掌握服务和 Web 工作者

  • 所有静态文件都应该易于缓存

在引入能够构建模块图的新工具类别之前,人们使用专门的作业运行器,如GruntGulp。这些运行器受到了更通用方法(如Makefiles)的启发。然而,问题在于需要保持两个方面的同步——构建过程和源代码。仅仅添加一个文件到源代码是不够的;构建过程必须被告知这个新文件。有了打包器,这一切都改变了。

在核心上,打包器是一个利用其他工具的工具。最重要的补充是打包器理解模块图——即代码模块(如我们在前几章讨论的 CommonJS 或 ESM 模块)之间的关系(导入和导出)。它可以构建模块图,并使用它让其他工具(如 Babel)工作。

要开始使用,打包器需要所谓的入口点——通常这些被称为入口。这些文件用作模块图的根。这些文件可能依赖于其他文件,在这种情况下,打包器将继续在这些文件中构建模块图。

图 6.1 展示了一个由两个入口点构建的示例模块图。这个图的一个有趣特性是入口 2的内容也完全包含在入口 1中。在许多情况下,多个入口点的模块图之间不会存在任何显著的重叠:

图 6.1 – 由两个入口点构建的示例模块图

图 6.1 – 由两个入口点构建的示例模块图

大多数打包器都按阶段工作。虽然每个打包器使用的术语略有不同,但它们几乎总是包含以下高级阶段:

  1. 模块解析

  2. 模块转换

  3. 块和资源生成

  4. 应用优化

模块转换通常是必要的。一方面,打包器需要理解模块以找到导入的模块来构建模块图;另一方面,块生成必须依赖于规范化的输入模块。

在转换阶段需要与解析器协同工作,以持续构建模块图,而所有其他阶段基本上是独立的。通常,在开发过程中,优化阶段会被减少或完全禁用。这种减少可以显著加快打包过程。此外,一些在调试过程中非常有帮助的额外指令将被保留。

压缩

最常见的优化之一是压缩。压缩的目标是在不使用主动压缩的情况下使文件尽可能小。虽然表面上看压缩在 JavaScript 这样的语言中相对简单且高效,但其他语言如 CSS 或 HTML 则有点问题。特别是 HTML 的压缩已被证明是一个难题,与 JavaScript 的压缩相比,收益较少。压缩后,文件通常不如之前易读。一个原因是移除了不必要的空白,这些空白是为了给原始代码提供可读性和结构而引入的。

整个打包过程可以用图表来概述。图 6.2展示了不同入口如何进入不同的阶段:

图 6.2 – 现代网络打包器的高级阶段

图 6.2 – 现代网络打包器的高级阶段

另一个需要考虑的是,块生成通常会引入某种 JavaScript 运行时。这可以轻量到仅仅教会生成的代码如何加载作为脚本文件创建的额外包,但它也可以包括从外部代码加载共享依赖项的完整支持等。引入的代码完全符合打包器的特定要求。

考虑到这一点,让我们看看目前有哪些打包器以及它们是如何进行比较的。

比较可用的打包器

打包器有多个代。第一代打包器围绕着一个信念,即 Node.js 应用程序是唯一应该编写的应用程序类型。因此,将这些应用程序转换为在浏览器中工作的 JavaScript 文件一直是那一代打包器的首要关注点。那个类别中最受欢迎的是Browserify

第二代打包器继续将第一代的思想扩展到几乎所有 JavaScript 代码。在这里,甚至 HTML 和 CSS 资源也可以被理解。例如,使用 CSS 中的@import规则将模块图扩展到另一个 CSS 模块。重要的是,尽管仍然使用CommonJS(或后来,ESM)语法来推导 JavaScript 模块图,但这些第二代打包器并不关心 Node.js。它们始终假设代码是为浏览器编写的。然而,相当常见的是,你可以更改目标,并且也可以使用它们打包 Node.js 代码。这个类别中最受欢迎的是Webpack,尽管 Webpack 总是试图与时俱进并适应。

第三代打包器引入了改进的用户体验。它们试图找到处理事情的原生或明显的方式,并且通常甚至不需要任何配置。这个类别中最受欢迎的工具是原始的Parcel打包器。

当前的第四代打包器都关注性能。它们要么带有专门的运行时,要么位于原生编写的工具之上,这通常优于它们较老的 JavaScript 编写的对应工具。在这里,我们看到像esbuild这样的工具或像Bun这样的实验性运行时。

最大的问题是:何时使用什么?在六七个流行的打包器和更多可用的情况下,这个问题并不容易回答。当然,如果一个团队已经非常熟悉某个选项,那么在项目中选择它通常是正确的选择。否则,寻找类似的项目,并尝试了解他们选择了哪个打包器——以及原因。在任何其他情况下,你可以使用以下问题目录来识别哪个打包器可能是最佳选择:

  • 涉及到哪些类型的资产?如果只涉及 JavaScript,那么 Webpack 可能是个不错的选择。如果你有多个需要处理的 HTML 页面,那么Vite可能是个很好的选择。

  • 你使用了多少依赖项?尤其是在你使用 npm 中的较老库时,一个支持范围广泛的打包器——例如 Webpack——可能是最佳选择。否则,寻找更快的选项,如 esbuild。

  • 团队对打包器和它们的选项熟悉到什么程度?如果团队对打包一无所知,那么 Parcel 可能是一个很好的入门方式。否则,Webpack 可能拥有最多的文档。在 Vite 中可以找到一个相对较新但非常活跃且乐于助人的社区。

  • 你是在构建一个应用程序还是只想优化库的资产?特别是对于库,一些更小的工具,如 esbuild,可能很有用。另一方面,Parcel 在这里也有很多可提供的东西。总的来说,对于库,应避免使用 Vite。虽然支持存在,但它似乎还没有准备好比 Rollup.js 和 esbuild 更有效地构建库。

  • 你是否需要支持高级场景,如离线模式或 web workers?在这些情况下,Webpack 的生态系统通常很难被超越。Parcel 在这方面也做得很好,提供了许多辅助工具。对于此类场景,应避免使用 esbuild。

  • 性能有多重要?如果你有一个较大的代码库(超过 1,000 个模块或 10 万行代码),那么 Webpack 已知是一个性能杀手,可能需要 30 秒到几分钟。选择像 Vite 或如果可能的话 esbuild 将肯定有助于加快过程。虽然前者对开发者更友好,但它也带来了很多隐藏的复杂性。后者更直接,但缺乏标准功能,如热模块****重新加载HMR)。

  • 多少维护是可以接受的?依赖于大量插件的打包器传统上维护起来要困难得多。升级 Webpack 到下一个主要版本一直是出了名的困难。从缺少插件到插件 API 的破坏性更改——所有可能发生的情况在这种情况下都会发生。更倾向于使用如 Parcel 或 Vite 这样的打包器,这些打包器试图提供所有必要的功能。

  • 额外的开发功能(如打包洞察)有多重要?如果这些是至关重要的,那么没有什么比 Webpack 更好的了。由于 Webpack 生态系统非常大,你可以轻松找到额外的工具、库和指南。另一方面,选择一个拥有成长社区的选项,如 Vite,也可能工作得很好。如果缺少某些功能,社区也应该能够快速拾起它们。

在接下来的章节中,我们将通过一个示例项目来了解如何使用一些最受欢迎的打包器来构建它。我们将使用一个代码库较小但非平凡的示例项目。对于这个例子,我们将使用 React——但请放心,您不需要了解 React 就能跟随这一章节。

React

不可否认,React 是用于网络前端开发的最受欢迎的 UI 库。它允许开发者通过利用名为 ButtonCarousel 的语言扩展,在 JavaScript 中快速构建 UI。

我们将要覆盖的示例项目的代码库包括以下内容:

  • 单页应用(SPA)的源代码(SPA

  • SPA 的 index.html

  • 几个资产文件(视频、不同格式的图片、音频)

  • 几个非平凡依赖项

  • 一些使用 TypeScript 而不是 JavaScript 的文件

  • 一个名为 SASS 的特殊 CSS 预处理器

  • 正在使用一个网络框架(React 与 React Router)

  • 不同的虚拟路由应引导到需要懒加载的不同页面部分

总的来说,这个示例应该生成一个包含视频和音频播放器的小型演示应用程序,该播放器使用第三方依赖项。

懒加载

懒加载描述了一种技术,其中并非所有应用程序所需的部分都会立即加载。对于一个 SPA 来说,这很有意义——毕竟,并非每个组件或 SPA 的部分都会在当前用户交互中需要。即使它不是必需的,它也可能在某个未来的时间点需要。懒加载通常涉及在执行某些操作(例如用户点击按钮或跟随某些内部链接)时加载额外的脚本(或其他)文件。懒加载的实现需要得到相应的 UI 框架的支持(例如,React 有一个名为 lazy 的函数),但由打包器完成。

可以通过初始化一个新的 Node.js 项目来创建这个示例的模板:

$ npm init -y

现在,我们将添加所有运行时依赖项——也就是说,当我们的应用程序在浏览器中运行时所需的包:

$ npm i react react-dom react-router-dom video.js --save

最后,将添加前面的依赖项到可以在浏览器中运行的脚本中将是打包器的任务。然而,对我们来说,这样做以清楚地了解哪些包只是用于工具的,哪些依赖项是代码运行所需的,是有意义的。

基本的devDependencies——即那些用于工具的依赖项——如下所示:

$ npm i typescript sass @types/react @types/react-dom --save-dev

还需要额外的工具依赖项,但这些将取决于打包器。

示例应用程序包含以下源文件:

  • index.html:SPA 网站的模板

  • script.tsx:开始运行应用程序

  • App.tsx:应用程序根

  • Layout.tsx:应用程序的布局

  • Home.tsx:包含指向所有页面的主页

  • Video.tsx:包含视频播放器的页面

  • Audio.tsx:包含音频播放器的页面

  • Player.jsx:视频和音频播放器的 React 组件

  • earth.mp4:要播放的视频文件

  • river.webp:视频文件的预览图片(.webp格式)

  • snow.jpg:音频文件的预览图片(.jpg格式)

  • sound.mp3:要播放的音频文件

显示 UI 的过程通常被称为渲染。当 React 首次渲染应用程序时,它需要在 DOM 树中挂载其组件树。这是在script.tsx文件中完成的:

script.tsx

import * as React from 'react';
import { createRoot } from 'react-dom/client';
import './style.scss';
import App from './App';
const root = createRoot(document.querySelector('#app')!);
root.render(<App />);

使用尖括号来启动App的过程被称为 JSX。在底层,文件扩展名中的额外xtsx)使得这些表达式可以被处理,其中<App />将被转换为React.createElement(App)

App组件本身定义如下:

App.tsx

import * as React from "react";
import { BrowserRouter, Route, Routes } from
  "react-router-dom";
import Layout from "./Layout";
const Home = React.lazy(() => import("./Home"));
const Video = React.lazy(() => import("./Video"));
const Audio = React.lazy(() => import("./Audio"));
const App = () => (
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<Home />} />
        <Route path="video" element={<Video />} />
        <Route path="audio" element={<Audio />} />
      </Route>
    </Routes>
  </BrowserRouter>
);
export default App;

这种结构对于 SPA 来说是典型的。所有不同的路由都会在路由器或根组件中汇集,以便在找到特定路径时显示。例如,在我们的应用程序中,/video路径将显示Video组件,而/audio路径将显示Audio组件。所有这些组件都将嵌入到Layout组件中,该组件负责应用程序的一般布局,例如显示页眉和页脚。

App.tsx文件中,通过使用 ESM import函数启动懒加载。打包器应该能够将其转换为加载另一个脚本并在该位置返回一个Promise

Promises

规范描述了一个返回Promiseimport函数。Promise是一个可以用来确定异步操作何时完成的对象。该对象公开了函数,这些函数可以用来处理异步操作的结果或操作过程中抛出的错误。最重要的函数是thencatch。前者可以用来定义当成功返回时应该做什么,而后者可以用来处理错误。

在单页应用(SPA)中,将每个页面放入路由器中进行懒加载是有意义的。图 6**.3 展示了示例应用程序模块的高级概述。虚线框表示捆绑区域——即可以组合成输出文件的源文件。这种捆绑是任何打包器最重要的方面之一:

图 6.3 – 示例应用程序的模块

图 6.3 – 示例应用程序的模块

虽然一些给定的方面在打包器中实现起来可能相当简单,但示例应用程序的一些其他属性可能具有挑战性。例如,当找到重复模块时,打包器的行为是什么?一些打包器可能会重复生成的代码,而其他打包器可能会创建一个共享捆绑包,它是生成侧捆绑包的加载前提。

在这个示例中,我们可以看到 Player.jsx 出现了两次。我们将利用这一点来回答每个打包器的问题。此外,几乎每个模块都需要 react;然而,由于它已经在初始脚本模块(script.tsx)中要求,因此不应重复。

不再拖延,让我们看看这个示例应用程序如何使用 Webpack 进行捆绑。

使用 Webpack

Webpack 可能是所有打包器中最受欢迎的选项之一。它也是最早的打包器之一——追溯到 Node.js 仍很年轻,捆绑整个想法相当新颖的时代。当时,任务运行器仍然占主导地位。然而,前端开发的日益复杂为更复杂的工具打开了大门。

使 Webpack 突出的是其生态系统。从一开始,Webpack 决定只开发一个非常浅的核心,专注于模块解析。在某种程度上,Webpack 只是一个将所有这些插件组合在一起并具有固定执行计划的包装器。它基本上将用户抛入的配置与所有选定的插件的力量结合起来。

今天,Webpack 也可以在没有插件或配置的情况下工作。至少在理论上是这样。在实践中,任何超出一些简单示例的项目都需要一些配置。此外,支持 TypeScript 等其他语言等有趣的功能将需要插件。

要开始使用 Webpack,我们需要使用 npm 安装 webpackwebpack-cli 包:

$ npm install webpack webpack-cli --save-dev

如果我们只想以编程方式使用 Webpack,例如从一个 Node.js 脚本中,那么我们也可以省去 webpack-cli 包的安装。

要从命令行运行 Webpack,你可以使用 npxwebpack 可执行文件一起:

$ npx webpack

这样直接运行 Webpack 不会成功:

assets by status 0 bytes [cached] 1 asset

WARNING in configuration

The 'mode' option has not been set, webpack will fallback to 'production' for this value.

Set 'mode' option to 'development' or 'production' to enable defaults for each environment.

You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/

ERROR in main

Module not found: Error: Can't resolve './src' in '/home/node/examples/Chapter06/example01'

[...]

webpack 5.74.0 compiled with 1 error and 1 warning in 116 ms

修复关于 mode 的警告相当简单——我们只需要提供一个 CLI 标志,例如 --mode production。更成问题的是,Webpack 找不到任何入口点。

如前所述,Webpack 可能会直接工作,但通常我们被迫创建一个配置文件。Webpack 使用真实的 Node.js 模块来提供配置,这为我们提供了 Node.js 生态系统的全部功能。Webpack 配置称为 webpack.config.js,应放置在 package.json 文件旁边。

让我们创建一个相当轻量级的配置。突出显示的属性是 Webpack 基本配置部分之一,告诉打包器使用哪些入口点:

webpack.config.js

module.exports = {
  entry: {
    app: "./src/script.tsx",
  },
};

现在,我们可以再次尝试运行 Webpack:

$ npx webpack --mode production

assets by status 360 bytes [cached] 1 asset

./src/script.tsx 185 bytes [built] [code generated] [1 error]

ERROR in ./src/script.tsx 5:54

Module parse failed: Unexpected token (5:54)

You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders

| import App from './App';

|

> const root = createRoot(document.querySelector('#app')!);

| root.render(<App />);

|

webpack 5.74.0 compiled with 1 error in 145 ms

这样更好,但我们还没有达到目标。Webpack 需要一个插件来理解特殊文件,例如 TypeScript 或 SASS 源文件。因此,我们还需要安装这些开发依赖项。在这种情况下,我们需要相当多的插件来使一切正常工作:

  • ts-loader 是一个插件,用于通过将其转换为 JavaScript 来处理 TypeScript 文件

  • sass-loader 是一个插件,用于通过将其转换为 CSS 来处理 SASS 文件

  • css-loader 是一个插件,用于通过将其转换为文本模块来处理 CSS

  • style-loader 是一个插件,用于通过将其转换为 JavaScript 模块来处理 CSS

  • babel-loader 是一个插件,用于使用 Babel 将具有附加语法(如 JSX)的 JavaScript 文件转换为纯 JS

  • html-webpack-plugin 是一个插件,用于将 HTML 文件作为输出 HTML 文件的模板加载

Webpack 的一个重大缺点是最终一切都必须是 JavaScript 模块。相当常见的是,插件会玩一些技巧来最终得到空模块,但它们仍然会将结果(如单独的图像或 CSS 文件)输出到文件系统。

您可以从命令行安装剩余的依赖项:

$ npm i ts-loader sass-loader css-loader style-loader babel-loader @babel/core @babel/preset-env @babel/preset-react html-webpack-plugin --save-dev

我们还需要提供适当的 tsconfig.json 文件。没有这个文件,TypeScript 将无法正确配置。Webpack 的 ts-loader 插件与 TypeScript 工作得相当紧密,因此它需要这个文件来知道要考虑哪些文件以及要丢弃哪些文件。它还使用这个文件来正确转换文件:

tsconfig.json

{
  "compilerOptions": {
    "jsx": "react",
    "module": "ESNext"
  },
  "include": ["./src"],
  "exclude": ["./node_modules"]
}

在此配置中,TypeScript 已经设置为以默认的 React 方式(即,将 JSX 转换为 React.createElement 调用)处理 JSX。配置还将输出 ESM 模块语法(突出显示的选项),这对于 Webpack 正确识别导入和导出非常重要。如果没有这个,从 TypeScript 文件触发时,捆绑拆分将无法工作。最后,我们将 src 文件夹中的所有文件包含在内,并排除 node_modules 文件夹。后者是一种常见的做法,可以节省大量的处理时间。

现在,为了使所有这些功能协同工作,我们需要相当扩展 Webpack 配置。首先,我们需要导入(即 require)我们希望使用的所有插件。在这种情况下,我们只想使用 html-webpack-plugin。接下来,我们需要设置所有需要包含的加载器的规则。这是通过 module.rules 数组完成的。最后,我们需要定义对剩余资源的处理方式。

让我们看看如何编写 Webpack 配置以成功打包我们的示例:

webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");
const babelLoader = { // make the config reusable
  loader: "babel-loader", // name of the loader
  options: { // the specific Babel options
    presets: ["@babel/preset-env", "@babel/preset-react"],
  },
};
const tsLoader = {
  loader: "ts-loader", // name of the loader
  options: { // the specific TypeScript loader options
    transpileOnly: true,
  },
};
module.exports = {
  entry: { // defines the entry points
    app: "./src/script.tsx", // named ("app") entry point
  },
  resolve: {
    // defines what extensions to try out for resolving
    extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
  },
  module: {
    // defines the rules for transforming modules
    rules: [
      { // applied for all *.scss files
        test: /\.scss$/i,
        use: ["style-loader", "css-loader", "sass-loader"],
      },
      { // applied for all *.css files
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
      { // applied for all *.js and *.jsx files
        test: /\.jsx?$/i,
        use: [babelLoader],
        exclude: /node_modules/,
      },
      { // applied for all *.ts and *.tsx files
        test: /\.tsx?$/i,
        use: [babelLoader, tsLoader],
      },
      { // applied for anything other than *.js, *.jsx, ...
        exclude: [/^$/, /\.(js|jsx|ts|tsx)$/i, /\.s?css$/i,
          /\.html$/i, /\.json$/i],
        type: "asset/resource",
      },
    ],
  },
  // defines plugins to use for extending Webpack
  plugins: [
    new HtmlWebpackPlugin({
      template: "./src/index.html",
    }),
  ],
};

前面的代码相当长。Webpack 经常被听到的一个批评是,其配置很快就会变得相当复杂。

Webpack 配置的一个重要部分是使用正则表达式。规则中的 testexclude 部分与正则表达式配合使用效果最佳。因此,而不是使用具有一些魔法行为或非常明确且重复的函数的字符串,我们提供了一个正则表达式,该表达式将检查当前模块是否应该由此规则处理。

每个加载器或插件的选项由相应的加载器或插件确定。因此,仅了解 Webpack 并不足以成功编写 Webpack 配置。你总是需要查阅配置中使用的不同部分的文档。在前面的配置中,ts-loaderbabel-loader 配置就是这样处理的。

加载器是从右到左进行评估的。例如,在处理 *.scss 文件的情况下,内容首先由 sass-loader 转换,然后传递给 css-loader。最后,所有内容都通过 style-loader 打包成一个 style 标签。

我们并不总是需要为加载器使用专门的包。使用前面代码中突出显示的 type 属性,我们可以使用 Webpack 的一些预制加载器,例如资源加载器 (asset/resource) 来返回引用文件的路径。其他选项包括数据 URI (asset/inline) 和访问文件的原始内容 (asset/source)。

另一种使用 Webpack 的方法是,在开发过程中启动一个小型服务器。每当我们对代码进行更新时,打包器可以重新处理更改的部分,并自动更新任何活跃的浏览会话。总的来说,这是一种非常方便且相当高效的方式来编写前端应用程序。

为了使 Webpack 的实时服务器工作,我们还需要安装另一个工具依赖项:

$ npm install webpack-dev-server --save-dev

这允许我们运行 serve 命令:

$ npx webpack serve --mode development

<i> [webpack-dev-server] Project is running at:

<i> [webpack-dev-server] Loopback: http://localhost:8081/

<i> [webpack-dev-server] On Your Network (IPv4): http://172.25.98.248:8081/

<i> [webpack-dev-server] Content not from webpack is served from '/home/node/examples/Chapter06/example01/public' directory

[...]

webpack 5.74.0 compiled successfully in 1911 ms

实时服务器将持续运行,直到被强制关闭。在命令行中,可以通过按 Ctrl + C 来完成。

webpack.config.js 中需要添加的一项是开发服务器(Webpack 配置中的 devServer 部分)的历史 API 回退。这将显著提高单页应用程序(SPA)的开发体验:

// ... like beforehand
module.exports = {
  // ... like beforehand
  devServer: {
    historyApiFallback: true,
  },
};

此设置将响应所有 404 URL,使用根目录的index.html – 就像生产模式中应该配置的单页应用(SPA)一样。这样,在非/路径的页面上刷新时仍然可以工作。如果没有显示的配置,浏览器将显示 404 错误 – 没有 SPA 将加载和处理路由。

现在我们已经知道了在 Webpack 中捆绑示例应用程序的工作方式,是时候看看一个更轻量级的替代品 esbuild 了。

使用 esbuild

esbuild 是一个相当新的工具,专注于性能。esbuild 增强性能的关键在于它从头到尾是用 Go 编程语言编写的。结果是具有某些优势的原生二进制文件,这些优势超过了纯 JavaScript 解决方案。

如果 esbuild 只提供原生解决方案,它可能不足以进入这个列表。毕竟,灵活性和扩展原始功能的选择对于任何类型的捆绑器都是关键。幸运的是,esbuild 的创造者已经考虑了这一点,并提出了一种优雅的解决方案。虽然 esbuild 的核心仍然是原生的 – 即,用 Go 编写并提供为二进制文件 – 插件可以用 JavaScript 编写。这样,我们就得到了两者的最佳结合。

要开始使用 esbuild,我们需要使用 npm 安装esbuild包:

$ npm install esbuild --save-dev

通过这次安装,你可以以编程方式使用 esbuild,也可以直接从命令行使用。

要从命令行运行 esbuild,你可以使用npxesbuild可执行文件一起使用:

$ npx esbuild

这将显示所有 CLI 选项。要执行某些操作,至少需要提供一个入口点:

$ npx esbuild --bundle src/script.tsx --outdir=dist --minify

 [ERROR] No loader is configured for ".scss" files: src/style.scss

    src/script.tsx:3:7:

      3 │ import './style.scss';

                ~~~~~~~~~~~~~~

 [ERROR] No loader is configured for ".mp3" files: src/sound.mp3

    src/Audio.tsx:4:22:

      4 │ import audioPath from "./sound.mp3";

                               ~~~~~~~~~~~~~

[...]

5 errors

如预期的那样,我们缺少一些配置步骤。与 Webpack 一样,最好的方法是通过创建配置来教 esbuild 关于这些额外信息。与 Webpack 不同,我们没有预定义的配置文件 – 相反,配置 esbuild 的方式是通过编程方式使用它。

要做到这一点,我们必须创建一个名为build.js的新文件,并导入esbuild包。我们可以使用buildbuildSync函数通过 esbuild 触发捆绑过程。

之前的 CLI 命令可以像这样以编程方式编写:

build.js

const { build } = require("esbuild");
build({ // provide options to trigger esbuild's build
  entryPoints: ["./src/script.tsx"], // where to start from
  outdir: "./dist", // where to write the output to
  bundle: true, // bundle the resulting files
  minify: true, // turn on minification
});

当然,给定的脚本本质上会给我们带来与直接使用 CLI 相同的错误。因此,让我们添加一些东西:

  • esbuild-sass-plugin将 SASS 转换为 CSS 文件

  • @craftamap/esbuild-plugin-html允许我们使用模板 HTML 文件

在我们可以使用这两个插件之前,我们需要安装它们:

$ npm i esbuild-sass-plugin @craftamap/esbuild-plugin-html ––save-dev

一旦安装了插件,我们就可以扩展build.js文件,使其包含这两个插件:

build.js

const { build } = require("esbuild");
const { sassPlugin } = require("esbuild-sass-plugin");
const { htmlPlugin } = require("@craftamap/esbuild-plugin-
  html");
build({
  entryPoints: ["./src/script.tsx"],
  outdir: "./dist",
  format: "esm", // use modern esm format for output
  bundle: true,
  minify: true,
  metafile: true, // required for htmlPlugin
  splitting: true, // allow lazy loading
  loader: {
    ".jpg": "file",
    ".webp": "file",
    ".mp3": "file",
    ".mp4": "file",
  },
  plugins: [
    sassPlugin(),
    htmlPlugin({
      files: [
        {
          entryPoints: ["./src/script.tsx"],
          filename: "index.html",
          scriptLoading: "module", // because of esm
          htmlTemplate: "./src/index.html",
        },
      ],
    }),
  ],
});

在此过程中,我们教 esbuild 关于我们对于给定文件扩展名的偏好。通过loader部分,我们将扩展名映射到特定的文件加载器。file类型指的是将生成外部文件的加载器。该文件的导入将导致对文件相对输出路径的引用。

要启用包分割,需要设置splitting选项。这也使得使用esm格式成为必要。这是 esbuild 知道如何生成可以懒加载内容的脚本的唯一格式。此外,htmlPlugin需要 esbuild 生成一个元文件来反映构建工件。因此,需要将metafile选项设置为true

与 Webpack 一样,esbuild 的生态系统使得这个工具如此灵活,但难以掌握。不同插件的选择需要从不同的插件文档中收集。就像之前的 Webpack 生态系统一样,这些插件的质量、成熟度以及背后的社区差异很大。

如果你想要一个开发服务器——就像我们在上一节中添加到 Webpack 中的那样——你可以使用serve函数,该函数可以从esbuild导入。第一个参数描述了服务器特定的设置,例如服务应监听的端口。第二个参数包含构建选项——即我们现在提供的选项——作为build函数的唯一参数。

让我们再写一个名为serve.js的脚本来说明这种用法:

serve.js

const { serve } = require("esbuild");
const { sassPlugin } = require("esbuild-sass-plugin");
const { htmlPlugin } = require("@craftamap/esbuild-plugin-
  html");
// use helper from esbuild to open a dev server
serve(
  {
    // will be reachable at http://localhost:1234
    port: 1234,
  },
  {
    // same options as beforehand (supplied to build())
    // ...
  }
);

目前 esbuild 不做的一件事是 HMR。因此,与类似工具相比,仅使用 esbuild 的开发者体验可能在这方面略感不足。

考虑到这一点,让我们再探索一个广泛用于打包的选项——让我们来看看无需配置的 Parcel 打包器。

使用 Parcel

当包裹到达社区时,围绕它的炒作非常巨大。这其中的原因在于一个新特性:无需配置的打包。Parcel 试图利用package.json中已经提供的信息——或者为特定工具(如 Babel)编写的配置文件。通过这种机制,Parcel 的创造者认为可以消除配置打包器的复杂性。

然而,最终,整个方面在某些意义上适得其反。如前所述,打包器需要一些灵活性。为了实现这种灵活性,需要一个健全的配置系统。虽然 Webpack 的配置系统有点过于冗长和复杂,但 esbuild 提供的可能又有点过于底层。

原始 Parcel 的继任者现在也提供了一种可选的配置系统,它试图在 Webpack 的冗长和复杂性与 esbuild 的低级之间取得平衡。这使得 Parcel 不再是无需配置的,而是一个无需配置的打包器。

要开始使用 Parcel,我们需要使用 npm 安装parcel包:

$ npm install parcel ––save-dev

通过这次安装,你可以以编程方式使用 Parcel,也可以直接从命令行使用。

要从命令行运行 Parcel,你可以使用npxparcel可执行文件一起。对于 Parcel,入口点可以是 HTML 文件:

$ npx parcel src/index.html

在我们的情况下,我们仍然需要修改 HTML,使其也指向其他源文件以继续构建模块图。一个与 Parcel 更为契合的 index.html 文件版本如下所示:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,
      initial-scale=1.0">
    <title>Bundler Example</title>
    <link rel="stylesheet" href="./style.scss">
</head>
<body>
<div id="app"></div>
<script type="module" src="img/script.tsx"></script>
</body>
</html>

重要的是,我们添加了样式表和脚本入口点。这些将被 Parcel 检测并正确打包。最终,HTML 文件将被用作模板——入口点将被打包的样式表和脚本文件引用所替换。

立即启动 Parcel 将部分工作,但在此阶段,Parcel 仍然与我们的音频和视频文件存在一些问题。虽然 Parcel 已经知道大多数图像文件(如 *.webp*.png),但一些其他资产需要先进行配置。在 Parcel 中,这意味着创建一个 .parcelrc 文件并添加一个关于要使用的转换器的部分:

.parcelrc

{
  "extends": "@parcel/config-default",
  "transformers": {
    "*.{mp3,mp4}": ["@parcel/transformer-raw"]
  }
}

配置指示 Parcel 仍然依赖于非常精心选择的默认设置。然而,我们也在处理转换逻辑的部分添加了有关两种文件类型的定义。与 Webpack 或 esbuild 一样,Parcel 也内置了一个类型来处理此类导入,通过返回可以在代码中使用的文件名。在 Parcel 的情况下,这个类型被称为 @parcel/transformer-raw

现在,让我们看看 Parcel 是否已经启动:

$ npx parcel src/index.html

Server running at http://localhost:1234

 Built in 12ms

默认情况下,Parcel 将启动一个开发服务器。这已经包含了开发应用程序所需的一切。非常方便。如果我们想构建文件——例如,将输出工件放置在服务器上——我们可以使用 build 子命令:

$ npx parcel build src/index.html

 Built in 6.11s

dist/index.html                  402 B    4.73s

dist/index.3429125f.css       39.02 KB    149ms

dist/index.cb13c36e.js       156.34 KB    1.90s

dist/Home.bf847a6b.js          1.05 KB    148ms

dist/river.813c1909.webp      29.61 KB    150ms

dist/snow.390b5a72.jpg        13.28 KB    138ms

dist/Video.987eca2d.js           908 B    1.90s

dist/earth.4475c69d.mp4       1.5 MB     61ms

dist/Video.61df35c5.js       611.76 KB    4.62s

dist/Audio.677f10c0.js           908 B    149ms

dist/sound.6bdd55a4.mp3      746.27 KB     92ms

有 CLI 标志和选项可以设置几乎一切,例如输出目录。然而,默认情况下,相当常见的 dist 文件夹被选中。

最后,但同样重要的是,让我们来看看相当流行的 Vite 打包器,它试图将所有先前方法的优点结合成一个单一的工具。

使用 Vite

在流行的打包器集中,最新加入的是 Vite。它结合了几种现有的工具——例如 Rollup.js 和 esbuild——以及一个统一的插件系统,以允许快速开发。Vite 的方法是在 esbuild 的速度下提供 Webpack 的功能。

最初,Vite 是由前端框架 Vue 的创建者构建的。然而,随着时间的推移,Vite 的插件系统变得更为强大。随着其 API 表面的增加,其他前端框架如 React 或 Svelte 也可以得到支持。现在,Vite 已经从单一用途的工具发展成为一个真正的瑞士军刀——这要归功于一个精心设计的插件机制和活跃的社区。

要开始使用 Vite,我们需要使用 npm 安装 vite 包:

$ npm install vite --save-dev

通过这次安装,你可以以编程方式使用 Vite,也可以直接从命令行使用。

关于 Vite,有一件事需要知道的是,它比 Parcel 更加强调使用 index.html 文件作为入口点。为了使 Vite 正常工作,我们需要将 index.html 文件从 src 文件夹移动到父目录——即项目的根目录。

正如我们之前所做的那样,我们应该正确设置引用:

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,
      initial-scale=1.0">
    <title>Bundler Example</title>
    <link rel="stylesheet" href="./src/style.scss">
</head>
<body>
<div id="app"></div>
<script type="module" src="img/script.tsx"></script>
</body>
</html>

要从命令行运行 Vite,你可以使用 npxvite 可执行文件一起:

$ npx vite

  VITE v3.0.5  ready in 104 ms

    Local:   http://localhost:5173/

    Network: use --host to expose

这一切开始得很快,因为目前还没有进行任何打包或转换。只有当我们访问服务器时,Vite 才会开始转换事物——而且仅仅是当前我们正在查看的事物。如果你对更真实的情况感兴趣,那么 preview 子命令可能很有用。它执行生产构建,但通过开发服务器暴露结果。

当然,就像 Parcel 一样,我们仍然可以生成可以放置在服务器上的文件。与 Parcel 非常相似,我们可以使用 build 子命令来完成这项工作:

$ npx vite build

vite v3.0.5 building for production...

 110 modules transformed.

dist/assets/river.4a5afeaf.webp   29.61 KiB

dist/assets/snow.cbc8141d.jpg     13.96 KiB

dist/assets/sound.fa282025.mp3    746.27 KiB

dist/assets/earth.71944d74.mp4    1533.23 KiB

dist/index.html                   0.42 KiB

dist/assets/Home.82897af9.js      0.45 KiB / gzip: 0.23 KiB

dist/assets/Video.ce9d6500.js     0.36 KiB / gzip: 0.26 KiB

[...]

dist/assets/index.404f5c02.js     151.37 KiB / gzip: 49.28 KiB

dist/assets/Player.c1f283e6.js    585.26 KiB / gzip: 166.45 KiB

对于这个例子,Vite 是唯一一个只需满足所有先决条件就能正常工作的打包器——至少一开始是这样的。如果你需要自定义配置,例如添加一些插件,那么你可以遵循 Webpack 的方法,在项目的根目录中创建一个 vite.config.js 文件。

现在,让我们回顾一下你在本章中学到的内容。

摘要

在本章中,你学习了什么是打包器,为什么你需要它,有哪些打包器,以及如何配置和使用它们。你现在能够将你的网络项目从原始源代码构建成生产就绪的资产。

如果你具备关于打包器的详细知识,你可以创建非常可靠的代码库,这些代码库针对效率进行了定制。不仅打包时会移除不必要的代码,而且所有引用的文件都将被处理并考虑在内。因此,你永远不必担心遗漏文件。

现有的打包器种类繁多,一开始可能会让人感到畏惧。虽然有一些明显的选择,比如非常流行的 Webpack 打包器,但根据你手头的项目,其他选项可能由于更少的复杂性或更好的性能而更好。如果有疑问,你可以参考本章的 比较可用的打包器 部分以确定哪个打包器可能最适合你。

在下一章中,我们将更深入地探讨另一类至关重要的开发工具。我们将看到测试工具如何让我们对我们的代码在现在和未来都能按预期工作充满信心。

第七章:使用测试工具提高可靠性

现在我们能够高效地编写和构建浏览器代码,因此考虑验证代码的输出也是有意义的。它真的满足了给定的要求吗?在预期结果方面有什么变化吗?当传入意外值时,代码会崩溃吗?

为了回答这些问题,我们需要进行测试。测试可以意味着很多不同的事情——而且根据您询问的人不同,您将得到不同的“我们应该测试什么?”问题的答案。在本章中,我们将探讨作为开发者感兴趣的不同选项。我们将了解存在哪些工具可以自动化这些测试,以及我们如何实际设置和使用它们。

我们将开始我们的测试之旅,首先讨论备受喜爱的测试金字塔。然后,我们将继续学习关于测试工具的类型——最值得注意的是,纯运行器和完整框架。最后,我们将介绍这个领域的一些最受欢迎的工具。

到本章结束时,您将了解针对您的编程需求应选择哪种测试框架或测试运行器,以及每个选项的优缺点。

本章我们将涵盖以下关键主题:

  • 考虑到测试金字塔

  • 比较测试运行器和框架

  • 使用 Jest 框架

  • 使用 Mocha 框架

  • 使用 AVA 测试运行器

  • 使用 Playwright 进行视觉测试

  • 使用 Cypress 进行端到端测试

技术要求

本章的完整源代码可在github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter07找到。

本章的 CiA 视频可在bit.ly/3DW9yoV访问。

考虑到测试金字塔

近年来,越来越多的软件测试类型被识别并添加到软件项目和测试专业人员(如质量保证工程师)的标准工具库中。一个强大的工具,可以分类和排序最常见的软件测试类型,就是测试金字塔。

测试金字塔根据测试的可见性和努力程度来安排不同的测试类型。金字塔的较高层需要更多的努力,但具有更高的可见性。应该更多地编写位于金字塔较低层的测试——毕竟,这些是金字塔的基础。

图 7.1展示了测试金字塔的示意图。测试金字塔的基础是由单元测试构成的,它们提供了足够的可靠性,可以在其上运行组件和集成测试。最后,可以运行 UI 测试(通常被称为端到端测试),以验证解决方案是否适用于最终用户:

图 7.1 – 具有三个自动测试层的测试金字塔

图 7.1 – 具有三个自动测试层的测试金字塔

通常,端到端测试指的是使用面向最终用户的接口进行的测试。在 Web 应用程序的情况下,这将是指实际的网站。由于它们的本质,端到端测试通常是黑盒测试。整个系统被视为一个整体,因此尽可能接近生产环境运行。

黑盒测试

黑盒的概念来源于所谓的黑盒方法。这是一种通过改变输入并测量输出来分析开放系统的常见技术。当内部工作原理既不为人所知也不易访问时,这种方法是有意义的。同样,黑盒测试也是在不更改应用程序内部工作原理的情况下进行的。

端到端测试的变体主要关注性能(负载测试)或安全性(渗透测试)。虽然前者可能相当棘手且成本高昂,但后者应定期执行以抵御潜在的攻击。对于公司来说,最大的风险之一就是被黑客攻击。这不仅包括珍贵数据的盗窃,还会对公司的品牌产生强烈的负面影响。为了帮助防御此类场景,有时会使用灰盒测试,这与黑盒测试不同,它理解系统的一些已记录的操作。

测试的一个挑战是,许多使用的术语,如集成测试或组件测试,并没有统一的定义。例如,有些人认为集成测试非常狭窄——一次测试与一个外部部分的集成。其他人可能说集成测试应该涵盖与所有外部部分的集成。因此,在使用项目之前仔细审查和定义这些术语非常重要。

当我们提到单元测试时,我们指的是针对项目单个单元(如函数)的测试——仅针对其特定部分。通常,这个单元携带一些可以具体测试的逻辑。与这个单一单元无关的所有内容都必须得到控制。虽然一些单元测试可以编写成黑盒测试,但大多数单元测试将需要深入了解内部工作原理。这样,就可以按需控制被测试单元的行为。

考虑以下代码:

pure.js

export function pickSmallestNumber(...numbers) {
  if (numbers.length > 0) {
    return numbers.reduce(
      (currentMin, value) => Math.min(currentMin, value),
      Number.MAX_VALUE);
  }
  return undefined;
}

在前面的代码中,该函数非常适合进行单元测试:

  • 它被导出,因此我们可以从包含测试的另一个模块中访问它。

  • 它不使用函数之外的内容——它是一个所谓的纯函数。

  • 逻辑足够复杂,可以针对一组预定义的测试用例进行测试。

pickSmallestNumber 函数的单元测试可能如下所示:

test('check if undefined is returned for no input', () => {
  const result = pickSmallestNumber();
  assert(result === undefined);
});
test('check if a single value is the smallest number',
  () => {
  const result = pickSmallestNumber(20);
  assert(result === 20);
});
test('check if 1 is smaller than 5', () => {
  const result = pickSmallestNumber(5, 1);
  assert(result === 1);
});
test('check if -1 is smaller than 0 but larger than -5',
  () => {
  const result = pickSmallestNumber(-1, -5, 0);
  assert(result === -5);
});

注意

如前所述,代码可能看起来像这样。使用的函数定义在别处,前面的代码按所示无法运行。

对于这些测试,我们引入了一个新的函数test,它接受测试的描述和以函数形式运行的测试代码。我们还引入了一个断言函数assert,可以从内置的assert模块中获取。建议的assert函数接受布尔输入——如果输入为false则抛出异常。我们将查看的测试工具和框架将用更具有表现力和优雅的替代方案替换这些结构。

除了实际的测试和测试区域差异之外,工具选择也提供了一些变化。其中最关键的一个是完整测试框架和测试运行器之间的差异。

比较测试运行器和框架

从历史上看,针对 Web 浏览器的 JavaScript 测试不能简单地编写和自动运行。主要原因在于这涉及到处理真实的浏览器。没有方法可以仅仅“假装在浏览器中运行”。正因为如此,该领域最初的一些工具要么是脚本,要么是整个网站评估 JavaScript 或浏览器自动化工具。后者实际上形成了一个自己的类别——它是现代端到端测试的核心。

运行测试的主要驱动力——从历史上看,为了启动所有需要运行以实际执行测试的东西——被称为测试运行器。JavaScript 空间中第一个非常成功的测试运行器之一是Karma。Karma 的工作是启动一个服务器,该服务器运行托管测试的网站,这些测试针对的是应该在浏览器中运行的 JavaScript 代码。然后 Karma 打开可用的浏览器以访问运行测试的托管网站。结果被报告回服务器并在控制台中显示。

如果这一切听起来很复杂——你是对的,它确实很复杂。这些运行器的任务是使这个过程尽可能可靠。它们还试图尽可能用户友好,并尽可能隐藏底层复杂性。

今天,像 Karma 这样的测试运行器实际上并不是必需的。相反,大多数测试运行器,如AVA,通过利用 Node.js 留在控制台。当 JavaScript 代码需要浏览器 API,这最像 DOM API 时,运行器只是模拟这些缺失的 API。由于模拟的结果,被测试的 JavaScript 代码可以像在浏览器中一样运行,但一切都在 Node.js 中保持。

虽然关于模拟 DOM API 的部分听起来很棒,但实际上它并不在测试运行器的范围内。测试运行器实际上只专注于运行测试。相反,开发者会建立模拟部分或者选择一个完整的测试框架。一个完整的测试框架应该已经解决了诸如 DOM API 模拟等问题,以便它们可以轻松添加,或者它们已经是标准安装的一部分。

一个完整的测试框架不仅包括测试运行器,还包括断言库等。到目前为止,我们只使用了一些具有建议行为的 assert 函数。一个完整的断言库将为我们提供一组函数,使得在断言失败的情况下调试过程变得非常容易。从测试输出中,我们就可以看到哪个断言失败了——以及为什么。

一个好的断言库的例子是 shouldexpectassert。在测试代码中最常使用的导出是 expect

使用来自 chai 包的 expect,可以将我们前面的单元测试的前两个测试用例重写如下:

test('check if undefined is returned for no input', () => {
  const result = pickSmallestNumber();
  expect(result).to.be.undefined;
});
test('check if a single value is the smallest number',
  () => {
  const result = pickSmallestNumber(20);
  expect(result).to.equal(20);
});

重新编写的代码之美在于它几乎就像文本一样易读。即使是那些在测试框架、JavaScript 或 Node.js 方面经验较少的人也能识别出测试做了什么——更重要的是——它试图验证什么。使用成员运算符(.)链式调用期望是使 Chai 成为如此受欢迎的断言库的原因之一。

每个测试框架都附带一个断言库。有些框架甚至允许用户决定使用哪个断言库。

现在我们已经了解了基于 JavaScript 的应用程序测试的所有基础知识,我们应该探索一些实际实施此类测试的工具。我们将从最常用的测试实用工具之一:Jest 测试框架开始。

使用 Jest 框架

Jest 是一个由 Facebook 编写的现代测试框架,旨在充分利用 Node.js 来运行测试。它应该有运行 Facebook 所需的所有测试的能力,而无需拥有工程学学位来理解、控制或修改它。

要使用 Jest,你需要从 npm 安装 jest 包:

$ npm install jest --save-dev

这允许你使用 jest 命令行工具。理想情况下,使用 npx 运行它,就像我们使用其他工具一样:

$ npx jest

Jest 可以通过提供一个 jest.config.js 文件来配置。创建此类文件的最简单方法是通过使用带有 --init 标志的 jest 工具。这将引导我们回答一些问题以创建合适的配置:

$ npx jest --init

The following questions will help Jest to create a suitable configuration for your project

 Would you like to use Jest when running "test" script in "package.json"? … yes

 Would you like to use Typescript for the configuration file? … no

 Choose the test environment that will be used for testing › jsdom (browser-like)

 Do you want Jest to add coverage reports? … no

 Which provider should be used to instrument code for coverage? › v8

 Automatically clear mock calls, instances, contexts and results before every test? … yes

  Modified /home/node/example/Chapter07/package.json

  Configuration file created at /home/node/example/Chapter07/jest.config.js

在这种情况下,我们指示 Jest 修改 package.json 中的 test 脚本。现在,当我们在终端中运行 npm run testnpm test 以运行当前项目的测试时,Jest 将启动。测试环境和覆盖率选项对我们来说很有趣。

让我们看看生成的配置文件的基本部分:

module.exports = {
  clearMocks: true,
  coverageProvider: "v8",
  testEnvironment: "jsdom",
};

生成的配置文件还包含许多注释和注释选项。这样,你可以在不查阅官方文档网站的情况下配置 Jest。

给定的配置有一个问题……选定的 jsdom 环境仅在安装了名为 jest-environment-jsdom 的特殊包时才工作。这已经在 Jest 的第 28 版本中更改,但遗憾的是,这不是自动完成的:

$ npm install jest-environment-jsdom --save-dev

幸运的是,Jest 中的错误消息通常相当好,非常有帮助。即使不知道这些事情,我们也会得到正确的消息,告诉我们确切应该做什么。

我们最后应该考虑的是使用 Babel 进行代码转换。如果我们编写纯 Node.js 兼容的代码(例如,通过使用 CommonJS),则这些转换是不必要的。否则,代码转换是必要的。一般来说,Jest 使用代码转换来使任何类型的使用的代码——不仅限于纯 JavaScript,还包括 TypeScript 和 Flow 等变体——在无需特殊处理的情况下即可使用。

首先,让我们安装 babel-jest 插件和所需的 @babel/core 包:

$ npm install babel-jest @babel/core @babel/preset-env --save-dev

现在,让我们通过 transform 配置部分扩展 jest.config.js

module.exports = {
  // as beforehand
  "transform": {
    "\\.js$": "babel-jest",
  },
};

新的配置部分告诉 Jest 使用 babel-jest 转换器来转换所有以 .js 结尾的文件。另外,添加一个 .babelrc 文件,如 第四章使用 JavaScript 的不同 变体 中所述:

{
  "presets": ["@babel/preset-env"]
}

使用此配置,Babel 将正确转换指定的文件。现在可以按如下方式编写测试代码:

pure.test.js

import { pickSmallestNumber } from "./pure";
it("check if undefined is returned for no input", () => {
  const result = pickSmallestNumber();
  expect(result).toBeUndefined();
});
it("check if a single value is the smallest number", () => {
  const result = pickSmallestNumber(20);
  expect(result).toBe(20);
});
it("check if 1 is smaller than 5", () => {
  const result = pickSmallestNumber(5, 1);
  expect(result).toBe(1);
});
it("check if -1 is smaller than 0 but larger than -5",
  () => {
  const result = pickSmallestNumber(-1, -5, 0);
  expect(result).toBe(-1);
});

虽然 Jest 也支持与我们在 考虑测试金字塔部分 中引入的伪实现中的 test 函数一样使用 test 函数,但 it 函数更为常见。请注意,Jest 内置了自己的断言库,它使用 expect 函数。expect 函数也被称作 匹配器

匹配器

对于我们的简单示例,匹配器只需要处理字符串和数字。然而,通常情况下,任何类型的 JavaScript 输入,如数组或对象,都可以进行匹配和断言。expect 函数有一些辅助函数来处理,例如,对象相等(toBe),即具有相同的引用,以及等价(toEqual),即具有相同的内容。

让我们运行这个:

$ npm run test

> Chapter07@1.0.0 test /home/node/example/Chapter07

> jest

 PASS  src/pure.test.js

   check if undefined is returned for no input (2 ms)

   check if a single value is the smallest number (1 ms)

   check if 1 is smaller than 5

   check if -1 is smaller than 0 but larger than -5

Test Suites: 1 passed, 1 total

Tests:       4 passed, 4 total

Snapshots:   0 total

Time:        0.818 s, estimated 1 s

Ran all test suites.

太好了——我们的代码可以正常工作。默认情况下,Jest 会查找所有以 .test.js 结尾的文件。按照惯例,.spec.js 文件也可以使用。不过,使用的惯例是可以改变的。

现在,Jest 可能是使用最广泛的测试框架。然而,特别是较老的项目可能会使用其他框架。这里一个非常稳固且常见的例子是 Mocha。与 Jest 一样,它也是一个测试框架,但有一些关键的区别。

使用 Mocha 框架

jsdom

要使用 Mocha,你需要从 npm 安装 mocha 包:

$ npm install mocha --save-dev

这允许你使用 mocha 命令行工具。理想情况下,使用 npx 运行它,就像我们使用其他工具一样:

$ npx mocha

到目前为止,还没有什么起作用。默认情况下,Mocha 使用与 Jest 不同的约定。在这里,我们需要指定不同的模式或将测试放在名为 test 的文件夹中。

我们绝对需要做的是为代码转换包含 Babel。这与 Jest 的使用略有不同。我们不是使用专门的插件,而是仅集成 @babel/register 包,该包将在模块加载时自动转换任何代码:

$ npm install --save-dev @babel/register @babel/core @babel/preset-env

现在,我们可以复制之前与 Jest 一起使用的 .babelrc 文件。对于 Mocha,配置可以放在一个名为 .mocharc.js 的文件中。将配置文件设置为始终首先要求 @babel/register 包的设置如下所示:

.mocharc.js

module.exports = {
  require: "@babel/register",
};

Mocha 是一种特殊的测试框架,因为它不附带断言库。相反,它依赖于其他断言库。只要在出现不匹配时抛出异常,断言就会生效。

要使用 Mocha 编写测试而不使用除 Node.js 已经附带的特殊断言库之外的其他断言库,我们可以这样编写测试:

pure.test.js

import { equal } from "assert";
import { pickSmallestNumber } from "../src/pure";
it("check if undefined is returned for no input", () => {
  const result = pickSmallestNumber();
  equal(result, undefined);
});
it("check if a single value is the smallest number", () => {
  const result = pickSmallestNumber(20);
  equal(result, 20);
});
it("check if 1 is smaller than 5", () => {
  const result = pickSmallestNumber(5, 1);
  equal(result, 1);
});
it("check if -1 is smaller than 0 but larger than -5",
  () => {
  const result = pickSmallestNumber(-1, -5, 0);
  equal(result, -5);
});

在前面的代码中,it 函数的行为与 Jest 中的一致。

现在,让我们通过 npm test 运行 mocha:

$ npm run test

> example02@1.0.0 test /home/node/example/Chapter07/example02

> mocha

   check if undefined is returned for no input

   check if a single value is the smallest number

   check if 1 is smaller than 5

   check if -1 is smaller than 0 but larger than -5

  4 passing (3ms)

与 Jest 相比,我们得到的输出少一些。尽管如此,所有相关信息都得到了展示,如果有错误,我们就会得到所有必要的信息来识别和修复问题。Jest 和 Mocha 之间的关键区别在于,Jest 会根据相关的测试模块真正地分解测试,而 Mocha 只是展示结果。

Mocha 实际上功能非常丰富,但并非轻量级。一个更简洁的选项是避免使用完整的测试框架,而是仅使用测试运行器。一个选项是使用 AVA。

使用 AVA 测试运行器

AVA 是一个针对 Node.js 的现代测试运行器。它之所以突出,是因为它能够拥抱新的 JavaScript 语言特性以及 Node.js 的前沿特性,如进程隔离。因此,AVA 以非常快速和可靠的方式执行测试。

要使用 AVA,您需要从 npm 安装 ava 包:

$ npm install ava --save-dev

这允许您使用 ava 命令行工具。理想情况下,使用 npx 运行它,就像我们使用其他工具一样:

$ npx ava

虽然 Mocha 和 Jest 也可以全局安装,但 AVA 只在项目作为本地依赖项时工作。鉴于这已经是更好的设置,因此从这个限制中不应有实际缺点。

如前所述,AVA 与 Node.js 构建得非常紧密——尽可能遵循其约定和规则。在这方面,AVA 也允许我们快速适应 ESM 而不是 CommonJS。通过修改项目的 package.json,我们立即支持在测试中使用 ESM:

package.json

{
  // like beforehand
  "type": "module",
  // ...
}

默认情况下,AVA 会寻找与 Jest 相同模式的文件。因此,以 .test.js.spec.js 结尾的文件将与其他文件一起被找到。无需配置 AVA 或将测试放在单独的目录中。

AVA 还提供了一种作为 ava 包默认导出的函数。这个函数用于声明测试。每个测试都接收一个所谓的测试上下文作为其实现的回调参数。这样,AVA 感觉比其他解决方案更加明确和不太神秘。

让我们看看如何使用 AVA 编写测试:

pure.test.js

import test from 'ava';
import { pickSmallestNumber } from "./pure.js";
test("check if undefined is returned for no input", (t) => {
  const result = pickSmallestNumber();
  t.is(result, undefined);
});
test("check if a single value is the smallest number",
  (t) => {
  const result = pickSmallestNumber(20);
  t.is(result, 20);
});
test("check if 1 is smaller than 5", (t) => {
  const result = pickSmallestNumber(5, 1);
  t.is(result, 1);
});
test("check if -1 is smaller than 0 but larger than -5",
  (t) => {
  const result = pickSmallestNumber(-1, -5, 0);
  t.is(result, -5);
});

总体而言,结构类似于前两个完整框架。然而,AVA 只是一个运行器,缺少了特殊断言库、模拟选项和快照等功能。

要运行测试,我们可以调整package.json中的test脚本。触发ava实用程序,使用 AVA 测试运行器的运行看起来像这样:

$ npm run test

> example03@1.0.0 test /Users/node/example/Chapter07/example03

> ava

   check if undefined is returned for no input

   check if a single value is the smallest number

   check if 1 is smaller than 5

   check if -1 is smaller than 0 but larger than -5

  ─

  4 tests passed

现在我们已经介绍了三个用于运行以代码为中心的测试的工具,让我们也来探索一些运行 UI 测试的选项。我们将从Playwright开始,这是一个用于自动化 Google Chrome 或 Firefox 等现代网络浏览器行为的库。

使用 Playwright 进行视觉测试

Node.js 不仅是一个运行逻辑测试的绝佳基础,而且还可以用于验证浏览器中运行的网站等视觉。浏览器自动化的现代方法是 Playwright。

要使用 Playwright,您需要从 npm 安装playwright包:

$ npm install playwright --save-dev

playwright包使您能够在现有应用程序中使用 Playwright,这也可以是使用jest-playwright-preset包执行的单元测试中的现有测试。

通过使用@playwright/test测试运行器包,我们可以实现一个更好的设置:

$ npm install @playwright/test --save-dev

这允许您使用playwright命令行实用程序。理想情况下,使用npx运行它,就像我们使用其他工具一样:

$ npx playwright test

运行此命令将寻找所有与之前在 Jest 和 AVA 部分中提到的相同约定匹配的文件。每个以.test.js.spec.js结尾的文件都将被包括在内。此外,Playwright 测试运行器还能够评估 TypeScript 文件。因此,运行器默认还会查找.test.ts.spec.ts文件。

让我们再次查看一个简单的测试运行。我们将对位于microfrontends.art的公共网站运行测试。该测试也可以针对运行在 localhost 上的本地网站:

mf.test.ts

import { test, expect } from '@playwright/test';
test('homepage has micro frontends in the title and in an
  h1', async ({ page }) => {
  await page.goto('https://microfrontends.art/');
  // Expect the title "to contain" a substring.
  await expect(page).toHaveTitle(/Micro Frontends/);
  // Grab an element ("h1")
  const h1 = page.locator('h1');
  // Expect the element to have a specific text
  await expect(h1)
    .toHaveText('The Art of Micro Frontends');
});

结构感觉有点类似于 AVA。与 AVA 一样,我们使用显式导入来创建测试基础设施。我们还需要使用测试回调的参数来使用page对象对网站进行实际的有用操作。

让我们更改package.json中的test脚本并运行提供的测试:

$ npm run test

> example04@1.0.0 test /Users/node/example/Chapter07/example04

> playwright test

Running 1 test using 1 worker

    1 tests/mf.test.ts:3:1 › homepage has Playwright in title and get started link linking to the intro page (491ms)

  1 passed (5s)

写端到端测试的另一个选项是 Cypress。它承诺将更加方便,并且也具备测试单个组件的能力。

使用 Cypress 进行端到端测试

Cypress 是一个专注于端到端测试的框架,它还具备测试单个 UI 组件的能力。它试图通过主要避免浏览器自动化来有所不同。相反,其测试运行器直接位于浏览器内部。

要使用 Cypress,您需要从 npm 安装cypress包:

$ npm install cypress --save-dev

这允许您使用cypress命令行实用程序。理想情况下,使用npx运行它,就像我们使用其他工具一样:

$ npx cypress open

Cypress 核心是一个图形工具。因此,我们首先接触到的是一个小的配置器,它允许我们设置我们的项目。配置器在 图 7.2 中显示。选择 端到端测试 将使您能够影响要编写的文件:

图 7.2 – 第一次打开 Cypress 配置器

图 7.2 – 第一次打开 Cypress 配置器

配置器还允许您选择测试实际运行的浏览器。目前,ChromeEdgeElectronFirefox 受支持。

在这个时候,我们可以添加我们的第一个测试——在 Cypress 的上下文中,被称为 spec 或 specification。我们将使用与 Playwright 例子中添加的相同类型的测试:

mf.cy.js

describe("empty spec", () => {
  it("passes", () => {
    cy.visit("https://microfrontends.art");
    // Expect the title "to contain" a substring.
    cy.title().should("contain", "Micro Frontends");
    // Expect the h1 element to have a specific text.
    cy.get("h1").should("have.text",
      "The Art of Micro Frontends")
  });
});

如前所述的小测试所示,整个测试结构是隐式的。这种做法的主要缺点是没有良好的 IDE 支持,无法帮助进行适当的类型检查——即 TypeScript 可以使用的类型信息。一种解决办法是在项目中安装 typescript 包并创建一个 tsconfig.json 文件,让 TypeScript 了解 Cypress:

tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "lib": ["es5", "dom"],
    "types": ["cypress", "node"]
  },
  "include": ["**/*.ts"]
}

现在,您可以将测试文件重命名为以 .ts 结尾(在我们的示例中,mf.cy.ts),并在大多数编辑器和 IDE 中享受改进的自动完成功能。

运行此测试将生成图形结果。在 图 7.3 中,您可以看到在所选浏览器中运行测试的输出。这是 Cypress 的关键点。端到端测试永远不会离开视觉区域,并允许我们在其视觉边界内直接与测试交互。这使得使用 Cypress 编写的测试不仅非常适合初学者,而且调试起来也相当容易:

图 7.3 – 在浏览器中直接运行测试

图 7.3 – 在浏览器中直接运行测试

如果您想直接运行本地可用的测试而不进行视觉交互,您也可以使用 run 命令:

$ npx cypress run

在非本地环境中,例如用于验证软件构建的 CI/CD 管道中,这尤其方便。

考虑到这一点,让我们回顾一下在本章中学到的内容。

摘要

在本章中,您了解了我们可以自动化的不同类型的测试以及这些类型对于软件项目成功的重要性。您看到了现有的流行工具,这些工具可以帮助我们覆盖我们的项目。通过遵循测试金字塔,您应该能够决定需要关注哪些测试,以使您的项目尽可能可靠。

通过使用 Jest 或 Mocha 等强大的测试框架,或者使用 AVA 等灵活的运行器,你可以自动化很多不同的事情——从单元测试到端到端测试。像 Playwright 或 Cypress 这样的专用端到端测试框架也自带自己的运行器——这对于复杂的视觉测试尤其有意义。在单元和集成测试领域,Jest 非常方便。它还允许我们快速集成其他 JavaScript 版本或自定义许多不同的功能。

在下一章中,我们最终也将发布我们自己的包——到公共注册表和其他自定义注册表。

第三部分:高级主题

在这一部分,你将深入研究高级主题,例如发布自己的 npm 包以及将你的项目结构化为共享代码库,如 monorepo。你将了解有哪些选项,以及工具如 Nx、Lerna 或 Turbo 如何帮助你设置可扩展的项目。

为了使你对 Node.js 及其生态系统有更全面的了解,这一部分还将教你如何在 Node.js 中使用任何编译为 WebAssembly 的代码,以及哪些其他运行时可以用作 Web 开发工具的基础。

本书本部分包括以下章节:

  • 第八章, 发布 npm 包

  • 第九章, 在 Monorepos 中组织代码

  • 第十章, 将原生代码与 WebAssembly 集成

  • 第十一章**, 使用替代运行时

第八章:发布 npm 包

在此之前,我们的主要重点是学习如何改进和为现有项目做出贡献,但往往这并不全面。一些项目需要你正确启动,这个过程的一部分是决定哪些包应该被重用。

我们已经了解到,在 Node.js 中,可重用性主要通过模块系统获得,这可以通过 npm 包形式的第三方依赖来增强。在本章中,你将学习如何自己发布 npm 包。这样,一旦实现的功能就可以在同一个项目工作的团队或任何人之间共享。

为了实现本章的目标,首先,我们将设置一个简单的库来很好地服务于我们的案例。然后,我们将以使代码对任何 Node.js 开发者都可用的方式将此库发布到官方 npm 注册库。如果你想让你的库稍微不那么公开,那么以下章节对你来说将很有趣。在这些章节中,你将首先学习如何在实际选择用于发布和安装的本地注册库之前选择其他注册库。

最后,我们还将探讨扩大我们库范围的方法——通过使其同构或将其作为工具公开。总之,本章将涵盖以下关键主题:

  • 发布到官方注册库

  • 通过.npmrc选择另一个 npm 注册库

  • 设置 Verdaccio

  • 编写同构库

  • 发布跨平台工具

技术要求

本章的完整源代码可在github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter08找到。

本章的 CiA 视频可通过bit.ly/3UmhN4B访问。

发布到官方注册库

让我们从创建一个小型库开始,这个库使用的是在 Node.js 项目中非常常见的结构。该结构包括一个src文件夹,其中包含原始源代码,以及一个lib文件夹,其中包含用于目标系统的输出。目标系统可以是浏览器应用程序的打包器或 Node.js 的特定版本。

为了初始化此类项目,我们可以使用之前使用过的npm命令行工具:

$ npm init -y

现在,我们将设置一切。首先,我们将安装esbuild作为开发依赖项。这可以帮助我们将源文件转换为可用的库文件:

$ npm install esbuild --save-dev

接下来,我们将修改package.json以适应我们的需求:

package.json

{
  "name": "lib-test-florian-rappl",
  "version": "1.0.0",
  "description": "Just a test library",
  "keywords": [],
  "author": "Florian Rappl",
  "license": "MIT",
  "main": "lib/index.js",
  "source": "src/index.js",
  "scripts": {
    "build": "esbuild src/*.js --platform=node --outdir=lib
      --format=cjs"
  },
  "devDependencies": {
    "esbuild": "⁰.15.0"
  }
}

重要的是,将所选占位符的名称(name字段中的florian-rapplauthor字段中的Florian Rappl)替换为你的名字。对于name字段,请确保只使用允许用于包名称标识符的字母。你也可以随意更改所选许可证。

许可证

每个package.json文件中的一项重要信息是license字段。虽然 MIT 许可证对于许多开源项目来说是一个非常好的选择,但它绝不是唯一的选择。其他流行的选择包括 Apache License 2.0、BSD 3-Clause 和 ISC 许可证。

现在,我们将在我们的源文件中添加一些内容:

src/index.js

import { readFile } from "fs";
import { resolve } from "path";
export function getLibName() {
  const packagePath = resolve(__dirname,
    "../package.json");
  return new Promise((resolve, reject) => {
    readFile(packagePath, "utf8", (err, content) => {
      if (err) {
        reject(err);
      } else {
        const { name, version } = JSON.parse(content);
        resolve(`${name}@${version}`);
      }
    });
  });

这个文件是以对我们开发者有意义的方式编写的,但不能直接由 Node.js 运行。问题有两个方面。首先,我们使用了 ESM 语法,但没有保证 Node.js 支持它。其次,我们混合了 ESM 结构,如importexport,以及 CommonJS 结构,如__dirname

幸运的是,我们已安装了esbuild来处理这个问题,定义的build脚本实际上使用了它以便于操作:

$ npm run build

> lib-test-florian-rappl@1.0.0 build /home/node/code/example01

> esbuild src/*.js --platform=node --outdir=lib --format=cjs

  lib/index.js  1.4kb

 Done in 2ms

到目前为止,我们的项目中已有两个目录:src,包含原始源代码,以及lib,包含 CommonJS 输出。这一点也在package.json文件中得到了体现,其中源字段指向src/index.js,而main字段指向lib/index.js

就此提醒一下:main字段告诉 Node.js 在通过require包含包时应使用哪个模块 – 例如,require('lib-test-florian-rappl')将引用并评估lib/index.js文件。

假设你现在想将这个包发布到官方 npm 注册库。为此,你首先需要在npmjs.com/signup上创建一个账户。一旦成功注册并登录,你应该会看到一个类似于图 8.1的视图:

图 8.1 – 登录 npmjs.com 后的视图

图 8.1 – 登录 npmjs.com 后的视图

在你的机器上,你现在可以通过运行以下命令来认证官方 npm 注册库:

$ npm login

这将要求你输入用户名和密码。或者,你也可以使用所谓的访问令牌进行认证。这对于脚本特别有用,例如在 CI/CD 管道中运行的自动化脚本。要生成一个新的访问令牌,请点击图 8.1中突出显示的链接。

既然你已经认证了npm工具,你可以继续发布你的包:

$ npm publish

npm notice

npm notice   lib-test-florian-rappl@1.0.0

npm notice === Tarball Contents ===

npm notice 1.5kB lib/index.js

npm notice 425B  src/index.js

npm notice 344B  package.json

npm notice === Tarball Details ===

npm notice name:          lib-test-florian-rappl

npm notice version:       1.0.0

npm notice package size:  1.1 kB

npm notice unpacked size: 2.3 kB

npm notice shasum:        2b5d224949f9112eeaee435a876a8ea15ed3e7cd

npm notice integrity:     sha512-cBq1czwmN4vep[...]/vXrORFGjRjnA==

npm notice total files:   3

npm notice

+ lib-test-florian-rappl@1.0.0

这将把你的项目打包成一个压缩归档。然后,该工具将把 tarball 上传到官方 npm 注册库。

现在,你可以前往npmjs.com查找你的包名。你应该会看到一个类似于图 8.2的包信息页面,其中包含更多关于已发布包的详细信息。请注意,我们没有包含README.md或任何关键词:

图 8.2 – 已发布包的详细信息

图 8.2 – 已发布包的详细信息

你可能考虑的事情之一是给你的包指定一个作用域。当你发布一个带有作用域的包时,你需要配置包的访问设置。默认情况下,非作用域包是公开的,而作用域包是私有的。

为了将范围包发布到官方 npm 注册表,你首先需要成为 npm 网站上某个组织的成员或所有者。组织名称必须与范围名称匹配。

包范围

将包分组的一个好方法是将它们放在一个公共范围内。范围必须以一个“@”符号开头,后面跟着范围名称。范围名称的规则与包名称相同。除了分组包,范围还可以用来将某些包放置在不同的注册表中而不会遇到太多麻烦。最重要的是,范围可以在官方 npm 注册表中保留,这样只有授权账户才能使用保留范围发布新包。

为了一致地发布具有公共访问权限的范围包,例如@foo/bar,你需要修改package.json。相关的配置存储在一个名为publishConfig的属性中:

package.json

{
  "name": "@foo/bar",
  // ... like beforehand
  "publishConfig": {
    "access": "public"
  }
}

或者,访问配置也可以在执行带有--access=publish标志的npm publish命令时直接设置。

到目前为止,我们只讨论了如何将内容发布到官方 npm 注册表。那么,选择其他 npm 注册表怎么办?为此,我们需要更改.npmrc文件。

通过.npmrc 选择其他 npm 注册表

为了配置 npm 的行为,使用了一个特殊的文件,称为.npmrc。我们已经在第三章选择包管理器中简要提到了这个文件。此文件不仅可以用来确定包的来源,还可以用来定义发布的位置。

简单的修改可能看起来如下:

.npmrc

; Lines starting with a semicolon or
# with a hash symbol are comments
registry=https://mycustomregistry.example.org

这样,所有安装和发布尝试都将执行在https://mycustomregistry.example.org,而不是官方注册表https://registry.npmjs.org

很常见,这种极端的方法是不必要的,甚至是不受欢迎的。相反,你可能只想为包的子集使用另一个注册表。在最常见的案例中,子集已经由范围定义。

假设我们在上一节中使用的@foo范围,与@foo/bar包绑定到一个自定义注册表,而所有其他包仍然可以通过官方注册表解析。以下.npmrc涵盖了这一点:

.npmrc

@foo:registry=https://mycustomregistry.example.org

当地.npmrc——即与项目package.json相邻的文件——应用于定义注册表,而全局.npmrc——位于你的主目录中——应用于提供有关认证的信息。通常,私有注册表只能与这样的认证信息一起使用:

~/.npmrc

//mycustomregistry.example.org/:username="myname"
//mycustomregistry.example.org/:_password="mysecret"
//mycustomregistry.example.org/:email=foo@bar.com
always-auth=true

always-auth设置用于告诉npm,即使是GET请求——即请求解析或下载包的请求——也需要使用提供的认证。

测试自定义配置的一个简单方法是自己搭建一个 npm 注册表。在本地实现这一点的不错方式是使用开源项目Verdaccio

设置 Verdaccio

目前市面上有几个商业注册表选项。可以说,最受欢迎的选项是为官方 npm 注册表购买专业版。这样,你将能够发布和管理私有包。无论你选择哪种选项,你都将必须使用云版本来发布你的包。

尤其是在尝试发布过程时,拥有一个本地注册表会非常好。一个很好的选择是利用 npx

让我们采用 npx 方法:

$ npx verdaccio

 warn --- config file  - ~/.config/verdaccio/config.yaml

 info --- plugin successfully loaded: verdaccio-htpasswd

 info --- plugin successfully loaded: verdaccio-audit

 warn --- http address - http://localhost:4873/ - verdaccio/5.14.0

现在 Verdaccio 已经运行,你可以访问控制台显示的 URL。你应该看到与 图 8.3 所示的 Verdaccio 的主页一样:

图 8.3 – Verdaccio 的主页及发布说明

图 8.3 – Verdaccio 的主页及发布说明

假设我们想要将我们之前创建的包发布到 Verdaccio 而不是官方的 npm 注册表。我们需要遵循的步骤如下:

  1. 对新注册表进行身份验证(在 Verdaccio 中,默认情况下你可以使用你想要的任何凭证,但 npm 要求你进行身份验证)

  2. 要配置 Verdaccio 运行实例的 URL,可以通过 .npmrc 文件或通过在 npm publish 命令中显式使用 --registry 标志来实现

实际上,这两个步骤看起来如下:

$ npm adduser --registry http://localhost:4873/

Username: foo

Password:

Email: (this IS public) foo@bar.com

Logged in as foo on http://localhost:4873/.

$ npm publish --registry http://localhost:4873

npm notice

npm notice   lib-test-florian-rappl@1.0.0

npm notice === Tarball Contents ===

npm notice 1.5kB lib/index.js

npm notice 425B  src/index.js

npm notice 344B  package.json

npm notice === Tarball Details ===

npm notice name:          lib-test-florian-rappl

npm notice version:       1.0.0

npm notice package size:  1.1 kB

npm notice unpacked size: 2.3 kB

npm notice shasum:        2b5d224949f9112eeaee435a876a8ea15ed3e7cd

npm notice integrity:     sha512-cBq1czwmN4vep[...]/vXrORFGjRjnA==

npm notice total files:   3

npm notice

+ lib-test-florian-rappl@1.0.0

一旦发布,该包也会列在可访问于 http://localhost:4873/ 的 Verdaccio 实例网站上。当然,这主要用于测试发布过程或通过本地缓存加速 npm 安装。大多数时候,拥有本地 npm 注册表并不是真的必要。

此时可能会出现一个问题:我们如何确保发布的包可以被大多数用户使用?在客户端应用程序(在浏览器中运行)以及基于服务器的应用程序(在 Node.js 中运行)中实际使用包需要满足哪些要求?

说是基本不受目标依赖的概念被称为同构。这个术语本身也并非没有受到批评,有些人实际上更喜欢称之为通用。拥有同构代码对于获得灵活性是非常好的。让我们看看部署同构包需要什么。

编写同构库

网络开发的圣杯是能够编写既适用于前端也适用于后端的代码,而不是仅适用于其中一部分。许多框架和工具试图给我们提供这种能力。

为了使代码能够适用于多个平台,我们不仅需要提供我们代码的多个变体,而且还需要只使用所有支持平台上都可用的 API。例如,如果你想发起一个 HTTP 请求,那么对于现代浏览器来说,使用 fetch 就是一个正确的选择。然而,fetch 在 Node.js 的较旧版本中是不可用的。因此,你可能需要以不同的方式解决这个问题。

在 HTTP 请求的情况下,已经存在同构库——也就是说,这些库会根据目标运行时做正确的事情。你应该只依赖这些库。

同构 fetch

HTTP 请求问题可以通过多种方式解决——也就是说,通过选择同构库,如axiosisomorphic-fetch,问题可以委托给依赖项。这种方法的优势在于我们不需要在每个平台上找出我们需要遵循的方式。此外,以这种方式进行测试和验证要简单得多。

现在,我们将专注于提供多个变体。如果我们想发布支持多种模块格式的库——比如说 CommonJS 和 ESM——我们可以通过扩展package.json来实现。将type设置为module将告诉 Node.js,由main字段引用的模块实际上遵循 ESM。此外,我们可以明确地定义所有包的导出——还有一个额外的选项来定义根据使用的目标平台和模块系统要使用的模块。

让我们看看这种配置的一个例子:

package.json

{
  // ... like beforehand
  "type": "module",
  "main": "dist/index.js",
  "exports": {
    ".": {
      "browser": {
        "require": "./lib/index.min.js",
        "default": "./dist/index.min.js"
      },
      "default": {
        "require": "./lib/index.js",
        "default": "./dist/index.js"
      }
    }
  }
}

在我们的小型库中,浏览器版本和非浏览器版本之间存在显著差异。然而,为了优化,我们在浏览器中使用了压缩模块,而所有其他平台(包括 Node.js)都将解析为非压缩模块。

要创建适合 CommonJS 的输出,我们可以使用我们已推导出的build脚本:

$ esbuild src/*.js --platform=node --outdir=lib --format=cjs

ESM 的输出类似,但包含一个重要的变化:

$ esbuild src/*.js --platform=node --outdir=dist --format=esm --define:__dirname="'.'"

关键的改变是避免使用仅在 Node.js 中使用 CommonJS 的__dirname全局变量,我们只需使用当前目录。这个改变并不完美,但应该能完成这项工作。

目前,一切似乎都已准备就绪——但实际上并非如此。最重要的是仍然缺失——那就是移除 Node.js 内置的包引用。我们的简单库引用了fspath,但这些包在浏览器中并不存在。它们不知道如何在其中工作。幸运的是,在这种情况下,我们有多个解决方案。其中最好的一个可能是用包的package.json的静态导入来替换动态文件读取:

index.js

import { name, version } from '../package.json';
export function getLibName() {
  return `${name}@${version}`;
}

当然,这种算法上的改变并不总是可能的。在给定场景中,我们还从esbuild的捆绑选项中受益,它将包括从引用的 JSON 文件中必要的部分,以生成符合我们预期的输出文件。

考虑到这些变化,让我们看看build脚本是如何定义的:

{
  // ... like beforehand
  "scripts": {
    "build-cjs-node": "esbuild src/*.js --platform=node
      --outdir=lib --format=cjs",
    "build-cjs-browser": "esbuild src/*.js --platform=node
      --outdir=lib --bundle --format=cjs --minify --entry-
      names=[name].min",
    "build-cjs": "npm run build-cjs-node && npm run build-
      cjs-browser",
    "build-esm-node": "esbuild src/*.js --platform=node
      --outdir=dist --format=esm",
    "build-esm-browser": "esbuild src/*.js --platform=node
      --outdir=dist --bundle --format=esm --minify --entry-
      names=[name].min",
    "build-esm": "npm run build-esm-node && npm run build-
      esm-browser",
    "build": "npm run build-cjs && npm run build-esm"
  }
}

将脚本定义为可以独立运行,也可以方便地一起运行,而不需要太多努力是有意义的。在许多情况下,你选择的工具需要大量配置才能达到预期的行为。在我们的例子中,esbuild已经为这项任务做好了充分的准备——我们需要的所有事情都可以通过命令行选项来完成。

npm 包可以覆盖的一个额外案例是实际上提供一个工具。理想情况下,这些是用于 Node.js 运行的工具,使其成为跨平台工具。让我们看看我们如何编写和发布这类工具。

发布跨平台工具

没有它的生态系统,Node.js 就不会如此强大。正如我们在 第一章 中学到的,学习 Node.js 的内部机制,依赖其生态系统的力量是一个基本的设计决策。在这里,npm 通过在 package.json 中定义包元数据以及包的安装来扮演主导角色。

在安装包的过程中,会发生几件事情。在包下载完成后,它将被复制到目标目录。对于使用 npm 的本地安装,这是 node_modules 文件夹。对于使用 npm 的全局安装,目标将在你的主目录中全局可用。然而,还有一件事要做。如果包包含一个工具,那么工具的引用将被放入一个特殊的目录,对于本地安装,这个目录是 node_modules/.bin

如果你回到上一章的代码,你会看到例如 jestnode_modules/.bin 中可用。这正是我们用 npx 启动的同一个 jest 可执行文件。让我们看看以下内容:

$ ./node_modules/.bin/jest --help

我们可以将其比作:

$ npx jest --help

这两者都将产生相同的结果。原因是本地安装的 npx 只是一个方便的工具,用于避免写出路径。作为提醒,你应该选择本地安装而不是全局安装。

npx 和 npm

npx 是与 npm 安装一起提供的另一个命令。从命令行角度来看,npm 用于管理依赖项,而 npx 用于运行包。npm 工具还有一个 run 子命令,它运行 package.jsonscripts 部分定义的命令,而 npx 则运行 npm 包中 bin 部分定义的命令。

现在的问题是,我们如何创建一个包,它还向 .bin 文件夹添加一个脚本,以便在安装后就能直接使用?答案就在我们之前库的 package.json 中。

让我们稍微修改一下 package.json

package.json

{
  "name": "@foo/tool",
  "version": "1.0.0",
  "description": "A simple tool greeting the user.",
  "bin": {
    "hello": "lib/hello.js"
  },
  "license": "MIT"
}

我们添加了一个 bin 部分,它定义了一个从 .bin 目录引用的单个脚本。这个引用应该被称为 hello,并指向这个包内的 lib/hello.js 文件。

让我们再添加一个在调用 hello 时运行的脚本:

hello.js

#!/usr/bin/env node
// check that at least one argument has been provided
if (process.argv.length < 3) {
  console.log("No argument provided.");
  return process.exit(1);
}
// take the last argument
const name = process.argv.pop();
console.log(`Hello ${name}!`);

这实际上会检查是否至少提供了一个参数,并使用最后一个参数在控制台打印一条消息。

让我们看看直接通过 node 运行时的行为:

$ node hello.js

No argument provided.

$ node index.js foo

Hello foo!

现在,包可以像以前一样发布——例如,通过选择我们的本地 Verdaccio 实例:

$ npm publish --registry http://localhost:4873

在一个新项目中,你现在可以安装依赖项并运行工具:

$ npm install @foo/tool --registry http://localhost:4873

$ npx hello bar

Hello bar!

通过这些,我们已经了解了 npm 包发布过程中的最关键方面。让我们回顾一下我们学到了什么。

摘要

在本章中,你已经了解了将软件包发布到 npm 注册表所需的内容——无论是官方的还是私有的。你也接触到了一个常用的 npm 注册表形式,即 Verdaccio。

借助本章的知识,你现在应该能够编写可在基于浏览器的应用程序以及基于 Node.js 的应用程序中工作的可重用库。你也能够发布基于 Node.js 的工具。从某种意义上说,这些工具只是具有一些附加字段的关联包元数据中的库。

在下一章中,我们将探讨一种不同的代码结构方法——将多个软件包放置在一个称为 monorepo 的单个仓库中。

第九章:单仓库中的代码结构

在上一章中,你学习了创建和发布优秀的库和工具以增强你的项目的所有内容。虽然一些包是在一定程度上孤立的,但大多数包已经有了消费应用的概念。在这种情况下,有两个独立的仓库——即一个用于应用程序,一个用于库——会带来相当大的开销。毕竟,任何对库的更改都应该在库发布之前至少部分经过测试。使这种关系更有效的一种好方法是结构化这个代码在单仓库中。

一个 package.json

现在,单仓库经常被用来支持世界上一些最大的 Node.js 项目代码库。如果你想正确地阅读和贡献像 Angular、React 或 Vue 这样的项目,你需要对单仓库及其使单仓库成为可能的各种工具有深入的了解。对于你自己的项目,良好的结构——通常通过实现单仓库提供——也可能至关重要。

在本章中,我们将涵盖以下关键主题:

  • 理解单仓库

  • 使用工作区实现单仓库

  • 使用 Lerna 管理单仓库

  • 使用 Rush 管理大型仓库

  • 使用 Turborepo 替代或与 Lerna 集成

  • 使用 Nx 增强 Lerna 来管理单仓库

技术要求

本章的完整源代码可在 github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter09 获取。

本章的 CiA 视频可在 bit.ly/3EjGZTL 访问。

理解单仓库

专用仓库的结构始终非常相似;我们在根目录下有一个单一的 package.json 文件,一个包含已解析依赖项的单一 node_modules 文件夹,以及一组源文件和配置文件,通常分散在根目录和一些特定文件夹(如 src)之间。图 9.1 展示了一个相当流行的设置:

图 9.1 – 单个包的仓库常见设置

图 9.1 – 单个包的仓库常见设置

在常见的设置中,我们有一些用于 CI/CD 管道定义和潜在的有用工具的文件夹,以及如项目文档之类的辅助文件。当然,对于 Node.js 项目,我们将看到一个 node_modules 目录,以及一个 package.json 文件。

相反,单仓库将包含多个 package.json 文件和多个 node_modules(或替代)文件夹。同样,源文件以及可能的一些配置也将分散在多个位置。图 9.2 展示了一个非常常见的结构,用于主要部分,图 9.3 用于单个包:

图 9.2 – 多个包的仓库常见设置

图 9.2 – 包含多个包的存储库的常见设置

图 9.1相比,概述的文件夹层次结构要复杂一些。现在,我们无法立即看到源文件,需要进入packages文件夹内部的某些目录:

**图 9.3** – 单个包目录的内容

图 9.3 – 单个包目录的内容

理想情况下,单一代码库中包含的包应该以这种方式构建,使得它们在以后更容易提取。假设你有一个特定的库在你的单一代码库中,现在应该由另一个团队处理。如果你的单一代码库是为了作为当前团队的开发单一来源而创建的,那么转移这个库是有意义的。

很常见,常见的开发问题,例如package.json文件中devDependencies的常规包,都集中在专门的package.json文件中。在许多单一代码库中,这个package.json文件位于单一代码库的根目录。虽然从维护的角度来看,这种模式是有意义的,但它也可能在库提取时带来挑战。毕竟,你现在需要决定添加哪些依赖项以恢复提取库的开发能力。

通常,多个挑战使得支持单一代码库成为一项单独的任务。以下是一些最紧迫的问题:

  1. 如何有效地共享依赖项,以避免反复安装相同的依赖项?

  2. 如何将包视为从注册表中安装的依赖项?

  3. 如何以一致的方式运行常见的任务,例如构建步骤?

让我们逐一分析这些问题。对于(1),想法是单一代码库可以比仅仅拥有许多不同的目录更有效率,在这些目录中,你需要为每个目录运行npm install。在每个目录中运行npm install将是一个巨大的开销,不仅会重复直接依赖项,还会重复间接依赖项——即已安装依赖项的依赖项。

虽然(1)只是一个性能(安装时间和磁盘空间)问题,但(2)的问题在于开发者的便利性。最初采用单一代码库的原因是为了让相互依赖的包靠近。这样,一个错误应该在开发时间而不是在包已经发布后的集成时间变得明显。npm 处理这个问题的通常机制是使用npm link命令,这将使本地包全局可用以供引用。然而,这个机制存在多个缺点。此外,对于每个包使用此命令并不非常方便。

最后,在单仓库中运行命令时,包之间的依赖关系需要特别注意。在(3)方面,如构建源代码等任务需要按照反向引用顺序执行。这意味着,如果包 A 依赖于包 B,则必须在构建包 A 之前先构建包 B。原因是,通过依赖关系,包 A 的内容可能只有在包 B 的内容完全创建(即,包已构建)的情况下才能成功构建。类似的约束也出现在测试和发布包时。

有了这个想法,让我们从实现单仓库(monorepo)最简单的方法之一开始:利用最流行的 npm 客户端自带的工作区(workspaces)功能。

使用工作区实现单仓库

随着单仓库需求的增长,npm 客户端试图通过整合它们来帮助用户。其中之一是 Yarn。在 Yarn 的第一个版本中,package.json 中就引入了一个新的概念:workspaces

package.json

{
  "name": "monorepo-root",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

Yarn 工作区需要在单仓库的根目录中有一个 package.json 文件。这个 package.json 不会用于发布,并且需要将 private 字段设置为 trueworkspaces 字段本身是一个数组,包含不同包的路径。允许使用 *** 符号的通配符——如这里所示。

在 npm v7 中,标准 npm 客户端也获得了工作区功能。这个功能与 Yarn 的实现几乎相同。在这里,我们同样需要在根目录下有一个 package.json 文件。同样,行为由一个 workspaces 字段控制。

最后,pnpm 的实现略有不同。在这里,我们需要一个名为 pnpm-workspace.yaml 的专用文件。此文件包含不同包的路径:

pnpm-workspace.yaml

packages:
  - 'packages/*'

与其他两个 npm 客户端不同,使用 pnpm 时,你不需要在根目录中有一个 package.json 文件。由于工作区定义在单独的文件中,此文件本身就足以启用 pnpm 的工作区功能。

为了说明这一点,让我们创建一个新的目录,并将前面的 pnpm-workspace.yaml 文件添加到其中。然后,创建一个 packages 子目录。在那里,添加两个额外的文件夹,p1p2。在每个这些目录中,运行 npm init -y。你现在可以修改包含的 package.json 文件,为它们添加一些依赖项。

从包含 pnpm-workspace.yaml 文件的根目录运行以下命令:

$ pnpm install

Scope: all 2 workspace projects

Packages: +5

+++++

Packages are hard linked from the content-addressable store to the virtual store.

  Content-addressable store is at: /home/node/.local/share/pnpm/store/v3

  Virtual store is at:             node_modules/.pnpm

Progress: resolved 5, reused 5, downloaded 0, added 5, done

虽然始终可以编辑相应的 package.json 文件,但 pnpm 还使得向某个包含的包——或者用 pnpm 术语来说,工作区——添加依赖项变得容易。

假设你想要将 react-dom 添加到 p1 工作区:

$ pnpm add react-dom --filter p1

No projects matched the filters "/home/node/ Chapter09/example01" in "/home/node/Chapter09/example01"

.                                        |   +2 +

Progress: resolved 5, reused 5, downloaded 0, added 0, done

--filter 参数允许你选择依赖项应该添加的工作区。虽然接受全名,但也可以使用通配符(*)指定名称。

在单仓库中指定依赖项

在同一 monorepo 中包含的其他包的依赖关系声明就像任何其他依赖关系一样——在相应的 package.json 字段中,如 dependenciesdevDependencies。然而,指定的版本在这里至关重要。你需要确保匹配引用包的版本(例如,1.2.3¹.0.0 都可以正确匹配版本为 1.2.3 的包)或使用通配符指定符 *。如今,大多数包管理器也支持特殊的 workspace 协议。使用这个协议,你可以写 workspace:* 而不是版本来链接到另一个 workspace 中的包。

workspaces 选项确实很有吸引力,可以优化包并使它们的链接变得相当容易;然而,它并没有使常见的 monorepo 任务更加易于接近或方便。一个替代方案是在 workspace 之上使用像 Lerna 这样的工具。

使用 Lerna 管理单一代码库

Lerna 是管理单一代码库的最古老工具之一。我们甚至可以说在某种程度上,Lerna 不仅使单一代码库变得可管理,而且使其变得流行。Lerna 是一些最重要的单一代码库(如 Jest)的支柱。它也是像 BabelReact 这样的项目的原始选择。

最初,选择 Lerna 主要是因为它能够正确安装和解析所有包。当时,没有任何包管理器能够内在地做到这一点。然而,如今,Lerna 最常与不同包管理器提供的 workspace 功能一起使用。当然,你仍然可以使用 Lerna 的原始模式,其中使用 plain npm 来安装和链接不同的包。那么,当整个安装都由选择的包管理器完成时,Lerna 如何适应这个新角色呢?

结果表明,Lerna 是一个在包管理器之上的真正出色的任务运行层。例如,在所有包含的包中运行 package.json 脚本,如 build,就像调用以下命令一样简单:

$ npx lerna run build

这只会运行包含此类脚本的包中的脚本。相比之下,如果其中一个包没有 build 脚本,Yarn 实际上会出错。

要开始使用 Lerna,你需要将当前仓库初始化为 Lerna 单一代码库。为此,可以使用 init 命令:

$ npx lerna init

一旦初始化,仓库应包含一个 lerna.json 和一个 package.json 文件。通过检查这些文件,你会注意到 lerna.json 包含一个版本(默认为 0.0.0),但 package.json 不包含。这是故意的。实际上,Lerna 将会管理这里的版本。默认选择是统一版本控制——也就是说,所有包都将始终获得相同的版本。另一种选择是独立版本控制。在这里,每个包都可以有自己的版本号。如果不同的包有自己的发布周期,这会很有用。

要启用独立版本控制,我们可以更改 lerna.json

lerna.json

{
  // ... as beforehand
  "version": "independent"
}

或者,我们也可以使用 lerna init 命令的 --independent 标志来初始化存储库。

package.json 文件包含 workspaces 属性。默认情况下,这被配置为包括 package 目录下的所有目录作为包。在给定的配置中,Lerna 会使用 npm 作为包管理器。在任何情况下,整个包管理都留给了实际的 npm 客户端。

如前所述,Lerna 在运行任务方面确实很出色。Lerna 的哪些方面被认为是其优势?整个发布和版本管理。我们已经看到 Lerna 知道两种模式:独立版本控制和统一版本控制。在独立版本控制模式下,Lerna 将检查即将发布的版本与当前版本。只有在有新版本的情况下,publish 命令才会实际运行。

让我们看看上一个示例中的包如何使用 Lerna 实际发布。为此,我们将使用运行 Verdaccio 的本地注册库:

$ npx lerna publish --registry http://localhost:4873

lerna notice cli v5.5.2

lerna info versioning independent

lerna info Looking for changed packages since p1@1.0.1

? Select a new version for p1 (currently 0.0.0) Major (1.0.0)

? Select a new version for p2 (currently 0.0.0) Major (1.0.0)

Changes:

 - p1: 0.0.0 => 1.0.0

 - p2: 0.0.0 => 1.0.0

? Are you sure you want to publish these packages? Yes

lerna info execute Skipping releases

lerna info git Pushing tags...

lerna info publish Publishing packages to npm...

[...]

Successfully published:

 - p1@1.0.0

 - p2@1.0.0

lerna success published 2 packages

没有额外的标志,Lerna 将引导我们完成整个发布过程。由于我们指定了独立的版本控制,该工具将询问每个包含的包应选择哪个版本。在这种情况下,我们为两个包都选择了 1.0.0

Lerna 还做了一些比为每个包运行 npm publish 更多的事情。它与 Git 作为版本控制系统密切相关。它还将发布与当前提交绑定,并通过 Git 标签标记发布,这些标签会自动推送到潜在的原始存储库,如 GitHub

Lerna 还带来了一些关于 monorepo 的详细信息。由于 Lerna 需要知道哪些包存在以及它们之间的关系,因此将这些信息暴露给我们也是合理的。

一个很好的命令,可以用来查看当前 monorepo 中有什么是 lerna list

$ npx lerna list --graph

lerna notice cli v5.5.2

lerna info versioning independent

{

  "p1": [

    "react",

    "react-dom"

  ],

  "p2": [

    "react",

    "react-dom"

  ]

}

lerna success found 2 packages

有多种选项——所有这些选项都是为了微调要包含、排除的信息以及如何表示它。最终,这是为了使以多种方式消费成为可能。无论您是从脚本中消费还是直接消费,lerna 工具都有适当的选项来相应地呈现数据。

Lerna 已经确立为处理 monorepos 的首选选项之一;然而,其配置选项可能令人望而生畏,在大型存储库中使其高效可能很麻烦。一个替代方案是使用一个有意见的工具。这个类别中最好的选项之一是Rush

与 Rush 一起处理大型存储库

虽然 Lerna 提供了许多使 monorepos 成为可能的功能,但其配置和灵活性也带来了一些挑战。此外,寻找最佳实践也证明是困难的。因此,出现了许多对使用 Lerna 的相当有意见的替代方案。其中最成功的一个是微软的 Rush。

Rush 允许使用各种 npm 客户端。传统上,Rush 曾经只使用 npm。今天,Rush 推荐使用 pnpm,这也是使用 Rush 设置单仓时默认的客户端。

为了高效地使用 Rush,建议全局安装此工具:

$ npm install -g @microsoft/rush

安装成功后,可以使用rush命令行工具。与npm一样,存在一个init子命令来实际初始化一个新项目:

$ rush init

这将创建和更新几个文件。最值得注意的是,你会在当前文件夹中找到一个rush.json文件。这个文件需要编辑。然而,在你继续之前,请确保删除你不需要的文件。例如,Rush 添加了一个.travis.yml文件,如果你使用 Travis 进行 CI/CD 管道,这可能很有用。如果你不知道 Travis 是什么,或者你已经知道你不想使用 Travis,只需删除该文件即可。

由于 Rush 中每个包都是明确添加的,因此没有直接需要packages子文件夹。如果你仍然希望以这种方式分组包含的包,当然可以这样做。

为了让 Rush 知道包含的包,我们需要编辑根目录下的rush.json文件。在我们的例子中,我们想要添加两个新的包:

rush.json

{
  // keep the rest as is
  "projects": [
    {
      "packageName": "p1",
      "projectFolder": "packages/p1"
    },
    {
      "packageName": "p2",
      "projectFolder": "packages/p2"
    }
  ]
}

保存文件后,你可以运行以下命令——只需确保给定的目录确实存在并包含有效的package.json文件:

$ rush update

在给定的输出中,你应该会看到一些包含类似消息的输出。如前所述,在底层,Rush 使用pnpm来使包安装非常高效。

在包目录中运行rush add来添加或更新包中的依赖项。假设我们想要将react-router添加到p1中:

$ cd packages/p1

$ rush add --package react-router

运行命令时,Rush 提供了两个基本命令。一个是通用的rushx命令,它可以看作是npm run的包装器。假设p1包定义了一个hello命令如下:

packages/p1/package.json

{
  // as beforehand
  "scripts": {
    "hello": "echo 'Hi!'"
  }
}

运行此脚本的方式如下:

$ cd packages/p1 && rushx hello

Found configuration in /home/node/examples/Chapter09/example02/rush.json

Rush Multi-Project Build Tool 5.68.2 - Node.js 14.19.2 (LTS)

> "echo 'Hi!'"

Hi!

另一个基本命令是使用内置命令,如rush buildrush rebuild。它们假设每个包都包含一个build脚本。而rebuild命令会运行所有的build脚本,而build命令实际上使用缓存来启用增量构建过程——也就是说,尽可能多地重用前一次运行的结果。

虽然 Rush 非常严格,需要掌握整个仓库,但另一种选择是使用更轻量级的工具,如 Turborepo。

将 Turborepo 集成到 Lerna 中或替代 Lerna

到目前为止,我们在这个章节中已经看到了各种各样的工具。虽然现代 npm 客户端的 workspace 功能对于较小的单仓库来说已经足够,但对于较大的单仓库,则需要更多专门的工具来管理。在 Lerna 简单而 Rush 过于固执己见的情况下,还存在另一种选择 – Turborepo,或简称 Turbo。它可以被视为 Lerna 的替代品或补充。

从零开始相当容易 – Turbo 附带一个 npm 初始化器:

$ npm init turbo

这将打开一个命令行调查,并使用一些示例代码来构建目录。最后,你应该会看到创建了一些新文件,例如 turbo.jsonpackage.json 文件。此外,Turbo 还创建了包含一些示例代码的 appspackages 目录。

让我们通过运行 build 脚本来展示 Turbo 的强大功能:

$ npx turbo run build

与 Lerna 不同,它不会在每个包中运行 build 脚本 – 跟随包图。相反,它将运行 turbo.json 中定义的管道之一。在那里,你可以看到以下内容:

turbo.json

{
  "$schema": "https://turborepo.org/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false
    }
  }
}

给定的 pipeline 属性定义了一组 Turbo build 管道。然后可以通过 turbo run 运行每个给定的键(在这里,buildlintdev)。每个管道的具体细节由其给定的值指定。例如,dev 管道不使用缓存,而 lint 管道不产生任何输出。默认情况下,每个管道在每个包中运行具有相同名称的脚本。

此处的 build 管道指定了一些用于执行增量构建的输出目录。它还指定在当前包中运行之前,必须先在依赖项中运行 build 脚本。因此,如果你有两个包,p1p2,其中 p1 依赖于 p2,则 p2 的构建脚本需要在 p1 的构建脚本被调用之前运行。

除了“在不同工作区”的依赖项(例如,^build)之外,您还可以指定“在同一工作区”。例如,如果构建脚本依赖于一个 prebuild 脚本,您只需写下 prebuild

turbo.json

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build", "prebuild"]
    }
}

turbo run 命令也可以同时调用多个命令:

$ npx lerna turbo lint build

由于 lint 没有指定依赖项,因此运行结果相当高效,所有代码检查都可以并行进行,而构建则是按层次执行的。这一想法在 图 9.4 中得到了说明:

图 9.4 – Turbo 的任务规划和执行

图 9.4 – Turbo 的任务规划和执行

Turbo 不是唯一可以用来使单仓库更高效的工具。一个很好的替代方案是 Nx,它不仅限于任务运行。

使用 Nx 管理单仓库以增强 Lerna

在本章早期讨论 Lerna 时,我们没有提到的一个问题是 lerna.json 中有一个特殊键,称为 useNx,并配置为 true。这是 Lerna 5 中的一个新功能,现在由 Nx 背后的人维护——另一个流行的用于管理 monorepo 的解决方案。那么,这实际上带来了什么,以及它如何增强 Lerna 或任何其他 monorepo 管理工具呢?

使用 Lerna 还是无需使用?

Nx 不依赖于 Lerna,并且在使用 Lerna 的情况下使用 Nx 也是可选的。因此,这两种技术可以被视为非排他性的——更确切地说,它们是互补的。最终,选择使用哪种技术由你决定。例如,本节中的示例没有使用 Lerna。

我们再次从一个新的仓库开始。这次,我们将使用 Nx 提供的 nx-workspace npm 初始化器:

$ npm init nx-workspace -- --preset=react

 Workspace name (e.g., org name)     · example05

 Application name                    · example

 Default stylesheet format           · css

 Enable distributed caching to make your CI faster · Yes

[...]

与 Turbo 一样,我们得到了一个命令行调查问卷。初始预设(在这种情况下,react)定义了一些出现的问题。还有其他与 Turbo 相似之处。例如,通过 nx 运行某些操作,如下所示:

$ npx nx build

这将在给定环境中(默认为 production)查找当前应用程序(在这种情况下,example)的 Nx build 任务执行器。以下是一个明确书写的示例:

$ npx nx run example:build:production

任务执行器在包的 project.json 中指定。Nx 使用插件来实际运行这些执行器;在我们的示例项目中,使用 react 预设时,使用 @nrwl/webpack 包作为插件。

为了使 Nx 能够工作,每个包都需要一个 package.jsonproject.json 文件。这两种文件都可以指定。在这种情况下,Nx 实际上会内部合并它们以获得所需的配置。通常,如果你想使用 npm 脚本,你会想要一个 package.json 文件。project.json 文件包含 Nx 任务执行器,它们功能更强大,但不幸的是,这些内容超出了本快速介绍的范畴。

让我们在这里停下来,回顾一下本章所学的内容。

摘要

在本章中,你学习了如何在单个称为 monorepo 的仓库中组织多个 Node.js 项目。你看到了不同的技术和工具,用于最大化效率和处理多个包及其依赖项。

现在,你已经准备好处理可用的最大代码库了。无论代码库是否仅使用 npm 客户端中的一个工作空间,或者在其之上使用 Lerna 等其他工具,你都能迅速理解其结构、运行命令和添加新包。

在下一章中,我们将以对 WebAssembly 的探讨作为总结,它不仅为在浏览器中运行的代码提供了很多灵活性,还可以用于在 Node.js 中运行任意语言。

第十章:将原生代码与 WebAssembly 集成

实际使用 Node.js 的全部意义在于便利性。Node.js 从未追求成为最快的运行时、最完整的或最安全的。然而,Node.js 建立了一个快速而强大的生态系统,能够开发出一套工具和实用程序,实际上赋予了我们现在所习惯的 Web 开发标准。

随着 Node.js 的发展,对更多专用系统的需求也增加了。提供 Node.js 替代方案的新运行时实际上正是出于这种需求而出现的。在 WebAssembly 语言中,我们可以找到一个有趣的替代方案。WebAssembly 是一种可移植的二进制代码格式,类似于 Java 虚拟机JVM)或 微软中间语言MSIL)。这使得它成为任何语言(尤其是像 C 或 Rust 这样的底层语言)的潜在编译器选择。

在本章中,你将了解 WebAssembly 提供的内容,如何将现有的 WebAssembly 代码集成到你的 Node.js 应用程序中,以及如何自己生成 WebAssembly 代码。到本章结束时,你将准备好将你的脚本提升到下一个层次——无论是使用 WebAssembly 本身还是使用在 Node.js 中运行的 WebAssembly。

我们将在本章中涵盖以下关键主题:

  • 使用 WebAssembly 的优势

  • 在 Node.js 中运行 WebAssembly

  • 使用 AssemblyScript 编写 WASM

技术要求

本章节的完整源代码可在以下链接找到:github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter10

本章节的 CiA 视频可通过以下链接访问:bit.ly/3DPH53P

使用 WebAssembly 的优势

WebAssemblyWASM)是一种没有运行时的语言。任何功能——从分配一些内存到发起 HTTP 请求——都需要由消费应用程序集成。然而,有一些新兴的标准,如 WebAssembly 系统接口WASI),旨在将一组标准功能带到任何平台。这样,我们可以使用 WASM 编写平台无关的应用程序,并使用集成 WASI 的运行器。

WASI 规范

WASI 规范涵盖了在浏览器外运行 WASM 所需的一切。流行的 WASM 运行时,如 WasmtimeWasmer,实现了 WASI,以便实际运行 WASM 应用程序。WASI 指定了系统资源如何被 WASM 访问。因此,除了在运行时实现 WASI 之外,执行中的 WASM 代码还需要(并使用)WASI 提供的 API。更多详细信息请参阅 wasi.dev/

因此,WASM 的一个优势是其可移植性和在沙盒中运行的能力。毕竟,没有链接和运行系统命令或访问关键系统资源的能力。

即使像向控制台记录(即 Node.js 中console.log()的等效物)这样简单的事情也需要由 WASI 层提供,这可能会将某些资源的访问决策留给用户。

WASM 的另一个优点是它不是一个直接的语言。因此,我们可以实际上使用任何支持 WASM 作为编译目标的语言。截至今天,大多数系统语言,如 C/C++、Rust、Zig 和 Go,都支持 WASM 生成。最后,Java 的“一次编写,到处运行”原则似乎得到了实现。

很常见,性能被认为是 WASM 的另一个优点。虽然 WASM 本身实际上可以提供比 Node.js 或类似运行时更好的性能,但它肯定仍然会比等效但非常优化的本地代码慢。毕竟,这也只是在本地运行,但信息略少,并且以更通用的模式运行。然而,对于某些算法,从 WASM 执行到本地执行的减速可以相当小,甚至不明显。

那么,这一切是如何实现的呢?首先,WASM 文件的格式是二进制的——也就是说,尽可能高效。在这个二进制中的结构是为了快速解析和执行而量身定制的。该语言不提供高级指令,如循环,而只提供标签和跳转点——就像真正的机器语言一样。

图 10.1中,你可以看到 WASM 提供的一般流程和可移植性承诺。作为开发者,我们只需要关心编译到.wasm文件。如果我们的工具能够做到这一点,用户就可以使用他们选择的 WASM 运行时来消费这些文件,这可以是浏览器或 Node.js,但还有许多其他选项。

图 10.1 – 使用 WASM 二进制文件的 WASM 的可移植性

图 10.1 – 使用 WASM 二进制文件的 WASM 的可移植性

就像机器语言一样,WASM 有两种格式——一种文本表示,这对于查看正在发生的事情非常有用,以及相应的二进制表示。文本表示看起来非常接近像Lisp这样的编程语言,较低级别的片段类似于实际的处理器指令。

让我们看看一个 WASM 文本表示的例子,这个库导出sum函数来将两个数字相加:

sum.wat

(module
(export "sum" (func $module/sum))
 (func $module/sum (param $0 i32) (param $1 i32)
   (result i32)
  local.get $0
  local.get $1
  i32.add
 )
)

存在将文本表示转换为其二进制对应物的工具。最受欢迎的工具是wat2wasm,它还有一个强大的在线演示。您可以通过webassembly.github.io/wabt/demo/wat2wasm/访问它。

将前面的例子添加进去,你将得到如图图 10.2所示的观点。你会发现在线工具在文本(左上角)到二进制(右上角)的转换之外还做了一些事情。它还包括一个小型的 JavaScript 游乐场(左下角),它集成了编译后的 WASM 二进制文件并运行它。游乐场中运行代码的输出随后显示在右下角。

图 10.2 – 将 wat2wasm 在线工具应用于我们的示例

图 10.2 – 将 wat2wasm 在线工具应用于我们的示例

既然我们已经了解了 WASM 是什么,它是如何工作的,以及它提供了哪些优势,现在是时候看看我们如何运行它,当然——以及如何与 Node.js 集成了。这使得我们的脚本比之前更加强大,允许以可靠、高性能和安全的方式集成平台无关的、几乎原生的代码。

在 Node.js 中运行 WASM

Node.js 通过 WASM 对象直接集成 WASM。API 与浏览器中的完全相同,允许我们有可能在 Node.js 和浏览器之间共享代码,以集成编译后的 WASM 文件。

在 API 中,WASM 有三个函数。我们可以编译现有的二进制文件,将其转换为 WASM 运行时模块。然后,可以使用 instantiate 方法激活该模块。我们还可以验证现有的二进制文件——以检查给定的文件是否确实是有效的 WASM 二进制文件。所有方法都是异步的,并返回 Promise

让我们通过一个使用 WASM 二进制文件 sum.wasm 的例子来看看,它导出一个单一的功能(sum)并将两个数字相加:

app.mjs

import{ readFile } from 'fs/promises';
const content = await readFile('./sum.wasm');
const wasm = await WebAssembly.compile(content);
const instance = await WebAssembly.instantiate(wasm);
const { sum } = instance.exports;
console.log(sum(2, 3)); // logs 5

Node.js 通过提供内置的 wasi 包,使得 WASM 的集成更加方便。这个包实现了 WASI 规范,允许我们在 Node.js 中运行的 WASM 应用程序内访问系统资源。

为了看到依赖于 WASI 的 WASM 模块集成是什么样的,我们将在稍后构建一个小型应用程序,该应用程序将使用 WASI 并集成到 Node.js 中。Node.js 集成将如下所示:

app.mjs

import { readFile } from "fs/promises";
import { WASI } from "wasi";
import { argv, env } from "process";
const wasi = new WASI({
  args: argv,
  env,
});
const api = { wasi_snapshot_preview1: wasi.wasiImport };
const path = "./echo.wasm";
const content = await readFile(path);
const wasm = await WebAssembly.compile(content);
const instance = await WebAssembly.instantiate(wasm, api);
wasi.start(instance);

至少在 Node.js 版本 18 中,wasi 包不是激活的。要实际运行前面的应用程序,你需要添加 --experimental-wasi-unstable-preview1 标志:

$ node --experimental-wasi-unstable-preview1 app.mjs

上一例的运行细节将在下一节中进行探讨。

虽然在 Node.js 中运行 WASM 很好,但我们可能也想自己编写一些代码。当然,如果你对 C 或 Rust 等语言有所了解,你可以使用这些语言,并将 WASM 作为编译目标。然而,一般来说,对于有 JavaScript 背景的开发者来说,AssemblyScript 是一个不错的选择。

使用 AssemblyScript 编写 WASM

虽然有生成有效 WASM 的许多选项,但其中最吸引人的方式之一是使用 AssemblyScript。AssemblyScript 是一种看起来和感觉与 TypeScript 非常相似的语言,从语法角度来看,学习起来相当容易。然而,在底层,仍然有一些与 WASM 相关的概念需要了解,以便编写中等大小到较大的 AssemblyScript 应用程序或库。

AssemblyScript 的一个核心概念是模拟 WASM 中使用的不同数据类型。例如,使用整数需要使用 i32 类型。

让我们看看一些示例代码。我们将从一个期望两个参数的小函数开始,将它们相加,并返回结果:

module.ts

export function sum(a: i32, b: i32): i32 {
  return a + b;
}

除了 i32 类型外,前一个示例中的所有内容看起来和感觉都像 TypeScript。甚至文件扩展名也表明这是一个 TypeScript 文件。

实际上编译前面的代码到 WASM,你需要 assemblyscript 包。像 typescript 一样,你可以全局或本地安装此包。

一旦安装了 AssemblyScript,你就可以运行 asc 工具将源代码编译成有效的 WASM 二进制文件:

$ npx asc module.ts --outFile sum.wasm --optimize

AssemblyScript 也可以非常有帮助地搭建一个工作良好的项目结构——不仅用于编译源代码,还可以在浏览器中运行 WASM。这为编写适用于多个平台(包括各种操作系统、浏览器和设备)的代码提供了一种很好的方式:

$ npx asinit .

Version: 0.21.6

[...]

  ./assembly

  Directory holding the AssemblyScript sources being compiled to WebAssembly.

  ./assembly/tsconfig.json

  TypeScript configuration inheriting recommended AssemblyScript settings.

  ./assembly/index.ts

  Example entry file being compiled to WebAssembly to get you

[...]

  ./index.html

  Starter HTML file that loads the module in a browser.

The command will try to update existing files to match the correct settings [...]

Do you want to proceed? [Y/n] Y

在搭建好的结构中,我们可以继续尝试让之前的示例工作——例如,在网页浏览器中。

为了这个,修改搭建项目文件夹中的 assembly 目录下的 index.ts。用包含 sum 函数的前一个片段替换其内容。现在,在项目的根目录中打开 index.html。将导入语句更改为获取 sum 而不是 add

index.html 文件的脚本部分现在应该看起来像这样:

import { sum } from "./build/release.js";
document.body.innerText = sum(1, 2);

现在,你可以使用在搭建过程中添加的 asbuild 脚本构建和运行一切:

$ npm run asbuild

$ npm start

现在,一个小的网络服务器应该在端口 3000 上运行。访问 http://localhost:9000 会带你到一个几乎空白的网页。你应该看到的是来自我们 WASM 库的 sum 函数。

WASM 调试

WASM 模块可以像任何其他网络应用程序一样调试。浏览器提供了一个可视化的调试器,可以用于检查。通过使用 WASM 的源映射,实际上可以调试原始代码而不是不那么容易阅读的 WASM。AssemblyScript 也能够生成 WASM 源映射。在这里,源映射目标文件必须在 --sourceMap CLI 标志之后指定。

AssemblyScript 也可以用来创建基于 WASI 的 WASM 应用程序和库。让我们看看这会怎样工作。我们从一个新项目开始,添加 assemblyscriptas-wasi 作为依赖项,然后搭建一个新的 AssemblyScript 项目:

$ npm init -y

$ npm install assemblyscript as-wasi --save-dev

$ npx asinit . -y

现在,我们可以使用 wasi 包修改 assembly/index.ts 文件,使用以下代码。

index.ts

import "wasi";
import { Console, CommandLine } from "as-wasi/assembly";
const args = CommandLine.all;
const user = args[args.length - 1];
Console.log(`Hello ${user}!`);

通过导入 wasi 包,整个模块被转换成 WASI 兼容的入口点。这允许我们使用 as-wasi 包中的抽象,例如 Console 来访问控制台或 CommandLine 来获取提供的命令行参数。

要构建代码,我们使用以下参数调用 asc 工具:

$ npx asc assembly/index.ts -o echo.wasm --use abort=wasi_abort --debug

这指示 AssemblyScript 构建位于assembly/index.ts中的应用程序。生成的 WASM 将被存储在echo.wasm中。通过--debug标志,我们指示asc创建一个调试构建。

调试构建可以非常快地完成,因为编译器不需要投资于任何优化。除了更快的编译时间外,缺乏进一步的优化还可以在运行时提供更好的错误信息,以便于处理关键的失败。

重要的是,abort命令的绑定(通常从隐含的env导入到 WASM 模块中)被设置为使用 WASI 提供的abort方法。

现在,我们可以使用上一节中的wasi包添加 Node.js 模块app.mjs。别忘了添加必要的命令行参数。由于这将会打印警告,我们可能想要添加--no-warnings来抑制它:

$ node --experimental-wasi-unstable-preview1 --no-warnings app.mjs Florian

Hello Florian!

带着这些知识,你现在可以继续编写编译为 WASM 的简单程序了。让我们回顾一下本章你学到了什么。

摘要

在本章中,你扩展了在 Node.js 中运行的潜在源代码文件的知识。你现在熟悉了运行 WASM——一种可以作为许多编程语言的编译目标的低级可移植二进制代码语言。

WASM 可以帮助你一次编写功能,并在多个平台上运行。由于 WASM 可以很好地沙箱化,它是有望成为下一波容器化计算的有力竞争者,在这一领域中,性能和安全被高度重视。你现在已经知道了如何使用 AssemblyScript 编写 WASM。你也被赋予了在 Node.js 中集成创建的 WASM 模块的能力。

在下一章和最后一章中,我们将探讨 JavaScript 在 Node.js 之外的用途。我们将看到存在其他运行时,它们部分兼容 Node.js 生态系统——提供了一种非常适合多种用例的即插即用替代方案。

第十一章:使用替代运行时

到目前为止,您已经看到了 Node.js 生态系统为创建优秀的 Web 应用提供了哪些优势和好处。然而,与几乎所有事情一样,构成我们所说的 Node.js 的设计决策也有一些缺点。

Node.js 中最大的挑战之一是所谓的依赖地狱——将许多小包组合起来创建一个稍微大一点的包。另一个挑战是 Node.js 没有保护这些依赖免于访问系统资源。因此,从第三方包导入任何内容都可能产生不希望出现的副作用。

虽然生态系统可靠性和安全性可以帮助我们抵御依赖地狱,但提高性能也是一个重要的策略。总体而言,Node.js 的性能可以被认为是相当不错的;然而,某些领域,如包解析或处理器核心利用率,可以通过相当大的份额得到改善。因此,性能也是一个可能被视为缺点的地方。

在本章中,您将了解两种最流行的替代运行时,以减轻 Node.js 带来的某些缺点。为了深入评估这些替代方案,我们将密切关注它们与现有 Node.js 生态系统的兼容性状态。

本章我们将涵盖以下关键主题:

  • 探索 Deno 运行时

  • 使用 Bun 进行 Web 应用捆绑

技术要求

本章的完整源代码可在github.com/PacktPublishing/Modern-Frontend-Development-with-Node.js/tree/main/Chapter11找到。

本章的 CiA 视频可通过bit.ly/3Uqi9aq访问。

探索 Deno 运行时

虽然 Node.js 是一个巨大的成功故事,但并非所有人都喜欢它。一些批评者表示,巨大的碎片化加上缺乏系统控制提供了太大的攻击面。在过去,我们看到了无数利用这个问题的漏洞进行的攻击。

另一个问题在于,Node.js 确实不得不发明了许多 API——例如,用于与文件系统交互。浏览器中没有可用的 API 与期望的类似。当然,正如我们现在所知,浏览器 API 一直在改进,甚至文件系统访问等功能也被实现。然而,API 从未对齐,这主要是因为 Node.js 的变体既不可控也不是完全异步的。

当然,上述问题在一段时间内都是众所周知的,但直到几年后,才出现了替代实现来解决这些问题。再次,是 Ryan Dahl——Node.js 的原始创造者——致力于解决这个问题。这个解决方案被称为Deno

Deno 的主要好处如下:

  • 它引入了系统访问控制,以允许或阻止对资源(如文件系统)的访问。

  • 它使用显式导入而不是神奇解析的导入——不再有隐含的包查找或索引文件。

  • 它试图与浏览器互换——仅提供原生浏览器 API 而不是自定义 API。

  • 它提供了一等 TypeScript 支持,不仅改善了开发体验,还增强了代码的可靠性。

  • 它自带了一些实用的工具,例如应用程序打包器,无需安装依赖项即可开始开发。

在底层,Deno 使用 Rust 编程语言而不是 C++。在这里,选择 Rust 是为了避免任何潜在的内存泄漏或漏洞,这些漏洞在 C++ 中比 Rust 更可能发生。这也意味着 libuv,正如在 第一章 “了解 Node.js 的内部结构” 中讨论的,作为 Node.js 的主要驱动程序已经不复存在。相反,Deno 使用另一个名为 Tokio 的事件循环系统。尽管如此,这两个运行时实际上都使用 V8 来运行 JavaScript。

Tokio

Tokio 是为 Rust 应用程序提供与网络交互所需一切功能的异步运行时。它可靠、快速且灵活。除了是 Rust 原生之外,Deno 选择使用 Tokio 的一个核心原因也是它非常易于集成。Tokio 包含 I/O 辅助工具、定时器、文件系统访问、同步和调度功能,使其成为完整的 libuv 替代品。更多信息可以在 tokio.rs/ 找到。

Deno 的架构如图 图 11.1 所示。值得注意的是,该图几乎与 图 1.1 完全匹配,后者展示了 Node.js 的架构。最显著的区别是接受 TypeScript,它将通过 swc(转译)和 tsc(类型检查)的组合转换为 JavaScript。另一个关键的区别是增加了额外的隔离层:

图 11.1 – Deno 的架构

图 11.1 – Deno 的架构

Deno 的安装可以通过命令行完成。例如,在 macOS 和 Linux 上,您只需运行以下 Shell 脚本:

$ curl -fsSL https://deno.land/x/install/install.sh | sh

而在 Windows 上,您可以使用 PowerShell 来完成此操作:

$ irm https://deno.land/install.ps1 | iex

对于常见的应用程序包管理器,如 Scoop、Chocolatey 或 Homebrew,也存在替代安装方式。

要尝试 Deno,您可以运行以下脚本:

$ deno run https://deno.land/std/examples/welcome.ts

Download  https://deno.land/std/examples/welcome.ts

Warning Implicitly using latest version (0.159.0) for https://deno.land/std/examples/welcome.ts

Welcome to Deno!

已经有一些事情发生了。首先,我们不是使用本地源来运行,而是一个地址。其次,由于这是一个地址,源需要被下载。第三,Deno 总是更喜欢接收显式版本,所以它会抱怨我们在这里只使用了 stdlib 的任何版本。相反,它会重定向到最新的版本,写作时为 0.159.0

最后,如果你再次运行脚本,你将只会看到输出,没有任何下载或警告。这是由于 Deno 的缓存造成的。为了保持良好的性能,假设每个下载的模块都是不可变的,并将它们缓存在本地。因此,未来的引用将不需要再次下载,这使得它们的启动时间是可以接受的。

现在的大问题是:Deno 是否也能运行 Node.js 库和应用程序?令人不满意的答案是也许可以。理论上,仅可以使用 JavaScript 文件 – 然而,Deno 只支持 ESM 模块。由于许多 Node.js 库是使用 CommonJS 编写的,我们在这里将不会成功。

作为缓解措施,我们只需将包进行转换 – 将其打包成一个文件并运行,而不会遇到任何麻烦 – 但即使在这种情况下,我们也可能面临与生态系统不兼容的问题,因为标准包如 fs 在 Node.js 中可用,但在 Deno 中不可用。

一种更好的解决方案是使用 Deno 的 Node 兼容模式。在版本 1.25 之前,它通过使用 --unstable--compat 标志来运行 deno 实现。目前,Deno 似乎只允许通过自定义导入来实现这一点。让我们尝试一下,看看它是如何工作的。为此,你可以创建一个新的 Node.js 项目,其中包含一个第三方包和一些使用它的代码:

$ npm init -y

$ npm install axios --save

为了测试这一点,以下代码提供了一个坚实的基础:

index.node.mjs

import axios from 'axios';
import { writeFile } from 'fs/promises';
const { data } = await
  axios.get('https://jsonplaceholder.typicode.com/photos');
const thumbnails = data.map(item => item.thumbnailUrl);
const content = JSON.stringify(thumbnails, undefined, 2);
await writeFile('thumbnails.json', content, 'utf8');

代码使用了为 Node.js 制作的第三方依赖项以及 Node.js 核心模块。它还使用了现代特性,如顶层 await 语句。

你可以尝试使用 Node.js 运行它以查看其工作情况,但更有趣的是使用 Deno 运行的情况:

$ deno run index.node.mjs

error: Relative import path "axios" not prefixed with / or ./ or ../

    at file:///home/node/examples/example01/index.mjs:1:19

如前所述,Deno 默认需要显式路径。没有它们,Deno 将无法工作。让我们修改这段代码以反映兼容性:

index.deno.mjs

import axios from 'npm:axios';
import { writeFile } from
  'https://deno.land/std@0.159.0/node/fs/promises.ts';
const { data } = await
  axios.get('https://jsonplaceholder.typicode.com/photos');
const thumbnails = data.map(item => item.thumbnailUrl);
const content = JSON.stringify(thumbnails, undefined, 2);
await writeFile('thumbnails.json', content, 'utf8');

index.node.mjs 相比,尽管前面的代码大部分保持不变,但导入已经略有调整。引用的 npm 包需要使用 npm: 协议进行引用。对于 Node.js 核心模块,我们可以引用 Deno 提供的 std/node 模块。

现在,我们可以使用 --unstable 标志来运行代码:

$ deno run --unstable index.deno.mjs

 Granted env access to "npm_config_no_proxy".

 Granted env access to "NPM_CONFIG_NO_PROXY".

 Granted env access to "no_proxy".

 Granted env access to "NO_PROXY".

 Granted env access to "npm_config_https_proxy".

 Granted env access to "NPM_CONFIG_HTTPS_PROXY".

 Granted env access to "https_proxy".

 Granted env access to "HTTPS_PROXY".

 Granted env access to "npm_config_proxy".

 Granted env access to "NPM_CONFIG_PROXY".

 Granted env access to "all_proxy".

 Granted env access to "ALL_PROXY".

 Granted read access to "/home/rapplf/.cache/deno/npm/node_modules".

 Granted read access to "/home/rapplf/.cache/deno/node_modules".

 Granted read access to "/home/rapplf/.cache/node_modules".

 Granted read access to "/home/rapplf/node_modules".

 Granted read access to "/home/node_modules".

 Granted read access to "/node_modules".

 Granted net access to "jsonplaceholder.typicode.com".

 Granted write access to "thumbnails.json".

由于我们没有提供任何额外的 CLI 标志,Deno 将以每项资源请求都会在命令行上反映为问题的模式运行。在这个会话中,每个请求都通过 yes 得到确认,从而授予了访问请求。

或者,我们本可以使用之前在第二章中讨论过的 Deno 功能,即将代码划分为模块和包,当时我们讨论了导入映射。让我们再次尝试使用以下导入映射运行我们的未修改文件:

importmap.json

{
  "imports": {
      "axios": "npm:axios",
      "fs/promises":
        "https://deno.land/std@0.159.0/node/fs/promises.ts"
    }
}

导入映射的作用是教会 Deno 应该查找什么。最初,Deno 无法理解对 axios 的导入,但现在它知道这应该通过 npm 解决。同样,也可以在那里添加核心 Node.js 包。

这次,我们设置了 --allow-all 标志以跳过所有访问确认:

$ deno run --unstable --import-map=importmap.json --allow-all index.node.mjs

而且…它就是如此简单。不再需要做任何工作——所有工作都使用 Deno 原语完成。当然,通常无法如此轻松地实现完全兼容。

虽然 Deno 主要关注安全性,但一个可能更有趣的领域是性能。这正是另一个替代品——Bun——发光的地方。

使用 Bun 打包 Web 应用

虽然 Deno 在第一眼看起来与 Node.js 很不相同,但它也提供了很多相似之处。毕竟,这两个运行时都使用 V8,并且可以与 ESMs 一起工作,但如果你想要与 Node.js 兼容性更高呢?另一种方法是完全不使用 libuv 或 V8 来实现 Node.js 兼容。这就是 Bun 的出现。

Bun 是 Node.js 的替代品,它在开发者友好性方面遵循了 Deno 的方法。在这里,例如 npm 客户端或应用程序打包工具等工具也是开箱即用的。然而,为了显著提高速度,Bun 不使用 libuv 和 V8。相反,Bun 使用编程语言 Zig 创建,并使用 JavaScriptCore 作为其 JavaScript 运行时。JavaScriptCore 也是 Webkit 浏览器引擎背后的运行时,为如 Safari 这样的浏览器提供支持。

Bun 的主要优势如下:

  • 它自带了一些有用的实用工具,例如打包器、转译器、包管理器和任务运行器。

  • 它在启动性能或请求处理方面优于 Node.js。

  • 它拥抱 Node.js 生态系统,但也包括一些标准 Web API,如 fetchWebSocket

图 11.2 中展示了 Node.js 和 Bun 的高层次架构比较。最重要的是,虽然 Node.js 需要额外的工具,如包管理器或打包器,但 Bun 已经内置了所有这些工具。所有这些工具在安装后都可用——并且由于所有这些工具都集成到 Bun 可执行文件中,它们提供了最佳的性能:

图 11.2 – Node.js 和 Bun 的高层次比较

图 11.2 – Node.js 和 Bun 的高层次比较

与 Deno 一样,Bun 也可以通过 Shell 脚本安装。在撰写本文时,Bun 不可直接在 Windows 上安装。如果你想要尝试 Bun,则需要回退到 Windows Subsystem for LinuxWSL)。

要在 macOS 和 Linux 上安装 Bun,你可以运行以下 Shell 脚本:

$ curl https://bun.sh/install | bash

使用 Bun 运行一个简单的示例 (hello.ts) 如下所示:

$ bun run hello.ts

Hello from Bun!

在前面的示例中,代码非常简单——只需在这里使用控制台输出即可:

hello.ts

console.log('Hello from Bun!');

Bun 的一个有趣方面是它还具有自动创建服务器的功能。如果我们使用带有 fetch 函数的默认导出,那么 Bun 将创建一个服务器,默认情况下,该服务器运行在端口 3000。端口也可以通过在该处添加另一个名为 port 的属性来更改:

http.ts

export default {
  fetch() {
    return new Response("Hello from Bun!");
  },
};

调用 bun run http.ts 将会启动服务器。要查看结果,请使用您的浏览器访问 http://localhost:3000 地址。

最后,让我们使用 Bun 作为捆绑器来处理我们在 第六章 中所做的那个小型演示项目的捆绑。你应该注意的第一件事是,你不需要任何开发依赖项——只需要运行时依赖项。此外,你不需要运行 npm install 或类似的命令,而应该通过 bun install 来解决依赖项:

$ bun install

bun install v0.1.13

 + react@18.2.0

 + react-dom@18.2.0

 + react-router-dom@6.4.2

 + video.js@7.21.0

 32 packages installed [2.21s]

坦白说,reactreact-domreact-router-domvideo.js 只包含四个包,但它们的安装速度仍然相当不错。现在,是时候捆绑 JavaScript 代码了:

$ bun bun src/script.tsx

[...]

  2.34 MB JavaScript

       58 modules

       20 packages

 107.61k LOC parsed

     62ms elapsed

 Saved to ./node_modules.bun

结果与之前我们所见到的捆绑器大不相同。我们得到一个单独的文件,node_modules.bun,它包含生成的 JavaScript 以及所有相关的元数据。该文件本身是可执行的——准备好输出包含的代码。

通过运行可执行文件提取 node_modules.bun 文件中包含的 JavaScript 可以通过将输出管道传输到 JavaScript 文件来实现。例如,请参见以下内容:

$ ./node_modules.bun > dist/app.js

这是否足够满足我们所有的捆绑需求?当然不是。目前,集成的捆绑器基本上忽略了我们的代码,而只捆绑了位于 node_modules 目录中的外部包的代码。然而,即使我们的代码被捆绑,这个过程也并不真正理想。目前,Bun 只考虑 JavaScript、TypeScript、JSON 和 CSS 文件。无法包含诸如图像或视频之类的资产。

对于未来,所有这些功能都计划实现。虽然 Bun(在版本 0.1.13)仍然是实验性技术,但现有的技术很有希望。综合考虑,它当然值得保持关注,但并不是可以积极用于创建生产就绪代码的东西。

让我们回顾一下你在本章中学到的内容。

摘要

在本章中,你学习了为什么存在 Node.js 的替代品以及最受欢迎的选项是什么。你已经了解了 Deno 是什么以及它是如何区别于 Node.js 的。你还看到了一个新兴的替代品——Bun。

拥有这些知识,你不仅能够编写可能在 Node.js 以外的运行时中运行的工具,而且你还可以决定你的现有工具应该在哪里运行。总的来说,这并不限制你于 Node.js 的缺点,并赋予你自由,根据你想要解决的问题做出正确的选择。

前言

通常来说,将 Node.js 视为一个出色的助手来完成任务是有意义的。整个生态系统——从其模块系统到其命令行工具,从其库到其框架——非常庞大。几乎每个问题都已被解决,并已发布了解决方案。

希望通过这本书,你能够得到适当的指南,帮助你穿越可用的助手丛林,不仅让你成为 Node.js 的更高效用户,还能成为贡献者。虽然现有的工具都很有帮助且功能强大,但它们当然不是终点。每个人都有独特的视角,事物总是在不断进步。不要等待别人解决问题——自己动手解决,并分享你的解决方案。

祝你一切顺利!

posted @ 2025-10-23 15:09  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报