優雅地打包非 JavaScript 靜態資源

語言: CN / TW / HK

:grinning: 加個關注,後續上新不錯過~

本文翻譯自 https://web.dev/bundling-non-js-resources/,原文未做修改

假設你正在開發一個網路應用程式。在這種情況下,你很可能不僅要處理 JavaScript 模組,還要處理各種其他資源--Web Workers(它也是 JavaScript ,但它擁有一套獨立的構建依賴圖)、圖片、CSS、字型、WebAssembly 模組等等。

一種可行的載入靜態資源的辦法是在 HTML 中直接引用它們,但通常它們在邏輯上是與其他可重用的元件耦合的。例如,自定義下拉選單的 CSS 與它的 JavaScript 部分相聯絡,圖示影象與工具欄元件相關,而 WebAssembly 模組與它的 JavaScript 膠水相依賴。在這些情況下,有種更加方便快捷的辦法是直接從它們的 JavaScript 模組中引用資源,並在載入相應的元件時動態地載入它們。

然而,大多數大型專案的構建系統都會對內容進行額外的優化和重組--例如打包和最小化(minimize)。構建系統不能執行程式碼並預測執行的結果是什麼,也沒理由去遍歷判斷 JavaScript 中每一個可能的字串是否是一個資源 URL。那麼,如何才能讓它們 "看到 "那些由 JavaScript 元件載入的動態資源,並將它們包含在構建產物中呢?

打包工具中的自定義匯入

一種常見的方法是利用已有的靜態匯入語法。有些打包工具可能會通過副檔名來自動檢測格式,而有些其他打包工具則允許外掛使用自定義的 URL Scheme,比如下面的例子:

// 普通 JavaScript 匯入
import { loadImg } from './utils.js';

// 特殊 "URL 匯入" 的靜態資源
import imageUrl from 'asset-url:./image.png';
import wasmUrl from 'asset-url:./module.wasm';
import workerUrl from 'js-url:./worker.js';

loadImg(imageUrl);
WebAssembly.instantiateStreaming(fetch(wasmUrl));
new Worker(workerUrl);

當一個打包工具外掛發現一個匯入項帶有它所識別的副檔名或 URL Scheme(上面的例子中的 asset-url:js-url: )時,它會將引用的資源新增到構建圖中,將其複製到最終目的地,執行適用於資源型別的優化,並返回最終的 URL,以便在執行時使用。

這種方法的好處是:重用 JavaScript 匯入語法,保證所有的 URL 都是靜態的相對路徑,這使得構建系統很容易定位這種依賴關係。

然而,它有一個明顯的缺點:這種程式碼不能直接在瀏覽器中工作,因為瀏覽器不知道如何處理那些自定義的匯入方案或副檔名。當然,如果你可以控制所有的程式碼,並且本來就要依靠打包工具進行開發,這聽起來還不錯。然而為了減少麻煩,直接在瀏覽器中使用 JavaScript 模組的情況越來越普遍(至少在開發過程中是這樣)。一個小 demo 可能根本就不需要打包工具,即使在生產中也不需要。

瀏覽器和打包工具中通用的匯入語法

如果你正在開發一個可重用的元件,你會希望它在任何環境下都能發揮作用,無論它是直接在瀏覽器中使用還是作為一個更大的應用程式的一部分預先構建。大多數現代的打包工具都接受下面這個JavaScript 模組匯入語法:

new URL('./relative-path', import.meta.url)

它看著像是一種特殊的語法,然而它確實是一種有效的 JavaScript 表示式,可以直接在瀏覽器中使用,也可以被打包工具靜態地檢測出來並加以處理。

使用這個語法,前面的例子可以改寫為:

// regular JavaScript import
import { loadImg } from './utils.js';
loadImg(new URL('./image.png', import.meta.url));
WebAssembly.instantiateStreaming(
fetch(new URL('./module.wasm', import.meta.url)),
{ /* … */ }
);
new Worker(new URL('./worker.js', import.meta.url));

讓我們分析一下它是什麼原理: new URL(...) 建構函式會基於第二個引數裡的絕對URL,解析出第一個引數中相對 URL 所對應的 URL。在我們的例子中,第二個引數是 import.meta.url [1] ,它是當前 JavaScript 模組的 URL ,所以第一個引數可以是相對於它的任何路徑。

