JavaScript 模組

本指南將為你提供開始使用 JavaScript 模組語法所需的一切知識。

模組背景

JavaScript 程式最初都相當小——早期的大部分用途是處理獨立的指令碼任務,在需要時為網頁提供一些互動性,因此通常不需要大型指令碼。快進幾年,我們現在有了在瀏覽器中執行的完整應用程式,其中包含大量 JavaScript,同時 JavaScript 也被用於其他環境(例如 Node.js)。

複雜的專案需要一種機制來將 JavaScript 程式拆分成獨立的模組,這些模組可以在需要時匯入。Node.js 很早就具備了這種能力,並且有許多 JavaScript 庫和框架支援模組化使用(例如,其他基於 CommonJSAMD 的模組系統,如 RequireJSwebpackBabel)。

所有現代瀏覽器都原生支援模組功能,無需轉譯。這隻會是一件好事——瀏覽器可以最佳化模組的載入,使其比使用庫並進行所有額外的客戶端處理和額外的往返更有效率。但這並不會讓 webpack 這樣的打包工具過時——打包工具在將程式碼分割成合理大小的塊方面仍然做得很好,並且能夠進行其他最佳化,如程式碼壓縮、死程式碼消除和搖樹最佳化(tree-shaking)。

示例介紹

為了演示模組的用法,我們建立了一組示例,你可以在 GitHub 上找到。這些示例演示了一組模組,它們在網頁上建立一個 <canvas> 元素,然後在畫布上繪製不同的形狀(並報告相關資訊)。

這些示例相當簡單,但為了清晰地演示模組,我們特意保持了簡潔。

備註: 如果你想下載這些示例並在本地執行它們,你需要透過一個本地 Web 伺服器來執行。

基本示例結構

在我們的第一個示例中(參見 basic-modules),檔案結構如下:

index.html
main.js
modules/
    canvas.js
    square.js

備註: 本指南中的所有示例都基本採用相同的結構;你應該會開始對上述結構感到熟悉。

modules 目錄下的兩個模組描述如下:

  • canvas.js — 包含與設定 canvas 相關的功能

    • create() — 在一個具有指定 ID 的包裝器 <div> 內部,建立一個具有指定 widthheight 的 canvas,該包裝器本身被附加到一個指定的父元素內。返回一個包含 canvas 的 2D 上下文和包裝器 ID 的物件。
    • createReportList() — 建立一個無序列表,附加到指定的包裝元素內,可用於輸出報告資料。返回列表的 ID。
  • square.js — 包含

    • name — 一個包含字串“square”的常量。
    • draw() — 在指定的 canvas 上繪製一個正方形,具有指定的大小、位置和顏色。返回一個包含正方形大小、位置和顏色的物件。
    • reportArea() — 根據給定的邊長,將正方形的面積寫入指定的報告列表。
    • reportPerimeter() — 根據給定的邊長,將正方形的周長寫入指定的報告列表。

題外話 — .mjs 與 .js

在本文中,我們對模組檔案使用了 .js 副檔名,但在其他資源中,你可能會看到使用 .mjs 副檔名。例如,V8 的文件就推薦這樣做。給出的理由是:

  • 這有利於清晰度,即清楚地表明哪些檔案是模組,哪些是常規 JavaScript。
  • 它能確保你的模組檔案被 Node.js 等執行時和 Babel 等構建工具解析為模組。

然而,我們決定繼續使用 .js,至少目前是這樣。為了讓模組在瀏覽器中正常工作,你需要確保你的伺服器在提供這些檔案時,帶有一個包含 JavaScript MIME 型別的 Content-Type 標頭,例如 text/javascript。否則,你會收到一個嚴格的 MIME 型別檢查錯誤,類似於“伺服器響應了非 JavaScript MIME 型別”,瀏覽器將不會執行你的 JavaScript。大多數伺服器已經為 .js 檔案設定了正確的型別,但尚未為 .mjs 檔案設定。已經能正確提供 .mjs 檔案的伺服器包括 GitHub Pages 和 Node.js 的 http-server

如果你已經在使用這樣的環境,或者你雖然沒有使用但清楚自己在做什麼並且有許可權(即你可以配置你的伺服器為 .mjs 檔案設定正確的 Content-Type),這是可以的。但是,如果你無法控制提供檔案的伺服器,或者像我們在這裡一樣釋出檔案供公眾使用,這可能會引起困惑。

出於學習和可移植性的目的,我們決定堅持使用 .js

如果你真的看重使用 .mjs 來區分模組和“普通”JavaScript 檔案的清晰度,但又不想遇到上述問題,你可以在開發過程中使用 .mjs,並在構建步驟中將它們轉換為 .js

同樣值得注意的是:

  • 有些工具可能永遠不會支援 .mjs
  • 如下文所示,<script type="module"> 屬性用於指明指向的是一個模組。

匯出模組功能

要訪問模組功能,你首先要做的是匯出它們。這是透過 export 語句完成的。

最簡單的方法是將其放在任何你想從模組中匯出的專案前面,例如:

js
export const name = "square";

export function draw(ctx, length, x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x, y, length, length);

  return { length, x, y, color };
}

你可以匯出函式、varletconst,以及——我們稍後會看到的——類。它們必須是頂層專案:例如,你不能在函式內部使用 export

