Importing a frontend Javascript library without a build system

Julia Evans

不使用建置系統匯入前端 JavaScript 函式庫

我喜歡不使用建置系統來寫 JavaScript,昨天又第無數次遇到一個問題:我想在程式碼中匯入某個 JavaScript 函式庫,卻不想用建置系統,結果花了超久才搞懂怎麼匯入,因為那個函式庫的設定說明預設你就是會用建置系統。

幸好到了現在,我大致已經學會如何應付這種情況——要嘛成功用上那個函式庫,要嘛判斷太麻煩就換另一個——所以這篇就是我希望多年前就能看到的 JavaScript 函式庫匯入指南。

這篇文章只會談在前端使用 JavaScript 函式庫,而且只談在不使用建置系統的設定下該怎麼用。

在這篇文章中,我會談到:

  1. 函式庫可能會提供的三種主要 JavaScript 檔案類型(ES Modules(ES 模組)、「傳統(classic)」的全域變數類型,以及 CommonJS(CommonJS 模組))
  2. 如何判斷一個 JavaScript 函式庫的建置版本中包含了哪幾種類型的檔案
  3. 在程式碼中匯入每種類型檔案的方法

三種 JavaScript 檔案類型

函式庫可以提供三種基本的 JavaScript 檔案類型:

  1. 定義全域變數的「傳統」類型檔案。這種檔案只要用 <script src> 引入就會直接生效。如果拿得到這種檔案最方便,但不一定每個函式庫都有提供
  2. ES Modules(之後簡稱 ES 模組)——可能會相依於其他檔案,這點稍後會說明
  3. 「CommonJS」模組。這是給 Node 用的,不搭配建置系統就完全無法在瀏覽器中使用。

我不確定「傳統」類型有沒有更好的名稱,但我就先叫它「傳統」類型。另外還有一種叫做「AMD(非同步模組定義)」的類型,不過我不確定在 2024 年還有多少關聯性。

既然已經知道這三種檔案類型,接下來就來談談如何判斷函式庫實際上提供了哪幾種!

在哪裡找到檔案:NPM 建置版本

每個 JavaScript 函式庫都有一個上傳到 NPM 的建置版本。你可能會想(就像我一開始那樣)——Julia(茱莉亞)!重點不就是我們不用 Node 來建置函式庫嗎!為什麼還要談 NPM?

但就算你用的是來自 https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js 這類 CDN 的連結,你用的仍然是 NPM 的建置版本!CDN 上的所有檔案最初都是來自 NPM。

正因如此,我有時就算完全不打算用 Node 來建置,也還是會先 npm install 那個函式庫——我會先建立一個暫時的資料夾,在裡面 npm install,用完再刪掉。我喜歡直接在自己的檔案系統裡翻閱 NPM 建置版本中的檔案,因為這樣我就能百分之百確定自己看到的是函式庫建置版本中提供的所有內容,而不會被 CDN 隱藏掉什麼。

所以,讓我們來 npm install 幾個函式庫,試著弄清楚它們的建置版本中提供了哪些類型的 JavaScript 檔案吧!

範例函式庫 1: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

這個函式庫看起來有三種基本選項:

選項 1: chart.cjs。從 .cjs 副檔名就能看出這是 CommonJS 檔案,是給 Node 用的。這表示如果沒有某種建置步驟,就無法直接在瀏覽器中使用它。

選項 2:chart.js。光看 .js 副檔名無法判斷它是哪種類型的檔案,但如果打開來看,會看到 import '@kurkle/color';,這立刻就能看出它是 ES 模組——import ... 語法就是 ES 模組的語法。

選項 3:chart.umd.js。「UMD(通用模組定義)」代表「Universal Module Definition」,我認為它的意思是這個檔案可以透過基本的 <script src>、CommonJS,或是另一個我不太懂、叫做 AMD 的方式來使用。

如何使用 UMD 檔案

我在使用 Chart.js 時選了選項 3。我只需要在程式碼中加入這一行:

<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.jspackage.json 裡還寫了 "type": "module",根據這份文件,這是告訴 Node 預設將檔案視為 ES 模組。我想它並沒有明確指出哪個檔案是 ES 模組、哪個不是,但它確實告訴我們裡面有東西是 ES 模組。

