前言

2022了,自从CommonJS奠定了javascript的模块基调后,近十多年来,ESModule作为EcmaScript钦定的模块标准,早已经深入人心,其静态连接的特性在使用中能帮我们避免类似循环引用等之前CommonJS无法解决的问题。再加上现代打包工具的支持,让我们在编写代码的时候也能按照ESM的规范coding。今天我们以前些年的一篇强烈推荐ESM好文做基础,加上我的认知,深入理解一下日常使用的ESModule。

几个概念

在深入理解ESM的工作流程前,我们要先明确几个和ESM有关的概念。

依赖关系图(graph of dependencies)

我们在编码中,通过import在不同依赖之间的连接来确定依赖关系,ESM会构建一份不同模块依赖的依赖关系图。
这份依赖申明就能精确地告诉浏览器和node需要去家在什么代码。一般我们都会以一个文件作为依赖的入口,从这开始就可以通过入口文件的import语句来解析所以来的模块。

模块记录(Module Record)

依赖的文件其实并不能被浏览器直接拿来使用,它需要通过解析,将导入的依赖文件解析成特定的数据结构(Module Record),这样浏览器才能知道发生了什么。

模块实例(Module Instance)

解析成模块记录后,模块记录需要被转换成模块实例,一个模块实例包含两个内容:

  • 模块代码(code)
    • 基本上就是一组指令,就像是做菜的菜谱,但是只有代码本身还不行,需要有原材料才行
    • 可以简单理解就是源码
  • 模块状态(state)
    • 就是上面说的原材料,是变量在任意时刻的真实值,通过变量名保存在内存中
    • 记录着该模块所有的变量


其实我们想要的就是每个模块其对应的模块实例,模块加载的过程就是从入口文件到拥有完整的模块实例图。

ESM核心的三个步骤

  • 构建(Construction):查找,下载并解析所有的文件到模块记录
  • 实例化(Instantiation):分配内存空间来存放模块所有导出的变量,但这时候内存中并没有分配变量的值。然后将对应导出与导入指向同一内存空间
  • 求值(Evaluation):运行代码,将内存中的之前未分配值的变量赋为真实的值


正如同我们常说ES模块是异步的,是因为核心流程被分为构建实例化求值这三个不同的阶段,这些阶段可以分别完成。所以可以理解为是异步的。

在CommonJS中,一个模块和它的依赖都是一次性构建,实例化和求值的,中间没有中断,因此是同步的。

然而,上述核心三步本身并不一定是异步的。它们可以以同步的方式完成。这取决于正在加载的内容。这是因为并不是所有的工作都由ES模块规范控制。实际上,分为两部分,由不同的规范涵盖。

ES模块规范说明了应该如何将文件解析为模块记录,以及应该如何实例化和求值该模块,统一采用异步机制来处理模块。但是,它并没有说明如何获得文件。

对浏览器而言,模块文件加载器的规范就是HTML规范.

当然了,使用不同的平台环境,就有不同的文件加载器规范


模块加载器还精确地控制模块的加载方式。它调用ES模块的方法:

  • ParseModule
  • Module.Instantiate
  • Module.Evaluate
    有点像一个完成工作后去控制JS引擎的字符串。

step1-1: 构建(Construction)

主要做了三件事:

  • 得到模块文件的下载地址
  • 通过URL下载或文件系统加载的方式,获取模块文件
  • 解析成模块记录

获取模块文件

浏览器里,模块加载器通过script标记来找到入口文件。
通过入口文件的import语句,来指导模块加载器获取下一个模块文件。


我们必须一层一层地遍历树,解析一个文件,然后找出它的依赖项,然后找到并加载这些依赖项。

不同环境的模块说明符解析都不同,因此有一种称为模块解析的算法,不同的平台环境都不一样。

ESM与CommonJS的区别

说到这里,就要回顾一下,ESM和CommonJS在加载文件这件事上的区别,以此来看看为什么ESM为什么会有不同阶段。
因为在浏览器中,下载文件可能需要很长时间。如果主线程等待每个文件下载,那么许多其他任务就会堆积在它的队列中。


像这样阻塞主线程会使使用模块的应用程序使用起来太慢。这也是ES模块规范为什么将算法分成多个阶段的原因之一。将构造划分为自己的阶段允许浏览器获取文件,并在开始实例化的同步工作之前构建它们对模块图的理解。
CommonJS的文件加载比起浏览器要快得多(一般理解为服务器的I/O比浏览器的网络请求要快很多),因此Node的模块加载可以阻塞主线程(同步)。既然已经加载了,实例化和求值就不是独立的阶段了,这也意味着在返回模块实例之前,要遍历整个树,加载、实例化和求值任何依赖关系。


CommonJS在查找下一个模块之前,将执行此模块中的所有代码(直到require语句)。这意味着当你做模块解析时变量会有一个值(运行时加载)。但是ESM,在做任何求值之前都要建立整个模块图(编译时输出)。这意味着在import语句中不能有变量,因为这些变量还没有值。


这种将算法分成阶段的方法是ES模块和CommonJS模块之间的关键区别之一。

ESM动态import

通过使用import()加载的任何文件都作为单独图的入口点处理。
动态导入提案的实现原理是:在入口文件处进行模块静态分析时遇到了动态导入声明则开启一个独立的静态代码分析来解析动态导入模块标识指向的模块,返回的是一个Promise对象;这个过程也是可以嵌套的,即动态导入的模块可以再进行另外的动态导入。

