问题一:ESM 是什么?
之前 JavaScript 一直没有一个模块机制,所谓的 IIFE、CommonJs、UMD 等都是三方规范和实现,与 JavaScript 自身并无关系。
Node.js 一直以来用的都是 CommonJS 模块机制,而非 "JavaScript 模块机制"或 "ECMAScript 模块机制"。
像 Sea.js、require.js 都是三方实现的 ,甚至 Node.js 中的 CommonJS 也是 Node.js 在自身代码里面实现的,并不是 V8 的能力。
ECMAScript modules 是 ECMAScript 的官方模块机制,与 CommonJS 等都不一样,其内置在 JavaScript 语言特性中,而不用自己实现一套模块机制。
如果你用了 V8 对应版本,那么只要通过 V8 的 API 适配一下模块加载,就直接拥有了导入模块的能力。
问题二:Node.js 中同时存在 ESM 和 CJS两种模块机制
ESM 是 ECMAScript 官方的模块机制,从语法层面直接支持。
虽然语法上面支持了,但是当 Node.js 拿到一个 import ... 'foo' 的时候,还是得决定怎么加载一个模块。
V8 只是实现了语法上面的解析,具体加载代码等操作还是需要各运行时自行适配。
毕竟不同运行时对于标识解析、代码加载的规则不一样,比如 Deno 支持从 HTTP 进行远端加载,而 Node.js 至少在 v18 还没有内置默认打开,需要用户自行实现或开启。
Node.js 支持两套模块机制,但其又各自独立。
不同的场景下,Node.js 会将一个模块判断为是 CJS 模块,还是 ESM 模块。
通常一个 *.mjs 会被认为是 ECMAScript module,而一个 *.cjs 则会被认为是 CommonJS 模块。
如果是 *.js 文件,则需要看离它最近的父 package.json 文件,Node.js 在 v12.0.0 中,为 package.json 增加了 type 字段,用于判别其麾下的 *.js 文件是 ECMAScript module 还是 CommonJS 模块。
-
若
type值为module,则其*.js为ECMAScript module; -
若
type值为commonjs或者不存在该值,则其*.js为CommonJS模块。
问题三: CJS 与 ESM 能互相引用吗?
在 CJS模块机制下能使用使用ESM 的 import语法吗?
js
// test2.js
console.log('test2', module)
console.log('test2 require', require.main)
// test.js
import * as a from './test2.js'
执行 node test.js,报错:

如果把 test.js 重命名为 test.mjs 则可正常运行。
在 ESM 下对 CJS 模块进行 import 可以吗?
比如,把 test2.js 改成 cjs 模式导出,test.mjs仍然可以用 import 导入。
不过这么一来,由于入口文件是 ESM 的,所以 test2.js 中的require.main 就不存在了。
在 ESM 的作用域下,不再存在 CJS 对应的上下文,如 require()、module、exports 这些统统不存在。
也就说,把 test.mjs 源码改为如下代码,直接会报错。
js
require('test2');

ESM 可以通过 import 加载 CJS 模块,而反过来 CJS 模块是无法通过 require() 来加载 ESM 的。
这里涉及到一个本质问题,那就是模块加载的异同步问题。
CJS 的 require() 机制是完全同步的,而 ECMAScript module 的 import 机制则是异步的。
import 是异步的,那么在内部通过同步的方式模拟一个 require 流程是没问题的,所以 ECMAScript module 下可以通过 import 去加载 CommonJS 模块;
反过来不行,一个同步的东西是无法加载异步内容的,至少无法通过比较正统的方式解决。
ECMAScript module 加载机制是异步的。虽然在 import * as mod from 'xxx' 的语法中我们看起来是同步的,但其实在引擎内部帮你吃掉了异步的部分。
在 import() 中,该异步需要自己处理,它的返回值是一个 Promise。如:
js
import('xxx').then(mod => {
// 这里的 mod 就是加载的 ECMAScript module
});
问题四:什么叫运行时加载和编译时加载?
JavaScript 执行过程分为两个阶段: 编译阶段 和 执行阶段。

JavaScript代码经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。如下图:

