
前端小魔女
2023/04/06阅读:118主题:蔷薇紫
你真的了解ESM吗?
❝在努力堆数量的同时,要不断在小范围上,去找新的标杆,去找微创新,要不断迭代。不能一直停留在低水平的重复。
❞
大家好,我是「柒八九」。
最近在做基于Vite
为模块打包工具的项目优化处理,在通过深入了解后,发现Vite
在「开发环境」下,真是提效神器。在基于ESM
特性上,Vite
抛弃了以往以bundle
为主的开发服务器,进而选择了一种nobundle
的资源处理模式。让在「开发阶段」有一个质的飞升。

而就在前天(2023/3/10)字节跳动发布了基于 Rust
的高性能模块打包工具Rspack
。它的内核其实就是利用Rust
的高性能编译器支持, Rust
编译生成的 Native Code
通常比 JavaScript
性能更为高效。同时得益于 Rust
语言的并行化
的良好支持, Rspack
采用了「高度并行化的架构」,如模块图生成,代码生成等阶段,都是采用「多线程并行执行」,这使得其编译性能随着 CPU
核心数的增长而增长,充分挖掘 CPU
的多核优势。
如果,硬要从设计角度来看,在「开发阶段」,Rspack
还是基于bundle
来提供页面内容。只不过是,它采用了性能更好的工具和优化方案。
而Vite
是在开发阶段真正的利用了「浏览器」内置的语言特性--ESM
来实现极致的开发体验。
而为了能够更好理解Vite
如何能够做到在开发模式下,如「德芙般丝滑」的开发体验。我们今天来讲讲之所以能够让Vite
有如此魔力的关键先生 ---「JS模块系统」。 (这篇文章不会涉及具体API
的使用,而是从上帝视角来查看ESM
的各种特性)
好了,天不早了,我们开始今天JS模块
的探索之旅。

你能所学到的知识点
❝❞
模块兼容性 「推荐阅读指数」⭐️⭐️⭐️ ESM是个啥 「推荐阅读指数」⭐️⭐️⭐️⭐️ 在浏览器中使用ESM 「推荐阅读指数」⭐️⭐️⭐️⭐️ 模块指定符 「推荐阅读指数」⭐️⭐️⭐️⭐️⭐️ 默认情况下,模块是defer的 「推荐阅读指数」⭐️⭐️⭐️⭐️⭐️ 动态导入 「推荐阅读指数」⭐️⭐️⭐️⭐️⭐️ import.meta 「推荐阅读指数」⭐️⭐️⭐️⭐️⭐️ 优化建议 「推荐阅读指数」⭐️⭐️⭐️ 网站中使用ESM 「推荐阅读指数」⭐️⭐️⭐️
模块兼容性
在接触一个新的技术或者知识点时,首先要做的就是看看ta
的兼容性如何?
因为,在如今技术大爆发的时代,技术更迭速度是远远超过浏览器更新速度。

