隨著我們的應用程式規模越來越大,我們希望將其分割成多個檔案,也就是所謂的「模組」。一個模組可能包含一個類別或一個特定用途的函式庫。
很長一段時間以來,JavaScript 都沒有語言層級的模組語法。這不是問題,因為最初的腳本很小而且很簡單,所以沒有這個需求。
但是,腳本最終變得越來越複雜,因此社群發明了各種方法將程式碼組織成模組,特別的函式庫可以依需求載入模組。
舉幾個例子(出於歷史原因)
- AMD – 最古老的模組系統之一,最初由函式庫 require.js 實作。
- CommonJS – 為 Node.js 伺服器建立的模組系統。
- UMD – 另一個模組系統,建議作為通用系統,與 AMD 和 CommonJS 相容。
現在這些都已逐漸成為歷史的一部分,但我們仍可以在舊腳本中找到它們。
語言層級的模組系統於 2015 年出現在標準中,自此逐漸演進,現在已獲得所有主要瀏覽器和 Node.js 的支援。因此,我們將從現在開始研究現代 JavaScript 模組。
什麼是模組?
模組只是一個檔案。一個腳本就是一個模組。就是這麼簡單。
模組可以互相載入,並使用特殊指令 export
和 import
來交換功能,從一個模組呼叫另一個模組的函式
export
關鍵字標記變數和函式,這些變數和函式應可從目前模組外部存取。import
允許從其他模組匯入功能。
例如,如果我們有一個檔案 sayHi.js
匯出一個函式
// 📁 sayHi.js
export function sayHi(user) {
alert(`Hello, ${user}!`);
}
…然後另一個檔案可以匯入並使用它
// 📁 main.js
import {sayHi} from './sayHi.js';
alert(sayHi); // function...
sayHi('John'); // Hello, John!
import
指令會透過相對於目前檔案的路徑 ./sayHi.js
載入模組,並將匯出的函式 sayHi
指定給對應的變數。
我們在瀏覽器中執行範例。
由於模組支援特殊關鍵字和功能,我們必須使用屬性 <script type="module">
告訴瀏覽器應將腳本視為模組。
像這樣
export function sayHi(user) {
return `Hello, ${user}!`;
}
<!doctype html>
<script type="module">
import {sayHi} from './say.js';
document.body.innerHTML = sayHi('John');
</script>
瀏覽器會自動擷取並評估匯入的模組(以及其匯入的模組,如果需要),然後執行腳本。
如果您嘗試透過 file://
協定在本地開啟網頁,您會發現 import/export
指令無法運作。請使用本地網頁伺服器,例如 static-server,或使用編輯器的「即時伺服器」功能,例如 VS Code 即時伺服器擴充功能 來測試模組。
核心模組功能
與「一般」腳本相比,模組有何不同?
有一些核心功能,對瀏覽器和伺服器端 JavaScript 都有效。
永遠「使用嚴格模式」
模組總是會以嚴格模式執行。例如,指定未宣告的變數會產生錯誤。
<script type="module">
a = 5; // error
</script>
模組層級範圍
每個模組都有自己的頂層範圍。換句話說,來自模組的頂層變數和函式在其他指令碼中看不到。
在以下範例中,匯入了兩個指令碼,而 hello.js
會嘗試使用在 user.js
中宣告的 user
變數。它會失敗,因為它是獨立的模組(你會在主控台中看到錯誤)
alert(user); // no such variable (each module has independent variables)
let user = "John";
<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>
模組應該 export
它們想要從外部存取的內容,並 import
它們需要的內容。
user.js
應該 exportuser
變數。hello.js
應該從user.js
模組 import 它。
換句話說,使用模組時,我們使用 import/export,而不是依賴全域變數。
這是正確的變體
import {user} from './user.js';
document.body.innerHTML = user; // John
export let user = "John";
<!doctype html>
<script type="module" src="hello.js"></script>
在瀏覽器中,如果我們討論 HTML 頁面,每個 <script type="module">
也存在獨立的頂層範圍。
以下是同個頁面上的兩個指令碼,兩個都是 type="module"
。它們看不到彼此的頂層變數
<script type="module">
// The variable is only visible in this module script
let user = "John";
</script>
<script type="module">
alert(user); // Error: user is not defined
</script>
在瀏覽器中,我們可以透過將變數明確指定給 window
屬性來建立視窗層級全域變數,例如 window.user = "John"
。
然後所有指令碼都會看到它,包括 type="module"
和沒有它的指令碼。
話雖如此,建立此類全域變數是不受歡迎的。請盡量避免它們。
模組程式碼只會在第一次匯入時評估
如果將同一個模組匯入到多個其他模組中,它的程式碼只會在第一次匯入時執行一次。然後,它的 export 會提供給所有進一步的匯入者。
一次性評估有重要的後果,我們應該注意。
讓我們看幾個範例。
首先,如果執行模組程式碼會產生副作用,例如顯示訊息,那麼多次匯入它只會觸發一次 - 第一次
// 📁 alert.js
alert("Module is evaluated!");
// Import the same module from different files
// 📁 1.js
import `./alert.js`; // Module is evaluated!
// 📁 2.js
import `./alert.js`; // (shows nothing)
第二次匯入不會顯示任何內容,因為模組已經評估過了。
有一個規則:頂層模組程式碼應該用於初始化、建立模組特定的內部資料結構。如果我們需要建立可以多次呼叫的內容 - 我們應該將它作為函式 export,就像我們上面對 sayHi
所做的那樣。
現在,讓我們考慮一個更深入的範例。
假設一個模組匯出一個物件
// 📁 admin.js
export let admin = {
name: "John"
};
如果這個模組從多個檔案匯入,則這個模組只會在第一次評估時,建立 `admin` 物件,然後傳遞給所有進一步的匯入者。
所有匯入者都只會取得一個且唯一的 `admin` 物件
// 📁 1.js
import {admin} from './admin.js';
admin.name = "Pete";
// 📁 2.js
import {admin} from './admin.js';
alert(admin.name); // Pete
// Both 1.js and 2.js reference the same admin object
// Changes made in 1.js are visible in 2.js
正如你所見,當 `1.js` 變更匯入的 `admin` 中的 `name` 屬性時,`2.js` 可以看到新的 `admin.name`。
這正是因為模組只執行一次。匯出會產生,然後在匯入者之間共用,因此如果某個東西變更了 `admin` 物件,其他匯入者會看到。
這種行為實際上非常方便,因為它允許我們設定模組。
換句話說,一個模組可以提供需要設定的通用功能。例如,驗證需要憑證。然後,它可以匯出一個設定物件,期待外部程式碼指派給它。
以下是傳統模式
- 一個模組匯出一些設定方式,例如設定物件。
- 在第一次匯入時,我們初始化它,寫入它的屬性。頂層應用程式指令碼可能會這樣做。
- 進一步的匯入會使用這個模組。
例如,`admin.js` 模組可能會提供某些功能(例如驗證),但期待憑證從外部進入 `config` 物件
// 📁 admin.js
export let config = { };
export function sayHi() {
alert(`Ready to serve, ${config.user}!`);
}
在這裡,`admin.js` 匯出 `config` 物件(最初為空,但可能也有預設屬性)。
然後在 `init.js` 中,我們的應用程式的第一個指令碼,我們從中匯入 `config` 並設定 `config.user`
// 📁 init.js
import {config} from './admin.js';
config.user = "Pete";
…現在 `admin.js` 模組已設定。
進一步的匯入者可以呼叫它,它會正確顯示目前的使用者
// 📁 another.js
import {sayHi} from './admin.js';
sayHi(); // Ready to serve, Pete!
import.meta
物件 `import.meta` 包含有關目前模組的資訊。
它的內容取決於環境。在瀏覽器中,它包含指令碼的 URL,或如果在 HTML 內部,則包含目前的網頁 URL
<script type="module">
alert(import.meta.url); // script URL
// for an inline script - the URL of the current HTML-page
</script>
在模組中,「this」是未定義的
這是一個小功能,但為了完整性,我們應該提到它。
在模組中,頂層 `this` 是未定義的。
將它與非模組指令碼進行比較,其中 `this` 是全域物件
<script>
alert(this); // window
</script>
<script type="module">
alert(this); // undefined
</script>
瀏覽器專屬功能
與一般腳本相比,type="module"
腳本也有一些瀏覽器專屬的差異。
如果您是第一次閱讀,或沒有在瀏覽器中使用 JavaScript,您可能想要先跳過此部分。
模組腳本會延遲
模組腳本總是會延遲,與 defer
屬性(在章節 腳本:async、defer 中描述)有相同效果,適用於外部和內嵌腳本。
換句話說
- 下載外部模組腳本
<script type="module" src="...">
不會阻擋 HTML 處理,它們會與其他資源並行載入。 - 模組腳本會等到 HTML 文件完全準備好(即使它們很小而且載入速度比 HTML 快)後才執行。
- 腳本的相對順序會被保留:出現在文件中的第一個腳本會先執行。
作為副作用,模組腳本總是「看到」載入完成的 HTML 頁面,包括它們下方的 HTML 元素。
例如
<script type="module">
alert(typeof button); // object: the script can 'see' the button below
// as modules are deferred, the script runs after the whole page is loaded
</script>
Compare to regular script below:
<script>
alert(typeof button); // button is undefined, the script can't see elements below
// regular scripts run immediately, before the rest of the page is processed
</script>
<button id="button">Button</button>
請注意:第二個腳本實際上會在第一個腳本之前執行!因此,我們會先看到 undefined
,然後看到 object
。
這是因為模組會延遲,所以我們會等到文件處理完畢。一般腳本會立即執行,所以我們會先看到它的輸出。
在使用模組時,我們應該知道 HTML 頁面會在載入時顯示,而 JavaScript 模組會在之後執行,因此使用者可能會在 JavaScript 應用程式準備好之前看到頁面。某些功能可能還無法使用。我們應該放置「載入指示器」,或確保訪客不會因此感到困惑。
Async 在內嵌腳本中運作
對於非模組腳本,async
屬性只會在外部腳本中運作。Async 腳本會在準備好時立即執行,與其他腳本或 HTML 文件無關。
對於模組腳本,它也會在內嵌腳本中運作。
例如,以下內嵌腳本有 async
,所以它不會等待任何東西。
它會執行匯入(擷取 ./analytics.js
)並在準備好時執行,即使 HTML 文件尚未完成,或其他腳本仍在等待中。
這對於不依賴任何東西的功能很有用,例如計數器、廣告、文件層級事件監聽器。
<!-- all dependencies are fetched (analytics.js), and the script runs -->
<!-- doesn't wait for the document or other <script> tags -->
<script async type="module">
import {counter} from './analytics.js';
counter.count();
</script>
外部腳本
具有 type="module"
的外部腳本在兩個方面有所不同
-
具有相同
src
的外部腳本只會執行一次<!-- the script my.js is fetched and executed only once --> <script type="module" src="my.js"></script> <script type="module" src="my.js"></script>
-
從其他來源(例如其他網站)擷取的外部腳本需要 CORS 標頭,如章節 擷取:跨來源請求 中所述。換句話說,如果模組腳本從其他來源擷取,遠端伺服器必須提供標頭
Access-Control-Allow-Origin
以允許擷取。<!-- another-site.com must supply Access-Control-Allow-Origin --> <!-- otherwise, the script won't execute --> <script type="module" src="http://another-site.com/their.js"></script>
這可確保更好的安全性(預設)。
不允許「裸露」模組
在瀏覽器中,import
必須取得相對或絕對 URL。沒有任何路徑的模組稱為「裸露」模組。此類模組不允許在 import
中使用。
例如,這個 import
無效
import {sayHi} from 'sayHi'; // Error, "bare" module
// the module must have a path, e.g. './sayHi.js' or wherever the module is
某些環境(例如 Node.js 或套件工具)允許裸露模組,而沒有任何路徑,因為它們有自己的方式來尋找模組和調整它們的掛鉤。但瀏覽器尚未支援裸露模組。
相容性,「nomodule」
舊式瀏覽器不了解 type="module"
。未知類型的指令碼只會被忽略。對於這些瀏覽器,可以使用 nomodule
屬性提供備用方案
<script type="module">
alert("Runs in modern browsers");
</script>
<script nomodule>
alert("Modern browsers know both type=module and nomodule, so skip this")
alert("Old browsers ignore script with unknown type=module, but execute this.");
</script>
建置工具
在實際應用中,瀏覽器模組很少以其「原始」形式使用。通常,我們會使用特殊工具(例如 Webpack)將它們捆綁在一起,並部署到生產伺服器。
使用套件工具的好處之一是,它們可以更靈活地控制模組解析方式,允許裸露模組和更多功能,例如 CSS/HTML 模組。
建置工具執行下列動作
- 取得「主要」模組,也就是打算放入 HTML 中的
<script type="module">
。 - 分析其相依關係:匯入,然後匯入的匯入,依此類推。
- 建置一個包含所有模組的單一檔案(或多個檔案,可調整),使用套件工具函式取代原生
import
呼叫,使其運作。也支援「特殊」模組類型,例如 HTML/CSS 模組。 - 在過程中,可能會套用其他轉換和最佳化
- 移除無法到達的程式碼。
- 移除未使用的輸出(「樹狀搖晃」)。
- 移除開發特定陳述,例如
console
和debugger
。 - 使用 Babel 將現代、尖端的 JavaScript 語法轉換為具有類似功能的舊版語法。
- 縮小產生的檔案(移除空格、將變數替換為較短的名稱,等等)。
如果我們使用套件工具,那麼當指令碼被捆綁到單一檔案(或少數檔案)時,這些指令碼中的 import/export
陳述會被特殊套件工具函式取代。因此,產生的「已捆綁」指令碼不包含任何 import/export
,不需要 type="module"
,我們可以將其放入常規指令碼
<!-- Assuming we got bundle.js from a tool like Webpack -->
<script src="bundle.js"></script>
話雖如此,原生模組也可以使用。因此,我們在此處不會使用 Webpack:您可以稍後設定它。
摘要
總而言之,核心概念是
- 模組是一個檔案。為了讓
import/export
運作,瀏覽器需要<script type="module">
。模組有幾個差異- 預設遞延。
- 非同步處理內嵌指令碼。
- 要從另一個來源(網域/通訊協定/埠)載入外部指令碼,需要 CORS 標頭。
- 重複的外部指令碼會被忽略。
- 模組有自己的本地頂層範圍,並透過
import/export
交換功能。 - 模組總是
use strict
。 - 模組程式碼只執行一次。輸出建立一次,並在匯入者之間共用。
當我們使用模組時,每個模組都實作功能並將其匯出。然後我們使用 import
直接匯入到需要的地方。瀏覽器會自動載入並評估腳本。
在製作階段,人們通常會使用 Webpack 等套件管理工具將模組打包在一起,以提升效能和其他原因。
在下一章,我們將看到更多模組範例,以及如何匯出/匯入。
留言
<code>
標籤,對於多行,請用<pre>
標籤將其包起來,對於超過 10 行,請使用沙盒 (plnkr、jsbin、codepen…)