JavaScript 模組

發布日期 · 標籤 ECMAScript ES2015

JavaScript 模組現在 獲得所有主要瀏覽器的支援

本文說明如何使用 JS 模組、如何負責任地部署模組,以及 Chrome 團隊如何努力讓模組在未來變得更好。

什麼是 JS 模組? #

JS 模組(也稱為「ES 模組」或「ECMAScript 模組」)是一項重大的新功能,或更確切地說是一系列新功能。您過去可能使用過使用者端 JavaScript 模組系統。也許您使用 類似 Node.js 中的 CommonJS,或 AMD,或其他東西。所有這些模組系統有一個共同點:它們允許您匯入和匯出內容。

JavaScript 現在有標準化的語法,專門用於此目的。在模組中,您可以使用 export 關鍵字匯出幾乎任何東西。您可以匯出 constfunction 或任何其他變數繫結或宣告。只要在變數陳述式或宣告前加上 export,就大功告成了

// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
return `${string.toUpperCase()}!`;
}

然後,您可以使用 import 關鍵字從另一個模組匯入模組。在此,我們從 lib 模組匯入 repeatshout 功能,並在我們的 main 模組中使用它

// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
// → 'MODULES IN ACTION!'

您也可以從模組匯出一個預設

// 📁 lib.mjs
export default function(string) {
return `${string.toUpperCase()}!`;
}

這種 default 匯出可以使用任何名稱匯入

// 📁 main.mjs
import shout from './lib.mjs';
// ^^^^^

模組與傳統腳本略有不同

  • 模組預設啟用 嚴格模式

  • 模組不支援 HTML 式註解語法,儘管它在傳統腳本中可行。

    // Don’t use HTML-style comment syntax in JavaScript!
    const x = 42; <!-- TODO: Rename x to y.
    // Use a regular single-line comment instead:
    const x = 42; // TODO: Rename x to y.
  • 模組具有詞法頂層範圍。這表示例如在模組中執行 var foo = 42; 不會建立名為 foo 的全域變數,可透過瀏覽器中的 window.foo 存取,儘管在傳統腳本中會這樣做。

  • 類似地,模組中的 this 不會參照全域 this,而是 undefined。(如果你需要存取全域 this,請使用 globalThis。)

  • 新的靜態 importexport 語法僅在模組中可用,在傳統腳本中無法使用。

  • 頂層 await 可在模組中使用,但在傳統腳本中不可用。相關地,await 不能在模組中的任何地方用作變數名稱,儘管傳統腳本中的變數 可以 在非同步函式外命名為 await

由於這些差異,相同的 JavaScript 程式碼在當作模組與傳統腳本處理時,行為可能不同。因此,JavaScript 執行環境需要知道哪些腳本是模組。

在瀏覽器中使用 JS 模組 #

在網路上,你可以透過將 type 屬性設定為 module,告訴瀏覽器將 <script> 元素當作模組處理。

<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

了解 type="module" 的瀏覽器會忽略具有 nomodule 屬性的腳本。這表示你可以對支援模組的瀏覽器提供基於模組的載荷,同時為其他瀏覽器提供備援。能夠做出這種區別真是太棒了,即使只是為了效能!想想看:只有現代瀏覽器支援模組。如果瀏覽器了解你的模組程式碼,它也支援 在模組之前就存在的特性,例如箭頭函式或 async-await。你不再需要在你的模組套件中轉譯這些特性!你可以 為現代瀏覽器提供更小且大部分未轉譯的基於模組的載荷。只有舊版瀏覽器會取得 nomodule 載荷。

由於 模組預設會延後,你可能也希望以延後的方式載入 nomodule 腳本

<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>

模組和傳統腳本之間的瀏覽器特定差異 #

如你所知,模組與傳統腳本不同。除了我們上面概述的與平台無關的差異外,還有一些特定於瀏覽器的差異。

例如,模組只會評估一次,而傳統腳本會在您將它們新增到 DOM 時評估多次。

<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js executes multiple times. -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs executes only once. -->

此外,模組腳本及其依賴項會透過 CORS 擷取。這表示任何跨來源的模組腳本都必須使用適當的標頭提供服務,例如 Access-Control-Allow-Origin: *。這不適用於傳統腳本。

