import、export、default,ESM模塊系統的一些研究

從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,因爲本質上兩者不是同一個變量。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章