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