另一個差異與 async 屬性有關,它會導致腳本在不阻擋 HTML 解析器的情況下下載(就像 defer),但它也會盡快執行腳本,沒有保證的順序,也不會等待 HTML 解析完成。async 屬性不適用於內嵌傳統腳本,但適用於內嵌 <script type="module">

關於檔案副檔名的說明 #

你可能已經注意到我們正在為模組使用 .mjs 檔案副檔名。在網路上,檔案副檔名並不重要,只要檔案使用 JavaScript MIME 類型 text/javascript 提供服務即可。瀏覽器知道它是模組,因為腳本元素上有 type 屬性。

儘管如此,我們建議出於兩個原因對模組使用 .mjs 副檔名

  1. 在開發過程中,.mjs 副檔名讓您和任何查看您的專案的人一目了然,該檔案是模組而不是傳統腳本。(僅從程式碼中查看並不總是能分辨出來。)如前所述,模組的處理方式與傳統腳本不同,因此差異非常重要!
  2. 它可確保您的檔案被執行時間(例如 Node.jsd8)和建置工具(例如 Babel)解析為模組。雖然這些環境和工具各自都有透過設定檔來將具有其他副檔名的檔案解釋為模組的專有方式,但 .mjs 副檔名是確保檔案被視為模組的跨相容方式。

注意:要在網路上部署 .mjs,您的網路伺服器需要設定為使用適當的 Content-Type: text/javascript 標頭來提供具有此副檔名的檔案,如上所述。此外,您可能希望將編輯器設定為將 .mjs 檔案視為 .js 檔案以取得語法突顯。大多數現代編輯器預設已經這樣做了。

模組指定符 #

import模組時,用於指定模組位置的字串稱為「模組指定符」或「匯入指定符」。在我們先前的範例中,模組指定符為'./lib.mjs'

import {shout} from './lib.mjs';
// ^^^^^^^^^^^

瀏覽器對模組指定符有一些限制。目前不支援所謂的「裸」模組指定符。此限制已指定,以便瀏覽器未來可以允許自訂模組載入器賦予裸模組指定符特殊意義,例如以下範例

// Not supported (yet):
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';

另一方面,以下範例都受支援

// Supported:
import {shout} from './lib.mjs';
import {shout} from '../lib.mjs';
import {shout} from '/modules/lib.mjs';
import {shout} from 'https://simple.example/modules/lib.mjs';

目前,模組指定符必須是完整的 URL,或以/./../開頭的相對 URL。

模組預設為延遲載入 #

傳統的<script>預設會阻擋 HTML 解析器。您可以透過新增defer屬性來解決此問題,這可確保腳本下載與 HTML 解析同時進行。

模組腳本預設為延遲載入。因此,您不需要在<script type="module">標籤中新增defer!不僅主模組的下載與 HTML 解析同時進行,所有相依模組的下載也都是如此!

其他模組功能 #

動態import() #

到目前為止,我們只使用靜態import。使用靜態import時,您的整個模組圖形都必須下載並執行,您的主程式碼才能執行。有時,您不想要預先載入模組,而是依需求在需要時才載入,例如當使用者按一下連結或按鈕時。這可以改善初始載入時間效能。動態import()讓這成為可能!

<script type="module">
(async () => {
const moduleSpecifier = './lib.mjs';
const {repeat, shout} = await import(moduleSpecifier);
repeat('hello');
// → 'hello hello'
shout('Dynamic import in action');
// → 'DYNAMIC IMPORT IN ACTION!'
})();
</script>

與靜態import不同,動態import()可以從一般腳本中使用。這是一種在現有程式碼庫中逐步開始使用模組的簡單方法。如需更多詳細資訊,請參閱我們關於動態import()的文章

注意: webpack有自己的import()版本,它巧妙地將匯入的模組分割成它自己的區塊,與主套件分離。

import.meta #

另一個與模組相關的新功能是 import.meta,它會提供您關於目前模組的元資料。您取得的精確元資料並未指定為 ECMAScript 的一部分;它取決於主機環境。例如,在瀏覽器中,您可能取得與在 Node.js 中不同的元資料。

