How ES Module Shims became a Production Import Maps Polyfill
Refer
Source
: How ES Module Shims became a Production Import Maps Polyfill - 4 April 2022
Author
: Guy Bedford
Translator
: SenaoXi
Copyright Statement
Translation and Republication Notice:
This translation is provided for educational and informational purposes only. All intellectual property rights, including copyright, remain with the original author and/or publisher. This translation maintains the integrity of the original content while making it accessible to chinese readers.
Modifications Disclosure:
- This is a complete and faithful translation of the original content with no substantive modifications.
- This translation includes minor adaptations to improve clarity for chinese readers while preserving all essential information and viewpoints.
- Sections marked with [†] contain supplementary explanations added by the translator to provide cultural or technical context.
Rights Reservation:
If you are the copyright holder and believe this translation exceeds fair use guidelines, please contact us at email. We are committed to respecting intellectual property rights and will promptly address any legitimate concerns.
ES Module Shims
最初是作为一种在浏览器正式支持新的原生模块特性(如 import maps
)之前,就能够提前使用这些新特性的 polyfill
,这适用于快速开发或简单的生产工作流中。
随着时间推移,ES Module Shims
已经成为了一个高度优化适用于生产环境的可插入式 polyfill
,用于 import maps
。
随着 import maps
现已获得 70%
用户的浏览器支持,且 Firefox
今天 刚刚宣布
将开始支持 import maps
,现在是分享 ES Module Shims
及其实现细节的好时机。
本文首先介绍 项目架构 的背景,然后描述 polyfill
模式 是如何形成的,同时提供全面的 性能基准测试,最后展望该项目 未来 的发展方向。
感谢 Basecamp
看到了该项目的潜力并赞助了 ES Module Shims
的新性能研究工作,也感谢 Rich Harris
为该项目提供的最初灵感。
Loader Architecture
ES Module Shims
的核心是一个 模块加载器。虽然脚本执行可以通过简单的单参数 eval()
函数轻易实现,但设置 模块加载器 要比这个复杂得多。因为模块化执行需要提供模块源码、支持从源码中发现和解析依赖关系,然后异步提供这些依赖的源码,并重复执行上述解析过程。
不幸的是,Web
上根本没有模块化执行 API
。在 2018
年 ES Module Shims
创建时,所有主流浏览器都支持了 <script type="module">
,但对 import()
或 import.meta.url
等其他新特性的支持程度并不一致。
要实现像 import maps
这样的特性,需要自定义模块依赖解析,因此需要某种 loader
钩子来实现这一点。
ES Module Shims
最初面临的问题是:如何在 <script type="module">
之上构建一个具有自定义依赖和源文本解析的任意模块加载器?
要实现这一点并不显而易见,但可以通过以下两种技术方案来实现:
- 一个小而快的 JS 模块词法分析器。
- 通过
Blob URL
实现源码自定义。
1. A small & fast JS module lexer
给定任意源文本字符串 source
,首先要确定其导入说明符的位置。
例如,对于这样的源码:
const source = `import { dep } from 'dep';
console.log(dep, mapped);`;
需要知道的是在源字符串的 [21, 28]
位置上有一个导入 'dep'
。
如果我们能确定导入的位置,我们可以解析这些导入,然后通过字符串操作使用相同的偏移量来重写源字符串:
const transformed =
source.slice(0, 21) + '/dep-resolved.js' + source.slice(28);
解析后的结果:
import { dep } from '/dep-resolved.js';
console.log(dep, mapped);
那么问题来了,我们需要如何编写这样的词法分析器来获取导入说明符的位置信息呢?
How to write an imports lexer?
如果 js
的规范有要求导入只能出现在其他类型的 js
语句之前,那么一切就变得容易。但问题在于以下代码也是有效的 js
语法:
const someText = `import 'not an import'`;
// import 'also not an import';
'asdf'.match(/import 'you guessed it'/);
import 'oh dear';
上述的例子可能看起来像是愚蠢的边缘情况,但要提供完善的解决方案时,这些边缘情况必须要关注。
虽然可以使用各种与 import
相关的正则表达式来获取 import
说明符的位置信息,但这对这样的项目来说是不可靠的基础。因为 使用正则表达式的问题在于无法一致地处理语言语法边缘情况 —— 总会遇到某些模块,会因为边缘情况导致解析失败,破坏了可靠实现模块化解析的整个工作流程。
另一种选择是使用解析器,但当时即使是最小的解析器也超过 100KB
,并且对于这种用例来说会带来显著的性能开销。
Rich-Harris-Inspired Magic
当 Rich Harris
发布 Shimport
(我想象他在一个周末的几个小时内创造性地完成了这个项目,然后在下一个周末就开始构建 Svelte
)时,Shimport
展示了在浏览器中进行依赖分析,使用一个小型且高效的 javascript
词法分析器,适用于快速开发甚至简单生产工作流程,这是我从未想到可能的。
Shimport
使用自定义词法分析器在浏览器中动态重写 ES modules
,来支持没有 ES modules
支持的浏览器。
在 Shimport
重新解析所有导入和导出时,与仅解析导入并重写其内部字符串相比,问题似乎更简单,并且通过使用原生模块,动态模块绑定将自然得到支持。
与 Rich Harris
交流如何处理 js
语言复杂性的方法时,在没有更深入的解析器知识的情况下,他分享了处理 js
词法分析器的主要问题之一的技术要点 —— 正则表达式除法运算符歧义问题。
Division Regular Expression Ambiguity
对于大多数 js
词法分析,规则相当简单 —— 对于字符串,从第一个 "
读到最后一个,处理转义。注释(/*
和 //
)也遵循简单规则。模板表达式(`..${`..`}..`
)有一些嵌套需要通过嵌套解析器函数处理,正则表达式也有一些需要检查的次要情况。除此之外,基本上只需将开括号 [
、(
和 {
与其对应的闭括号匹配,而无需进一步的解析器上下文信息即可完成解析。
然后,当所有的括号都已关闭,我们知道我们处于顶层,可以开始仅使用他们的语法解析器规则来解析导入,以获得一个小型模块词法分析器。
然而,一旦遇到 /
,就会出现词法分析器歧义问题。例如:
while (items.length) /regexp/.test(items.pop()) || error();
在上述中,将 /
区分为 正则表达式 而不是 除法 的原因在于 括号 是 while
语句的结尾,但要知道这一点需要完全了解 while
语句解析器的状态。
因此,技巧是创建一个最小的词法分析器上下文栈,匹配 开括号((
) 和 闭括号()
),并包含足够的状态信息来处理像上面这样的主要歧义情况。
确定解析器是否处于 import()
的表达式位置(否则可能会与 类方法 定义产生歧义)可以使用类似的方法来处理,通过 大括号 和 括号 栈关联的最小词法分析器上下文来判断。
最初在 ES Module Shims
中实现为基于 js
的嵌入式词法分析器,后来该实现被转换为通过 C
语言实现,并通过 WebAssembly
编译(C
编译为最小的 Wasm
输出之一)来获得更多性能优势(实际上主要是冷启动优势,V8
不是免费的!),成为了 es-module-lexer
项目。
来说明一下 JS
解析器的性能,整个项目是一个 4.5kb
的 js
文件,在大多数桌面设备上可以在不到 10ms
内分析 1MB
的 js
代码,性能基本呈线性增长。es-module-lexer
已经成为一个受欢迎的 npm
包,每周下载量超过 750
万次。分享一个常见问题好的解决方案是很有价值的。
2. Source customization via Blob URLs
Blob URL 可以与 import()
一起使用,执行任意模块源码:
const blob = new Blob(['export const itsAModule = true'], {
type: 'text/javascript'
});
const blobUrl = URL.createObjectURL(blob);
const { itsAModule } = await import(blobUrl);
console.log(itsAModule); // true!
在不支持动态导入的浏览器中,可以通过动态注入 <script type="module">import 'blob:...'</script>
标签到 DOM
中实现同样的效果。
所有部分就绪后,就需要考虑处理整个模块依赖图,假设我们有一个像这样的应用程序:
<script type="importmap">
{
"imports": {
"dep": "/dep.js"
}
}
</script>
<script type="module" src="./app.js"></script>
其中 app.js
包含:
import depA from 'dep';
export default {};
导入 dep
依赖将通过 import map
解析为 /dep.js
。
如果我们重写或自定义 /dep.js
的源码,我们会得到 dep
的 blob URL
,然后必须将其写入上面 /app.js
中相应的导入位置。然后我们从这个转换后的 app.js
源码创建一个 blob
URL,最后动态导入该 blob
URL。
最终的 import('blob:site.com/72ac72b2-8106')
完全包含了 app.js
的整个依赖图执行,使 ES Module Shims
成为在基础原生模块之上的全面 可定制 加载器,完全支持实时绑定和循环依赖的处理(除了对 live bindings in cycles 进行一些轻微篡改外)。
Shim Mode
最初的实现是使用自定义的 module-shim
和 importmap-shim
脚本类型:
<script type="importmap-shim">
{
"imports": {
"dep": "/packages/dep/main.js"
}
}
</script>
<script type="module-shim">
import dep from 'dep';
console.log(dep);
</script>
这样可以保证与原生加载器不冲突,ES Module Shims
使用 fetch()
处理依赖,然后通过 es-module-lexer
懒惰地重写源码,并将它们内联为 Blob
URL。
Polyfill Mode
2021.03
,Chrome
发布了对 import maps
的无标志支持。随后,使用完整的原生 import maps
工作流程变得具有吸引力,在没有 import maps
支持的浏览器中仅在需要时应用 ES Module Shims
。
在不支持的浏览器中使用 import maps
时,会抛出静态错误:
由于这是一个静态错误(发生在 link
阶段,而不是执行时的动态错误),因此报错时并不会执行任何模块。因此,只有当原生执行失败时,ES Module Shims
才会通过自己的加载器执行模块依赖图,同时由于这是 link
阶段的决策,并不会有重复执行模块的风险。根据浏览器的不同,fetch
缓存也可以共享。
ES Module Shims
中的 polyfill
模式随后变成了重新执行静态失败的一种方式。当你静态地使用你想要进行填充的功能,一旦浏览器不支持 import maps
,那么浏览器将始终抛出静态错误,ES Module Shims
将检测到这一点并通过其加载器运行模块来实现 polyfill
。
此外,在具有全面 import maps
支持的浏览器中(或者当前新热门的基线模块功能),甚至无需 ES Module Shims
来分析源代码,就能实现完全的基线直通。
polyfill
模式处理包括以下几个步骤:
- 运行
import maps
和相关模块特性的功能检测。 - 如果浏览器支持所有现代特性,无需后续操作。
- 通过加载器
fetch
和词法分析器(es-module-lexer
)跟踪分析模块源码,确定模块中是否有使用了原生新特性导致静态抛出的场景。 - 如果根据源码分析确定模块图已静态抛出,则在
ES Module Shims
加载器中加载模块依赖图。
69% 的用户浏览器已经支持 import maps
,ES Module Shims
只做功能检测,基本上零工作,因此 polyfill
模式最终成为仅为旧浏览器填充模块特性的高性能方法。
最简单的工作流程就是从一开始就使用 import map
:
<script
async
src="https://ga.jspm.io/npm:es-module-shims@1.5.4/dist/es-module-shims.js"
></script>
<script type="importmap">
{
"imports": {
"app": "/app/main.js"
}
}
</script>
<script type="module">
import 'app';
</script>
通过直接使用 import map
,在不支持原生功能的浏览器中会立即出错,可避免不必要的浏览器处理,并确保干净的 polyfill
切换。
Performance
为验证性能,Basecamp
赞助了 ES Module Shims
的一些基准测试和优化工作。
ES Module Shims
性能工作的目标是验证:
- 基线传递: 在支持
import maps
的浏览器中,性能与原生加载完全一致。 - Polyfill 性能: 量化在不支持
import maps
的浏览器中的polyfill
加载成本。 - Import maps 性能: 调查具有许多模块的大型
import maps
的性能。
Benchmark Setup
所有性能测试都使用 Preact
进行简单组件渲染,并包括页面的 full load
时间。
每个样本 n
包含了组件和 Preact
的加载和执行,约 10KB
的 js
代码的加载并与 DOM
的交互。
当 n = 100
时,意味着加载并执行了 1MB
代码(每个样本约 10KB
)。以下所有场景都基于未缓存的性能检测,并根据每种情况使用不同的 网络环境。
基准测试的结果和源码可在 ES Module Shims repo
上获得。使用 Tachometer
执行多次运行。测试在标准台式机上执行。
Baseline Passthrough Performance
在这种情况下,目标是 验证 对于约 70%
支持 import maps
的用户来说,加载 ES Module Shims
的 polyfill
不会导致任何不必要的减速,并匹配原生性能。
为验证这一点,基准测试比较了使用 import maps
加载 n
个 Preact + 组件渲染
样本,在 Chrome
中并行加载和执行它们,有和没有 ES Module Shims
脚本标签的情况。
基准测试包括将 ES Module Shims
加载到浏览器并运行功能检测的完整时间。
Chrome
中原生import maps
的加载时间,页面上有(橙色)和没有(蓝色)ES Module Shims
,针对加载的n
个样本进行变化。
我们可以看到,页面上有 ES Module Shims
会导致轻微的额外加载时间,平均约 6.5ms
,这是 ES Module Shims
初始化和运行功能检测的时间。
在大多数情况下,性能是相同的,对应于应用的原生传递,polyfill
根本没有参与。
Baseline Passthrough with Throttling
随着我们开始限制网络,ES Module Shims
的带宽成本应该开始显现,因为它有 12KB
的网络下载大小。
限流设置为 750KB/s
和 25ms RTT
。
Chrome
中原生import maps
的限流网络加载时间,页面上有(橙色)和没有(蓝色)ES Module Shims
,针对加载的n
个样本进行变化。
限流会引入了一些噪声,但平均而言,额外的加载时间约为 10ms
,在 750KB/s
网络上 12KB
的 js
的预期加载时间约为 15ms
(请记住,一切都是并行的,所以执行时间填补了网络间隙)。
因此,我们可以得出结论,对于约 70%
支持 import maps
的用户,polyfill
对性能的影响大多可以忽略,仅对应于 12KB
的下载和一些初始化时间,通常不超过约 15ms
。
Polyfill Performance
要测试 ES Module Shims
polyfill
完全参与时的开销,我们不能关闭 Chrome
中的原生模块支持,但可以使用以下假设:
使用和不使用小型 import map
加载模块依赖图的成本应该大致相似。
基于这个假设,为了比较原生加载性能和 polyfill
参与时的加载性能,我们可以使用 Firefox
浏览器来确保 polyfill
参与时的场景,比较使用 import maps
和裸规范符(目前在 Firefox
中不支持 import maps
,因此会启用 polyfill
)的情况与直接导入原生支持的 URL
(页面中完全没有 ES Module Shims
)的情况。
Firefox
原生模块的加载时间,没有使用import maps
和ES Module Shims
(蓝色),使用import maps
和ES Module Shims
并启用polyfill
(橙色)相比,针对加载的n
个样本进行变化。
与原生相比,ES Module Shims
的 polyfill
层的成本显示出明显的线性减速,因为在 import maps
情况下,所有代码都通过 ES Module Shims
加载器执行。
线性相关性显示 polyfill
的成本平均比原生加载慢 1.59
倍。100
个模块的 1MB
代码原生加载和执行需要 220ms
,而使用 ES Module Shims
polyfill
需要 320ms
(包括所有额外的 polyfill
加载和初始化时间)。
Polyfill with Throttling
同样,检查网络限流为 750KB/s
和 5ms RTT
的结果:
Firefox
原生模块的限流网络加载时间,没有使用import maps
和ES Module Shims
(蓝色),使用import maps
和ES Module Shims
并启用polyfill
(橙色)相比,针对加载的n
个样本进行变化。
从数据来看,在限流连接下平均减速约为 1.14
倍。100
个模块的 1MB
代码原生加载需要 692ms
,而使用限流的 polyfill
需要 744ms
。随着限速,polyfill
的开销减少,因为网络成为瓶颈,而不是 polyfill
。
Import Maps Performance
最后一个问题是,非常大的 import maps
(例如数百行)是否会减慢页面加载速度,因为这个场景位于关键加载路径上。
为了研究这个问题,我们用新的 import map
替换了之前的简单两行 import map
,该 import map
为每个样本案例生成一行 import map
行。因此,n = 10
对应于必须使用的 20
个不同的 import map
行,n = 100
时增加到 200
行。
我们使用限流连接 750KB/s
和 25ms RTT
,因为这主要是网络问题而不是 CPU
问题。
Chrome
原生模块的限流网络加载时间,只有两个条目的import map
(蓝色)与每个n
样本的单独import map
条目对(橙色)相比,针对不同的n
样本进行变化。
减速非常轻微,n = 100
的情况下仅不到 10ms
,对应于较大 import map
仅增加额外的下载时间,证明较大的 import map
的性能成本可忽略不计。
Polyfill Import Maps Performance
最后,与之前一样进行比较,但针对 Firefox
和 ES Module Shims
,我们期望大致相同的动态,没有任何意外。比较具有两个条目的 import map
与每个样本 n
具有两个导入映射条目的 import map
,都使用 ES Module Shims
的 polyfill
加载:
Firefox
和ES Module Shims
import map
的限流网络加载时间,比较只有两个条目的import map
(蓝色)与每个n
样本的单独import map
条目对(橙色),针对不同的n
样本进行变化。
n = 30
处的波动可能是限流过程本身的产物。
按照预期,在限流连接带宽上,加载 200
行 import map
在 Chrome
原生中增加约 7.5ms
开销,在 Firefox
与 ES Module Shims
polyfill
中增加约 10ms
的页面加载时间 —— 较大的 import map
成本大多较低,且符合预期。
Project Future
ES Module Shims
将继续跟踪即将推出的新模块特性,其基线支持目标将随着时间自然变化。
CSS Modules
和JSON Modules
是当前的新原生模块功能,它们通过可选支持将polyfill
支持基线移至 opt-in,因为它们就像import maps
一样形成静态polyfill
失败。- 支持
TLA
是很棘手,但在需要确保广泛支持的时候,通过添加一些 词法分析器功能 应该是可行的。 - 不幸的是,在
Firefox
中无法polyfill
module workers
,因为脚本worker
从未实现动态import()
(根据第一部分,这是实现加载器所需的)。我相信该特性可能很快就会到来,如果还没有的话,那么我们将有一个polyfill
路径将加载器钩子注入到module workers
中,可能甚至在此基础上填充模块块等特性。 - 由于
Firefox
从未为script workers
实现import()
,因此无法对module workers
进行polyfill
。我相信该功能可能很快就会推出,这样我们将有一个polyfill
路径来向module workers
注入加载器钩子,甚至可以在此基础上polyfill
诸如module blocks
之类的特性。 - 添加对
Wasm
模块 的支持也将是项目的主要目标,可能甚至与Wasm
相关的polyfill
结合。 - 尝试
polyfill
资源引用 也会很有趣。
作为项目的规则,只有在浏览器中实现时才会实现稳定的原生特性。否则,polyfill
模式有风险与原生功能产生分歧。
感谢你读到这里,现在你没有理由不 使用 import maps
了!(并受益于更优秀的 细粒度缓存)。