範例函式庫 2:@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 map(匯入映射) 使用 ES 模組

使用 ES 模組不像直接加上 <script src="whatever.js"> 那麼簡單。相反地,如果 ES 模組有相依套件(像 @atcute/oauth-browser-client 這樣),步驟會是:

  1. 在 HTML 中設定 import map
  2. 在 JavaScript 程式碼中加入像 import { configureOAuth } from '@atcute/oauth-browser-client'; 這樣的匯入敘述
  3. 在 HTML 中像這樣引入你的 JavaScript 程式碼:<script type="module" src="YOURSCRIPT.js"></script>

之所以需要 import map,而不是直接寫 import { BrowserOAuthClient } from "./oauth-client-browser.js",是因為模組內部還有像 import {something} from @atcute/client 這樣的匯入敘述,我們需要告訴瀏覽器要去哪裡取得 @atcute/client 以及其他所有相依套件的程式碼。

以下是我為 @atcute/oauth-browser-client 使用的 import map:

<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 map 正常運作有點麻煩,我覺得一定有工具可以自動產生它們,但我還沒找到。寫一支用 esbuild 的 metafile 自動產生 import map 的腳本當然是可行的,但我還沒做,或許有更好的方法。

我昨天為了讓 github.com/jvns/bsky-oauth-example 動起來而設定了 import map,所以那個儲存庫裡有一些範例程式碼。

另外,有人向我推薦了 Simon Willison(西蒙·威利森)的 download-esm,它會下載 ES 模組並將其中的匯入路徑重寫為直接指向 JavaScript 檔案,這樣就不需要 import map 了。我還沒試過,但看起來是個很棒的點子。

使用 import map 的問題:檔案太多

不過,我在瀏覽器中使用 import map 時也遇到了一些問題——它需要下載數十個 JavaScript 檔案才能載入我的網站,而我在開發環境中的網頁伺服器不知為何跟不上。我一直看到檔案隨機載入失敗,然後只好重新整理頁面,祈禱這次能成功。

當我把網站部署到正式環境後就沒再發生這個問題了,所以我想這應該是本地開發環境的問題。

另外,關於 ES 模組有個稍微麻煩的地方,就是你必須跑一個網頁伺服器才能使用它們,我確定這是有充分理由的,但可以直接打開 index.html 檔案而不用啟動網頁伺服器,還是比較方便。

因為「檔案太多」這個問題,我覺得用這種方式搭配 import map 來使用 ES 模組其實沒那麼吸引人,但知道有這個可能性還是很不錯的。

不使用 import map 來使用 ES 模組的方法

如果 ES 模組沒有相依套件,那就更簡單了——你不需要 import map!你只要:

  • 在 HTML 中放入 <script type="module" src="YOURCODE.js"></script>。其中的 type="module" 很重要。
  • YOURCODE.js 中放入 import {whatever} from "https://example.com/whatever.js"

替代方案:使用 esbuild

如果你不想使用 import map,也可以使用像 esbuild 這樣的建置系統。我在關於使用 esbuild 的一些筆記中談過做法,但這篇部落格文章的重點是完全避免建置系統的方法,所以在這裡就不談這個選項了。不過我還是很喜歡 esbuild,也認為在這種情況下它是個不錯的選項。

import map 的瀏覽器支援度如何?

CanIUse 顯示 import map 屬於「Baseline 2023:在主流瀏覽器中新近可用」的狀態,所以我的感覺是到了 2024 年可能還是有一點太新?我想如果是寫一些有趣的實驗性程式碼,只給自己和十幾個人用,我會用 import map;但如果想讓程式碼更廣泛地被使用,我會改用 esbuild

範例函式庫 3:@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

裡面有許多像這樣的內容:

__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 議題

我覺得 esbuild 的做法可能比 esm.sh 更吸引我,因為它是已經裝在我電腦上的工具,所以我比較信任它。不過這部分我還沒有做太多實驗。

三種檔案類型的總結

以下是你可能會遇到的三種 JavaScript 檔案類型、可用的使用方式以及辨識方法的總結。

