快速了解JavaScript的模塊
概述
隨著現(xiàn)代 JavaScript 開(kāi)發(fā) Web 應(yīng)用變得復(fù)雜,命名沖突和依賴關(guān)系也變得難以處理,因此需要模塊化。而引入模塊化,可以避免命名沖突、方便依賴關(guān)系管理、提高了代碼的復(fù)用性和和維護(hù)性,因此,在 JavaScript 沒(méi)有模塊功能的前提下,只能通過(guò)第三方規(guī)范實(shí)現(xiàn)模塊化:
- CommonJS:同步模塊定義,用于服務(wù)器端。
- AMD:異步模塊定義, 用于瀏覽器端。
- CMD:異步模塊定義,用于瀏覽器端。
- UMD:統(tǒng)一 COmmonJS 和 AMD 模塊化方案的定義。
它們都是基于 JavaScript 的語(yǔ)法和詞法特性 “偽造” 出類似模塊的行為。而 TC-39 在 ECMAScript 2015 中加入了模塊規(guī)范,簡(jiǎn)化了上面介紹的模塊加載器,原生意味著可以取代上述的規(guī)范,成為瀏覽器和服務(wù)器通用的模塊解決方案,比使用庫(kù)更有效率。而 ES6 的模塊化的設(shè)計(jì)目標(biāo):
- 像 CommonJS 一樣簡(jiǎn)單的語(yǔ)法。
- 模塊必須是靜態(tài)的結(jié)構(gòu)
- 支持模塊的 異步加載 和 同步加載,能同時(shí)用在 server 和 client 端
- 支持模塊加載的 ‘靈活配置’
- 更好地支持模塊之間的循環(huán)引用
- 擁有語(yǔ)言層面的支持,超越 CommonJS 和 AMD
ECMAScript 在 2015 年開(kāi)始支持模塊標(biāo)準(zhǔn),此后逐漸發(fā)展,現(xiàn)已經(jīng)得到了所有主流瀏覽器的支持。ECMAScript 2015 版本也被稱為 ECMAScript 6。
模塊
ES6 模塊借用了 CommonJS 和 AMD 的很多優(yōu)秀特性,如下所示:
- 模塊代碼只在加載后執(zhí)行。
- 模塊只能加載一次。
- 模塊是單例。
- 模塊可以定義公共接口,其他模塊可以基于這個(gè)公共接口觀察和交互。
- 模塊可以請(qǐng)求加載其他模塊。
- 支持循環(huán)依賴。
ES6 模塊系統(tǒng)也增加了一些新行為。
- ES6 模塊默認(rèn)在嚴(yán)格模式下執(zhí)行。
- ES6 模塊不共享全局命名空間。
- 模塊頂級(jí) this 的值是 undefined;常規(guī)腳本中是 window。
- 模塊中的 var 聲明不會(huì)添加到 window 對(duì)象。
- ES6 模塊是異步加載和執(zhí)行的。
瀏覽器運(yùn)行時(shí)在知道應(yīng)該把某個(gè)文件當(dāng)成模塊時(shí),會(huì)有條件地按照上述 ES6 模塊行為來(lái)施加限制。與 <script type="module"> 關(guān)聯(lián)或者通過(guò) import 語(yǔ)句加載的 JavaScript 文件會(huì)被認(rèn)定為模塊。
導(dǎo)出
ES6 模塊內(nèi)部的所有變量,外部無(wú)法獲取,因此提供了 export 關(guān)鍵字從模塊中導(dǎo)出實(shí)時(shí)綁定的函數(shù)、對(duì)象或原始值,這樣其他程序可以通過(guò) import 關(guān)鍵字使用它們。export 支持兩種導(dǎo)出方式:命名導(dǎo)出和默認(rèn)導(dǎo)出。不同的導(dǎo)出方式對(duì)應(yīng)不同的導(dǎo)入方式。
在 ES6 模塊中,無(wú)論是否聲明 "use strict;" 語(yǔ)句,默認(rèn)情況下模塊都是在嚴(yán)格模式下運(yùn)行。export 語(yǔ)句不能用在嵌入式腳本中。
命名導(dǎo)出
通過(guò)在聲明的前面加上 export 關(guān)鍵字,一個(gè)模塊可以導(dǎo)出多個(gè)內(nèi)容。這些導(dǎo)出的內(nèi)容通過(guò)名字區(qū)分,被稱為命名導(dǎo)出。
- // 導(dǎo)出單個(gè)特性(可以導(dǎo)出 var,let,const)
- export let name = "小明";
- export function sayHi(name) {
- console.log(`Hello, ${name}!`);
- }
- export class Sample {
- ...
- }
或者導(dǎo)出事先定義的特性
- let name = "小明";
- const age = 18;
- function sayHi(name) {
- console.log(`Hello, ${name}!`);
- }
- export {name, age, sayHi}
導(dǎo)出時(shí)也可以指定別名,別名必須在 export 子句的大括號(hào)語(yǔ)法中指定。因此,聲明值、導(dǎo)出值和未導(dǎo)出值提供別名不能在一行完成。
- export {name as username, age, sayHi}
但導(dǎo)出語(yǔ)句必須在模塊頂級(jí),不能嵌套在某個(gè)塊中:
- // 允許
- export ...
- // 不允許
- if (condition) {
- export ...
- }
默認(rèn)導(dǎo)出
默認(rèn)導(dǎo)出就好像模塊與被導(dǎo)出的值是一回事。默認(rèn)導(dǎo)出使用 default 關(guān)鍵字將一個(gè)值聲明為默認(rèn)導(dǎo)出,每個(gè)模塊只能有一個(gè)默認(rèn)導(dǎo)出。重復(fù)的默認(rèn)導(dǎo)出會(huì)導(dǎo)致 SyntaxError。如下所示:
- // 導(dǎo)出事先定義的特性作為默認(rèn)值
- export default {
- name: "Xiao Ming",
- age: 18,
- sex: "boy"
- };
- export {sayHi as default} // ES 6 模塊會(huì)識(shí)別作為別名提供的 default 關(guān)鍵字。此時(shí),雖然對(duì)應(yīng)的值是使用命名語(yǔ)法導(dǎo)出的,實(shí)際上則會(huì)稱為默認(rèn)導(dǎo)出 等同于 export default function sayHi() {}
- // 導(dǎo)出單個(gè)特性作為默認(rèn)值
- export default function () {...}
- export default class {...}
ES6 規(guī)范對(duì)不同形式的 export 語(yǔ)句中可以使用什么不可以使用什么規(guī)定了限制。某些形式允許聲明和賦值,某些形式只允許表達(dá)式,而某些形式則只允許簡(jiǎn)單標(biāo)識(shí)符。注意,有的形式使用了分號(hào),有的則沒(méi)有。
下面列出幾種會(huì)導(dǎo)致錯(cuò)誤的 export 形式:
- // 會(huì)導(dǎo)致錯(cuò)誤的不同形式:
- // 行內(nèi)默認(rèn)導(dǎo)出中不能出現(xiàn)變量聲明
- export default const name = '小劉';
- // 只有標(biāo)識(shí)符可以出現(xiàn)在export 子句中
- export { 123 as name }
- // 別名只能在export 子句中出現(xiàn)
- export const name = '小紅' as uname;
注意:聲明、賦值和導(dǎo)出標(biāo)識(shí)符最好分開(kāi)。這樣不容易搞錯(cuò)了,同時(shí)也可以讓 export 語(yǔ)句集中在一塊。而且,沒(méi)有被 export 關(guān)鍵字導(dǎo)出的變量、函數(shù)或類會(huì)在模塊內(nèi)保持私有。
模塊重定向
模塊導(dǎo)入的值還可以再次導(dǎo)出,這樣的話,可以在父模塊集中多個(gè)模塊的多個(gè)導(dǎo)出??梢允褂?export from 語(yǔ)法實(shí)現(xiàn):
- export {default as m1, name} from './module1.js'
- // 等效于
- import {default as m1, name} from "./module1.js"
- export {m1, name}
外部模塊的默認(rèn)導(dǎo)出也可以重用為當(dāng)前模塊的默認(rèn)導(dǎo)出:
- export { default } from './module1.js';
也可以在重新導(dǎo)出時(shí),將導(dǎo)入模塊修改為默認(rèn)導(dǎo)出,如下所示:
- export { name as default } from './module1.js';
而想要將所有命名導(dǎo)出可以使用如下語(yǔ)法:
- export * from './module1.js';
該語(yǔ)法會(huì)忽略默認(rèn)導(dǎo)出。但這種語(yǔ)法也要注意導(dǎo)出名稱是否沖突。如下所示:
- // module1.js
- export const name = "module1:name";
- // module2.js
- export * from './mudule1.js'
- export const name = "module2:name";
- // index.js
- import { name } from './module2.js';
- console.log(name); // module2:name
最終輸出的是 module2.js 中的值,這個(gè) “重寫(xiě)” 是靜默發(fā)生的。
導(dǎo)入
使用 export 關(guān)鍵字定義了模塊的對(duì)外接口以后,其它模塊就能通過(guò) import 關(guān)鍵字加載這個(gè)模塊了。但與 export 類似,import 也必須出現(xiàn)在模塊的頂級(jí):
- // 允許
- import ...
- // 不允許
- if (condition) {
- import ...
- }
模塊標(biāo)識(shí)符可以是相對(duì)于當(dāng)前模塊的相對(duì)路徑,也可以是指向模塊文件的絕對(duì)路徑。它必須是純字符串,不能是動(dòng)態(tài)計(jì)算的結(jié)果。例如,不能是拼接的字符串。
當(dāng)使用 export 命名導(dǎo)出時(shí),可以使用 * 批量獲取并賦值給保存導(dǎo)出集合的別名,而無(wú)須列出每個(gè)標(biāo)識(shí)符:
- const name = "Xiao Ming", age = 18, sex = "boy";
- export {name, age, sex}
- // 上面的命名導(dǎo)出可以使用如下形式導(dǎo)入(上面的代碼是在 module1.js 模塊中)
- import * as Sample from "./module1.js"
- console.log(`My name is ${Sample.name}, A ${Sample.sex},${Sample.age} years old.`);
也可以指名導(dǎo)入,只需要把名字放在 {} 中即可:
- import {name, sex as s, age} from "./module1.js";
- console.log(`My name is ${name}, A ${s},${age} years old.`);
import 引入是采用的 Singleton 模式,多次使用 import 引入同一個(gè)模塊時(shí),只會(huì)引入一次該模塊的實(shí)例:
- import {name, age} from "./module1.js";
- import {sex as s} from "./module1.js";
- // 等同于,并且只會(huì)引入一個(gè) module1.js 實(shí)例
- import {name, sex as s, age} from "./module1.js";
而使用默認(rèn)導(dǎo)出的話,可以使用 default 關(guān)鍵字并提供別名來(lái)導(dǎo)入,也可以直接使用標(biāo)識(shí)符就是默認(rèn)導(dǎo)出的別名導(dǎo)入:
- import {default as Sample} from "./module1.js"
- // 與下面的方式等效
- import Sample from "./module1.js"
而模塊中同時(shí)有命名導(dǎo)出和默認(rèn)導(dǎo)出,可以在 import 語(yǔ)句中同時(shí)導(dǎo)入。下面三種方式都等效。
- import Sample, {sayHi} from "./module1.js"
- import {default as Sample, sayHi} from "./module1.js"
- import Sample, * as M1 from "./module1.js"
當(dāng)然,也可以將整個(gè)模塊作為副作用而導(dǎo)入,而不導(dǎo)入模塊中的特定內(nèi)容。這將運(yùn)行模塊中的全局代碼,但實(shí)際上不導(dǎo)入任何值。
- import './module1.js'
import 導(dǎo)入的值與 export 導(dǎo)出的值是綁定關(guān)系,綁定是不可變的。因此,import 對(duì)所導(dǎo)入的模塊是只讀的。但是可以通過(guò)調(diào)用被導(dǎo)入模塊的函數(shù)來(lái)達(dá)到目的。
- import Sample, * as M1 from "./module1.js"
- Sample = "Modify Sample"; // 錯(cuò)誤
- M1.module1 = "Module 1"; // 錯(cuò)誤
- Sample.name = "小亮"; // 允許
這樣做的好處是能夠支持循環(huán)依賴,并且一個(gè)大的模塊可以拆成若干個(gè)小模塊時(shí)也可以運(yùn)行,只要不嘗試修改導(dǎo)入的值。
注意:如果要在瀏覽器中原生加載模塊,則文件必須帶有 .js 擴(kuò)展名,不然可能無(wú)法解析。而使用構(gòu)建工具或第三方模塊加載器打包或解析 ES6 模塊,可能不需要包含擴(kuò)展名。
import()
標(biāo)準(zhǔn)的 import 關(guān)鍵字導(dǎo)入模塊是靜態(tài)的,會(huì)使所有被導(dǎo)入的模塊,在加載時(shí)就被編譯。而最新的 ES11 標(biāo)準(zhǔn)中引入了動(dòng)態(tài)導(dǎo)入函數(shù) import(),不必預(yù)先加載所有模塊。該函數(shù)會(huì)將模塊的路徑作為參數(shù),并返回一個(gè) Promise,在它的 then 回調(diào)里使用加載后的模塊:
- import ('./module1.mjs')
- .then((module) => {
- // Do something with the module.
- });
這種使用方式也支持 await 關(guān)鍵字。
- let module = await import('./module1.js');
import() 的使用場(chǎng)景如下:
- 按需加載。
- 動(dòng)態(tài)構(gòu)建模塊路徑。
- 條件加載。
加載
ES6 模塊既可以通過(guò)瀏覽器原生加載,也可以與第三方加載器和構(gòu)建工具一起加載。
完全支持 ES6 模塊的瀏覽器可以從頂級(jí)模塊異步加載整個(gè)依賴圖。瀏覽器會(huì)解析入口模塊,確定依賴,并發(fā)送對(duì)依賴模塊的請(qǐng)求。這些文件通過(guò)網(wǎng)絡(luò)返回后,瀏覽器會(huì)解析它們的內(nèi)容,確認(rèn)依賴,如果二級(jí)依賴還沒(méi)有加載,則會(huì)發(fā)送更多請(qǐng)求。這個(gè)異步遞歸加載過(guò)程會(huì)持續(xù)到整個(gè)依賴圖都解析完成。解析完依賴,應(yīng)用就可以正式加載模塊了。
模塊文件按需加載,且后續(xù)模塊的請(qǐng)求會(huì)因?yàn)槊總€(gè)依賴模塊的網(wǎng)絡(luò)延遲而同步延遲。即,module1 依賴 module2,module2 依賴 module3。瀏覽器在對(duì) module2 的請(qǐng)求完成之前并不知道要請(qǐng)求 module3。這種架子啊方式效率高,也不需要外部工具,但加載大型應(yīng)用的深度依賴圖可能要花費(fèi)很長(zhǎng)時(shí)間。
HTML
想要在 HTML 頁(yè)面中使用 ES6 模塊,需要將 type="module" 屬性放在 <script> 標(biāo)簽中,來(lái)聲明該 <script> 所包含的代碼在瀏覽器中作為模塊執(zhí)行。它可以嵌入在網(wǎng)頁(yè)中,也可以作為外部文件引入:
- <script type="module">
- // 模塊代碼
- </script>
- <script type="module" src="./module1.js"></script>
<script type="module">模塊加載的順序與 <script defer> 加載的腳本一樣按順序執(zhí)行。但執(zhí)行會(huì)延遲到文檔解析完成,但執(zhí)行順序就是<script type="module">在頁(yè)面中出現(xiàn)的順序。
也可以給模塊標(biāo)簽添加 async 屬性。這樣影響是雙重的,不僅模塊執(zhí)行順序不再與 <script> 標(biāo)簽在頁(yè)面中的順序綁定,模塊也不會(huì)等待文檔完成解析才執(zhí)行。不過(guò),入口模塊必須等待其依賴加載完成。
Worker
Worker 為了支持 ES6 模塊,在 Worker 構(gòu)造函數(shù)中可以接收第二個(gè)參數(shù),其 type 屬性的默認(rèn)值是 classic,可以將 type 設(shè)置為 module 來(lái)加載模塊文件。如下所示:
- // 第二個(gè)參數(shù)默認(rèn)為{ type: 'classic' }
- const scriptWorker = new Worker('scriptWorker.js');
- const moduleWorker = new Worker('moduleWorker.js', { type: 'module' });
在基于模塊的工作者內(nèi)部,self.importScripts() 方法通常用于在基于腳本的工作者中加載外部腳本,調(diào)用它會(huì)拋出錯(cuò)誤。這是因?yàn)槟K的 import 行為包含了 importScripts()。
向后兼容
如果瀏覽器原生支持 ES6 模塊,可以直接使用,而不支持的瀏覽器可以使用第三方模塊系統(tǒng)(System.js)或在構(gòu)建時(shí)將 ES6 模塊進(jìn)行轉(zhuǎn)譯。
腳本模塊可以使用 type="module" 屬性設(shè)定,而對(duì)于不支持模塊的瀏覽器,可以使用 nomodule 屬性。此屬性會(huì)通知支持 ES6 模塊的瀏覽器不執(zhí)行腳本。不支持模塊的瀏覽器無(wú)法識(shí)別該屬性,從而忽略該屬性。如下所示:
- // 支持模塊的瀏覽器會(huì)執(zhí)行這段腳本
- // 不支持模塊的瀏覽器不會(huì)執(zhí)行這段腳本
- <script type="module" src="module.js"></script>
- // 支持模塊的瀏覽器不會(huì)執(zhí)行這段腳本
- // 不支持模塊的瀏覽器會(huì)執(zhí)行這段腳本
- <script nomodule src="script.js"></script>
總結(jié)
ES6 在語(yǔ)言層面上支持了模塊,結(jié)束了 CommonJS 和 AMD 這兩個(gè)模塊加載器的長(zhǎng)期分裂狀況,重新定義了模塊功能,集兩個(gè)規(guī)范于一身,并通過(guò)簡(jiǎn)單的語(yǔ)法聲明來(lái)暴露。
模塊的使用不同方式加載 .js 文件,它與腳本有很大的不同:
- 模塊始終使用 use strict 執(zhí)行嚴(yán)格模式。
- 在模塊的頂級(jí)作用域創(chuàng)建的變量,不會(huì)被自動(dòng)添加到共享的全局作用域,它們只會(huì)在模塊頂級(jí)作用域的內(nèi)部存在。
- 模塊頂級(jí)作用域的 this 值為 undefined。
- 模塊不允許在代碼中使用 HTML 風(fēng)格的注釋。
- 對(duì)于需要讓模塊外部代碼訪問(wèn)的內(nèi)容,模塊必須導(dǎo)出它們。
- 允許模塊從其他模塊導(dǎo)入綁定。
- 模塊代碼執(zhí)行一次。導(dǎo)出僅創(chuàng)建一次,然后會(huì)在導(dǎo)入之間共享。
瀏覽器對(duì)原生模塊的支持越來(lái)越好,但也提供了穩(wěn)健的工具以實(shí)現(xiàn)從不支持到支持 ES6 模塊的過(guò)渡。