它的優點和劣勢都類似於 動態匯入 [2] 。雖然可以使用 import(...) 匯入內容,如 import(someUrl) ,但打包工具會特殊處理帶有靜態 URL import('./some-static-url.js') 的匯入方式:把它作為一種在編譯時預處理已知依賴關係的匯入方式,把 程式碼分塊 [3] 並動態載入。

同樣,你可以使用 new URL(...) ,如 new URL(relativeUrl, customAbsoluteBase) ,然而 new URL('...', import.meta.url) 語法可以明確地告訴打包工具預處理依賴,並將其與主 JavaScript 資源打包在一起。

模稜兩可的相對URL

你可能會想,為什麼打包工具不能檢測到其他常見的語法--例如,沒有 new URL 包裝的 fetch('./module.wasm')

原因是,與 import 關鍵字不同,任何動態請求都是相對於文件本身的,而不是相對於當前的JavaScript檔案進行解析。比方說,你有以下結構:

  • index.html:

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

    • main.js

    • module.wasm

如果你想從 main.js 中載入 module.wasm ,你的第一反應可能是使用 fetch('./module.wasm') 這樣的相對路徑引用。

然而,fetch不知道它所執行的 JavaScript 檔案的 URL,相反,它是相對於文件來解析 URL 的。因此, fetch('./module.wasm') 最終會試圖載入 http://example.com/module.wasm ,而不是預期的 http://example.com/src/module.wasm ,從而造成失敗(運氣更不好的情況下,還可能默默地載入一個與你預期不同的資源)。

通過將相對的URL包裝成 new URL('...', import.meta.url) ,你可以避免這個問題,並保證任何提供的URL在傳遞給任何loader之前都是相對於 當前 JavaScript 模組的 URL(import.meta.url) 解析的。

只要用 fetch(new URL('./module.wasm', import.meta.url)) 代替 fetch('./module.wasm') ,就可以成功地載入預期的 WebAssembly 模組,同時給打包工具一個在構建時找到這些相對路徑的可靠方法。

工具鏈中的支援

打包工具

