優雅地打包非 JavaScript 靜態資源

語言: CN / TW / HK

本文首發於公眾號 ByteDance Web Infra 關注一下,後續上新不錯過~,團隊介紹:https://webinfra.org/about

本文翻譯自 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,比如下面的例子:

```js // 普通 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 模塊導入語法:

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

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

使用這個語法,前面的例子可以改寫為: js // 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,它是當前 JavaScript 模塊的 URL ,所以第一個參數可以是相對於它的任何路徑。

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

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

模稜兩可的相對URL

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

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

  • index.html: ```html

``` - 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語法:

WebAssembly

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

通過Emscripten編譯的C/C++

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

```bash $ emcc input.cpp -o output.mjs

如果你不想用mjs擴展名:

$ emcc input.cpp -o output.js -s EXPORT_ES6 ```

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

通過添加-pthread參數,這個語法也可以支持 WebAssembly 線程的編譯

```bash $ 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--WebAssembly 的主要 Rust 工具鏈,也有幾種輸出模式。

默認情況下,它將輸出一個依賴於 WebAssembly ESM 集成提議的 JavaScript 模塊。在寫這篇文章的時候,這個提議仍然是實驗性的,只有在使用 Webpack 打包時,輸出才會有效。

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

bash $ wasm-pack build --target web

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

如果你想通過 Rust 使用 WebAssembly 線程,這就有點複雜了。請查看指南的相應部分以瞭解更多。

簡而言之,你不能使用任意的線程 API,但如果你使用 Rayon,你可以試試wasm-bingen-rayon適配器,這樣它就可以生成 Web 上可以運行的 Worker 。wasm-bindgen-rayon使用的 JavaScript 膠水也包括 new URL(...)語法,因此 Workers 也能被打包工具發現和引入。

未來的導入方式

import.meta.resolve

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

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

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

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

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

導入斷言

導入斷言(import assertions)是一項新功能,允許導入 ECMAScript 模塊以外的類型,不過現在只支持JSON 類型。

  • foo.json

json { "answer": 42 }

  • main.mjs

json 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上的功能解釋

小結

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

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