我们日常提到的变量提升和函数提升,就是在 编译阶段 做的,所以下面的写法并不会报错:
js
showName()
console.log(myname)
var myname = 'jack'
function showName() {
console.log('函数showName被执行');
}
下面,利用JavaScript 的常见报错类型来理解什么是编译阶段和执行阶段。
RangeError
这类错误很常见,例如栈溢出就是 RangeError;
js
function a () {
b()
}
function b () {
a()
}
a()
// out:
// RangeError: Maximum call stack size exceeded
ReferenceError
ReferenceError 也很常见,打印一个不存在的值就是 ReferenceError:
js
hello
// out:
// ReferenceError: hello is not defined
SyntaxError
SyntaxError 也很常见,当语法不符合 JS 规范时,就会报这种错误:
js
console.log(1));
// out:
// SyntaxError: Unexpected token ')'
TypeError
TypeError 也很常见,当一个基础类型当作函数来用时,就会报这个错误:
js
var a = 1;
a()
// out:
// TypeError: a is not a function
上面的各种 Error 类型中,SyntaxError 最为特殊,因为它是 编译阶段 抛出来的错误,如果发生语法错误,JS 代码一行都不会执行 。而其他类型的异常都是 执行阶段 的错误,就算报错,也会执行异常之前的脚本。
ESM 编译时加载 VS CJS 运行时加载
ESM 之所以被称为 编译时加载,是因为它的模块解析是发生在 编译阶段。
也就是说,import 和 export 这些关键字是在编译阶段就做了模块解析,这些关键字的使用如果不符合语法规范,在编译阶段就会抛出语法错误。
与此对应的 CJS,它的模块解析发生在 执行阶段 ,因为 require 和 module 本质上就是个函数或者对象,只有在 执行阶段 运行时,这些函数或者对象才会被实例化。因此被称为运行时加载。
ESM 的设计理念是希望在编译时就确定模块依赖关系即输入输出,那如何在编译时就能确定依赖关系呢?
要想在编译阶段就能确定依赖关系,那必须要把 import 进行类似于变量提升。
import 提升 其实跟 JavaScript 代码的变量提升一样,在编译阶段发现有 import 就会像 var 一样进行提升。
为了验证这一点,看一下如下代码:
js
console.log('main.js开始执行')
import say from './a.js'
console.log('main.js执行完毕')
// a.js
console.log('a模块加载')
export default function say() {
console.log('hello , world')
}
执行顺序如下:
js
a模块加载
main.js开始执行
main.js执行完毕
因为这种静态语法,所以import, export不能放在块级作用域或条件语句中。
js
// 错误写法
function say() {
import name from './a.js'
export const author = 'dog'
}
// 错误写法
isexport && export const name = '《React进阶实践指南》'
在编译过程中确定了导入和导出的关系,所以更方便去查找依赖,这为tree shaking(摇树)创造了条件,这也是 ESM 支持tree-shaking操作的原因。
像这段CJS模块的加载代码被 parse 成 AST(抽象语法树) 时,很难分析出究竟依赖了哪些模块。
js
const bar = require(`all/${['f', 'o', 'o'].join('')}`)
const foo = _.get(require('all'), 'foo')
同样,CJS 在做模块导出时也无法静态识别:
js
module.exports = {}
Object.assign(module.exports, {bar: 'foo'})
但是在 ESM 中,import/exports 一目了然,对于没有被 import 的部分,也很自然的容易区分出来。
总之,就是通过限定语法,让本来需要运行代码才能确定的依赖,可以在 AST 阶段就能确定下来。
问题五:ESM 的使用需要注意什么?
import的值是一个常量
js
// test.mjs
export let a = 1
export function plus() {
a++
}
// main.mjs
import { a, plus } from './test.mjs'
console.log(a) // 1
a++
当执行 main.js的时候会报错 Uncaught TypeError: Assignment to constant variable。
通过 import 导入的值可以看出是一个 const 常量,不能修改。
当第一次打印导入的变量 a 的值是 1,然后执行 plus 方法,再次打印 a 发现值是 2。
也就是,使用 import 导入的变量是与原变量是引用关系,而不是拷贝。
ESM不能解构
与 CJS 不同,ESM 中 import 的不是对象, export 的也不是对象。例如,下面的写法会提示语法错误:
js
// 语法错误!这不是解构!!!
import { a: myA } from './a.mjs'
// 语法错误!
export {
a: 'a'
}
import 和 export 的用法很像导入一个对象或者导出一个对象,但这和对象完全没有关系 。他们的用法是 ECMAScript 语言层面的设计的,并且"恰巧"的对象的使用类似。
EMS的使用
请参考这篇文字全解析 ESM 模块语法,出去还是进来都由你说了算
总结
-
ESM是JS在语法层面实现了模块机制,而不是第三方实现。如果你用了 V8 对应版本,那么只要通过 V8 的 API 适配一下模块加载,就直接拥有了导入模块的能力。 -
Nodejs目前支持ESM和CJS两种模块机制,ESM可以通过import导入CJS模块,反之不行。这是因为两者模块加载的同步异步问题,CJS是同步加载,ESM是异步加载,异步可以实现同步,但是同步无法实现异步。另外,ESM的异步过程发生在引擎内部,对外表现为同步。 -
ESM在编译阶段就确定了模块依赖,而不是在运行时阶段确定,这为tree shaking提供了条件。