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