JavaScript模块体验升级:探索最新改进

发表时间: 2024-06-15 20:46

多个长期提案(统称为“模块和谐”)将完成 JavaScript 离开 CommonJS 时失去的功能。

译自 How JavaScript Is Finally Improving the Module Experience,作者 Mary Branscombe。

JavaScript 曾经被视为一种开发人员可以快速编写代码的语言,但它并不一定适合大规模应用程序的开发人员团队编写代码。原因之一是,直到最近,它还没有本机强大的模块支持。

在为打包 JavaScript 代码块以供反复使用制定官方标准格式之前,开发人员使用了 Webpack、Babel 和 CommonJS (CJS) 等工具。ECMAScript 6 中引入了 ECMAScript 模块(简称 ESM),它具有明显的优势:一旦现代浏览器在 2018 年开始广泛支持它们,浏览器就可以接管优化模块加载,这比使用框架或库时所需的客户端处理和往返行程更高效。

但即使经过十多年的努力,ESM 仍然不包括 CJS 模块的所有功能和细微差别,尤其是对于创建捆绑器等工具的开发人员。

Igalia 工程师兼流行的 Babel 转换器的维护者 Nicolò Ribaudo 解释说:“在某些领域,ES 模块不如以前的系统强大且易于使用。”

使用 CommonJS 编写捆绑器比使用 ESM 更容易。

使用 CJS 编写捆绑器比使用 ESM 更容易:例如,Webpack 继续在内部将代码编译为 CJS。“如果你想依赖纯 ESM 语义,你必须重命名所有变量,你必须手动创建命名空间对象,甚至无法捆绑所有内容。如果两个模块都使用顶级 await,则无法编译它们。”

到目前为止,Babel 一直保留在 CJS 中,因为这样可以推迟加载模块,直到它们因性能需要而加载:虽然使用 ESM 可以做到这一点,但它的效率要差得多。尽管 Node.JS 用户已经能够在他们的项目中使用 ESM 一段时间了,但 Node 22 仍在添加对某些 ESM 功能的支持以简化迁移。

Ribaudo 说:“仅仅从 CommonJS 迁移就很困难。”

将模块重新放回和谐中

为了解决这些差距并通常使 ES 模块更好地为开发人员服务,一组相关的提案(统称为“模块和谐”)正在缓慢地通过标准化流程。

模块和谐名称也是一个提醒,虽然各种提案看起来可能不同,但它们都是为了改善在 JavaScript 中使用模块的体验。

这些改进专门针对无法迁移到 ESM 的捆绑器等工具的开发人员,因此当新功能成为语言的一部分时,大多数开发人员将获得好处,而无需对其代码进行更改。

“模块和谐”一词有点文字游戏。它不仅是指协调 JavaScript 中模块的先前和当前选项,还暗示了 Harmony 既是 ECMAScript 6 的代号,也是 TC39 用于其标准化流程的名称——这就是 从 ECMAScript 2015 开始每年发布 重新启动 JavaScript 语言的常规开发(ECMAScript 6 的正式名称)。这就是为什么 ES 模块有时被称为“Harmony 模块”的原因。

此外,在模块和谐的保护伞下,JavaScript 中有六到九个不同的模块改进提案,所有这些提案都解锁了 ESM 的新功能,并且都以自己的速度进行标准化。事实上,有如此多的提案,我们将在两篇文章中研究它们。

Ecma 副总裁兼 Bloomberg 软件工程师 Daniel Ehrenberg 解释说:“人们会说,‘嘿,有这么多模块提案,你们都在互相交谈吗?’答案是‘是的,我们在互相交谈!’”他正在研究其中几个提案。

不同的模块和谐提案如何组合在一起;通过 TC39 演示。

因此,模块和谐名称也是一个提醒,虽然各种提案可能 看起来不同,但它们都是为了改善在 JavaScript 中使用模块的体验。

Guy Bedford,Fastly 工程师,参与了许多模块统一提案,他说:“JavaScript 模块有着悠久的历史,并且一直在不断演进。”这是 JavaScript 中常见的过程,“人们急于求成,构建事物,采取最短路径解决问题,而标准化是一个缓慢得多的后续过程。”

