不使用打包工具匯入前端 JavaScript 函式庫
原文由 Julia Evans 于 發布,訂閱此部落格
我喜歡不使用打包工具寫 JavaScript,昨天又——大概是第一百萬次了——遇到一個問題:要在不使用打包工具的情況下在程式碼中匯入 JavaScript 函式庫,結果花了超久才搞定,因為函式庫的安裝說明都預設你有在使用打包工具。
幸好到了現在,我已經大致學會怎麼處理這種情況——要嘛成功把函式庫用起來,不然就判斷太麻煩、換另一個函式庫——所以這篇就是我希望幾年前就有的 JavaScript 函式庫匯入指南。
這篇文章只會談在前端使用 JavaScript 函式庫,而且只談在不使用打包工具的環境下該怎麼用。
在這篇文章中,我會談到:
- 函式庫可能會提供的三種主要 JavaScript 檔案類型(ES Modules、傳統的全域變數形式,以及 CommonJS)
- 如何判斷一個 JavaScript 函式庫的建置成果裡包含了哪幾種檔案
- 在程式碼中匯入各種檔案類型的方法
三種 JavaScript 檔案類型
函式庫提供的 JavaScript 檔案大致可分為三種:
- 傳統型:定義全域變數的檔案。這種檔案只要用
<script src>就會動。能拿到這種當然最好,但不是隨時都有 - ES 模組(可能會依賴其他檔案,這部分後面會提到)
- 「CommonJS」模組。這是給 Node 用的,不透過打包工具就完全無法在瀏覽器中使用。
我不確定「傳統型」有沒有更好的稱呼,不過這裡就先叫它「傳統型」吧。另外還有一種叫「AMD」的類型,但我不太確定在 2024 年還有多重要。
既然已經知道有這三種檔案,接下來就來看看怎麼判斷函式庫實際上提供了哪幾種!
在哪裡找到這些檔案:NPM 建置成果
每個 JavaScript 函式庫都會有一個上傳到 NPM 的建置成果。你可能會想(就像我一開始那樣)—— Julia!重點不就是我們不用 Node 來建置嗎!為什麼還要談 NPM?
但就算你用的是來自 CDN 的連結,像是 https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js,其實用的還是 NPM 上的建置成果!CDN 上的所有檔案最初都是來自 NPM。
正因如此,就算我完全不打算用 Node 來建置,有時還是會先 npm install 這個函式庫——我會隨便開一個暫存資料夾,在裡面 npm install,用完就刪掉。我喜歡直接在檔案系統裡翻 NPM 建置成果裡的檔案,這樣就能百分之百確定自己看到的是函式庫建置成果中提供的全部內容,而不會被 CDN 藏起來什麼沒看到。
所以就讓我們來 npm install 幾個函式庫,試著搞清楚它們的建置成果裡到底提供了哪幾種 JavaScript 檔案吧!
範例函式庫一:Chart.js
首先來看看繪圖函式庫 Chart.js 裡面長怎樣。
$ cd /tmp/whatever
$ npm install chart.js
$ cd node_modules/chart.js/dist
$ ls *.*js
chart.cjs chart.js chart.umd.js helpers.cjs helpers.js
這個函式庫看起來有三種基本選項:
選項一: chart.cjs。.cjs 這個附檔名告訴我這是 CommonJS 檔案,是給 Node 用的。這表示不經過某種建置步驟,就不可能直接在瀏覽器中使用它。
選項二:chart.js。光看 .js 附檔名無法判斷它是哪種檔案,但打開一看,會看到 import '@kurkle/color';,這馬上就能看出它是 ES 模組——import ... 語法就是 ES 模組的語法。
選項三:chart.umd.js。「UMD」是「Universal Module Definition」(通用模組定義)的縮寫,我的理解是,這個檔案既可以用基本的 <script src> 引入,也可以用 CommonJS,或是用一種我也不太懂、叫 AMD 的第三種方式。
如何使用 UMD 檔案
我在用 Chart.js 時選了選項三。只需要在程式碼中加入這一行:
<script src="./chart.umd.js"> </script>
然後就可以透過全域的 Chart 變數來使用這個函式庫了。再簡單不過。我直接把 chart.umd.js 複製到我的 Git 儲存庫裡,這樣就不用擔心 NPM 或 CDN 掛掉之類的問題。
建置檔案不一定都在 dist 目錄裡
很多函式庫會把建置成果放在 dist 目錄裡,但不一定都是這樣!建置檔案的位置是在函式庫的 package.json 裡指定的。
舉例來說,這是 Chart.js 的 package.json 節錄:
"jsdelivr": "./dist/chart.umd.js",
"unpkg": "./dist/chart.umd.js",
"main": "./dist/chart.cjs",
"module": "./dist/chart.js",
我想它的意思是,如果你想用 ES 模組(module),就該用 dist/chart.js,而 jsDelivr 和 unpkg 這兩個 CDN 則該用 ./dist/chart.umd.js。我想 main 是給 Node 用的。
chart.js 的 package.json 裡還寫著 "type": "module",根據這份文件,這是告訴 Node 預設要把檔案當成 ES 模組來處理。我想它並沒有明確指出哪些檔案是 ES 模組、哪些不是,但至少能告訴我們裡面有東西是 ES 模組。
範例函式庫二:@atcute/oauth-browser-client
@atcute/oauth-browser-client 是一個用來在瀏覽器中透過 OAuth 登入 Bluesky 的函式庫。
來看看它的建置成果裡提供了哪幾種 JavaScript 檔案!
$ npm install @atcute/oauth-browser-client
$ cd node_modules/@atcute/oauth-browser-client/dist
$ ls *js
constants.js dpop.js environment.js errors.js index.js resolvers.js
看起來這裡唯一合理的進入點檔案就是 index.js,內容大概像這樣:
export { configureOAuth } from './environment.js';
export * from './errors.js';
export * from './resolvers.js';
這種 export 語法代表它是 ES 模組。也就是說,我們可以在不使用打包工具的情況下直接在瀏覽器中使用它!來看看該怎麼做。
如何透過 import maps 使用 ES 模組
使用 ES 模組不像直接加個 <script src="whatever.js"> 那麼簡單。相反地,如果 ES 模組有相依套件(像 @atcute/oauth-browser-client 就有),步驟會是:
- 在 HTML 中設定 import map
- 在 JS 程式碼中加入像
import { configureOAuth } from '@atcute/oauth-browser-client';這樣的 import 陳述式 - 在 HTML 中像這樣引入你的 JS 程式碼:
<script type="module" src="YOURSCRIPT.js"></script>
我們需要 import map,而不是直接寫 import { BrowserOAuthClient } from "./oauth-client-browser.js",原因是模組內部還有更多像 import {something} from @atcute/client 這樣的 import 陳述式,我們得告訴瀏覽器要去哪裡取得 @atcute/client 以及其他所有相依套件的程式碼。
這是我為 @atcute/oauth-browser-client 使用的 importmap,長這樣:
<script type="importmap">
{
"imports": {
"nanoid": "./node_modules/nanoid/bin/dist/index.js",
"nanoid/non-secure": "./node_modules/nanoid/non-secure/index.js",
"nanoid/url-alphabet": "./node_modules/nanoid/url-alphabet/dist/index.js",
"@atcute/oauth-browser-client": "./node_modules/@atcute/oauth-browser-client/dist/index.js",
"@atcute/client": "./node_modules/@atcute/client/dist/index.js",
"@atcute/client/utils/did": "./node_modules/@atcute/client/dist/utils/did.js"
}
}
</script>
要讓這些 import maps 正常運作還挺麻煩的,我總覺得應該有工具可以自動產生,但目前還沒找到。用 esbuild 的 metafile 來寫個自動產生 importmaps 的腳本,理論上絕對可行,但我還沒試過,也許有更好的方法。
我昨天就是為了讓 github.com/jvns/bsky-oauth-example 動起來才決定設定 importmaps 的,所以那個儲存庫裡有一些範例程式碼。
另外有人向我推薦了 Simon Willison 的 download-esm,它會下載 ES 模組並把其中的 imports 重寫成直接指向 JS 檔案,這樣就不需要 importmaps 了。我還沒試過,但感覺是個很棒的點子。
使用 importmaps 的問題:檔案太多
不過我在瀏覽器中使用 importmaps 時確實遇到了一些問題——載入我的網站需要下載數十個 JavaScript 檔案,而開發環境中的網頁伺服器不知為何跟不上。我一直看到檔案隨機載入失敗,只好重新整理頁面,祈禱這次會成功。
把網站部署到正式環境後就沒這個問題了,所以我想應該是我本機開發環境的問題。
另外,ES 模組整體來說有個有點煩人的地方,就是你必須跑一個網頁伺服器才能使用它們,我確定這是有充分理由的,但如果能直接打開 index.html 而不用啟動伺服器,當然更方便。
因為「檔案太多」這個問題,我覺得用這種方式透過 importmaps 來使用 ES 模組,其實對我來說吸引力不大,不過知道有這個可能性還是很好。
如何在不使用 importmaps 的情況下使用 ES 模組
如果 ES 模組沒有相依套件,那就更簡單了——你根本不需要 importmaps!只要這樣做就好:
- 在 HTML 中加入
<script type="module" src="YOURCODE.js"></script>。type="module"很重要。 - 在
YOURCODE.js中寫入import {whatever} from "https://example.com/whatever.js"
替代方案:使用 esbuild
如果你不想用 importmaps,也可以使用像 esbuild 這樣的打包工具。我在 Some notes on using esbuild 中談過具體做法,但這篇文章的重點是完全避開打包工具的方法,所以這裡就不多談這個選項了。不過我還是很喜歡 esbuild,也覺得在這種情況下它是個不錯的選擇。
瀏覽器對 importmaps 的支援度如何?
CanIUse 顯示 importmaps 屬於「Baseline 2023:在主流瀏覽器中新近可用」,所以我的感覺是,到了 2024 年可能還是有點太新?我想,如果只是寫些好玩的實驗性程式碼、只有我跟另外十幾個人會用,我會用 importmaps;但如果想讓程式碼更廣泛地被使用,我會改用 esbuild。
範例函式庫三:@atproto/oauth-client-browser
來看看最後一個範例函式庫!這是跟 @atcute/oauth-browser-client 不同的另一個 Bluesky 驗證函式庫。
$ npm install @atproto/oauth-client-browser
$ cd node_modules/@atproto/oauth-client-browser/dist
$ ls *js
browser-oauth-client.js browser-oauth-database.js browser-runtime-implementation.js errors.js index.js indexed-db-store.js util.js
同樣地,這裡唯一真正的候選檔案似乎就是 index.js。但這次的情況跟上一個範例函式庫不同!來看看 index.js 的內容:
index.js 裡面有一大堆像這樣的東西:
__exportStar(require("@atproto/oauth-client"), exports);
__exportStar(require("./browser-oauth-client.js"), exports);
__exportStar(require("./errors.js"), exports);
var util_js_1 = require("./util.js");
這種 require() 語法就是 CommonJS 語法,代表我們完全無法在瀏覽器中直接使用這個檔案,必須經過某種建置步驟才行,而且用 esbuild 也不行。
而且這個函式庫的 package.json 裡寫著 "type": "commonjs",這也是判斷它是 CommonJS 的另一個線索。
如何透過 esm.sh 使用 CommonJS 模組
我本來以為不懂打包工具就不可能使用 CommonJS 模組,但後來在 Bluesky 上有人告訴我 esm.sh!它是一個會把任何東西都轉譯成 ES 模組的 CDN。skypack.dev 也有類似的功能,我不太確定兩者的差別,但有人提到如果其中一個不行,有時換另一個試試就會成功。
以 @atproto/oauth-client-browser 來說,用起來似乎很簡單,我只需要在 HTML 中加入這段:
<script type="module" src="script.js"> </script>
然後在 script.js 中加入這段。
import { BrowserOAuthClient } from "https://esm.sh/@atproto/[email protected]"
這樣似乎就能直接用了,超酷的!當然,這某種程度上還是用了打包工具——只是改由 esm.sh 幫我執行建置而已。我對這種做法主要的顧慮是:
- 我不太相信 CDN 能永遠穩定運作——通常我喜歡把相依套件直接複製到自己的儲存庫裡,以免未來因為某些原因不見了。
- 我也聽說過 CDN 曾發生安全漏洞,這讓我有點擔心。
- 我也不太清楚 esm.sh 到底在做什麼。
esbuild 也能把 CommonJS 模組轉成 ES 模組
我後來也學到,你也可以用 esbuild 把 CommonJS 模組轉成 ES 模組,不過有些限制——import { BrowserOAuthClient } from 這種語法就無法運作。相關的討論可以看這個 GitHub issue。
我覺得 esbuild 的做法對我來說可能比 esm.sh 更有吸引力,因為它是我電腦上本來就有的工具,所以我對它比較信任。不過這部分我還沒做太多實驗。
三種檔案類型的總結
以下是你可能會遇到的三種 JS 檔案類型、各自的使用方式,以及辨識方法的總結。
比較麻煩的是,.js 或 .min.js 這種附檔名可能是這三種的任何一種,所以如果檔案是 something.js,你就得再多做一點偵探工作,才能搞清楚自己面對的是哪一種。
- 「傳統型」JS 檔案
- 使用方式::
<script src="whatever.js"></script> - 辨識方法:
- 網站在安裝說明中有個顯眼又友善的橫幅寫著「用 CDN 來使用!」之類的話
- 附檔名是
.umd.js - 直接試著用
<script src=...標籤引入看看能不能動
- 使用方式::
- ES 模組
- 使用方式:
- 如果沒有相依套件,直接在程式碼中寫
import {whatever} from "./my-module.js"就好 - 如果有相依套件,就建立一個 importmap,然後寫
import {whatever} from "my-module"- 或是使用 download-esm 來省去 importmap
- 使用 esbuild 或任何 ES 模組打包工具
- 如果沒有相依套件,直接在程式碼中寫
- 辨識方法:
- 找找看有沒有
import或export陳述式。(不是module.exports = ...,那是 CommonJS) - 附檔名是
.mjs - 也可能是
package.json裡的"type": "module"(不過我不太確定這到底是指哪個檔案)
- 找找看有沒有
- 使用方式:
- CommonJS 模組
- 使用方式:
- 使用 https://esm.sh 把它轉成 ES 模組,例如
https://esm.sh/@atproto/[email protected] - 想辦法用打包工具處理(??)
- 使用 https://esm.sh 把它轉成 ES 模組,例如
- 辨識方法:
- 在程式碼中找
require()或module.exports = ... - 附檔名是
.cjs - 也可能是
package.json裡的"type": "commonjs"(不過我不太確定這到底是指哪個檔案)
- 在程式碼中找
- 使用方式:
有 ES 模組這個標準真的很棒
以我的角度來看,CommonJS 模組和 ES 模組最大的差別,在於 ES 模組是真正的標準。這讓我用起來安心很多,因為瀏覽器對網頁標準的承諾是永遠保持向下相容——如果我今天用 ES 模組寫了程式碼,就可以確定它在 15 年後依然會以同樣的方式運作。
這也讓我對使用 esbuild 這類工具更放心,因為就算 esbuild 這個專案哪天沒了,由於它實作的是標準,未來很可能會出現另一個類似的工具可以替換。
JS 社群打造了很多超酷的工具
很多時候當我聊到這些東西,都會得到像「我討厭 JavaScript!!!爛透了!!!」這樣的回應。但我的經驗是,JavaScript 有很多很棒的工具(我昨天才剛認識 https://esm.sh,感覺就很不錯!我超愛 esbuild!),而且只要花時間搞懂運作原理,就能善用其中一些工具,讓生活輕鬆不少。
所以這篇文章的目的絕對不是要抱怨 JavaScript,而是要釐清整個生態,這樣我才能用自己覺得舒服的方式來使用這些工具。
我還有一些疑問
以下是我仍有的一些疑問,如果找到答案,我會補進這篇文章裡。
- 有沒有工具可以為我本機設定好的 ES 模組自動產生 importmaps?(看來有:jspm)
- 要怎麼在自己的電腦上,像 https://esm.sh 那樣把 CommonJS 模組轉成 ES 模組?(看來 esbuild 在某種程度上可以做到,不過 具名匯出無法運作)
- 當大家平常把 CommonJS 模組打包成一般的 JS 程式碼時,到底是哪段程式碼在做這件事?顯然有 webpack、rollup、esbuild 等工具,但這些工具是不是都各自實作了自己的 JS 解析器/靜態分析?市面上到底有多少種 JS 解析器?
- 有沒有辦法把 ES 模組打包成單一檔案(像是
atcute-client.js),但在瀏覽器中仍能從同一個檔案匯入多個不同路徑(例如同時匯入@atcute/client/lexicons和@atcute/client)?
提到的所有工具
以下是這篇文章中提到的所有工具:
- Simon Willison 的 download-esm,它會下載 ES 模組並把其中的 imports 轉換成直接指向 JS 檔案,這樣就不需要 importmap 了
- https://esm.sh/ 和 skypack.dev
- esbuild
- JSPM 可以產生 importmaps
寫這篇文章讓我想到,雖然我平常不想在每次更新專案時都執行建置,但我也許願意接受一個建置步驟(用 download-esm 之類的工具),只在建立專案時執行一次,之後除非要更新相依套件版本,否則就不再執行。
就是這樣!
感謝 Marco Rogers 教了我這篇文章中的許多內容。這篇文章裡可能有些錯誤,很希望能有人告訴我——歡迎在 Bluesky 或 Mastodon 上告訴我!
隨機一篇部落格
留言
登入後參與討論