问题一: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
提供了条件。