“我们看到模块统一工作正在填补用例,这些用例非常枯燥乏味,但直接让我们回到了 CommonJS 的同等水平,但它实际上是原生的一流浏览器模块系统。”

Bedford 建议,虽然 ESM 提供了非常直接的代码结构,但开发人员希望从模块中获得额外的功能。“例如,惰性加载、优化和虚拟化,以及编写工具和模拟的能力,以及支持捆绑器捆绑代码所需的所有功能。这些都是我们每天都在做的标准事情,但以一流的符合标准的方式来做却出人意料地困难。”

ES 模块确实有优势:“从一开始就有了立竿见影的好处,”Bedford 指出。“我们看到了大规模的采用和 Web 开发的巨大改进。我们在这里添加的只是锦上添花:获得最后的高级用例。”

“我们对模块统一的目标是确保 ESM 足够强大,以至于根本不需要 CJS。” — Nicolò Ribaudo,Igalia 工程师

CommonJS 的最初创建者 Kris Kowal 将模块统一描述为“完成一项始终打算至少达到这一程度的设计”。

他说:“当我提出 CommonJS 时,目的是创建一种方式,让人们可以表达 JavaScript,而无需将它们耦合到特定框架,就可以在项目之间共享。”

当时,Dojo 和 jQuery 等框架有自己的插件系统,编写模块化代码的开发人员必须选择要针对哪个框架。

Kowal 说:“我想创建一种前瞻性的方式来表达模块,当出现合适的系统时,可以轻松地过渡到合适的系统。”

Ribaudo 也支持多项提案,他同意:“我们对模块统一的目标是确保 ESM 足够强大,以至于根本不需要 CJS。”

慢慢地重建开发人员在 CJS 中已经拥有的功能,这似乎是一个令人沮丧的倒退,但 Bedford 建议将其视为创建新基础:“构建该基线可以让未来的新工作朝着新的方向发展。”

JavaScript 和 WebAssembly 之间的互操作性

取得最大进展的模块统一提案之一是源阶段导入。它已经达到第 3 阶段,实施工作正在进行中,因为能够通过导入表示模型源而不是直接使用模块的对象来定制模块的加载、链接和执行方式,这将提供 JavaScript 和 WebAssembly 之间更无缝的互操作性。

Ribaudo 解释说,将 WebAssembly 和 JavaScript 一起使用目前是一个复杂的过程:“如何获取模型并准备它以使其准备好运行并不明显。”

“大多数开发人员不会直接使用此功能,但可以帮助开发人员将 JavaScript 代码导入 WebAssembly 的粘合代码编写工具可以使用它来提高安全性。” — Nicolò Ribaudo,Igalia 工程师

“此外,获取内容不会遵循与普通导入相同的同源策略,因此基本上获取和评估 WebAssembly 模块的唯一方法是大幅放松安全策略。因为你可以将 JavaScript 函数传递给模块,所以你可能会意外地公开新功能。”

该提案允许开发人员应用 JavaScript 风格的同源策略,作为限制可以运行哪些代码的一种方式,因为新对象包括原始源 URL。“你可以说我只希望我的应用程序能够从这两个域加载和运行 WebAssembly 代码,而不是从任何其他域加载的代码。”它还支持静态分析,以确定正在执行哪些 Wasm 模块,就像对 JavaScript 模块所做的那样。

他建议:“大多数开发人员不会直接使用此功能,但可以帮助开发人员将 JavaScript 代码导入 WebAssembly 的粘合代码编写工具可以使用它来提高安全性”。

Bedford 指出,该提案类似于 WebAssembly 允许你创建模块的多个实例的方式,并且涉及另一个名为 Compartments 的模块化提案,它将为 JavaScript 中更精细的隔离提供一种机制。这是对虚拟化原语进行标准化的相当重大的尝试,我们将在未来详细了解它。