以下是 import.meta 在網路上的範例。預設情況下,影像會相對於 HTML 文件中的目前 URL 載入。import.meta.url 可讓您載入相對於目前模組的影像。

function loadThumbnail(relativePath) {
const url = new URL(relativePath, import.meta.url);
const image = new Image();
image.src = url;
return image;
}

const thumbnail = loadThumbnail('../img/thumbnail.png');
container.append(thumbnail);

效能建議 #

持續組合 #

有了模組,就可以在不使用 webpack、Rollup 或 Parcel 等組合器的狀況下開發網站。在下列情況中,直接使用原生 JS 模組是沒問題的

  • 在本地端開發期間
  • 在總共少於 100 個模組且依賴關係樹相對較淺(即最大深度小於 5)的小型網路應用程式中用於製作

不過,正如我們在 載入由約 300 個模組組成的模組化函式庫時,分析 Chrome 載入管線的瓶頸 中所學到的,組合應用程式的載入效能比未組合的應用程式來得好。

原因之一是靜態 import/export 語法可進行靜態分析,因此它可以協助組合器工具透過消除未使用的匯出,來最佳化您的程式碼。靜態 importexport 不只是語法;它們是重要的工具功能!

我們的一般建議是在將模組部署到製作環境之前,繼續使用組合器。在某種程度上,組合類似於將程式碼壓縮的最佳化:它會帶來效能上的好處,因為您最終發布的程式碼較少。組合有相同的效果!持續組合。

一如往常,DevTools 程式碼涵蓋率功能 可以協助您找出是否將不必要的程式碼推播給使用者。我們也建議使用 程式碼拆分 來拆分組合,並延後載入非首次有意義繪製關鍵腳本。

組合與發布未組合模組的權衡 #

在網路開發中,一切通常都是權衡。發布未組合模組可能會降低初始載入效能(冷快取),但與發布未進行程式碼拆分的單一組合相比,實際上可以改善後續造訪的載入效能(暖快取)。對於 200 KB 的程式碼庫,變更單一細緻的模組,並讓它成為後續造訪時從伺服器執行的唯一擷取,會比必須重新擷取整個組合來得好得多。

如果您更關注具有熱快取的訪客體驗,而不是首次造訪的效能,並且您的網站具有少於數百個細緻模組,您可以嘗試運送未綑綁的模組,衡量冷載入和熱載入的效能影響,然後做出資料驅動的決策!

瀏覽器工程師正努力改善模組的開箱即用效能。隨著時間推移,我們預期在更多情況下運送未綑綁的模組將變得可行。

使用細緻模組 #

養成使用小型、細緻模組撰寫程式碼的習慣。在開發期間,每個模組只有幾個輸出通常比手動將許多輸出組合成單一檔案來得更好。

考慮一個名為 ./util.mjs 的模組,它輸出三個名為 droppluckzip 的函式

export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }

如果您的程式碼庫實際上只需要 pluck 功能,您可能會如下匯入它

import {pluck} from './util.mjs';

在這種情況下(沒有建置時間的綑綁步驟),瀏覽器仍然必須下載、剖析和編譯整個 ./util.mjs 模組,即使它實際上只需要那一個輸出。這很浪費!

如果 pluckdropzip 沒有共用任何程式碼,最好將它移到自己的細緻模組,例如 ./pluck.mjs

export function pluck() { /* … */ }

然後,我們可以匯入 pluck,而不用處理 dropzip 的開銷

import {pluck} from './pluck.mjs';

注意:您可以使用 default 輸出,而不是這裡的命名輸出,具體取決於您的個人喜好。

這不僅讓您的原始碼保持良好且簡潔,還能減少綑綁器執行的無效程式碼消除需求。如果您的原始碼樹中的某個模組未被使用,則它永遠不會被匯入,因此瀏覽器永遠不會下載它。確實會被使用的模組可以由瀏覽器個別快取程式碼。(讓這件事發生的基礎架構已經登陸 V8,而且正在進行相關工作,以便在 Chrome 中也啟用它。)

使用小型、細緻的模組有助於為您的程式碼庫做好準備,以應對未來可能出現原生綑綁解決方案的情況。