一個更方便的匯出所有專案的方法是在模組檔案末尾使用單個 export 語句,後跟一個用大括號包裹的、逗號分隔的你想匯出的功能列表。例如:

js
export { name, draw, reportArea, reportPerimeter };

將功能匯入指令碼

一旦你從模組中匯出了一些功能,你就需要將它們匯入到你的指令碼中才能使用。最簡單的方法如下:

js
import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";

使用 import 語句,後跟一個用大括號包裹的、逗號分隔的你想匯入的功能列表,然後是關鍵字 from,最後是模組說明符

模組說明符提供一個字串,JavaScript 環境可以將其解析為模組檔案的路徑。在瀏覽器中,這可能是一個相對於站點根目錄的路徑,對於我們的 basic-modules 示例來說,就是 /js-examples/module-examples/basic-modules。然而,我們在這裡使用了點(.)語法來表示“當前位置”,後跟我們要查詢的檔案的相對路徑。這比每次都寫出完整的絕對路徑要好得多,因為相對路徑更短,並且使 URL 具有可移植性——即使你將示例移動到站點層次結構中的不同位置,它仍然可以工作。

所以例如:

bash
/js-examples/module-examples/basic-modules/modules/square.js

變成了:

bash
./modules/square.js

你可以在 main.js 中看到這樣的程式碼行。

備註: 在某些模組系統中,你可以使用像 modules/square 這樣的模組說明符,它既不是相對路徑也不是絕對路徑,並且沒有副檔名。如果預先定義了匯入對映表,這種型別的說明符可以在瀏覽器環境中使用。

一旦你將功能匯入到你的指令碼中,你就可以像它們在同一個檔案中定義的一樣使用它們。以下內容位於 main.js 的 import 行之後:

js
const myCanvas = create("myCanvas", document.body, 480, 320);
const reportList = createReportList(myCanvas.id);

const square = draw(myCanvas.ctx, 50, 50, 100, "blue");
reportArea(square.length, reportList);
reportPerimeter(square.length, reportList);

備註: 匯入的值是所匯出功能的只讀檢視。與 const 變數類似,你不能重新分配匯入的變數,但仍然可以修改物件值的屬性。該值只能由匯出它的模組重新分配。有關示例,請參閱 import 參考

使用匯入對映表匯入模組

上面我們看到了瀏覽器如何使用模組說明符匯入模組,該說明符可以是一個絕對 URL,也可以是使用文件的基礎 URL 解析的相對 URL:

js
import { name as circleName } from "https://example.com/shapes/circle.js";
import { name as squareName, draw } from "./shapes/square.js";

匯入對映表允許開發者在匯入模組時,在模組說明符中指定幾乎任何他們想要的文字;對映表提供了一個相應的值,當模組 URL 被解析時,該值將替換該文字。

例如,下面匯入對映表中的 imports 鍵定義了一個“模組說明符對映”JSON 物件,其中屬性名可以用作模組說明符,當瀏覽器解析模組 URL 時,相應的值將被替換。這些值必須是絕對或相對 URL。相對 URL 將使用包含匯入對映表的文件的基礎 URL解析為絕對 URL 地址。

html
<script type="importmap">
  {
    "imports": {
      "shapes": "./shapes/square.js",
      "shapes/square": "./modules/shapes/square.js",
      "https://example.com/shapes/square.js": "./shapes/square.js",
      "https://example.com/shapes/": "/shapes/square/",
      "../shapes/square": "./shapes/square.js"
    }
  }
</script>

匯入對映表使用一個 JSON 物件<script> 元素中定義,該元素的 type 屬性設定為 importmap。請注意,匯入對映表僅適用於該文件——規範並未涵蓋如何在 worker 或 worklet 上下文中應用匯入對映表。

有了這個對映表,你現在就可以使用上面的屬性名作為模組說明符。如果模組說明符鍵的末尾沒有斜槓,則會匹配並替換整個模組說明符鍵。例如,下面我們匹配了裸模組名,並將一個 URL 重新對映到另一個路徑。

js
// Bare module names as module specifiers
import { name as squareNameOne } from "shapes";
import { name as squareNameTwo } from "shapes/square";

// Remap a URL to another URL
import { name as squareNameThree } from "https://example.com/shapes/square.js";

如果模組說明符的末尾有斜槓,那麼其值也必須有斜槓,並且該鍵被匹配為“路徑字首”。這允許重對映一整類 URL。

js
// Remap a URL as a prefix ( https://example.com/shapes/)
import { name as squareNameFour } from "https://example.com/shapes/moduleshapes/square.js";

一個匯入對映表中的多個鍵可能都能有效匹配一個模組說明符。例如,一個模組說明符 shapes/circle/ 可能與模組說明符鍵 shapes/shapes/circle/ 都匹配。在這種情況下,瀏覽器將選擇最具體(最長)的匹配模組說明符鍵。

匯入對映表允許使用裸模組名(如在 Node.js 中)匯入模組,也可以模擬從包中匯入模組,無論是否帶有副檔名。雖然上面沒有展示,但它們還允許根據匯入模組的指令碼路徑來匯入特定版本的庫。總的來說,它們讓開發者能夠編寫更符合人體工程學的匯入程式碼,並使管理網站使用的模組的不同版本和依賴關係變得更容易。這可以減少在瀏覽器和伺服器上使用相同 JavaScript 庫所需的工作量。

