众所周知,早期 JavaScript 原生并不支持模块化,直到 2015 年,TC39 发布 ES6,其中有一个规范就是 ES modules(为了方便表述,后面统一简称 ESM)。但是在 ES6 规范提出前,就已经存在了一些模块化方案,比如 CommonJS(in Node.js)、AMD。ESM 与这些规范的共同点就是都支持导入(import)和导出(export)语法,只是其行为的关键词也一些差异。
CommonJS
- // add.js
- const add = (a, b) => a + b
- module.exports = add
- // index.js
- const add = require('./add')
- add(1, 5)
AMD
- // add.js
- define(function() {
- const add = (a, b) => a + b
- return add
- })
- // index.js
- require(['./add'], function (add) {
- add(1, 5)
- })
ESM
- // add.js
- const add = (a, b) => a + b
- export default add
- //index.js
- import add from './add'
- add(1, 5)
关于 JavaScript 模块化出现的背景在上一章(《前端模块化的前世》))已经有所介绍,这里不再赘述。但是 ESM 的出现不同于其他的规范,因为这是 JavaScript 官方推出的模块化方案,相比于 CommonJS 和 AMD 方案,ESM采用了完全静态化的方式进行模块的加载。
ESM规范
模块导出
模块导出只有一个关键词:export,最简单的方法就是在声明的变量前面直接加上 export 关键词。
- export const name = 'Shenfq'
可以在 const、let、var 前直接加上 export,也可以在 function 或者 class 前面直接加上 export。
- export function getName() {
- return name
- }
- export class Logger {
- log(...args) {
- console.log(...args)
- }
- }
上面的导出方法也可以使用大括号的方式进行简写。
- const name = 'Shenfq'
- function getName() {
- return name
- }
- class Logger {
- log(...args) {
- console.log(...args)
- }
- }
- export { name, getName, Logger }
最后一种语法,也是我们经常使用的,导出默认模块。
- const name = 'Shenfq'
- export default name
模块导入
模块的导入使用import,并配合 from 关键词。
- // main.js
- import name from './module.js'
- // module.js
- const name = 'Shenfq'
- export default name
这样直接导入的方式,module.js 中必须使用 export default,也就是说 import 语法,默认导入的是default模块。如果想要导入其他模块,就必须使用对象展开的语法。
- // main.js
- import { name, getName } from './module.js'
- // module.js
- export const name = 'Shenfq'
- export const getName = () => name
如果模块文件同时导出了默认模块,和其他模块,在导入时,也可以同时将两者导入。
- // main.js
- import name, { getName } from './module.js'
- //module.js
- const name = 'Shenfq'
- export const getName = () => name
- export default name
当然,ESM 也提供了重命名的语法,将导入的模块进行重新命名。
- // main.js
- import * as mod from './module.js'
- let name = ''
- name = mod.name
- name = mod.getName()
- // module.js
- export const name = 'Shenfq'
- export const getName = () => name
上述写法就相当于于将模块导出的对象进行重新赋值:
- // main.js
- import { name, getName } from './module.js'
- const mod = { name, getName }
同时也可以对单独的变量进行重命名:
- // main.js
- import { name, getName as getModName }
导入同时进行导出
如果有两个模块 a 和 b ,同时引入了模块 c,但是这两个模块还需要导入模块 d,如果模块 a、b 在导入 c 之后,再导入 d 也是可以的,但是有些繁琐,我们可以直接在模块 c 里面导入模块 d,再把模块 d 暴露出去。
- // module_c.js
- import { name, getName } from './module_d.js'
- export { name, getName }
这么写看起来还是有些麻烦,这里 ESM 提供了一种将 import 和 export 进行结合的语法。
- export { name, getName } from './module_d.js'
上面是 ESM 规范的一些基本语法,如果想了解更多,可以翻阅阮老师的 《ES6 入门》。
ESM 与 CommonJS 的差异
首先肯定是语法上的差异,前面也已经简单介绍过了,一个使用 import/export 语法,一个使用 require/module 语法。
另一个 ESM 与 CommonJS 显著的差异在于,ESM 导入模块的变量都是强绑定,导出模块的变量一旦发生变化,对应导入模块的变量也会跟随变化,而 CommonJS 中导入的模块都是值传递与引用传递,类似于函数传参(基本类型进行值传递,相当于拷贝变量,非基础类型【对象、数组】,进行引用传递)。
下面我们看下详细的案例:
CommonJS
- // a.js
- const mod = require('./b')
- setTimeout(() => {
- console.log(mod)
- }, 1000)
- // b.js
- let mod = 'first value'
- setTimeout(() => {
- mod = 'second value'
- }, 500)
- modmodule.exports = mod
- $ node a.js
- first value
ESM
- // a.mjs
- import { mod } from './b.mjs'
- setTimeout(() => {
- console.log(mod)
- }, 1000)
- // b.mjs
- export let mod = 'first value'
- setTimeout(() => {
- mod = 'second value'
- }, 500)
- $ node --experimental-modules a.mjs
- # (node:99615) ExperimentalWarning: The ESM module loader is experimental.
- second value
另外,CommonJS 的模块实现,实际是给每个模块文件做了一层函数包裹,从而使得每个模块获取 require/module、__filename/__dirname 变量。那上面的 a.js 来举例,实际执行过程中 a.js 运行代码如下:
- // a.js
- (function(exports, require, module, __filename, __dirname) {
- const mod = require('./b')
- setTimeout(() => {
- console.log(mod)
- }, 1000)
- });
而 ESM 的模块是通过 import/export 关键词来实现,没有对应的函数包裹,所以在 ESM 模块中,需要使用 import.meta 变量来获取 __filename/__dirname。import.meta 是 ECMAScript 实现的一个包含模块元数据的特定对象,主要用于存放模块的 url,而 node 中只支持加载本地模块,所以 url 都是使用 file: 协议。
- import url from 'url'
- import path from 'path'
- // import.meta: { url: file:///Users/dev/mjs/a.mjs }
- const __filename = url.fileURLToPath(import.meta.url)
- const __dirname = path.dirname(__filename)
加载的原理
步骤:
- Construction(构造):下载所有的文件并且解析为module records。
- Instantiation(实例):把所有导出的变量入内存指定位置(但是暂时还不求值)。然后,让导出和导入都指向内存指定位置。这叫做『linking(链接)』。
- Evaluation(求值):执行代码,得到变量的值然后放到内存对应位置。
模块记录
所有的模块化开发,都是从一个入口文件开始,无论是 Node.js 还是浏览器,都会根据这个入口文件进行检索,一步一步找到其他所有的依赖文件。
- // Node.js: main.mjs
- import Log from './log.mjs'
-
- <script type="module" src="./log.js">script>
值得注意的是,刚开始拿到入口文件,我们并不知道它依赖了哪些模块,所以必须先通过 js 引擎静态分析,得到一个模块记录,该记录包含了该文件的依赖项。所以,一开始拿到的 js 文件并不会执行,只是会将文件转换得到一个模块记录(module records)。所有的 import 模块都在模块记录的 importEntries 字段中记录,更多模块记录相关的字段可以查阅tc39.es。
模块构造
得到模块记录后,会下载所有依赖,并再次将依赖文件转换为模块记录,一直持续到没有依赖文件为止,这个过程被称为『构造』(construction)。
模块构造包括如下三个步骤:
- 模块识别(解析依赖模块 url,找到真实的下载路径);
- 文件下载(从指定的 url 进行下载,或从文件系统进行加载);
- 转化为模块记录(module records)。
对于如何将模块文件转化为模块记录,ESM 规范有详细的说明,但是在构造这个步骤中,要怎么下载得到这些依赖的模块文件,在 ESM 规范中并没有对应的说明。因为如何下载文件,在服务端和客户端都有不同的实现规范。比如,在浏览器中,如何下载文件是属于 HTML 规范(浏览器的模块加载都是使用的