接上篇 require()、import、import()加載模塊詳解(一)
ES6 Module 的 import
通過 import 靜態(tài)地導入另一個通過 export 導出的模塊。
區(qū)分于 CJS 運行時才和導入模塊建立關系,ESM 在轉化成中間代碼時(編譯階段) import 語句就和模塊建立了靜態(tài)引用關系,在運行時導入和導出是不可更改的。這就意味著我們只能在頂層進行導入和導出 (比如絕不能嵌套在條件語句中),同時 import 和 export 語句不能有「具有邏輯或含有變量」的動態(tài)部分,即不能依賴于運行時計算的任何內容 (如import foo from './module' + 變量;),不然編譯時就會報錯。而 require 可以在運行時通過 if 判斷決定導入哪個模塊。
在編譯期,import 語句會被內部移動至當前作用域最開頭 (類似 var 和 function 的變量提升),先于其他代碼執(zhí)行。JS 解析器編譯到 import 語句時,會生成一個接口標識符或默認導出接口對應的引用。如 import { a } from './module-a',a 指向的是export const a = xxx 接口中的 a;而 import defaultB from './module-b',defaultB 指向的是 export default b 中的 b (默認接口導入時的名稱可以自定義)。到了運行期,也不會去執(zhí)行完整模塊,只有在調用 a / defaultB 的時候才會加載模塊中相應的接口取值。
換句話說,ESM模塊規(guī)則有點像Unix系統(tǒng)的“符號連接”,原始值變了,import 輸入的值也會跟著變。導入的變量綁定其所在的模塊,不會緩存值。不同腳本加載同一個模塊得到的是同一個實例。因此ESM設定了不能修改導入值的只讀規(guī)則。
CJS 導入的是導出值的淺拷貝副本,而ESM導入是導出值的實時只讀引用。
靜態(tài)型的 import 是初始化加載依賴項的最優(yōu)選擇, 靜態(tài)模塊結構 更容易從代碼靜態(tài)分析工具和 tree shaking 中受益。而且自動支持模塊間的循環(huán)依賴。
在用 webpack、Rollup 這樣的模塊打包器時,證明ESM模塊可以更高效地組合:
- 加載所有模塊時,import 查找變量是靜態(tài)檢索,比 require() 的動態(tài)檢索快很多。
- 壓縮綁定的文件比壓縮單獨的文件效率更高。
- 在綁定過程中,通過刪除未使用的出口代碼,從而節(jié)省大量空間。
在瀏覽器中,import 語句只能在 <script type="module"></script> 標簽中使用 (<script type="module"> 擁有自己的局部作用域)?;蛘邔懺?code>.mjs擴展名的文件里。
語法:
ESM模塊有兩種導出方式:命名導出(每個模塊可以幾個)和默認導出(每個模塊一個)??梢酝瑫r使用兩者,但通常最好將它們分開。
命名導出:export
// 1. 關鍵字標記聲明
// 導出單個聲明常量/變量
export const name1 = … // 用 let, var 定義變量也可,不過通常還是常量
export let name2 = …
// 導出聲明函數
export function functionName() {...}
// 導出聲明類
export class className {...}
// 2. 用對象列出要導出的所有內容
// name1,name2... 是事先定義好的標識符。如果在一個模塊要導出多個值,同時數量不算多時推薦這樣做,代碼結構會比較清晰
const name1 = …
const name2 = …
export { name1, name2, …, nameN }
// 重命名導出
export { variable1 as name1, variable2 as name2, …, nameN }
- name1… nameN、functionName、className —— 要導出的“標識符”。在其他腳本
import時需要用這些“標識符”進行針對性的導入
直接在 export 關鍵字后面聲明的語句叫 內聯(lián)導出
export const name1 = 11
export function foo() {}
// 等效于
const name1 = 11
function foo() {}
export { name1, foo };
同時不能直接 export 一個對象,如export { name1: 1, name2: 2 },export { ... }只允許放用,分隔的標識符。因為不能通過對象強制執(zhí)行靜態(tài)關聯(lián),從而失去所有靜態(tài)模塊結構相關的優(yōu)勢。
默認導出:export default
實質上是個語法糖。export default 命令就是將輸出內容賦值給名為 default 的 變量,導出內容可以是任意表達式 (函數或Class也在內),在導入時可以隨意為這個 default 更名。因為已經聲明變量 default 了,后面就不能跟變量聲明語句了,這一點要和 export 區(qū)分開。
expression(表達式) 屬于 satement(語句),但 expression 是可以通過 evaluation 產生結果的。也就是說這個結果不是馬上產生,而是需要時才會被evaluated。
簡單判斷:可以被當作參數傳遞的就是expression,一般是放在小括號里的(expression),而 statement 一般是放在大括號里的{ statement }。expression 被放到函數體內就變成了 satement。
// 導出
// a.js
export default ?expression?;
// 等效于
const a = ?expression?;
export { a as default };
// 導入時:
import b from './a.js'
// 等效于
import { default as b } from './a';
默認導出的本意是讓 import 時不受限于接口名稱任意命名模塊,通常用于整個模塊的導出,如 React 組件。Vue組件則是把組件的數據和邏輯以一個對象的形式導出。默認導出簡單類型的常量意義不大,幾乎不用。命名導出和默認導出混用也存在,比如一個庫是單個函數,但通過該函數的屬性提供了其他服務:import _, { each } from 'underscore';。
為了快速區(qū)分不同模塊,以及導入時命名的統(tǒng)一,默認導出類和函數的時候還是建議命名 (盡管可以匿名)。
同時一個js只能有一個 export default,多個并存只有最后一個生效。以下為演示故沒有將多個注釋掉。
個人推薦的方式有以下幾種:
// 導出函數
export default function fun() {}
// 如果是箭頭函數,我寫 React 組件都這樣用
const funArrow = () => {}
export default funArrow
// 導出類
export default class Dog {}
// 導出對象
const foo = 'foo1'
const bar = 'bar2'
export default { foo, bar } // 實際導出的是 { foo: foo, bar: bar }
// 這里的 foo 和 bar 不是 標識符,只是鍵值對同名的簡寫,有本質區(qū)別, 注意區(qū)分
// 也可以直接將值寫在對象里,Vue組件的做法
export default {
name: 'foo',
data: {...}
}
導入 import 類型:
默認導入:對應默認導出,導入名可以自定義
import customName from 'src/my_lib';
// src/my_lib.js
export default anyThing // 任意類型,函數、類、對象 及表達式
命名空間導入:通過 * 導入完整的模塊,把模塊中的全部屬性和方法放到一個對象中 (每個命名導出為一個屬性) 進行導入。
import * as my_lib from 'src/my_lib';
console.log(my_lib) // { a, fun }
console.log(my_lib.a) // 'aaa'
my.lib.fun()
// src/my_lib.js
export const a = 'aaa'
export function fun() { ... }
命名導入,可以通過 as 重命名導出標識符:
import { name1, name2 as fun } from 'src/my_lib';
console.log(name1)
fun()
// src/my_lib.js
export const name1 = 'aaa'
export function name2() { ... }
空導入:僅加載模塊,不導入任何內容。程序中的第一個此類導入將執(zhí)行模塊的主體。
import 'src/my_lib';
組合導入:導入順序是固定的,默認導出必須始終在第一個。
// 將默認導入與名稱空間導入相結合:
import theDefault, * as my_lib from 'src/my_lib';
// 將默認導入與命名導入結合
import theDefault, { name1, name2 } from 'src/my_lib';
-
as—— 重命名導出“標識符”。比如需要同時導入兩個同名的 export 接口,用 as 重命名其中一個就可以解決沖突 -
from后面的字符串是要導入的模塊。通常是包含目標模塊的.js文件的相對或絕對路徑。
每次 import 都是到導出數據的實時連接。
//------ lib.js ------
export let counter = 3;
export function incCounter() {
counter++;
}
//------ main1.js ------
import { counter, incCounter } from './lib';
// The imported value `counter` is live
console.log(counter); // 3
incCounter();
console.log(counter); // 4
// The imported value can’t be changed
counter++; // TypeError
如果通過*導入模塊對象,會得到相同的結果:
//------ main2.js ------
import * as lib from './lib';
// The imported value `counter` is live
console.log(lib.counter); // 3
lib.incCounter();
console.log(lib.counter); // 4
// The imported value can’t be changed
lib.counter++; // TypeError
請注意,雖然不允許直接更改導入的值 (即重新賦值),但是可以修改它們引用的對象。例如:
//------ lib.js ------
export let obj = {};
//------ main.js ------
import { obj } from './lib';
obj.prop = 123; // OK
obj = {}; // TypeError
ES6 Module 的 import()
靜態(tài) import 命令會被JS引擎靜態(tài)分析,先于其他代碼執(zhí)行,做不到運行時加載。而且 import 和 export 語句都必須始終位于模塊的頂層,無法按需執(zhí)行。為了實現(xiàn)類似于require的動態(tài)加載,從而提高首屏加載速度,就出現(xiàn)了一個import()函數方法。import()括號內接收的參數和import語句from后面的一致。
按照一定的條件或者按需加載模塊的時候,動態(tài)import() 是非常有用的。
import()函數是動態(tài)按需加載,它返回一個 Promise 對象。import()是運行時執(zhí)行,什么時候運行到這一句,才會加載指定的模塊。因此通過 if 判斷可以實現(xiàn)按條件import()模塊 。除了模塊,還可以用來加載非模塊的腳本。
import()與所加載的模塊沒有靜態(tài)連接關系,這點也與 import 語句不同 (import語句會建立靜態(tài)引用)。import()類似于 Node 的require(),但區(qū)別是import()為異步加載,而require()是同步加載。
當出現(xiàn)以下的情況,一般就可以用動態(tài)import()代替靜態(tài) import 了:
- 靜態(tài)導入的模塊很明顯地降低了代碼的加載速度/占用了大量系統(tǒng)內存并且被使用的可能性很低,或者并不需要馬上使用它。
- 被導入的模塊在加載時還不存在,需要異步獲取
- 導入模塊的標識符需要動態(tài)構建。(靜態(tài)導入只能使用靜態(tài)標識符)
- 被導入的模塊有副作用(這個副作用,可以理解為模塊中會直接運行的代碼),這些副作用只有在觸發(fā)了某些條件才被需要時。
另外請只在必要情況下采用動態(tài)導入。靜態(tài)框架能更好地初始化依賴,而且更有利于靜態(tài)分析工具和tree shaking發(fā)揮作用。
import('./modules/my-module.js')
.then(module => {
// Do something with the module.
});
因為是一個 promise,import() 也支持 await 關鍵字。
let module = await import('./modules/my-module.js');
獲取模塊接口
import()加載模塊成功以后,這個模塊會作為一個對象,當作then方法的參數。因此,可以使用對象解構賦值的語法,獲取輸出的命名接口。
import('./modules/my-module.js')
.then(({export1, export2}) => {
// ...
});
如上,export1和export2都是my-module.js用export導出的輸出具名接口,可以直接解構獲得。
如果要獲取 default 默認導出,需要用default屬性獲?。?/p>
import('./modules/my-module.js')
.then(module => {
console.log(module.default)
});
// 或者這樣
import('./modules/my-module.js')
.then(({default: theDefault}) => {
console.log(theDefault);
});
總結
CJS 的 require() 和 exports
-
require()為同步導入。 -
動態(tài)結構:導入和導出的對象可以在運行時通過變量動態(tài)生成,也可以把
require()/exports放在 if 語句之類的代碼塊內實現(xiàn)按需加載/導出。 - 代碼執(zhí)行到
require()會先把()內的模塊代碼執(zhí)行一遍,返回值是模塊導出對象的淺拷貝副本。 -
require()進來的屬性副本,可以修改和刪除,簡單類型不會影響被導入模塊,引用類型會改變導入模塊數據。但require()的目的主要是導入一些供使用的函數或常量,這樣顯然是不合理的,因此盡量不要試圖修改模塊源數據,并在導入時表明引入的是常量,如:const path = require('path') - 需要用
exports.屬性導出并仔細地規(guī)劃, 才能使模塊循環(huán)依賴正常工作
ESM 的 import 和 export
- import 語句為同步導入。
- 靜態(tài)模塊結構(可以利用于消除無效代碼,優(yōu)化,靜態(tài)檢查等):導入和導出的關聯(lián)關系在運行時不可更改。
- 在代碼編譯階段(而非執(zhí)行階段)
import語句就和模塊建立了只讀靜態(tài)引用關系,且代碼運行到import不會執(zhí)行模塊的內容,而是當導出值被調用時才會真正執(zhí)行對應模塊。 - 不能修改 import 進來的對象,因為
import/export輸出的模塊是動態(tài)綁定的常量,是只讀的。但修改對象引用地址的屬性還是可以的。如無特殊需要請不要這么做。 -
import/export不能嵌套在任何塊級作用域或函數作用域內,必須寫在模塊頂層(因為 import 會先于其他任何代碼執(zhí)行) -
import/export語句不能有動態(tài)計算部分 - 不能直接在瀏覽器執(zhí)行,需要寫在
<script type="module"></script>內 - 自動支持模塊之間的循環(huán)依賴關系
盡管ESM模塊規(guī)范大有優(yōu)勢,但鑒于很多庫還在廣泛使用CJS,我們仍需要理解require和module.exports/exports。自己在日常開發(fā)中使用import和export default/export即可,webpack 會幫你做兼容處理 (可以看到webpack自身是遵循CJS的,因此會在打包過程中先把esm轉成cjs) 。期待全面支持ESM的一天~
參考:es6 modules