下面這些打包工具已經支援 new URL 語法:

  • Webpack v5 [4]

  • Rollup [5] (通過外掛支援: @web/rollup-plugin-import-meta-assets [6] 支援通用資源,而 @surma/rollup-plugin-off-main-thread [7] 支援 Workers.)

  • Parcel v2 (beta) [8] (譯者注:在本譯文釋出時,Parcel V2已經正式釋出:https://parceljs.org/blog/v2)

  • Vite [9]

WebAssembly

當使用 WebAssembly 時,你通常不會手動載入 Wasm 模組,而是匯入由工具鏈發出的 JavaScript 膠水程式碼。下面的工具鏈可以替你生成 new URL(...) 語法:

通過Emscripten編譯的C/C++

當使用 Emscripten 工具鏈時,你可以通過以下選項要求它輸出 ES6 模組膠水程式碼,而非普通 JS 程式碼:

$ emcc input.cpp -o output.mjs
## 如果你不想用mjs副檔名:
$ emcc input.cpp -o output.js -s EXPORT_ES6

當使用這個選項時,輸出的膠水程式碼將使用new URL(..., import.meta.url) 語法,這樣打包工具可以自動找到相關的 Wasm 檔案。

通過新增 -pthread 引數,這個語法也可以支援 WebAssembly [10] 執行緒的編譯

$ emcc input.cpp -o output.mjs -pthread
## 如果你不想用mjs副檔名:
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread

在這種情況下,生成的Web Worker將以同樣的方式被引用,並且也能被打包工具和瀏覽器正確載入。

通過 wasm-pack / wasm-bindgen 編譯的 Rust

wasm-pack [11] --WebAssembly 的主要 Rust 工具鏈,也有幾種輸出模式。

預設情況下,它將輸出一個依賴於 WebAssembly ESM 整合提議 [12] 的 JavaScript 模組。在寫這篇文章的時候,這個提議仍然是實驗性的,只有在使用 Webpack 打包時,輸出才會有效。

或者,你可以通過 -target web 引數要求 wasm-pack 通過輸出一個與瀏覽器相容的 ES6 模組:

$ wasm-pack build --target web

輸出將使用前面所說的 new URL(..., import.meta.url) 語法,而且 Wasm 檔案也會被打包工具自動發現。

如果你想通過 Rust 使用 WebAssembly 執行緒,這就有點複雜了。請檢視指南的 相應部分 [13] 以瞭解更多。

簡而言之,你不能使用任意的執行緒 API,但如果你使用 Rayon [14] ,你可以試試 wasm-bingen-rayon [15] 介面卡,這樣它就可以生成 Web 上可以執行的 Worker 。 wasm-bindgen-rayon 使用的 JavaScript 膠水 也包括 [16] new URL (...)語法,因此 Workers 也能被打包工具發現和引入。

未來的匯入方式

import.meta.resolve

有一個潛在的未來改進是專門的 import.meta.resolve(...) 語法。它將允許以一種更直接的方式解析相對於當前模組的內容,而不需要額外的引數。

// 現在的語法
new URL('...', import.meta.url)

// 未來的語法
await import.meta.resolve('...')

它還能與匯入依賴圖(import maps)還有自定義解析器更好地整合,因為它和 import 語法通過同一個模組解析系統處理。這對打包工具來說也是一個更可靠的訊號,因為它是一個靜態語法,不依賴於像 URL 這樣的執行時 API 。

import.meta.resolve 已經作為一個 實驗性功能 [17] 在 Node.js 中實現了,但是關於它在 Web 上應該如何工作 還有一些問題沒有定論 [18]

匯入斷言

匯入斷言(import assertions)是一項新功能,允許匯入 ECMAScript 模組以外的型別,不過現在只支援JSON 型別。

  • foo.json

{ "answer": 42 }
  • main.mjs

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

(譯者注:關於這個不太符合直覺的語法選擇也有點意思 https://github.com/tc39/proposal-import-assertions/issues/12)

它們也可能被打包工具使用,並取代目前由new URL語法所支援的場景,但匯入斷言中的型別需要一個一個被支援,目前被支援的只有 JSON,CSS 模組即將被支援,但其他型別的資源匯入仍然需要一個更通用的解決方案。

要想了解更多關於這個功能的資訊,請檢視 v8.dev上的功能解釋 [19]

小結

正如你所看到的,有各種方法可以在網路上包含非 JavaScript 資源,但它們有各自的優缺點,而且都不能同時在所有工具鏈中工作。一些未來的提議可能會讓我們用專門的語法來匯入這些資源,但我們還沒有走到這一步。

在那一天到來之前, new URL(..., import.meta.url) 語法是最有希望的解決方案,並且今天已經可以在瀏覽器、各種捆綁器和 WebAssembly 工具鏈中工作。

參考資料

[1]

import.meta.url: https://v8.dev/features/modules#import-meta

[2]

動態匯入: https://v8.dev/features/dynamic-import

[3]

程式碼分塊: https://web.dev/reduce-javascript-payloads-with-code-splitting/

[4]

Webpack v5: https://webpack.js.org/guides/asset-modules/#url-assets

[5]

Rollup: https://rollupjs.org/

[6]

@web/rollup-plugin-import-meta-assets: https://modern-web.dev/docs/building/rollup-plugin-import-meta-assets/

[7]

@surma/rollup-plugin-off-main-thread: https://github.com/surma/rollup-plugin-off-main-thread

[8]

Parcel v2 (beta): https://v2.parceljs.org/languages/javascript/#url-dependencies

[9]

Vite: https://vitejs.dev/guide/assets.html#new-url-url-import-meta-url

[10]

WebAssembly: https://web.dev/webassembly-threads/#c

[11]

wasm-pack: https://github.com/rustwasm/wasm-pack

[12]

WebAssembly ESM 整合提議: https://github.com/WebAssembly/esm-integration

[13]

相應部分: https://web.dev/webassembly-threads/#rust

[14]

Rayon: https://github.com/rayon-rs/rayon

[15]

wasm-bingen-rayon: https://github.com/GoogleChromeLabs/wasm-bindgen-rayon

[16]

也包括: https://github.com/GoogleChromeLabs/wasm-bindgen-rayon/blob/4cd0666d2089886d6e8731de2371e7210f848c5d/demo/index.js#L26

[17]

實驗性功能: https://nodejs.org/api/esm.html#esm_import_meta_resolve_specifier_parent

[18]

還有一些問題沒有定論: https://github.com/WICG/import-maps/issues/79

[19]

v8.dev上的功能解釋: https://v8.dev/features/import-assertions

Modern.js 開源預告

現代Web工程體系 Modern.js 將在 10.27-10.28 的稀土開發者大會正式釋出,歡迎參與 ~

官網:https://modernjs.dev/