npm パッケージをブラウザから直接使う
import mapとCDN URLで、通常のHTMLからnpmパッケージを使う方法。ESMとCommonJSの見分け方、版の固定、ビルド不要の手順を解説。
バンドラーも node_modules も設定ファイルもなしに、プレーンな HTML ページで npm パッケージを使うことができます。そのためには、bare specifier を、対象パッケージを ES モジュールとして配信する CDN URL に向けるインポートマップを宣言すればよいのです。
1 ページ、1 ライブラリ、1 インタラクション程度のものに、開発サーバーやビルド出力ディレクトリ、デプロイ手順まで抱えた Vite プロジェクトを用意する価値はたいていありません。そして、うまくいかない原因はインポートマップの構文であることはまれです。npm パッケージは 3 つの異なるモジュール形式で配布されており、そのうちブラウザで動くのは 2 つだけなのです。本記事では、どの形式を扱っているのかを見極める方法、CDN から読み込む 2 通りの方法、そしてバージョン未固定の URL がスタイルの好みの問題ではなく正しさ(correctness)のバグである理由を取り上げます。
要点
- インポートマップとは、
<script type="importmap">タグ内に記述する JSON ブロックであり、canvas-confettiのような bare specifier がどの URL に解決されるかをブラウザに伝えるものです。これはバンドラーがビルド時に行う仕事と同じものを、ページの中へ移したものです。 - インポートマップは CommonJS のみのパッケージを救えません。マップが変えるのは specifier の解決方法であって、ファイルがどの形式で書かれているかではないからです。
- MDN はインポートマップを Baseline Widely available として掲載しており、2023 年 3 月以降、各ブラウザでサポートされています。
- マップ内のすべての CDN URL で厳密なバージョンを固定してください。さもなければ、デプロイもコミットもしていないのにページが実行するコードが変わり得ます。
- ビルドステップを省くということは tree shaking がないということであり、使っている部分だけでなくパッケージに含まれるもの全部を配信することになります。
ビルドステップを省いてよいのはどんなときか
ビルドステップを省くべきなのは、それを維持するコストが、それによってビルドされる対象よりも長生きしてしまう場合です。CodePen 風のデモ、WordPress テンプレートや Rails のビューに埋め込む単一のインタラクティブなウィジェット、2 人しか使わない社内ダッシュボード、寿命が日単位で測られるプロトタイプなどが該当します。判断基準は規模ではなく、オーナーシップです。半年後に誰もツールチェーンをアップグレードしないのであれば、ツールチェーンは負債になります。成長が見込まれるもの、実トラフィックに出すもの、チームに引き継ぐものは、依然としてバンドラーの領域です。
3 種類のファイル、うちブラウザで動くのは 2 つ
npm パッケージは 3 つのモジュール形式のいずれかで配布され、そのうちブラウザで動くのは 2 つだけです。したがって、インポートマップを書き始める前に、そのパッケージがどのビルドを配布しているのかを確認してください。classic あるいは UMD のファイルはプレーンな <script src> で動作し、グローバル変数を割り当てます。ES モジュールは type="module" と import 文を必要とします。require() と module.exports で書かれた CommonJS ビルドは、ブラウザではまったく実行できません。
もっとも手早い確認方法は、パッケージをインストールして中身を読むことです。
npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json
その出力では 2 点に注目します。パッケージ内のファイル拡張子と、エントリーポイントのフィールドです。Node のパッケージドキュメントは main、exports、type を定義しています。module は Node が仕様として定めるフィールドではなく、バンドラーや CDN が読むエコシステム上の慣習です。パッケージによっては、ブラウザ向けビルドを指す jsdelivr や unpkg フィールドを持つものもあります。たとえば canvas-confetti@1.9.4 はその package.json の中で "main": "src/confetti.js"、"module": "dist/confetti.module.mjs"、"jsdelivr": "dist/confetti.browser.js" を宣言しており、ブラウザ向けビルドと ES モジュールビルドの両方が存在することがわかります。
| 形式 | 見分け方 | ブラウザに必要なもの | ビルドステップなしの場合 |
|---|---|---|---|
| Classic / UMD | .umd.js、dist/*.browser.js、あるいは window に代入しているソース | 特別なものは不要 | <script src> を書き、グローバルを使う |
| ES モジュール | .mjs、ソース中の import/export、"type": "module" | type="module" | インポートマップとモジュールスクリプト |
| CommonJS | .cjs、require()、module.exports、"type": "commonjs" | まず変換が必要 | ESM にトランスパイルする CDN、またはビルドステップ |
多くの試みが静かに失敗するのは、この最後の行です。インポートマップは CommonJS のみのパッケージを救えません。マップが変えるのは specifier の解決方法であって、ファイルがどの形式で書かれているかではないからです。
シンプルな方法: CDN からの 1 つの script タグ
パッケージが classic または UMD ビルドを配布している場合、script タグ 1 つで統合は完了です。グローバル名はあなたではなくパッケージ作者が決めるので、README を確認してください。canvas-confetti の README には、CDN ビルドが window に confetti 関数を置くと書かれています。
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
パッケージが ESM または CommonJS を配布している場合は、代わりに CDN が変換を行います。jsDelivr の /+esm エンドポイントへのリクエストは、ブラウザで即使える ES モジュールとして返ってきます。jsDelivr はこれを単なる構文の入れ替え以上のものだと説明しています。パッケージ自身のフィールドから適切なエントリーポイントを判断し、必要に応じて CommonJS を変換し、依存関係をレスポンスに取り込み、返すコードを不要部分の削除と minify にかけるのです。esm.sh は https://esm.sh/PKG[@SEMVER][/PATH] という URL 文法の下で同等の仕事をします。いずれも、import 文にそのまま書ける URL を提供してくれます。
より良い方法: type importmap の script タグ
インポートマップとは、<script type="importmap"> タグ内の JSON ブロックで、bare specifier を URL にマッピングするものです。これにより、モジュールコードはバンドラー内にあるときとまったく同じように書けます。
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
これで得られる違いは 1 行分です。マップがなければ、ライブラリを必要とするすべてのファイルが CDN URL とバージョンを繰り返すことになります。
import confetti from 'https://esm.sh/canvas-confetti@1.9.4';
マップがあれば、バージョンはただ 1 箇所にだけ存在し、import 文はそのままバンドル済みプロジェクトへ移植できます。
実運用で重要なルールは 4 つあります。第 1 に、順序がマップの動作そのものを左右します。ブラウザは、それを通して import を行うモジュールスクリプトに出会う前にマップを読まなければならないため、<script type="importmap"> ブロックはそのコードより上に置きます。第 2 に、HTML 標準は 1 つのドキュメントが複数のマップを持つことを許容し、それらがどうマージされるかを規定していますが、エンジン側のサポートは一律ではないため、1 ドキュメントにマップは 1 つとしてください。第 3 に、相対値は /、./、../ のいずれかで始まらなければなりません。第 4 に、マッピングの両辺に末尾スラッシュを付けると、単一のエントリーポイントではなくパッケージのディレクトリ全体をマッピングできます。
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
"canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>
MDN はインポートマップを Baseline Widely available と評価しており、2023 年 3 月以降ブラウザに実装されています。したがって polyfill は通常のセットアップの一部ではなくなりました。それでもランタイムでのチェックが欲しい場合は、HTMLScriptElement.supports() が使え、HTMLScriptElement.supports?.("importmap") のように書きます。
エラーメッセージが一切出ない落とし穴が 1 つあります。ES モジュールは CORS のルールの下で取得されるため、HTML ファイルをディスクから直接開くと失敗します。まったく同じファイルでも、ローカルサーバー経由で配信された瞬間に動作するのです。
毎回、バージョンを固定する
マップ内のすべての CDN URL で厳密なバージョンを固定してください。バージョン未固定または範囲指定の URL は、デプロイもコミットもなく、リポジトリ内に違いを説明するものが何ひとつないまま、ページが実行するコードが変わり得ることを意味します。ページのデプロイ済みの挙動が、あなたの git 履歴ではなく CDN の時計に依存する関数になってしまい、ありふれたバグ報告が考古学的作業に変わります。HTML は変わっていない、サーバーログも変わっていない、なのに JavaScript だけが違う、というわけです。
これは破って得することが何もない唯一のルールです。canvas-confetti@1.9.4 はあなたが論理的に扱える事実であり、canvas-confetti@latest は誰か他人が守る約束です。
何を手放すことになるのか
CDN からパッケージを読み込むことは、サードパーティのオリジンに、あなたのページのコンテキストで任意のスクリプトを実行する能力を与えることです。これは CSP と subresource integrity で狭めることができます。MDN によれば、インポートマップの JSON オブジェクトは imports や scopes と並んで integrity キーを受け取り、モジュール URL を sha384-… のような SRI ハッシュにマッピングできます。配信経路を完全に自分で握りたい場合、自前のアセットを配信するのは別のセットアップになります。これについてはフロントエンドパフォーマンスにおける CDN の役割およびCDN プラットフォームの比較で扱っています。
この領域には他にも 3 つのコストが付いてきます。まず tree shaking がないため、使っている部分ではなくパッケージに含まれるものをそのまま配信することになります。デモなら妥当なトレードオフですが、成長が見込まれるアプリケーションでは悪い取引です。次に、ランタイムで解決される深い依存グラフでは、ブラウザは親モジュールを取得した後でなければ各モジュールを発見できません。だからこそ CDN が介入します。esm.sh はデフォルトでパッケージのサブモジュールをレスポンスにバンドルし、exports フィールドが宣言するエントリーポイント間で共有されるものだけを除外します。?bundle=false でこれを無効化できます。そして失敗の仕方が静かです。ドキュメントはパースされ、レイアウトも完成しているのに、プロキシや拡張機能、CSP ルールがオリジンをブロックしたために 1 つのモジュールだけがついに届かない。これは何も throw されていないため、エラーレポートよりもセッションリプレイのほうが速く表面化させられる類のバグです。
本番環境で実質的な規模のものには、バンドラーを使ってください。ここで紹介したテクニックは、バンドラーを正当化できないもののためのものです。
HTML を 1 行書く前に、まずパッケージを読むことから始めましょう。ファイルを一覧し、main、module、exports、type を読み、そこから script タグで済むのか、インポートマップが必要なのか、あるいは結局ビルドステップが必要なのかを判断してください。
FAQ
インポートマップを HTML 内にインラインで書く代わりに、別の JSON ファイルに置けますか?
いいえ。仕様は type importmap の script 要素が src 属性を持つことを一切禁じており、async、nomodule、defer、crossorigin、integrity、referrerpolicy も同様です。したがって JSON はドキュメント内に置く必要があります。マップを生成する場合は、リンクするのではなくサーバー側でページ内にレンダリングし、最初のモジュールスクリプトより上に置いてください。
同じパッケージの異なる 2 つのバージョンを 1 ページで読み込むにはどうすればよいですか?
scopes キーを使います。scope は URL パスに 2 つ目の specifier マップを紐付けるため、そのパス以下から読み込まれたスクリプトはパッケージをある固定バージョンに解決し、ページの残りの部分は別のバージョンに解決できます。2 つの scope がどちらもマッチする場合は、より長いパスが先にチェックされ、imports マップがフォールバックになります。より単純な代替案は、各バージョンにそれぞれ独自の bare specifier を与えることです。
インポートマップは Web Worker や script タグの src 属性にも適用されますか?
いいえ。マップはドキュメント自体の import 文と import() 呼び出しにおける specifier のみを書き換えます。script タグの src 属性の URL はマップを通らず、worker や worklet の内部で読み込まれるものも同様です。ドキュメント内のモジュールにおける動的 import はマップを通して解決されますが、worker のエントリースクリプトとその import は完全な URL を必要とします。
bare specifier がインポートマップに存在しない場合はどうなりますか?
モジュールが実行される前に解決が TypeError を throw し、その文言は 2 つのエンジンで異なります。Chrome はモジュール specifier の解決に失敗したと報告し、その specifier 名を示した上で、相対参照は /、./、../ のいずれかで始まらなければならないと付け加えます(実際のメッセージではこの 3 つはそれぞれ引用符で囲まれています)。Firefox は次のように報告します: The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”. あなたのアプリケーションコード側では何も throw されないため、ページは通常どおりレンダリングされ、そのモジュールに依存する機能だけが動かなくなります。