从ES6开始,我们可以使用import
和export
来使用模块系统,更好的组织代码。但在使用过程中,我们难免遇到一些问题,本篇文章就其中一些问题进行研究。
声明:本文研究的内容都针对于原初的ESM模块系统,一些打包系统(Webpack等)会将ESM转换为CJS,从而导致ESM的特性消失。测试环境:Chrome 99。
1. 导出变量或值
默认导出
当我们在一个模块中声明了一个变量,但是变量的初始值我们需要在异步操作中获得,比如:
// a.js
let x = 0
setTimeout(() => {
x = 1
})
export default x
在使用到x
的地方:
import x from './a.js'
console.log(x) // 0
setTimeout(() => {
console.log(x) // 0
})
我们会发现打印出的x
的值都是为 0。
因为对于default
这种默认导出方式来说,导出的是x
的值,而不是变量x
本身。相当于模块把x
赋值给一个特殊的内部变量,此后始终导出这个特殊变量。
命名导出
如果我们使用命名导出:
// a.js
let x = 0
setTimeout(() => {
x = 1
})
export { x }
在使用到a
的地方:
import { x } from './a.js'
console.log(x) // 0
setTimeout(() => {
console.log(x) // 1
})
我们会发现先打印 0,再打印 1。因为setTimout
中的console.log
执行时,x
已经被赋值为 1,而且命名导出的是变量本身,类似于 C 语言中的指针。
引用类型
但是对于引用类型(比如一个{}
)来说没有这个问题,因为默认导出了引用类型的值,但是这个值并不是对象的实际内容,而是这个变量所指向的对象的地址,同样类似于指针。
命名 default 导出
如果我们既希望使用默认导入,但是希望导出变量,那么可以使用一下的写法:
// a.js
export { x as default }
import x from './a.js'
2. 空间导入
如果命名导出的变量的很多,我们有时会用空间导入所有变量:
let x = 0
setTimeout(() => {
x = 1
})
export default x
export { x }
import * as a from './a.js'
然后像使用对象成员一样使用导出的变量:
console.log(a.x) // 0
setTimeout(() => {
console.log(a.x) // 1
})
a.x
打印先是 0 再是 1。这里不能把导出的模块认为是一个简单的对象,实际上它是一个“Module”类型“变量”,即使成员x
被赋予了字面量,但是它仍然会随它指向的变量而变化。
同时,这个模块变量中的成员是只读的。
如果我们在 Chrome 打印a
,可以考到这样的对象:
Module {Symbol(Symbol.toStringTag): 'Module'}
default: 0
x: 1
Symbol(Symbol.toStringTag): "Module"
这里的 default
就是默认导出对应的成员,至于它代表的是值还是指针,取决于 default
的导出方式。
3. 循环依赖
如果一个模块 a 引入了 模块 b,b 也引入了 a,那么它们之间就形成了循环依赖。ESM模块通过静态检查解决这一问题。
如果有这样两个模块:
// a.js
import y from './b.js'
let x = 0
export default x
export { y }
// b.js
import x from './a.js'
let y = 0
export default y
export { x }
两个模块虽然相互引入了默认导出,但是ESM模块系统是基于静态的,解释器会先检查a.js
的全部代码,发现了x
的声明。然后再检查b.js
,发现'y'的声明。两者满足要求,于是将两者引入各自的作用域,并导出变量。
当第三个模块引入其中的模块时,比如:
import x, { y } from './a.js'
console.log(x, y) // 0, 0
暂时性死区
但是如果在a.js
和b.js
中直接使用引入的变量:
// a.js
import y from './b.js'
let x = y
export default x
export { y }
// b.js
import x from './a.js'
let y = x
export default y
export { x }
那么在浏览器中获取x
会报错:
Uncaught ReferenceError: Cannot access 'x' before initialization
原因是x
和y
是使用let
声明的,存在暂时性死区,无法在声明前使用,而不是模块的引用错误。
变量提升
如果我们改用var
声明x
和y
并且使用命名导出,那么模块是可以导入成功的。原因是var
的变量提升,允许在声明前使用变量且命名导出的是变量的指针。只不过此时变量没有被赋值,因此值为undefined
。
// a.js
import { y } from './b.js'
var x = y
export default x
export { y }
// b.js
import { x } from './a.js'
var y = x
export default y
export { x }
但是即使是var
声明的默认导出依然无法使用,原因同上,因为默认导出的变量是保存在模块的一个特殊变量中,没有变量提升。
也就是说只要具有变量提升的变量没有被直接使用,那么久不会出错。比如默认导出的函数:
// a.js
import y from './b.js'
function x() {
console.log(y)
}
export default x
export { y }
// b.js
import x from './a.js'
function y() {
console.log(x)
}
export default y
export { x }
这样模块没有问题。但是如果在a.js
中直接调用了y
:
// a.js
import y from './b.js'
function x() {
console.log(y)
}
y()
export default x
export { y }
就会发生上述相同的问题。
4. 顶层 await 对模块的影响
在支持新语法“top-level-await”的系统中,顶层 await 使得整个模块都变成了异步操作,进而使得默认导出的是变量的最新值。比如:
let x = 0
await new Promise(resolve =>
setTimeout(() => {
x = 1
resolve()
})
)
setTimeout(() => {
x = 2
resolve()
})
export default x
import x from './a.js'
console.log(x)
setTimeout(() => {
console.log(x)
})
x
打印始终为 1。因为虽然导出的了最新值,但是导出的始终是值,即使在a.js
的第二个setTimeout
中更改了x
的值为 2,但是不会影响外部已经默认导入的 x
,因为本质上两者不是同一个变量。