好在,ESM
是一个「利国利民」的技术,市面上主流的「JS宿主环境」
-
常规浏览器( chrome 61+/firefox60+/safari11+
) -
Node 13.2.0+
都提供了对ESM
的支持。
换句话说,针对新的浏览器环境和Node
环境,可以「肆无忌惮」的使用ESM
。
ESM是个啥
「先认识它,再驾驭它」
❝❞
JS模块
(也被称为ES模块
或ECMAScript模块
)是一个重要的新功能,或者说是一个「新功能的集合」。
模块化的不同规范
❝「了解一件事情最好从它的历史开始」
❞
模块模式(IIFE)
let MODULE = (function () {
let my = {},
privateVariable = 1;
function privateMethod() {
// ...
}
my.moduleProperty = 1;
my.moduleMethod = function () {
// ...
};
return my;
}());
模块模式
是JS
语言在早期比较常用的模块化处理方式。「模块模式就是利用IIFE
实现的」。
CommonJS - 同步模块加载
在 Google Chrome
推出 V8
引擎后,基于其高性能和平台独立的特性,Nodejs
这个 JS 运行时
也现世了。至此,「JS 打破了浏览器的限制,拥有了文件读写的能力」。Nodejs
不仅在服务器领域占据一席之地,也将前端工程化带进了正轨。
CommonJS
作为非浏览器端的 「JS 规范」,从几个方面来描述该规范。
-
「模块定义」 -
一个模块即是一个 JS 文件,代码中 module
指向当前模块对象
-
-
「模块引用」 -
通过引用 require()
函数来实现模块的引用 -
参数可以是相对路径也可以是绝对路径 -
在「绝对路径的情况下」,会按照 node_modules
规则「递归查找」,在解析失败的情况下,会抛出异常
-
-
「模块加载」: -
require()
的执行过程是同步的 -
执行时即进入到被依赖模块的执行上下文中,「执行完毕后再执行依赖模块的后续代码」
-
AMD/CMD - 异步模块加载
为了解决浏览器端 JS 模块化的问题,出现了通过引入相关工具库的方式来解决这一问题。出现了两种应用比较广的规范及其相关库:
-
AMD(RequireJs)
-
AMD
推崇依赖前置、提前执行
-
-
CMD(Sea.js)
。-
CMD
推崇依赖就近、延迟执行
-
Require.js
// 加载完jquery后,将执行结果 $ 作为参数传入了回调函数
define(["jquery"], function ($) {
$(document).ready(function(){
$('#root')[0].innerText = 'Hello World';
})
return $
})
Sea.js
// 预加载jquery
define(function(require, exports, module) {
// 执行jquery模块,并得到结果赋值给 $
var $ = require('jquery');
// 调用jquery.js模块提供的方法
$('#header').hide();
});
AMD/CMD 解决问题:
-
手动维护代码引用顺序。 -
从此不再需要手动调整 HTML
文件中的脚本顺序,依赖数组会自动侦测模块间的依赖关系,并自动化的插入页面。
-
-
全局变量污染问题。 -
将模块内容在函数内实现,利用闭包导出的变量通信,不会存在全局变量污染的问题。
-
UMD - 兼容 CommonJS
与 AMD
❝❞
UMD
本质上是兼容CommonJS
与AMD
这两种规范的「代码语法糖」
通过判断执行上下文中是否包含 define
或 module
来包装模块代码,适用于需要「跨前后端的模块」。
❝所有这些模块系统都有一个共同点:允许你导入和导出东西。
❞
ESM - JavaScript 官方的模块化
现在,JavaScript
有标准化的语法来实现这一点。
❝在一个模块中,你可以使用
export
关键字来导出任何东西。❞
你可以导出一个常量,一个函数,或任何其他变量绑定或声明。 只要在变量语句或声明前加上 export
,你就可以了。
// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
return `${string.toUpperCase()}!`;
}
然后你可以使用 import
关键字从另一个模块中导入模块。
在这里,我们从lib模块
中导入repeat
和shout
功能,并在mian.mjs
中使用它。
// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('ni hao');
// → 'NI HAO'
你也可以从一个模块导出一个「默认值」。
// 📁 lib.mjs
export default function(string) {
return `${string.toUpperCase()}!`;
}
❝这种默认出口可以使用任何名称导入。
❞
// 📁 main.mjs
import shout from './lib.mjs';
// ^^^^^
ESM VS 传统脚本
❝
ESM
与传统脚本
有一些不同。❞
Module
默认启用了「严格模式」。Module
中不支持HTML
风格的注释语法,但在传统脚本中可以使用。Module
有一个顶层词法作用域。
在一个 Module
中运行var foo = 42;
并不会创建一个名为foo
的全局变量,不可以通过浏览器中的window.foo
来访问,在一个传统脚本中会出现这种情况。( window.foo
是被赋值的)Module
中的this
也不会指代全局this
,而是undefined
的。
如果需要访问 全局this
,可以使用globalThis
。新的「静态」 import
和export
语法只在Module
中可用
它在传统脚本中不起作用。 顶层的 await
在Module
中可用,但在传统脚本中不行。
与此相关, await
不能在模块中作为变量名使用
由于这些差异,同样的JavaScript
代码在被当作Module
和传统脚本时可能会有不同的表现。因此,JavaScript运行时
需要知道哪些脚本是模块。
ESM VS CommonJS
-
CommonJS
是同步加载模块,ES6
是异步加载模块-
CommonJS
规范加载模块是「同步的」,也就是说,只有加载完成,才能执行后面的操作。由于Node.js
主要用于「服务器编程」,模块文件一般都已经存在于「本地硬盘」,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS
规范比较适用。 -
浏览器加载 ES6
模块是「异步加载」,不会造成堵塞浏览器,即「等到整个页面渲染完,再执行模块脚本」
-
-
CommonJS
模块输出的是一个值的拷贝,ES6
模块输出的是值的引用。-
CommonJS
模块输出的是值的拷贝,也就是说,「一旦输出一个值,模块内部的变化就影响不到这个值」 -
ES6
模块的运行机制与CommonJS
不一样。「JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值」。
-
-
CommonJS
模块是运行时加载,ES6 模块是编译时输出接口。
在浏览器中使用ESM
❝在浏览器中,你可以通过设置
❞<script>
元素的type=module
来将其设置为ESM
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>
能够理解type="module"
的浏览器会忽略带有nomodule
属性的脚本。这意味着你可以向支持ESM
的浏览器提供基于ESM
的有效载荷,同时向其他不支持该特性的浏览器通过设置nomodule
来提供回退版本的脚本信息。
单纯的从性能角度出发,该特性是具有特殊的含义的。 试想一下:「如果一个浏览器能理解你的模块代码,它也会支持模块之前的功能」,比如箭头函数
或async-await
。你不必再在你的模块包中「转译」这些功能了!
-
你可以向现代浏览器提供「更小的、基本没有转译」的基于模块的有效载荷。 -
只有低版本的浏览器才会得到 nomodule
的有效载荷。
由于模块默认是defer的,你也应该以延时的方式加载nomodule
脚本,以便为那些低版本浏览器提供对应脚本信息。
<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>
浏览器环境下,模块和传统脚本之间差异
正如上文介绍的,模块与传统脚本不同。除了上面概述的与「平台无关」的差异之外,还有一些在浏览器环境下的差异。
❝ESM只被执行一次,而传统脚本被引用了几次就会被执行几次
❞
<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js 被执行多次. -->
<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs 只执行一次. -->
另外,模块脚本和它们的依赖关系是用CORS
获取的。这意味着,任何跨源模块的脚本都必须使用适当的头信息,如Access-Control-Allow-Origin:*
. 这对传统脚本是不需要的。
另一个区别与async
属性有关,它使脚本在下载时不会阻塞HTML解析器
(像defer
一样),但它也会尽快执行脚本,不保证顺序,也不等待HTML解析
的完成。async
属性「对内联的传统脚本不起作用,但它对内联的<script type="module">
起作用」。
关于文件扩展名的说明
在上述的示例中,ESM
中使用了.mjs
文件扩展名。「在网页上,文件扩展名其实并不重要」,只要JS文件
设置了正确的MIME
类型(text/javascript
)。根据<script/>
元素上的type
属性,浏览器能识别出它是一个模块文件。
尽管如此,我们还是建议对模块使用.mjs
扩展名,原因有二。
-
在开发过程中, .mjs
这个扩展名可以开发人员清楚地知道这个文件是一个模块,而不是一个传统脚本。 -
它可以确保你的文件被 Node.js
和d8
等运行系统以及Babel
等构建工具解析为模块。-
虽然这些环境和工具都有通过配置将其他扩展名的文件解释为模块的专有方式,但 .mjs
扩展名是确保文件被视为模块的交叉兼容的方式。
-
模块指定符
❝当导入模块时,指定模块位置的字符串被称为
❞模块指定符
或导入指定符
。
在前面的例子中,模块指定符
是"./lib.mjs"
。
import {shout} from './lib.mjs';
// ^^^^^^^^^^^
❝针对浏览器的模块解析器,目前不支持所谓的「裸模块指定器」。
❞
这点和vite/webpack/Rspack
等常规的打包工具不一样,因为,他们在进行资源解析和打包的过程中,会对「裸模块」进行替换处理。
// 暂未支持
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';
另一方面,下面的例子都是支持的。
// 支持:
import {shout} from './lib.mjs';
import {shout} from '../lib.mjs';
import {shout} from '/modules/lib.mjs';
import {shout} from 'https://simple.example/modules/lib.mjs';
❝目前,
模块指定符
必须是❞
完整的URL
或以 /
、./
或../
开头的相对URL
默认情况下,模块是defer的
传统<script>
默认会阻止HTML解析器
。我们可以通过添加defer
属性来解决这个问题,它可以「确保脚本下载与HTML解析并行进行」。

