import、export、default,ESM模块系统的一些研究

1,584 阅读5分钟

从ES6开始,我们可以使用importexport来使用模块系统,更好的组织代码。但在使用过程中,我们难免遇到一些问题,本篇文章就其中一些问题进行研究。

声明:本文研究的内容都针对于原初的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.jsb.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

原因是xy是使用let声明的,存在暂时性死区,无法在声明前使用,而不是模块的引用错误。

变量提升

如果我们改用var声明xy并且使用命名导出,那么模块是可以导入成功的。原因是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,因为本质上两者不是同一个变量。