2022 年 10 月 14 日

模組簡介

隨著我們的應用程式規模越來越大,我們希望將其分割成多個檔案,也就是所謂的「模組」。一個模組可能包含一個類別或一個特定用途的函式庫。

很長一段時間以來,JavaScript 都沒有語言層級的模組語法。這不是問題,因為最初的腳本很小而且很簡單,所以沒有這個需求。

但是,腳本最終變得越來越複雜,因此社群發明了各種方法將程式碼組織成模組,特別的函式庫可以依需求載入模組。

舉幾個例子(出於歷史原因)

  • AMD – 最古老的模組系統之一,最初由函式庫 require.js 實作。
  • CommonJS – 為 Node.js 伺服器建立的模組系統。
  • UMD – 另一個模組系統,建議作為通用系統,與 AMD 和 CommonJS 相容。

現在這些都已逐漸成為歷史的一部分,但我們仍可以在舊腳本中找到它們。

語言層級的模組系統於 2015 年出現在標準中,自此逐漸演進,現在已獲得所有主要瀏覽器和 Node.js 的支援。因此,我們將從現在開始研究現代 JavaScript 模組。

什麼是模組?

模組只是一個檔案。一個腳本就是一個模組。就是這麼簡單。

模組可以互相載入,並使用特殊指令 exportimport 來交換功能,從一個模組呼叫另一個模組的函式

  • 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"> 告訴瀏覽器應將腳本視為模組。

像這樣

結果
say.js
index.html
export function sayHi(user) {
  return `Hello, ${user}!`;
}
<!doctype html>
<script type="module">
  import {sayHi} from './say.js';

  document.body.innerHTML = sayHi('John');
</script>

瀏覽器會自動擷取並評估匯入的模組(以及其匯入的模組,如果需要),然後執行腳本。

模組只能透過 HTTP(s) 運作,無法在本地運作

如果您嘗試透過 file:// 協定在本地開啟網頁,您會發現 import/export 指令無法運作。請使用本地網頁伺服器,例如 static-server,或使用編輯器的「即時伺服器」功能,例如 VS Code 即時伺服器擴充功能 來測試模組。

核心模組功能

與「一般」腳本相比,模組有何不同?

有一些核心功能,對瀏覽器和伺服器端 JavaScript 都有效。

永遠「使用嚴格模式」

模組總是會以嚴格模式執行。例如,指定未宣告的變數會產生錯誤。

<script type="module">
  a = 5; // error
</script>

模組層級範圍

每個模組都有自己的頂層範圍。換句話說,來自模組的頂層變數和函式在其他指令碼中看不到。

在以下範例中,匯入了兩個指令碼,而 hello.js 會嘗試使用在 user.js 中宣告的 user 變數。它會失敗,因為它是獨立的模組(你會在主控台中看到錯誤)

結果
hello.js
user.js
index.html
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 應該 export user 變數。
  • hello.js 應該從 user.js 模組 import 它。

換句話說,使用模組時,我們使用 import/export,而不是依賴全域變數。

這是正確的變體

結果
hello.js
user.js
index.html
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` 物件,其他匯入者會看到。

這種行為實際上非常方便,因為它允許我們設定模組。

換句話說,一個模組可以提供需要設定的通用功能。例如,驗證需要憑證。然後,它可以匯出一個設定物件,期待外部程式碼指派給它。

以下是傳統模式

  1. 一個模組匯出一些設定方式,例如設定物件。
  2. 在第一次匯入時,我們初始化它,寫入它的屬性。頂層應用程式指令碼可能會這樣做。
  3. 進一步的匯入會使用這個模組。

例如,`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" 的外部腳本在兩個方面有所不同

  1. 具有相同 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>
  2. 從其他來源(例如其他網站)擷取的外部腳本需要 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 模組。

建置工具執行下列動作

  1. 取得「主要」模組,也就是打算放入 HTML 中的 <script type="module">
  2. 分析其相依關係:匯入,然後匯入的匯入,依此類推。
  3. 建置一個包含所有模組的單一檔案(或多個檔案,可調整),使用套件工具函式取代原生 import 呼叫,使其運作。也支援「特殊」模組類型,例如 HTML/CSS 模組。
  4. 在過程中,可能會套用其他轉換和最佳化
    • 移除無法到達的程式碼。
    • 移除未使用的輸出(「樹狀搖晃」)。
    • 移除開發特定陳述,例如 consoledebugger
    • 使用 Babel 將現代、尖端的 JavaScript 語法轉換為具有類似功能的舊版語法。
    • 縮小產生的檔案(移除空格、將變數替換為較短的名稱,等等)。

如果我們使用套件工具,那麼當指令碼被捆綁到單一檔案(或少數檔案)時,這些指令碼中的 import/export 陳述會被特殊套件工具函式取代。因此,產生的「已捆綁」指令碼不包含任何 import/export,不需要 type="module",我們可以將其放入常規指令碼

<!-- Assuming we got bundle.js from a tool like Webpack -->
<script src="bundle.js"></script>

話雖如此,原生模組也可以使用。因此,我們在此處不會使用 Webpack:您可以稍後設定它。

摘要

總而言之,核心概念是

  1. 模組是一個檔案。為了讓 import/export 運作,瀏覽器需要 <script type="module">。模組有幾個差異
    • 預設遞延。
    • 非同步處理內嵌指令碼。
    • 要從另一個來源(網域/通訊協定/埠)載入外部指令碼,需要 CORS 標頭。
    • 重複的外部指令碼會被忽略。
  2. 模組有自己的本地頂層範圍,並透過 import/export 交換功能。
  3. 模組總是 use strict
  4. 模組程式碼只執行一次。輸出建立一次,並在匯入者之間共用。

當我們使用模組時,每個模組都實作功能並將其匯出。然後我們使用 import 直接匯入到需要的地方。瀏覽器會自動載入並評估腳本。

在製作階段,人們通常會使用 Webpack 等套件管理工具將模組打包在一起,以提升效能和其他原因。

在下一章,我們將看到更多模組範例,以及如何匯出/匯入。

教學課程地圖

留言

留言前請先閱讀…
  • 如果您有改進建議,請 提交 GitHub 問題 或提出拉取請求,而不是留言。
  • 如果您無法理解文章中的內容,請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,對於多行,請用 <pre> 標籤將其包起來,對於超過 10 行,請使用沙盒 (plnkrjsbincodepen…)