❝ESM 默认是延迟的。
❞
因此,没必要在 <script type="module">
标签中加入defer
!不仅是主模块的下载与 HTML解析
平行进行,「所有的依赖模块也是如此」。
动态导入
到目前为止,我们只使用了静态导入
。
❝使用静态导入,整个模块图需要在主代码运行之前被下载和执行
❞
有时,我们想在我们需要模块的时候,才去加载对应的模块信息。也就是我们常说的「按需加载」。例如,当用户点击一个链接或一个按钮时,才去加载指定的模块资源。
而import()
使之变的「唾手可得」。
<script type="module">
(async () => {
const moduleSpecifier = './lib.mjs';
const {repeat, shout} = await import(moduleSpecifier);
repeat('hello');
// → 'hello hello'
shout('ni hao');
// → 'NI HAO'
})();
</script>
与静态导入不同,import()
可以在常规脚本中使用。
<!DOCTYPE html>
<meta charset="utf-8">
<title>我的书房</title>
<nav>
<a href="books.html" data-entry-module="books">我的书籍</a>
<a href="movies.html" data-entry-module="movies">喜欢的电影</a>
<a href="video-games.html" data-entry-module="video-games">我的视频</a>
</nav>
<main>此处的内容,会根据link标签的点击而变化</main>
<script>
const main = document.querySelector('main');
const links = document.querySelectorAll('nav > a');
for (const link of links) {
link.addEventListener('click', async (event) => {
event.preventDefault();
try {
const module = await import(`/${link.dataset.entryModule}.mjs`);
// The module exports a function named `loadPageInto`.
module.loadPageInto(main);
} catch (error) {
main.textContent = error.message;
}
});
}
</script>
上面的这个示例中,简单的展示了,import()
如何在常规脚本中使用。通过对<a>
标签设置click
监听事件,在点击不同的标签,然后通过import()
获取到对应链接想展示的内容信息,并插入到<main>
中。从而实现,按需加载资源。
import.meta
ES2020
为 import
命令添加了一个元属性import.meta
,返回当前模块的元信息。
❝❞
import.meta
只能在模块内部使用,如果在模块外部使用会报错。
这个属性返回一个对象,该「对象的各种属性就是当前运行的脚本的元信息」。具体包含哪些属性,标准没有规定,由各个运行环境自行决定。一般来说,import.meta
至少会有下面两个属性。
(1)import.meta.url
import.meta.url
返回当前模块的 URL 路径。
举例来说,当前模块主文件的路径是https://foo.com/main.js
,import.meta.url
就返回这个路径。如果模块里面还有一个数据文件data.txt
,那么就可以用下面的代码,获取这个数据文件的路径。
new URL('data.txt', import.meta.url)
❝❞
import.meta.url
使得「相对于当前模块」加载图像成为可能。
function loadThumbnail(relativePath) {
const url = new URL(relativePath, import.meta.url);
const image = new Image();
image.src = url;
return image;
}
const thumbnail = loadThumbnail('../img/thumbnail.png');
container.append(thumbnail);
(2)import.meta.scriptElement
import.meta.scriptElement
是浏览器特有的元属性,返回加载模块的那个<script>
元素,相当于document.currentScript
属性。
// HTML 代码为
// <script type="module" src="my-module.js" data-foo="abc"></script>
// my-module.js 内部执行下面的代码
import.meta.scriptElement.dataset.foo
// "abc"
优化建议
继续打包处理
❝有了
❞ESM
,无需使用webpack
、Rollup
或Parcel
等打包工具就可以开发网站。
在以下情况下,直接使用ESM
就可以了。
-
本地开发 ( vite
在开发环境就是利用这个特性) -
在生产过程中,对于总模块数少于100个且依赖树相对较浅(即最大深度小于5)的小型网络应用程序。
在Chrome浏览器加载由约300个模块组成的模块化库时的瓶颈分析中了解到的那样,打包后应用
的加载性能要好于未打包应用
。

