Importing a frontend Javascript library without a build system

Julia Evans

ビルドシステムを使わずにフロントエンドのJavaScriptライブラリを読み込む

私はビルドシステムなしでJavaScriptを書くのが好きなのですが、昨日も例によって、ビルドシステムを使わずにJavaScriptライブラリを読み込む方法を調べる必要に迫られました。何度目かわからないくらい繰り返してきたことです。ライブラリのセットアップ手順はビルドシステムを使う前提で書かれているため、読み込み方を突き止めるのにとてつもなく時間がかかりました。

幸い、今ではこの状況への対処法がだいたいわかるようになりました。ライブラリをうまく使いこなすか、難しすぎると判断して別のライブラリに切り替えるか、どちらかを選べるようになっています。そこで、数年前にあったらよかったと思う、JavaScriptライブラリの読み込み方ガイドをまとめてみます。

この記事で扱うのは、フロントエンドでJavaScriptライブラリを使う方法だけです。しかも、ビルドシステムを使わない構成に限ってお話しします。

この記事では、次の3つについて説明します。

  1. ライブラリが提供する可能性のある3つの主要なJavaScriptファイルの種類(ES Modules、グローバル変数を定義する「クラシック」な形式、CommonJS)
  2. ライブラリのビルドにどの種類のファイルが含まれているかを見分ける方法
  3. それぞれの種類のファイルをコード内で読み込む方法

JavaScriptファイルの3つの種類

ライブラリが提供するJavaScriptファイルには、基本的に次の3つの種類があります。

  1. グローバル変数を定義する「クラシック」な形式のファイルです。<script src>で読み込むだけでそのまま動きます。入手できれば一番手軽ですが、常に用意されているわけではありません
  2. ESモジュール(他のファイルに依存している場合とそうでない場合があります。後ほど詳しく説明します)
  3. 「CommonJS」モジュールです。Node.js用の形式で、ビルドシステムなしではブラウザでまったく使えません。

「クラシック」な形式にもっと適切な呼び方があるのかもしれませんが、この記事では「クラシック」と呼ぶことにします。なお、「AMD」という形式もありますが、2024年時点でどれだけ重要かはよくわかりません。

3つの種類がわかったところで、ライブラリが実際にどれを提供しているかを見分ける方法について見ていきましょう。

ファイルはどこにある? 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ビルドの中身を直接のぞけると、ライブラリがビルドで公開しているものすべてを確実に確認でき、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

このライブラリには、基本的に3つの選択肢があるようです。

選択肢1: chart.cjsです。.cjsという拡張子から、これがCommonJSファイルだとわかります。Node.js用の形式なので、何らかのビルド手順なしではブラウザで直接使うことはできません。

選択肢2:chart.jsです。.jsという拡張子だけではファイルの種類は判別できませんが、中を開いてみるとimport '@kurkle/color';という記述があります。これはESモジュールの構文なので、ESモジュールだとすぐにわかります。

選択肢3: chart.umd.jsです。「UMD」は「Universal Module Definition」の略で、たしか基本的な<script src>でも、CommonJSでも、AMDというよくわからない第三の形式でも使えるファイルという意味だったと思います。

UMDファイルの使い方

Chart.jsを使うとき、私は選択肢3を選びました。コードに次の1行を追加するだけでした。

<script src="./chart.umd.js"> </script>

これで、グローバルなChart変数を通じてライブラリを使えるようになります。これ以上ないくらい簡単です。NPMを使ったりCDNが落ちることを気にしたりしなくて済むように、私はchart.umd.jsをそのままGitリポジトリにコピーしました。

ビルド成果物は必ずしも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.js用でしょう。

Chart.jsのpackage.jsonには"type": "module"という記述もあります。こちらのドキュメントによると、これはNode.jsにファイルをデフォルトで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モジュールであることを意味します。つまり、ビルド手順なしでブラウザで使えるということです。では、その方法を見ていきましょう。

importmapを使ってESモジュールを使う

ESモジュールを使うのは、単に<script src="whatever.js">を追加するほど簡単ではありません。ESモジュールに依存関係がある場合(@atcute/oauth-browser-clientのように)、手順は次のとおりです。

  1. HTML内にimport mapを設定する
  2. JSコード内にimport { configureOAuth } from '@atcute/oauth-browser-client';のようなimport文を書く
  3. HTMLでJSコードを<script type="module" src="YOURSCRIPT.js"></script>のように読み込む

単にimport { BrowserOAuthClient } from "./oauth-client-browser.js"のようにするのではなくimport mapが必要なのは、モジュールの内部で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 mapを動くようにするのは、なかなか面倒です。自動生成してくれるツールがあってもよさそうなものですが、まだ見つけられていません。esbuildのmetafileを使えばimportmapを自動生成するスクリプトを書くことは確実に可能ですが、まだ試していませんし、もっと良い方法があるかもしれません。

昨日、github.com/jvns/bsky-oauth-exampleを動かすためにimportmapを設定してみたので、そのリポジトリにサンプルコードがあります。

また、Simon Willison氏のdownload-esmというツールを教えてもらいました。これはESモジュールをダウンロードし、import先をJSファイルに直接向くように書き換えてくれるので、importmapが不要になります。まだ試していませんが、とても良いアイデアだと思います。

importmapの問題点: ファイル数が多すぎる