模块映射(Module Map)

模块加载器会缓存模块实例,对于特定全局作用域中的每个模块,只有一个模块实例。这对于引擎的优化是非常好的。
加载器使用模块映射来管理模块实例的缓存,当需要去获取一个URL时,把URL放在模块映射中,并记录下它当前正在获取的文件。
如果另一个模块依赖相同的文件,加载器就会在模块映射中查找URL,如果有就返回。


step1-2: 解析(Parsing)

将模块文件解析成模块记录,一旦模块记录被创建,就会被放入模块映射中。
ESM的解析是严格模式(use strict)
对于同样的文件,不同的解析方式(也称为解析目标),会得到不同的结果。所以在解析之前要设置ESM是否按照模块来解析。

在浏览器环境

设置很简单,只要在script上添加属性type=“module”。浏览器就会用ESM来把这个文件解析成模块,并且之后的任何导入,也都是模块,因为ESM只可以导入模块

在node环境

因为没有html标签,所以无法像浏览器一样在标签上使用type属性。可以有两种方法:

  • 社区采用.mjs扩展名来标识这个文件是一个ESM
  • package.json设置"type": "module",来把js文件用ESM处理
    在这里需要注意的是:
  • 在版本12之前,需要加上experimental-modules
  • 所有的文件名后缀需要改成.mjs
  • 导入文件路径的时候需要输入完整的文件名,不能省略.mjs(因为默认是导入js后缀名)
  • 所有的导入导出都必须用ESM,使用CommonJs会报错
  • require、__dirname 、 __filename将无法使用,因为这是node环境注入的
  • 无法用import导入JSON文件

后缀名mjs对应ESM,那么cjs就对应了CommonJS
package.json设置”type”: “module”对应ESM,”type”: “commonjs”对应CommonJS

无论哪种方式,加载器都将决定是否将文件解析为模块。如果它是一个模块,并且有导入,那么它将再次启动该过程,直到获取和解析所有文件。
在加载过程结束后,就把入口文件变成了一堆模块记录了。下一步就是要实例化这些模块,并将所有模块实例连接在一起。

step2: 实例化(Instantiation)

上文已经提到,模块实例化会将codestate结合起来。state存在于内存中,所以实例化步骤就是将所有变量函数等连接到内存

这里要区别于求值过程,因为此阶段只是内存与变量的连接,并不是真实运行后的真实值

主要过程:

  • JS引擎会创建一个模块环境记录(上下文)来管理模块记录中的变量
  • 找出export的变量和内存空间地址,将导出的变量绑定到对应的内存空间地址
  • 模块环境记录(上下文)将跟踪内存中于每个导出的对应关系
    ESM采用深度优先顺序遍历来遍历模块依赖关系图中每一个模块记录。就是说先从入口文件的第一个导入语句开始一层层往更深层查找,直到最后一个没有导入语句的模块为止,连接好这个模块的导出变量,然后回到上一级的模块继续这个步骤。


完成一个模块所依赖的模块导出变量的连接,将返回导出变量所在模块的上一级模块,再将上一级模块的导入变量进行连接。
所以由上可知,上下级模块的export和import都指向同一个内存地址


这一过程和CommonJS不同,CommonJS整个导出对象是被复制,所以导出的任何值都是一个副本

这也就是CommonJS存在的一个问题,如果导出模块后面做了更改,导入模块将不会发生变化


ESM使用动态绑定(实时绑定),导出变量的模块与导入变量的模块所使用的变量都指向了同一个内存地址,因此导出模块中对相应变量更改将会更新导入模块中导入的相应变量。

  • 导出值的模块可以在任何时候更改值
  • 导入值的模块不能更改,但能更改对象上的属性值


采用动态绑定(实时绑定),就可以不用运行代码就能连接模块

解决了循环引用的问题

至此,ESM就获得了导出和导入变量连接的模块实例及其内存地址,接下去就是最后一步,求值,运行代码并向内存地址中填入真实值。

step3: 求值(Evaluation)

  • JS引擎执行顶层代码

  • 由于存在副作用,求值阶段每个模块只运行一次

    实例化阶段的时候多次连接都将是同一个值,求值阶段多次运行可能会有不同的结果。
    依赖模块映射(Module Map),实现每个模块只运行一次

  • 深度优先的后序遍历

再讲讲循环依赖

一个模块A引入的另一个模块B中又引入了这个模块A,这种 A=>B=>A 的环形依赖关系叫做循环依赖。

CommonJS处理方式为什么有问题

  • 首先,主模块将一直执行到require语句。然后它会去加载计数器模块
  • 计数器模块视图访问导出对象,但是因为该对象在主模块中并未求值,所以返回的是undefined(JS引擎会在内存中为变量分配空间,但赋值为undefined)

  • 是不是如果让主模块运行完就可以了呢?答案显然是否定的,比如设置一个setTimeout

  • 主模块在完成求值阶段后,变量初始化并放到内存中,但由于导出是值拷贝,所以两者之间完全没关系,因此在导入的模块中仍然是undefined

ESM的处理方式

动态绑定就是解决循环依赖的方法。因此相比CommonJS,ESM最终不会有这个问题。详解可以看上文。