預載模組 #

您可以透過使用 <link rel="modulepreload"> 進一步最佳化模組的傳遞。這樣一來,瀏覽器就能預載甚至預先剖析和預先編譯模組及其相依性。

<link rel="modulepreload" href="lib.mjs">
<link rel="modulepreload" href="main.mjs">
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

這對於較大的相依樹而言特別重要。如果沒有 rel="modulepreload",瀏覽器需要執行多個 HTTP 要求才能找出完整的相依樹。但是,如果您使用 rel="modulepreload" 宣告相依模組指令碼的完整清單,瀏覽器就不必逐步找出這些相依關係。

使用 HTTP/2 #

如果可行,使用 HTTP/2 永遠都是好的效能建議,即使只是為了 其多工支援。透過 HTTP/2 多工,多個要求和回應訊息可以同時進行,這對於載入模組樹有益。

Chrome 團隊調查了另一個 HTTP/2 功能,特別是 HTTP/2 伺服器推送,是否可以成為部署高度模組化應用程式的實用解決方案。很不幸地,HTTP/2 伺服器推送很難正確執行,而且網路伺服器和瀏覽器的實作目前並未針對高度模組化的網路應用程式使用案例進行最佳化。例如,很難只推送使用者尚未快取的資源,而且透過將來源的整個快取狀態傳達給伺服器來解決此問題會造成隱私風險。

因此,無論如何,請繼續使用 HTTP/2!請記住,HTTP/2 伺服器推送(很不幸地)並非萬靈丹。

JS 模組的網路採用率 #

JS 模組在網路上的採用率正在緩慢提升。 我們的使用率計數器 顯示,目前所有網頁載入中,有 0.08% 使用 <script type="module">。請注意,此數字不包括其他進入點,例如動態 import()工作單元

JS 模組的下一步是什麼? #

Chrome 團隊正透過各種方式致力於改善 JS 模組的開發時間體驗。讓我們來討論其中一些方式。

更快速且確定性的模組解析演算法 #

我們建議變更模組解析演算法,以解決速度和確定性的不足。新的演算法現在已同時在 HTML 規格ECMAScript 規格 中採用,並已在 Chrome 63 中實作。預計此項改善很快就會在更多瀏覽器中採用!

新的演算法更有效率且更快速。舊演算法的計算複雜度為二次方,即 𝒪(n²),相依圖的大小,而 Chrome 當時的實作也是如此。新的演算法為線性,即 𝒪(n)。

此外,新的演算法以確定性的方式報告解析錯誤。在包含多個錯誤的圖形中,舊演算法的不同執行可能會報告不同的錯誤導致解析失敗。這使得偵錯不必要地困難。新的演算法保證每次報告相同的錯誤。

工作執行緒和網頁工作執行緒 #

Chrome 現在實作 工作執行緒,讓網頁開發人員可以自訂網頁瀏覽器「低層級部分」中的硬編碼邏輯。透過工作執行緒,網頁開發人員可以將 JS 模組提供給渲染管線或音訊處理管線(以及未來可能更多的管線!)。

Chrome 65 支援 PaintWorklet(又稱 CSS Paint API)來控制 DOM 元素的繪製方式。

const result = await css.paintWorklet.addModule('paint-worklet.mjs');

Chrome 66 支援 AudioWorklet,讓您可以使用自己的程式碼控制音訊處理。相同的 Chrome 版本啟動 AnimationWorklet 的 OriginTrial,用於建立與捲軸連結和其他高效能程序動畫。

最後,LayoutWorklet(又稱 CSS Layout API)現已在 Chrome 67 中實作。

我們正在 努力在 Chrome 中加入對使用 JS 模組與專用網頁工作執行緒的支援。您已經可以使用已啟用的 chrome://flags/#enable-experimental-web-platform-features 來試用此功能。

const worker = new Worker('worker.mjs', { type: 'module' });

JS 模組支援即將推出,適用於共用工作執行緒和服務工作執行緒

const worker = new SharedWorker('worker.mjs', { type: 'module' });
const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });

匯入對應 #

在 Node.js/npm 中,通常會透過「套件名稱」匯入 JS 模組。例如