“我们在 JavaScript 中所说的虚拟化只是实例化:目前,当我们加载一个模块时,你只加载一次。在你的整个应用程序中只有一个这样的模块,它所具有的任何状态都将在应用程序的生命周期内存在。”

Source Phase Imports 将成为虚拟化的一个构建模块,其工作方式更类似于 WebAssembly 选项:“获取模块的抽象表示并创建它的多个实例,可能具有不同的导入,可能具有不同类型的内部状态。”

这允许在 JavaScript 中具有更大的灵活性——例如,启用用户空间加载器——但还提供了更好的集成和更符合人体工程学的 WebAssembly 在 JavaScript 中的使用。

Bedford 解释说:“当你使用源阶段从 WebAssembly 导入时,你将获得 WebAssembly 已有的高级模块,这些模块可以被多次实例化。”这项工作已经在 V8 JavaScript 引擎中开始实施,这应该会导致在 JavaScript 工具链中更轻松、更便携地传输 Wasm。

虽然 Source Phase Imports 听起来像是一个相对有限的进步,但分离加载模块的不同阶段(或阶段)的概念对于影响模块如何被使用的许多其他模块化提案至关重要。

模块管道中实际上有五个不同的阶段:

  1. 解析到模块的网络路由,以便浏览器知道它在哪里。
  2. 获取(并可能编译)模块。
  3. 检索和附加它将如何执行以及需要从何处加载其依赖项的上下文的“源”阶段。
  4. 链接那些加载的依赖项并绑定任何导入。
  5. 最后,评估所有加载的模块被执行的地方。

链接和评估阶段统称为“实例”阶段(因为现在有一个模块实例)。

导入模块的五个阶段;通过 TC39 演示。

稍后保存

Source Phase Imports 允许开发人员使用已通过其上下文获取的模块, 执行模块代码之前,但仍然依赖于静态分析显示将执行什么代码并获得更好的人体工程学、工具支持和安全的保证。

其他提案——模块声明、模块表达式(很快就会回来了解这两个提案的更多详细信息)和 延迟模块导入——也依赖于模块管道的这些不同阶段,例如将最终评估阶段推迟到你实际需要模块的属性为止。

“有了这个提案,你可以将初始化工作推迟到需要模块时,同时仍然加载模块并解析它,并让它准备好进入模块系统。”

—— Fastly 工程师 Guy Bedford

延迟导入的目标是改善包含大量 JavaScript 的网页和 Node 应用程序的启动时间,这些 JavaScript 实际上直到以后(也许在用户单击按钮时)或可能根本不会被调用,Bedford 解释说。

“当你导入一个模块时,你必须预先执行它的所有依赖项。当你进入一个网页时,它正在加载一大堆模块,你一次性初始化所有这些模块,即使你没有使用它们。有了这个提案,你可以将初始化工作推迟到需要模块时,同时仍然加载模块并解析它,并让它准备好进入模块系统。”

这是 CommonJS 中的一种常见技术(它也存在于其他语言中,如 Go、 Python、Ruby 和 Swift)。“当你的初始化变慢时,你将 require 移到函数内部,而这对于本机模块来说非常相似。”许多尚未从 CJS 迁移的捆绑器依赖于这种加速。

这比使用异步代码和动态导入(目前是延迟加载 JavaScript 模块的唯一选项)所需的重大重构要简单得多。

Ehrenberg 解释说,动态导入“让你可以将不同代码块分离出来,以便在不同时间加载”,这就是许多打包器实现它的原因。“但这仅在你能够负担得起访问网络并加载某些慢速内容的情况下才有效。在其他情况下,你正处于深度计算中,并且所有内容都是同步的,并且你希望能够快速响应。”这就是延迟导入的用武之地:“它允许某些延迟执行的情况,否则是不可能的。”

使用顶级 await(它添加了异步加载逻辑)的模块无法延迟评估,并且将在应用程序启动时执行。尽管在该提案从第 2 阶段(它在 2023 年达到该阶段)向前推进之前需要明确这些含义,但 Ehrenberg 在彭博社的经验表明,彭博社已经使用过一段时间 JavaScript 中顶级 await 和延迟导入的等效项,这表明“这是它们组合的自然方式”,而不是一个错误。

