callcc.dev

在 ES6 之前,JS 没有一个模块化系统。模块化对于开发来说是极其重要的一个功能,因此出现了 CJS,UMD 等模块化方案。而 ES6 正式规范了模块化,现代浏览器也对 ESM 做了支持,支持程度可以在caniuse - esmodule查看。

Δ怎么用?

一般的脚本我们是这样在 HTML 里引入的
<script src="/normal-script.js"></script>
而如果我们的 JS 是一个 ES module,那么我们可以这样来引入
<script src="/module-script.js" type="module"></script>
我们现在知道怎么使用了,那么我们会好奇,对于模块脚本,浏览器是如何加载的。普通的脚本只要下载单文件完成之后就可以执行了,但是对于模块脚本来说不是这样,它的加载过程要复杂得多,这是因为入口模块可能会有其他的依赖模块,而依赖的模块可能又会有其他的依赖,一直嵌套。
我们先提出几个问题,这些问题都会在接下来的内容中得到答案。
  1. 对比 CJS 的模块系统,区别是什么?
  2. ESM 对待循环依赖是怎么解决的?
  3. ESM 是不是异步的?
  4. Webpack、Rollup 等打包工具利用 ESM 做了什么优化功能?

Δ加载机制

不想写得太长,因此想看特别详细版,可以查看 Lin Clark 的原文,里面还有图解,特别推荐。

ΔFetching

最开始,我们得先获得js文件。在 ESM 的 spec 里是没有规范这个过程的,意味着可以有不同的实现(loader)。它可以存在内存里,那么直接读取内存获得;它可以存在本地磁盘里,那么通过本地 IO 读取可以获得;它还可以通过网络 IO 的方式获得。这三种方式,可以把它们想象成浏览器里的memory cachedisk cacherequest。而且它们都可以做成同步的实现,但是很明显,我们都不会想在后两者,也就是磁盘 IO、网络 IO 上做同步阻塞,这样的效率太低了,所以一般都会采用异步的方式。ESM 考虑到了这一点,因此它的设计对于 loader 是同步还是异步是没有感知的。
而 CJS 的require是同步的。
到这里,我们回答了问题 3,以及问题 1。

ΔParsing

在 ESM 里,一个.js在逻辑层面不再是一个文件,而是一个模块,一个模块有它的结构(Module Record),因此我们会需要一个解析的过程。 一个模块的包含的东西大概就是四个部分
  1. 依赖(import)
  2. 导出项(export)
  3. code
  4. state(变量)
解析完成之后,根据 import,我们就可以知道当前模块所依赖的模块有哪些,根据这个信息,去获取新的模块,也就是一个模块走到一个新的fetching阶段。
一直到所有的依赖都完成解析之后,我们的依赖树也就构建完成了,这才能走到下一步,也就是Instantiation。所有的Module Record都会存在一个叫做Record Map的结构里。

ΔInstantiation

ParsingInstantiation阶段在我看来就像是 OOP 里的创建类和创建类实例一样。
在这个阶段里,我们会根据Module Record做一些实例化的工作。分配内存空间,根据export标记哪些是模块外可以访问的。值得注意的是,ESM 的一个模块只有一个实例,这个和 CJS 是不同的。CJS 通过require得到的是一个副本,也就是说如果存在两个模块 A 和 B,它们都依赖于模块 C 的某个变量 V,那么 A 模块通过 C 模块的方法修改了 V 变量之后,B 模块是无法感知的。而 ESM 在living binding机制下,则是相互之间是有感知的,这是因为 import 进来的变量都是 boxing 的。
一个比较简单的区分描述就是 ESM 导出的都是指针,而 CJS 则是拷贝。living binding 能很好的解决因为循环依赖导致的部分初始化问题(下面会看到例子)。JS 的 compiler 需要做相应的处理,确保 ES6 的代码编译后行为保持一致。到这里,我们回答了问题 1。
特别需要注意的一点是,虽然 ESM 给变量分配了内存,但是它实际上并没有对内存进行初始化/赋值操作。

ΔEvaluation

到这里,JS 引擎就会执行 top-level-code,也就是非方法定义之外的代码,这样变量就得到初始化赋值了。而循环依赖可能发生在这里,比如这个例子
模块 main
import { count } from "./counter";
console.log(count);
export const message = "Eval complete";
模块 counter
import { message } from "./main";
console.log(message);
export const count = 5;

setTimeout(() => console.log(message));
main 一开始就依赖 counter 的 count 变量,引起 counter 模块的 Eval,而这时候访问了还没初始化的message,那么将会得到 undefined,counter 完成 Eval 后回到 main 继续。完成之后 counter 模块的setTimeout再执行获得了经过初始化的message
以下是输出
undefined
5
Eval complete
而如果是把这段逻辑改写成 CJS,那么输入将会是,并且会得到一个循环依赖的 warning
undefined
5
undefined
因为require得到的是副本,回到 main 继续 Eval 初始化了 message 之后,counter 不能感知这个更改,而 ESM 借助living binding可以感知。
到这里,我们回到了问题 2。并且对问题 1 补充了实例说明。

ΔTree Shaking

Tree Shaking 是最初是 Rollup 加入的,之后 Webpack 也加入了这个功能。它主要实现原理是根据 ESM 的依赖关系构建了依赖树,对代码进行分析,没有使用到的、没有副作用的代码(dead code)在打包的时候就可以把它们移除掉,减少打包后的 bundle 体积。
到这里,我们回答了问题 4。(基于 ESM 的功能可能不止这个)

Δ参考

  1. ES modules: A cartoon deep-dive
  2. Tree Shaking
  3. What do ES6 modules export?
  4. Node.JS load module async
  5. Node.js - asynchronous module loading