ただ、ブラウザでimportmapを使っていていくつか問題にも遭遇しました。サイトを読み込むのに何十ものJavaScriptファイルをダウンロードする必要があり、なぜか開発用のウェブサーバーがそれに追いつきませんでした。ランダムにファイルの読み込みに失敗することが頻発し、ページを再読み込みして今度はうまくいくことを祈るしかありませんでした。

本番環境にデプロイした後は問題にならなくなったので、おそらくローカルの開発環境の問題だったのでしょう。

また、ESモジュール全般について少し面倒なのは、使うのにウェブサーバーを立ち上げる必要があることです。きっと正当な理由があるのでしょうが、ウェブサーバーを起動せずにindex.htmlを直接開くだけで済む方が楽なのは確かです。

「ファイルが多すぎる」問題のせいで、正直なところ、この方法でimportmapとESモジュールを組み合わせて使うのはあまり魅力的に感じませんでした。ただ、こういう方法があると知っておくのは良いことだと思います。

importmapなしでESモジュールを使う方法

ESモジュールに依存関係がなければ、さらに簡単です。importmapは必要ありません。次のようにするだけです。

  • HTMLに<script type="module" src="YOURCODE.js"></script>と記述します。type="module"が重要です。
  • YOURCODE.jsの中にimport {whatever} from "https://example.com/whatever.js"と記述します

代替案: esbuildを使う

importmapを使いたくない場合は、esbuildのようなビルドシステムを使う方法もあります。その方法についてはSome notes on using esbuildで書きましたが、この記事ではビルドシステムを完全に避ける方法がテーマなので、ここでは詳しく触れません。ただ、個人的には今でもesbuildが好きですし、このケースでも良い選択肢だと思います。

importmapのブラウザ対応状況は?

CanIUseによると、importmapは「Baseline 2023: 主要ブラウザで新たに利用可能になった機能」に分類されています。なので2024年時点では、まだ少し新しすぎるかなという感覚です。自分ともう12人くらいしか使わないような、ちょっとした実験的なコードならimportmapを使ってもいいと思いますが、より多くの人に使ってもらいたいコードなら、代わりに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をのぞいてみましょう。

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を使う方法よりも個人的には魅力的に感じています。すでに自分のパソコンに入っているツールなので、より信頼できるからです。ただ、このあたりはまだあまり試せていません。

3つのファイル形式まとめ

ここで、これまで出てきた3つのJSファイル形式について、使い方の選択肢と見分け方をまとめておきます。

やっかいなことに、.js.min.jsという拡張子は、これら3つのどれにもなり得ます。そのため、ファイル名がsomething.jsとなっている場合は、もう少し調べて正体を突き止める必要があります。

  1. 「クラシック」なJSファイル
    • 使い方:: <script src="whatever.js"></script>
    • 見分け方:
      • ウェブサイトのセットアップ手順に「CDNで使えます!」のような大きく親切なバナーが表示されている
      • .umd.jsという拡張子がついている
      • とりあえず<script src=...タグに入れてみて、動くか試してみる
  2. 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"と書かれている場合もある(ただし、これがどのファイルを指すのかははっきりしません)
  3. CommonJSモジュール
    • 使い方:
      • https://esm.shを使ってESモジュールに変換する。たとえばhttps://esm.sh/@atproto/[email protected]のようにする
      • 何らかの方法でビルドする(??)
    • 見分け方:
      • コード内に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モジュール用に、importmapを自動生成してくれるツールはあるのでしょうか?(どうやらあるようです: jspm
  • https://esm.shのように、CommonJSモジュールを自分のパソコン上でESモジュールに変換する方法はあるのでしょうか?(どうやらesbuildでなんとかできるようですが、名前付きexportは使えません
  • 人々が普段CommonJSモジュールを通常のJSコードにビルドするとき、実際には何がその処理を担っているのでしょうか? もちろんwebpackやrollup、esbuildなどのツールがありますが、それらのツールはそれぞれ独自にJSパーサーや静的解析を実装しているのでしょうか? 世の中にはJSパーサーがいくつあるのでしょうか?
  • ESモジュールを1つのファイル(たとえばatcute-client.js)にバンドルしつつ、ブラウザ側ではその1ファイルから複数の異なるパス(たとえば@atcute/client/lexicons@atcute/clientの両方)をimportできるようにする方法はあるのでしょうか?

登場したツール一覧

この記事で登場したツールをすべてまとめておきます。

  • Simon Willison氏のdownload-esm — ESモジュールをダウンロードし、import先をJSファイルに直接向くように書き換えるのでimportmapが不要になります
  • https://esm.sh/skypack.dev
  • esbuild
  • JSPMはimportmapを生成できます

この記事を書いていて、普段はプロジェクトを更新するたびに実行するようなビルドは持ちたくないと思っているのですが、プロジェクトのセットアップ時に一度だけ実行し、依存関係のバージョンを更新するとき以外は二度と実行しないようなビルドステップ(download-esmなどを使ったもの)なら、あってもいいかもしれないと思い始めました。

以上です!

この記事の内容の多くを教えてくれたMarco Rogersさんに感謝します。おそらくこの記事にはいくつか間違いがあると思いますので、ぜひ教えてください — BlueskyやMastodonでお知らせいただけるとうれしいです。

原文は Julia Evans により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。