速度提升不会像在 Node.js 中那么大,因为对于服务器端代码,模块文件存储在代码执行的位置,但浏览器必须从其他位置加载文件。

Ribaudo 承认,“加载仍然是启动时初始发布的重要部分”,但延迟导入可以更轻松地添加一些性能改进,而无需要求开发人员重构代码。

“如果你试图在特定位置提高性能,则不必重构整个应用程序以异步支持函数调用。我们为开发人员提供了一个工具,让他们可以在人体工程学和性能之间做出权衡。现在,你可以在最佳人体工程学的情况下获得较差的性能,或者在不太理想的人体工程学的情况下获得非常好的性能,而我们正在两者之间添加一个点,它仍然保持良好的人体工程学,同时提高性能——即使没有达到应有的水平。”

Mozilla 在 Firefox 前端中使用其自己的延迟模块加载版本:在其内部代码中,加载和解析模块大约占启动时间的一半,但一半被延迟导入可以延迟到实际需要时才进行的评估所占用。其他实验显示出较小的改进,将 JavaScript 模块所需的时间缩短了 20%:在一个加载时间超过一秒的页面上,这是一个 6% 的改进。对于你可以使用新的 import defer 语法快速轻松地进行的代码更改,这仍然是一个重大的改进。

Bedford 指出:“对于应用程序来说,最重要的就是你能够多快地开始与它进行交互?” “如果我们能看到真正的性能数字,我认为能够说我们可以加速你的应用程序将非常有说服力。”

让工作人员更容易工作

在此基础上,模块阶段导入 也承诺提高性能,使用与源阶段导入相同的源阶段甚至相同的语法来获得类似的好处,但适用于工作人员而不是 Wasm 模块。

今年早些时候达到第 1 阶段,它即将被考虑用于第 2 阶段,并承诺提供一种更好的加载工作人员的方法,使代码更符合人体工程学,并更容易处理模块之间关系的静态分析。

当模块在工作人员中加载时,这目前很困难,并且对于库来说变得更加不透明,因为如果你的代码使用工作人员,并非所有构建工具都能很好地工作。这会阻止库作者利用工作人员可以提供的速度提升,以避免为其用户增加构建复杂性。

开发人员工具和 JavaScript 运行时中更好地支持工作人员最终应该会使 JavaScript 应用程序运行得更快,因为如果工作人员更容易使用,开发人员和库作者更有可能使用它们来卸载计算量大的任务。

分而治之

模块和谐最早的尝试之一,资产引用,旨在模块管道最初的“解析”阶段工作,那时你可以引用一个模块并像句柄一样传递它,而无需加载或初始化它,Ribaudo 解释说。

JavaScript 标准化的速度故意放慢,因为默认情况下不会在它们被证明有用之前向语言添加新特性。

“你可以静态声明在某个时候,你将使用资产。那可能是一个模块,它可能是一个 CSS 文件或一个图像——一个尚未加载的数据集,但你的打包器可以打包它,知道[在]某个时候你会使用它。”

听起来很有用,但随着对不同提案的研究的进展,资产引用并没有得到太多关注,因为其他提案涵盖了类似的问题,并且可能解决更多问题。事实上,它自 2018 年以来一直处于第 1 阶段,并且由于其他领域的进展,可能不再需要它。不过,这并不是浪费时间。其他模块和谐提案中采用的不同方法都有不同的优点和缺点,而找出其中哪种方法最能解决问题是标准化过程的关键部分。

这反映了 JavaScript 标准化的故意缓慢步伐,其中默认情况下不会在语言中添加新功能,直到它们被证明有用。

模块和谐提案套件确实包含一些更雄心勃勃的方法,特别是针对模块的安全性。在后续文章中,我们将深入探讨这意味着什么,并介绍其他提案,例如模块表达式和模块声明,这些提案展示了新语言功能在通过标准化流程时如何演变。