以下各節將詳細介紹上述的各種功能。

特性檢測

你可以使用靜態方法 HTMLScriptElement.supports()(該方法本身得到了廣泛支援)來檢查對匯入對映表的支援情況:

js
if (HTMLScriptElement.supports?.("importmap")) {
  console.log("Browser supports import maps.");
}

以裸名稱匯入模組

在一些 JavaScript 環境中,比如 Node.js,你可以對模組說明符使用裸名稱。這是因為環境可以將模組名稱解析到檔案系統中的一個標準位置。例如,你可能會使用以下語法來匯入“square”模組。

js
import { name, draw, reportArea, reportPerimeter } from "square";

要在瀏覽器上使用裸名稱,你需要一個匯入對映表,它為瀏覽器提供了將模組說明符解析為 URL 所需的資訊(如果 JavaScript 嘗試匯入一個無法解析為模組位置的模組說明符,它將丟擲一個 TypeError)。

下面你可以看到一個定義了 square 模組說明符鍵的對映表,在這種情況下,它對映到一個相對地址值。

html
<script type="importmap">
  {
    "imports": {
      "square": "./shapes/square.js"
    }
  }
</script>

有了這個對映表,我們現在可以在匯入模組時使用裸名稱了:

js
import { name as squareName, draw } from "square";

重對映模組路徑

模組說明符對映條目中,如果說明符鍵及其關聯值都以斜槓(/)結尾,則可以作為路徑字首使用。這允許將一整套匯入 URL 從一個位置重對映到另一個位置。它還可以用來模擬處理“包和模組”,就像你在 Node 生態系統中可能看到的那樣。

備註: 末尾的 / 表示模組說明符鍵可以作為模組說明符的一部分被替換。如果沒有這個斜槓,瀏覽器將只匹配(和替換)整個模組說明符鍵。

模組包

下面的 JSON 匯入對映表定義將 lodash 對映為一個裸名稱,並將模組說明符字首 lodash/ 對映到路徑 /node_modules/lodash-es/(相對於文件基礎 URL 解析):

json
{
  "imports": {
    "lodash": "/node_modules/lodash-es/lodash.js",
    "lodash/": "/node_modules/lodash-es/"
  }
}

透過這個對映,你可以使用裸名稱匯入整個“包”,也可以使用路徑對映匯入其中的模組:

js
import _ from "lodash";
import fp from "lodash/fp.js";

可以不帶 .js 副檔名匯入上面的 fp,但你需要為該檔案建立一個裸模組說明符鍵,例如 lodash/fp,而不是使用路徑。對於一個模組來說,這可能是合理的,但如果你想匯入許多模組,擴充套件性就很差。

通用 URL 重對映