import moment from 'moment';
import {pluck} from 'lodash-es';

目前,根據 HTML 規範,此類「單純匯入指定符」會擲回例外。我們的 匯入對應提案 允許此類程式碼在網頁上執行,包括在製作應用程式中。匯入對應是一種 JSON 資源,可協助瀏覽器將單純匯入指定符轉換為完整網址。

匯入對應仍處於提案階段。儘管我們已仔細思考它們如何處理各種使用案例,但我們仍與社群互動,尚未撰寫完整的規範。歡迎提供意見回饋!

網頁封裝:原生套件 #

Chrome 載入團隊目前正在探索 原生網頁封裝格式,作為一種新的網頁應用程式發行方式。網頁封裝的核心功能為

已簽署的 HTTP 交換,允許瀏覽器相信單一 HTTP 請求/回應配對是由它所宣稱的來源所產生;已綑綁的 HTTP 交換,也就是一組交換,其中每個交換可能是已簽署或未簽署,並附帶一些描述如何將該組交換整體解釋的元資料。

結合起來,這種網頁封裝格式將允許多個相同來源的資源安全地嵌入單一 HTTP GET 回應中。

現有的綑綁工具,例如 webpack、Rollup 或 Parcel 目前會發射單一 JavaScript 綑綁,其中原始獨立模組和資產的語意會遺失。有了原生綑綁,瀏覽器可以將資源解綑綁回其原始形式。簡而言之,你可以將已綑綁的 HTTP 交換想像成一組資源,可以透過目錄 (明細) 以任何順序存取,且其中包含的資源可以根據其相對重要性有效率地儲存和標記,同時仍維持個別檔案的概念。因此,原生綑綁可以改善偵錯體驗。在 DevTools 中檢視資產時,瀏覽器可以精確指出原始模組,而不需要複雜的原始碼對應。

原生綑綁格式的透明性開啟了各種最佳化機會。例如,如果瀏覽器已經快取了原生綑綁的一部分,它可以將此資訊傳達給網頁伺服器,然後只下載遺失的部分。

Chrome 已經支援提案的一部分 (SignedExchanges),但綑綁格式本身及其在高度模組化應用程式的應用仍處於探索階段。我們非常歡迎你在儲存庫中或透過電子郵件提供意見回饋,寄至 loading-dev@chromium.org

分層 API #

發布新功能和網頁 API 會產生持續的維護和執行時間成本 — 每個新功能都會污染瀏覽器命名空間、增加啟動成本,並代表一個會在整個程式碼庫中引入錯誤的新介面。分層 API 是一種以更具擴充性的方式在網頁瀏覽器中實作和發布較高層級 API 的方法。JS 模組是分層 API 的關鍵啟用技術

  • 由於模組是明確匯入的,因此要求分層 API 透過模組公開,可以確保開發人員只為他們使用的分層 API 付費。
  • 由於模組載入是可組態的,因此分層 API 可以具備內建機制,用於在不支援分層 API 的瀏覽器中自動載入多重填充。

模組和分層 API 如何一起運作的詳細資訊仍在制定中,但目前的提案看起來像這樣

<script
type="module"
src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
>
</script>

<script> 元素從瀏覽器的內建分層 API 組 (std:virtual-scroller) 或指向多重填充的備用網址載入 virtual-scroller API。此 API 可以執行網頁瀏覽器中 JS 模組可以執行的任何動作。一個範例是定義自訂 <virtual-scroller> 元素,以便以下 HTML 能依需求逐步增強

<virtual-scroller>
<!-- Content goes here. -->
</virtual-scroller>

致謝 #

感謝 Domenic Denicola、Georg Neis、Hiroki Nakagawa、Hiroshige Hayashizaki、Jakob Gruber、Kouhei Ueno、Kunihiko Sakamoto 和 Yang Guo 讓 JavaScript 模組變得快速!

此外,感謝 Eric Bidelman、Jake Archibald、Jason Miller、Jeffrey Posnick、Philip Walton、Rob Dodson、Sam Dutton、Sam Thorogood 和 Thomas Steiner 閱讀本指南的草稿版本並提供回饋。