其中一个原因是,静态导入/导出语法是可以「静态分析」的,因此它可以帮助打包工具通过「消除不使用的导出」来优化代码。「静态导入和导出不仅仅是语法,它们是一个重要的工具特性」
我们的一般建议是,在将模块部署到生产中之前仍然对其进行打包处理。在某种程度上,打包处理是一种类似于最小化你的代码:它导致了性能上的好处,因为你最终产生的代码更少。
使用细粒度的模块
养成使用小的、细粒度的模块编写代码的习惯。在开发过程中,一般来说,每个模块只有几个出口,比手动将许多出口合并到一个文件中要好。
考虑一个名为./util.mjs
的模块,它导出了三个名为drop
、pluck
和zip
的函数。
export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }
如果你的代码库只真正需要pluck
功能,你可能会按如下方式导入它。
import {pluck} from './util.mjs';
在这种情况下,(如果没有构建时的打包步骤)浏览器最终仍然不得不下载、解析和编译整个./util.mjs
模块,尽管它真正需要的只是一个出口。
如果pluck
不与drop
和zip
共享任何代码,最好把它移到自己的细粒度模块,例如./pluck.mjs
。
export function pluck() { /* … */ }
然后,我们可以导入pluck
,而不需要处理drop
和zip
的开销。
import {pluck} from './pluck.mjs';
这不仅可以使你的源代码简单明了,还可以减少打包程序对dead-code消除
的需要。如果你的源码树中的某个模块没有被使用,那么它就不会被导入,因此浏览器也不会下载它。那些被使用的模块可以被浏览器单独进行代码缓存。
使用小的、细粒度的模块有助于为你的代码库做好准备,以应对未来可能出现的本地捆绑解决方案。
预加载模块
你可以通过使用<link rel="modulepreload">
来进一步优化你的模块的交付。这样一来,浏览器就可以「预加载」,甚至准备和预编译模块及其依赖关系。
<link rel="modulepreload" href="lib.mjs">
<link rel="modulepreload" href="main.mjs">
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>
这对较大的依赖关系树来说尤其重要。如果没有rel="modulepreload"
,浏览器需要「执行多个HTTP请求来找出完整的依赖关系树」。然而,如果你用rel="modulepreload"
声明了「依赖模块脚本的完整列表,浏览器就不必逐步发现这些依赖关系」。
网站中使用ESM
ESM
正在慢慢地在网站开发上获得实战。我们的使用计数器显示,目前所有的页面加载中有8%
使用<script type="module">
。

后记
「分享是一种态度」。
参考资料
「全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。」

作者介绍

前端小魔女
微信公众号:前端柒八九