模組說明符鍵不必是路徑——它也可以是絕對 URL(或像 ./..// 這樣的類 URL 相對路徑)。如果你想將一個具有絕對路徑的模組重對映到你自己的本地資源,這可能會很有用。

json
{
  "imports": {
    "https://www.unpkg.com/moment/": "/node_modules/moment/"
  }
}

用於版本管理的範圍化模組

像 Node 這樣的生態系統使用 npm 等包管理器來管理模組及其依賴。包管理器確保每個模組都與其他模組及其依賴項分離開來。因此,雖然一個複雜的應用程式可能在模組圖的不同部分多次包含同一模組的不同版本,但使用者無需考慮這種複雜性。

備註: 你也可以使用相對路徑來實現版本管理,但這並不是最佳選擇,因為除其他外,這會強制你的專案採用特定結構,並阻止你使用裸模組名。

匯入對映表同樣允許你在應用程式中擁有多個版本的依賴項,並使用相同的模組說明符來引用它們。你可以透過 scopes 鍵來實現這一點,它允許你提供根據執行匯入的指令碼路徑來使用的模組說明符對映。下面的示例演示了這一點。

json
{
  "imports": {
    "cool-module": "/node_modules/cool-module/index.js"
  },
  "scopes": {
    "/node_modules/dependency/": {
      "cool-module": "/node_modules/some/other/location/cool-module/index.js"
    }
  }
}

有了這個對映,如果一個 URL 包含 /node_modules/dependency/ 的指令碼匯入了 cool-module,那麼將使用位於 /node_modules/some/other/location/cool-module/index.js 的版本。如果在範圍化對映中沒有匹配的範圍,或者匹配的範圍不包含匹配的說明符,則會使用 imports 中的對映作為備用。例如,如果從一個非匹配範圍路徑的指令碼中匯入 cool-module,那麼將使用 imports 中的模組說明符對映,對映到位於 /node_modules/cool-module/index.js 的版本。

請注意,用於選擇範圍的路徑不影響地址的解析方式。對映路徑中的值不必與範圍路徑匹配,相對路徑仍然是相對於包含匯入對映表的指令碼的基礎 URL 進行解析的。

與模組說明符對映一樣,你可以有多個範圍鍵,這些鍵可能包含重疊的路徑。如果多個範圍與引用者 URL 匹配,則首先檢查最具體的範圍路徑(最長的範圍鍵)以查詢匹配的說明符。如果沒有匹配的說明符,瀏覽器將回退到下一個最具體的匹配範圍路徑,依此類推。如果在任何匹配的範圍中都沒有匹配的說明符,瀏覽器將在 imports 鍵中的模組說明符對映中檢查匹配項。

透過對映雜湊檔名來改善快取

網站使用的指令碼檔案通常帶有雜湊檔名以簡化快取。這種方法的缺點是,如果一個模組發生變化,任何使用其雜湊檔名匯入它的模組也需要被更新/重新生成。這可能會導致一系列的更新,浪費網路資源。

匯入對映表為這個問題提供了一個方便的解決方案。應用程式和指令碼不再依賴於特定的雜湊檔名,而是依賴於模組名的非雜湊版本(地址)。然後,像下面這樣的匯入對映表提供了到實際指令碼檔案的對映。

json
{
  "imports": {
    "main_script": "/node/srcs/application-fg7744e1b.js",
    "dependency_script": "/node/srcs/dependency-3qn7e4b1q.js"
  }
}

如果 dependency_script 發生變化,那麼其檔名中包含的雜湊值也會改變。在這種情況下,我們只需要更新匯入對映表來反映模組名稱的變更。我們不必更新任何依賴於它的 JavaScript 程式碼的原始檔,因為 import 語句中的說明符沒有改變。

載入非 JavaScript 資源

統一模組架構帶來的一個令人興奮的特性是能夠將非 JavaScript 資源作為模組載入。例如,你可以將 JSON 作為 JavaScript 物件匯入,或者將 CSS 作為 CSSStyleSheet 物件匯入。

你必須明確宣告你正在匯入的資源型別。預設情況下,瀏覽器假定資源是 JavaScript,如果解析出的資源是其他型別,則會丟擲錯誤。要匯入 JSON、CSS 或其他型別的資源,請使用匯入屬性語法:

js
import colors from "./colors.json" with { type: "json" };
import styles from "./styles.css" with { type: "css" };

瀏覽器也會對模組型別進行驗證,如果例如 ./data.json 沒有解析為一個 JSON 檔案,驗證就會失敗。這確保了你不會在你只想匯入資料時意外地執行程式碼。成功匯入後,你就可以像使用普通的 JavaScript 物件或 CSSStyleSheet 物件一樣使用匯入的值了。

js
console.log(colors.map((color) => color.value));
document.adoptedStyleSheets = [styles];

在 HTML 中應用模組

現在我們只需要將 main.js 模組應用到我們的 HTML 頁面上。這與我們將常規指令碼應用到頁面上的方式非常相似,但有幾個顯著的區別。

首先,你需要在 <script> 元素中包含 type="module",以宣告此指令碼為一個模組。要匯入 main.js 指令碼,我們使用這個:

html
<script type="module" src="main.js"></script>

你也可以透過將 JavaScript 程式碼放在 <script> 元素的主體中,將模組的指令碼直接嵌入到 HTML 檔案中:

html
<script type="module">
  /* JavaScript module code here */
</script>

你只能在模組內部使用 importexport 語句,而不能在常規指令碼中使用。如果你的 <script> 元素沒有 type="module" 屬性並試圖匯入其他模組,將會丟擲錯誤。例如:

html
<script>
  import _ from "lodash"; // SyntaxError: import declarations may only appear at top level of a module
  // …
</script>
<script src="a-module-using-import-statements.js"></script>
<!-- SyntaxError: import declarations may only appear at top level of a module -->

你通常應該將所有模組定義在單獨的檔案中。在 HTML 中內聯宣告的模組只能匯入其他模組,但它們匯出的任何內容都無法被其他模組訪問(因為它們沒有 URL)。

備註: 模組及其依賴項可以透過在 <link> 元素中指定 rel="modulepreload" 來進行預載入。這可以在模組被使用時顯著減少載入時間。

模組與傳統指令碼的其他區別

  • 你需要注意本地測試——如果你嘗試在本地載入 HTML 檔案(即,使用 file:// URL),由於 JavaScript 模組的安全要求,你會遇到 CORS 錯誤。你需要透過伺服器進行測試。
  • 另外,請注意,在模組內部定義的指令碼部分與在經典指令碼中的行為可能會有所不同。這是因為模組會自動使用嚴格模式
  • 載入模組指令碼時,不需要使用 defer 屬性(參見 <script> 屬性);模組會自動延遲載入。
  • 模組只執行一次,即使它們在多個 <script> 標籤中被引用。
  • 最後但同樣重要的是,我們要明確一點——模組功能被匯入到單個指令碼的作用域中——它們在全域性作用域中是不可用的。因此,你只能在匯入它們的指令碼中訪問匯入的功能,並且你將無法從 JavaScript 控制檯中訪問它們,例如。你仍然會在開發者工具中看到語法錯誤,但你將無法使用一些你可能期望使用的除錯技術。

模組中定義的變數的作用域限於該模組,除非顯式地附加到全域性物件上。另一方面,全域性定義的變數在模組內部是可用的。例如,給定以下程式碼:

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <link rel="stylesheet" href="" />
  </head>
  <body>
    <div id="main"></div>
    <script>
      // A var statement creates a global variable.
      var text = "Hello";
    </script>
    <script type="module" src="./render.js"></script>
  </body>
</html>
js
/* render.js */
document.getElementById("main").innerText = text;

頁面仍然會渲染 Hello,因為全域性變數 textdocument 在模組中是可用的。(從這個例子中還請注意,一個模組不一定需要 import/export 語句——唯一需要的是入口點具有 type="module"。)

預設匯出與命名匯出

到目前為止,我們所匯出的功能都是由命名匯出組成的——每個專案(無論是函式、const 等)在匯出時都透過其名稱來引用,並且在匯入時也使用該名稱來引用它。

還有一種型別的匯出叫做預設匯出——這旨在使模組提供一個預設函式變得容易,並且還有助於 JavaScript 模組與現有的 CommonJS 和 AMD 模組系統互操作(正如 Jason Orendorff 在 ES6 深入:模組 中很好地解釋的那樣;搜尋“預設匯出”)。

讓我們來看一個例子,解釋它是如何工作的。在我們的 basic-modules square.js 檔案中,你可以找到一個名為 randomSquare() 的函式,它建立一個具有隨機顏色、大小和位置的正方形。我們想將它作為我們的預設匯出,所以在檔案底部我們這樣寫:

js
export default randomSquare;

注意這裡沒有大括號。

我們也可以在函式前加上 export default,並將其定義為一個匿名函式,像這樣:

js
export default function (ctx) {
  // …
}

在我們的 main.js 檔案中,我們使用下面這行程式碼匯入預設函式:

js
import randomSquare from "./modules/square.js";

再次注意,這裡沒有大括號。這是因為每個模組只允許一個預設匯出,我們知道 randomSquare 就是那個預設匯出。上面這行程式碼基本上是下面這行的簡寫:

js
import { default as randomSquare } from "./modules/square.js";

備註: 用於重新命名匯出項的 as 語法在下面的重新命名匯入和匯出部分有解釋。

避免命名衝突

到目前為止,我們的 canvas 圖形繪製模組似乎工作正常。但如果我們嘗試新增一個處理繪製其他形狀(如圓形或三角形)的模組會發生什麼?這些形狀可能也會有關聯的函式,如 draw()reportArea() 等;如果我們試圖將不同但同名的函式匯入到同一個頂層模組檔案中,我們就會遇到衝突和錯誤。

幸運的是,有多種方法可以解決這個問題。我們將在接下來的部分中探討這些方法。

重新命名匯入和匯出

importexport 語句的大括號內,你可以使用關鍵字 as 加上一個新的特性名稱,來改變你在頂層模組中將使用的特性標識名稱。

所以,例如,以下兩種方式都會做同樣的事情,儘管方式略有不同:

js
// -- module.js --
export { function1 as newFunctionName, function2 as anotherNewFunctionName };

// -- main.js --
import { newFunctionName, anotherNewFunctionName } from "./modules/module.js";
js
// -- module.js --
export { function1, function2 };

// -- main.js --
import {
  function1 as newFunctionName,
  function2 as anotherNewFunctionName,
} from "./modules/module.js";

讓我們來看一個實際的例子。在我們的 renaming 目錄中,你會看到與前一個示例相同的模組系統,不同的是我們添加了 circle.jstriangle.js 模組來繪製和報告圓形和三角形。

在這些模組的每一箇中,我們都有同名的功能被匯出,因此每個模組的底部都有相同的 export 語句:

js
export { name, draw, reportArea, reportPerimeter };

當將這些匯入到 main.js 時,如果我們嘗試使用:

js
import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/circle.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/triangle.js";

瀏覽器會丟擲一個錯誤,例如“SyntaxError: redeclaration of import name”(Firefox)。

相反,我們需要重新命名匯入,使它們是唯一的:

js
import {
  name as squareName,
  draw as drawSquare,
  reportArea as reportSquareArea,
  reportPerimeter as reportSquarePerimeter,
} from "./modules/square.js";

import {
  name as circleName,
  draw as drawCircle,
  reportArea as reportCircleArea,
  reportPerimeter as reportCirclePerimeter,
} from "./modules/circle.js";

import {
  name as triangleName,
  draw as drawTriangle,
  reportArea as reportTriangleArea,
  reportPerimeter as reportTrianglePerimeter,
} from "./modules/triangle.js";

請注意,你也可以在模組檔案中解決這個問題,例如:

js
// in square.js
export {
  name as squareName,
  draw as drawSquare,
  reportArea as reportSquareArea,
  reportPerimeter as reportSquarePerimeter,
};
js
// in main.js
import {
  squareName,
  drawSquare,
  reportSquareArea,
  reportSquarePerimeter,
} from "./modules/square.js";

這樣做的效果是完全一樣的。你使用哪種風格取決於你自己,但可以說,保持你的模組程式碼不變,而在匯入時進行更改更有意義。當你從你無法控制的第三方模組匯入時,這一點尤其重要。

建立模組物件

上述方法可行,但有點凌亂和冗長。一個更好的解決方案是將每個模組的功能匯入到一個模組物件中。以下語法形式可以做到這一點:

js
import * as Module from "./modules/module.js";

這會獲取 module.js 中所有可用的匯出,並將它們作為物件 Module 的成員提供,從而有效地為它建立了自己的名稱空間。例如:

js
Module.function1();
Module.function2();

再次,讓我們來看一個實際的例子。如果你去我們的 module-objects 目錄,你會再次看到相同的例子,但這次是重寫以利用這種新語法。在模組中,所有的匯出都採用以下簡單的形式:

js
export { name, draw, reportArea, reportPerimeter };

另一方面,匯入則如下所示:

js
import * as Canvas from "./modules/canvas.js";

import * as Square from "./modules/square.js";
import * as Circle from "./modules/circle.js";
import * as Triangle from "./modules/triangle.js";

在每種情況下,你現在都可以透過指定的物件名訪問模組的匯入,例如:

js
const square = Square.draw(myCanvas.ctx, 50, 50, 100, "blue");
Square.reportArea(square.length, reportList);
Square.reportPerimeter(square.length, reportList);

所以你現在可以像以前一樣編寫程式碼(只要在需要的地方包含物件名),並且匯入語句變得更加整潔。

模組與類

正如我們前面提到的,你也可以匯出和匯入類;這是避免程式碼衝突的另一個選擇,特別是如果你的模組程式碼已經採用面向物件的風格編寫,這將非常有用。

你可以在我們的 classes 目錄中看到一個用 ES 類重寫的形狀繪製模組示例。舉個例子,square.js 檔案現在將所有功能都包含在一個單獨的類中:

js
class Square {
  constructor(ctx, listId, length, x, y, color) {
    // …
  }

  draw() {
    // …
  }

  // …
}

然後我們匯出它:

js
export { Square };

main.js 中,我們像這樣匯入它:

js
import { Square } from "./modules/square.js";

然後使用該類來繪製我們的正方形:

js
const square = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, "blue");
square.draw();
square.reportArea();
square.reportPerimeter();

模組聚合

有時,你會想要將多個模組聚合在一起。你可能會有多層級的依賴關係,而你希望簡化這些關係,將幾個子模組合併到一個父模組中。這可以透過在父模組中使用以下形式的匯出語法來實現:

js
export * from "x.js";
export { name } from "x.js";

例如,請參閱我們的 module-aggregation 目錄。在這個示例中(基於我們之前的類示例),我們有一個名為 shapes.js 的額外模組,它將 circle.jssquare.jstriangle.js 的所有功能聚合在一起。我們還將我們的子模組移動到了 modules 目錄內一個名為 shapes 的子目錄中。因此,這個示例中的模組結構是:

modules/
  canvas.js
  shapes.js
  shapes/
    circle.js
    square.js
    triangle.js

在每個子模組中,匯出的形式都是相同的,例如:

js
export { Square };

接下來是聚合部分。在 shapes.js 檔案內部,我們包含了以下程式碼行:

js
export { Square } from "./shapes/square.js";
export { Triangle } from "./shapes/triangle.js";
export { Circle } from "./shapes/circle.js";

這些程式碼行從各個子模組中獲取匯出,並有效地使它們可以從 shapes.js 模組中獲得。

備註:shapes.js 中引用的匯出基本上是透過該檔案進行重定向,並沒有真正在那裡存在,所以你將無法在同一個檔案中編寫任何有用的相關程式碼。

所以現在在 main.js 檔案中,我們可以透過替換

js
import { Square } from "./modules/square.js";
import { Circle } from "./modules/circle.js";
import { Triangle } from "./modules/triangle.js";

用下面這一行程式碼來訪問所有三個模組類:

js
import { Square, Circle, Triangle } from "./modules/shapes.js";

動態模組載入

JavaScript 模組功能最近增加了一個新特性,即動態模組載入。這允許你只在需要時動態載入模組,而不必預先載入所有內容。這有一些明顯的效能優勢;讓我們繼續閱讀,看看它是如何工作的。

這個新功能允許你將 import() 作為函式呼叫,並將模組的路徑作為引數傳遞給它。它返回一個 Promise,該 Promise 會兌現為一個模組物件(參見建立模組物件),從而讓你能夠訪問該物件的匯出。例如:

js
import("./modules/myModule.js").then((module) => {
  // Do something with the module.
});

備註: 動態匯入在瀏覽器主執行緒、共享 worker 和專用 worker 中是允許的。但是,如果在 service worker 或 worklet 中呼叫 import(),將會丟擲錯誤。

讓我們來看一個例子。在 dynamic-module-imports 目錄中,我們有另一個基於我們類示例的例子。但這次,當示例載入時,我們不在畫布上繪製任何東西。相反,我們包含了三個按鈕——“Circle”、“Square”和“Triangle”——當按下時,會動態載入所需的模組,然後用它來繪製相關的形狀。

在這個示例中,我們只修改了我們的 index.htmlmain.js 檔案——模組的匯出與之前保持不變。

main.js 中,我們透過 document.querySelector() 呼叫獲取了每個按鈕的引用,例如:

js
const squareBtn = document.querySelector(".square");

然後我們為每個按鈕附加一個事件監聽器,以便在按下時,動態載入相關模組並用它來繪製形狀:

js
squareBtn.addEventListener("click", () => {
  import("./modules/square.js").then((Module) => {
    const square = new Module.Square(
      myCanvas.ctx,
      myCanvas.listId,
      50,
      50,
      100,
      "blue",
    );
    square.draw();
    square.reportArea();
    square.reportPerimeter();
  });
});

請注意,因為 promise 的兌現返回一個模組物件,所以類成為了該物件的子特性,因此我們現在需要透過在前面加上 Module. 來訪問建構函式,例如 Module.Square( /* … */ )

動態匯入的另一個優點是它們始終可用,即使在指令碼環境中也是如此。因此,如果你在 HTML 中有一個現有的 <script> 標籤,且沒有 type="module",你仍然可以透過動態匯入來重用以模組形式分發的程式碼。

html
<script>
  import("./modules/square.js").then((module) => {
    // Do something with the module.
  });
  // Other code that operates on the global scope and is not
  // ready to be refactored into modules yet.
  var btn = document.querySelector(".square");
</script>

頂層 await

頂層 await 是模組內可用的一個特性。這意味著 await 關鍵字可以在頂層使用。它允許模組像大型非同步函式一樣工作,這意味著程式碼可以在父模組使用前被求值,但不會阻塞兄弟模組的載入。

我們來看一個例子。你可以在 top-level-await 目錄中找到本節描述的所有檔案和程式碼,該目錄是基於之前示例擴充套件的。

首先,我們在一個單獨的 colors.json 檔案中宣告我們的調色盤:

json
{
  "yellow": "#F4D03F",
  "green": "#52BE80",
  "blue": "#5499C7",
  "red": "#CD6155",
  "orange": "#F39C12"
}

然後,我們將建立一個名為 getColors.js 的模組,它使用 fetch 請求載入 colors.json 檔案並以物件形式返回資料。

js
// fetch request
const colors = fetch("../data/colors.json").then((response) => response.json());

export default await colors;

請注意這裡的最後一行匯出語句。

我們在指定要匯出的常量 colors 之前使用了關鍵字 await。這意味著任何包含此模組的其他模組都會等到 colors 下載並解析完畢後才會使用它。

讓我們將這個模組包含在我們的 main.js 檔案中:

js
import colors from "./modules/getColors.js";
import { Canvas } from "./modules/canvas.js";

const circleBtn = document.querySelector(".circle");

// …

在呼叫我們的形狀函式時,我們將使用 colors 而不是之前使用的字串:

js
const square = new Module.Square(
  myCanvas.ctx,
  myCanvas.listId,
  50,
  50,
  100,
  colors.blue,
);

const circle = new Module.Circle(
  myCanvas.ctx,
  myCanvas.listId,
  75,
  200,
  100,
  colors.green,
);

const triangle = new Module.Triangle(
  myCanvas.ctx,
  myCanvas.listId,
  100,
  75,
  190,
  colors.yellow,
);

這很有用,因為 main.js 中的程式碼在 getColors.js 中的程式碼執行完成之前不會執行。但它不會阻塞其他模組的載入。例如,我們的 canvas.js 模組在 colors 被獲取時會繼續載入。

匯入宣告會被提升

匯入宣告會被提升。在這種情況下,這意味著匯入的值在模組程式碼中是可用的,即使在宣告它們的位置之前也是如此,並且匯入模組的副作用會在模組其餘程式碼開始執行之前產生。

因此,例如,在 main.js 中,在程式碼中間匯入 Canvas 仍然可以正常工作:

js
// …
const myCanvas = new Canvas("myCanvas", document.body, 480, 320);
myCanvas.create();
import { Canvas } from "./modules/canvas.js";
myCanvas.createReportList();
// …

儘管如此,將所有匯入放在程式碼頂部仍然被認為是良好實踐,這樣可以更容易地分析依賴關係。

迴圈匯入

模組可以匯入其他模組,而這些模組又可以匯入其他模組,以此類推。這形成了一個稱為“依賴圖”的有向圖。在理想世界中,這個圖是無環的。在這種情況下,可以使用深度優先遍歷來對圖進行求值。

然而,迴圈通常是不可避免的。如果模組 a 匯入了模組 b,但 b 又直接或間接地依賴於 a,就會產生迴圈匯入。例如:

js
// -- a.js --
import { b } from "./b.js";

// -- b.js --
import { a } from "./a.js";

// Cycle:
// a.js ───> b.js
//  ^         │
//  └─────────┘

迴圈匯入並不總是會失敗。匯入的變數值只有在變數實際被使用時才會被檢索(因此允許即時繫結),並且只有當變數在該時間點仍未初始化時,才會丟擲 ReferenceError

js
// -- a.js --
import { b } from "./b.js";

setTimeout(() => {
  console.log(b); // 1
}, 10);

export const a = 2;

// -- b.js --
import { a } from "./a.js";

setTimeout(() => {
  console.log(a); // 2
}, 10);

export const b = 1;

在這個例子中,ab 都是非同步使用的。因此,在模組求值時,ba 都沒有被實際讀取,所以其餘程式碼正常執行,兩個 export 宣告產生了 ab 的值。然後,在超時之後,ab 都可用了,所以兩個 console.log 語句也正常執行。

如果你將程式碼更改為同步使用 a,模組求值會失敗:

js
// -- a.js (entry module) --
import { b } from "./b.js";

export const a = 2;

// -- b.js --
import { a } from "./a.js";

console.log(a); // ReferenceError: Cannot access 'a' before initialization
export const b = 1;

這是因為當 JavaScript 對 a.js 求值時,它需要先對 a.js 的依賴 b.js 求值。然而,b.js 使用了 a,而 a 此時還不可用。

另一方面,如果你將程式碼更改為同步使用 b,但非同步使用 a,模組求值會成功:

js
// -- a.js (entry module) --
import { b } from "./b.js";

console.log(b); // 1
export const a = 2;

// -- b.js --
import { a } from "./a.js";

setTimeout(() => {
  console.log(a); // 2
}, 10);
export const b = 1;

這是因為 b.js 的求值正常完成,所以在對 a.js 進行求值時,b 的值是可用的。

在你的專案中通常應該避免迴圈匯入,因為它們會使你的程式碼更容易出錯。一些常見的消除迴圈的技術有:

  • 將兩個模組合併成一個。
  • 將共享程式碼移動到第三個模組中。
  • 將一些程式碼從一個模組移動到另一個模組。

然而,如果庫之間相互依賴,也可能出現迴圈匯入,這種情況更難修復。

編寫“同構”模組

模組的引入鼓勵 JavaScript 生態系統以模組化的方式分發和重用程式碼。然而,這並不一定意味著一段 JavaScript 程式碼可以在任何環境下執行。假設你發現一個模組可以為使用者密碼生成 SHA 雜湊值。你能在瀏覽器前端使用它嗎?你能在你的 Node.js 伺服器上使用它嗎?答案是:視情況而定。

如前所述,模組仍然可以訪問全域性變數。如果模組引用了像 window 這樣的全域性變數,它可以在瀏覽器中執行,但在你的 Node.js 伺服器上會丟擲錯誤,因為那裡沒有 window。同樣,如果程式碼需要訪問 process 才能正常工作,它只能在 Node.js 中使用。

為了最大化模組的可重用性,通常建議使程式碼“同構”——即在每個執行時中表現出相同的行為。這通常透過三種方式實現:

  • 將你的模組分為“核心”和“繫結”。對於“核心”,專注於純 JavaScript 邏輯,如計算雜湊,不涉及任何 DOM、網路、檔案系統訪問,並暴露實用函式。對於“繫結”部分,你可以從全域性上下文中讀取和寫入。例如,“瀏覽器繫結”可能會選擇從輸入框中讀取值,而“Node 繫結”可能會從 process.env 中讀取,但從任何一處讀取的值都將被傳遞給同一個核心函式並以相同的方式處理。核心可以在每個環境中匯入並以相同的方式使用,而只有通常是輕量級的繫結需要是平臺特定的。

  • 在使用特定全域性變數之前檢測它是否存在。例如,如果你測試 typeof window === "undefined",你就知道你可能處在 Node.js 環境中,不應該讀取 DOM。

    js
    // myModule.js
    let password;
    if (typeof process !== "undefined") {
      // We are running in Node.js; read it from `process.env`
      password = process.env.PASSWORD;
    } else if (typeof window !== "undefined") {
      // We are running in the browser; read it from the input box
      password = document.getElementById("password").value;
    }
    

    如果兩個分支最終確實具有相同的行為(“同構”),這種方式是更可取的。如果無法提供相同的功能,或者這樣做涉及載入大量程式碼而其中一大部分都未使用,那麼最好使用不同的“繫結”。

  • 使用 polyfill 為缺失的功能提供回退。例如,如果你想使用 fetch 函式,該函式在 Node.js v18 之後才被支援,你可以使用一個類似的 API,比如 node-fetch 提供的那個。你可以透過動態匯入有條件地這樣做:

    js
    // myModule.js
    if (typeof fetch === "undefined") {
      // We are running in Node.js; use node-fetch
      globalThis.fetch = (await import("node-fetch")).default;
    }
    // …
    

    globalThis 變數是一個在所有環境中都可用的全域性物件,如果你想在模組內讀取或建立全域性變數,它會很有用。

這些實踐並非模組獨有。然而,隨著程式碼重用和模組化的趨勢,我們鼓勵你使你的程式碼跨平臺,以便儘可能多的人能夠享用它。像 Node.js 這樣的執行時也在積極地儘可能實現 Web API,以提高與 Web 的互操作性。

故障排除

如果你在讓模組正常工作時遇到困難,這裡有一些可能會有幫助的提示。如果你發現了更多,歡迎隨時新增到列表中!

  • 我們之前提到過,但再次重申:.mjs 檔案需要以 text/javascript 的 MIME 型別(或其他與 JavaScript 相容的 MIME 型別,但推薦使用 text/javascript)載入,否則你會收到一個嚴格的 MIME 型別檢查錯誤,如“伺服器響應了非 JavaScript MIME 型別”。
  • 如果你嘗試在本地載入 HTML 檔案(即,使用 file:// URL),由於 JavaScript 模組的安全要求,你會遇到 CORS 錯誤。你需要透過伺服器進行測試。GitHub Pages 是理想的選擇,因為它還會以正確的 MIME 型別提供 .mjs 檔案。
  • 因為 .mjs 是一個非標準的副檔名,一些作業系統可能無法識別它,或者會嘗試用其他東西替換它。例如,我們發現 macOS 會在 .mjs 檔案的末尾靜默地新增 .js,然後自動隱藏副檔名。所以我們所有的檔案實際上都變成了 x.mjs.js。一旦我們關閉了自動隱藏副檔名,並讓它接受 .mjs,問題就解決了。

另見