一個 .js.min.js 副檔名的檔案可能會是這三種選項中的任何一種,這有點讓人困擾,所以如果檔案是 something.js,你就需要多做一些偵查來判斷自己面對的是哪一種。

  1. 「傳統」 JavaScript 檔案
    • 使用方式:<script src="whatever.js"></script>
    • 辨識方式:
      • 網站在設定說明中有大大的友善橫幅寫著「用 CDN 來使用吧!」之類的話
      • 副檔名為 .umd.js
      • 直接試著把它放進 <script src=... 標籤看看能不能動
  2. ES 模組
    • 使用方式:
      • 如果沒有相依套件,直接在程式碼中寫 import {whatever} from "./my-module.js"
      • 如果有相依套件,建立一個 import map 並寫 import {whatever} from "my-module"
      • 使用 esbuild 或任何 ES 模組打包工具
    • 辨識方式:
      • 尋找 import export 敘述。(不是 module.exports = ...,那是 CommonJS)
      • 副檔名為 .mjs
      • 或許是 package.json 中的 "type": "module"(不過我不太確定這究竟是指哪個檔案)
  3. CommonJS 模組
    • 使用方式:
    • 辨識方式:
      • 在程式碼中尋找 require()module.exports = ...
      • 副檔名為 .cjs
      • 或許是 package.json 中的 "type": "commonjs"(不過我不太確定這究竟是指哪個檔案)

ES 模組有了標準真的很棒

從我的角度來看,CommonJS 模組和 ES 模組之間的主要差異在於 ES 模組是一個真正的標準。這讓我在使用它們時感到安心許多,因為瀏覽器會對網頁標準承諾永遠向下相容——如果我今天用 ES 模組寫了一段程式碼,我可以確信它在 15 年後仍會以同樣的方式運作。

這也讓我對使用像 esbuild 這類工具感到更安心,因為即使 esbuild 專案停止維護,由於它實作的是標準,未來很可能會出現另一個類似的工具可以替換它。

JavaScript 社群打造了許多非常酷的工具

很多時候當我談到這些東西,會收到像「我討厭 JavaScript!!!它最爛了!!!」這樣的回應。但我的經驗是,JavaScript 有許多很棒的工具(我昨天才剛認識 https://esm.sh,看起來就很棒!我很喜歡 esbuild!),而且如果我花時間去了解運作原理,就能善用其中一些工具,讓生活輕鬆許多。

所以這篇文章的目標絕對不是要抱怨 JavaScript,而是要了解整個生態系,以便用一種讓自己感覺舒服的方式來運用這些工具。

我還有一些疑問

以下是我還有疑問的幾個問題,如果我找到答案,就會補到文章中。

  • 有沒有工具可以自動為我在本地設定好的 ES 模組產生 import map?(顯然有:jspm
  • 我該如何在自己的電腦上將 CommonJS 模組轉換為 ES 模組,就像 https://esm.sh 那樣?(顯然 esbuild 某種程度上可以做到,不過具名匯出無法運作
  • 當人們通常把 CommonJS 模組建置為一般的 JavaScript 程式碼時,實際上是哪段程式碼在做這件事?顯然有像 webpack、rollup、esbuild 等工具,但這些工具是否都各自實作了自己的 JavaScript 解析器/靜態分析?市面上到底有多少種 JavaScript 解析器?
  • 有沒有辦法將 ES 模組打包成單一檔案(像是 atcute-client.js),但在瀏覽器中仍然可以從該檔案匯入多個不同的路徑(像是同時匯入 @atcute/client/lexicons@atcute/client)?

提到的所有工具

以下是我們在這篇文章中談到的所有工具清單:

寫這篇文章讓我開始思考,雖然我通常不想在每次更新專案時都執行建置流程,但或許我可以接受一個只在專案初期設定時執行一次的建置步驟(使用 download-esm 之類的工具),之後除了更新相依套件版本時,幾乎都不用再執行。

就這樣!

感謝Marco Rogers(馬可·羅傑斯)教了我這篇文章中的許多內容。這篇文章中我可能犯了一些錯誤,很希望能知道錯在哪裡——歡迎在 Bluesky 或 Mastodon 上告訴我!

原文由 Julia Evans 發布

本文章由 muse-spark-1.2-contributor 進行翻譯