無料公開中
公開翌日4時まで

script要素にtype="module"を付ける利点 最終回 ビルドなしでnpmのパッケージを使う

ESM対応のCDNとimport mapを組み合わせると、ビルド環境なしでnpmのパッケージを読み込めます。ベア指定子の解決やCDNの選び方を見たうえで、バンドラーとの違いと使い分けを整理します。

発行

著者 小山田 晃浩 フロントエンド・エンジニア
script要素にtype="module"を付ける利点 シリーズの記事一覧

前回まで

前回は、importexportを書かなくてもtype="module"だけで効いてくる特徴として、変数がそのscript要素の中に閉じこもること、DOMの完成を待ってから実行されること、strict modeになること、トップレベルでawaitが書けることを見てきました。

今回は、ここまでの仕組みを使って、ビルド環境なしでnpmのパッケージを読み込む方法を扱います。ESM対応のCDNとimport map、そしてバンドラーとの使い分けを見ていきましょう。

CDNからライブラリを直接読み込める

ここまでの仕組みが揃うと、うれしいことが1つあります。npmのパッケージを、ビルドなしでそのままブラウザから読み込めるのです。

importで読み込めるのは、ES Modulesの形式で配信されているライブラリです。この形式はESMと略されます。ESMで配信しているCDNなら、そのURLをそのままimportに書くだけで動きます。

CDNから直接importする

<script type="module">
  import confetti from 'https://esm.sh/[email protected]';

  confetti();
</script>

インストールもビルドも設定ファイルも要りません。ライブラリの挙動をちょっと確かめたいとき、この手軽さはとても便利です。

npmのパッケージをESMとして配信している、代表的なCDNを挙げておきます。

  • unpkg:npmの中身を、そのままのURLで配信する
  • jsDelivr:npmとGitHubに対応。URLの末尾に/+esmを付けるとESMに変換もしてくれる
  • esm.sh:ESMへの変換を前提としたCDN。依存パッケージもまとめて解決してくれる
  • JSPM:esm.shと同様。import mapを生成するツールも提供している

パッケージを読み込むだけなら、どれを選んでも大きくは変わりません。違いが出るのは、そのパッケージがESMに対応していない場合です。迷ったらCDNはjsDelivrかesm.shを選んでおくと、変換してくれる分、読み込めるパッケージの幅が広がります。

CDNの選択肢は上記のとおりいくつかありますが、混在させずにCDNは1つに揃えましょう。CDNを混在させると、次のことが起きます。

  • 接続先のドメインが増えるぶん、通信の準備にかかる時間が積み上がる
  • ドメインが違うと、末端の依存パッケージが別物と見なされ、二重に読み込まれる

import mapで名前とURLを対応づける

import mapを使うと、完全なURLを書かずに、名前だけでモジュールを呼び出せます。HTMLにtype="importmap"のscript要素を置き、名前とURLの対応表を書いておきます。

補足:import mapのブラウザサポート

import mapはBaselineのWidely availableです。2023年3月から主要なブラウザで利用できます。

import mapで名前とURLを対応づける

<script type="importmap">
{
  "imports": {
    "canvas-confetti": "https://esm.sh/[email protected]",
    "#utils/": "/assets/js/utils/"
  }
}
</script>

<script type="module">
  import confetti from 'canvas-confetti';

  confetti();
</script>

これで、JavaScript側はバンドラーを使うときと同じようにパッケージ名で書けます。バージョンを上げたいときも、import mapの1か所を直すだけで済むのです。

補足:ベア指定子

import 'canvas-confetti'のような、パスでもURLでもないパッケージ名だけの指定をベア指定子(bare specifier)と呼びます。バンドラーやNode.jsはこれを解決してくれますが、ブラウザ単体では解決できません。import mapは、この名前をURLに対応づけるための仕組みです。

末尾を/にすると、前方一致の置き換えとして働きます。前述の例ならimport '#utils/dom.js'/assets/js/utils/dom.jsに解決されます。プロジェクト内のパスに短い別名を付けたいときなどに便利です。

なお、import mapはモジュールを読み込むscript要素より前に置く必要があります。後ろに書いても適用されません。

import mapは外部ファイルにできない

<script type="importmap" src="...">のように、外部ファイルから読み込むことはできません。仕様上src属性は指定できず、JSONはHTMLに直接書く決まりです。ページ数が多いと、同じ内容を全ページに書き写すことになってしまいます。

回避策として、対応表をもったJavaScriptファイルからimport mapを差し込む方法があります。

importmap.js:import mapを差し込む

( () => {

  const map = {
    imports: {
      'canvas-confetti': 'https://esm.sh/[email protected]',
      '#utils/': '/assets/js/utils/',
    },
  };

  const script = Object.assign( document.createElement( 'script' ), {
    type: 'importmap',
    textContent: JSON.stringify( map ),
  } );
  document.currentScript.after( script );

} )();

type属性を付けずに、先頭で読み込む

<script src="/importmap.js"></script>

読み込む側は、type="module"asyncdefer付けないのがポイントです。従来のscript要素はその場で実行されるので、あとに続くモジュールが読み込まれる前にimport mapを登録できます。

バンドラーとの違い

ここまで見てきたように、現在のブラウザはES Modulesをそのまま理解します。すると気になるのが、webpackやViteといったバンドラーとの関係でしょう。

少し昔のブラウザはimportexportを理解できませんでした。そこで、ファイルを分割して開発するため、あるいはnpmのパッケージを使うために、webpack、Rollup、Parcel、Browserifyといったバンドラーが登場しました。

しかし、現在ではtype="module"により、バンドラーがやっていた仕事をブラウザがネイティブにこなせるようになりました。

それでもバンドラーは必要?

では不要になったかというと、そうではありません。バンドラーには、モジュールの結合以外にもさまざまな機能があります。

役割 具体例
変換 TypeScript、JSX、新しい構文のトランスパイル
最適化 Tree Shaking、コードの圧縮、CSSの圧縮
依存解決 npmでインストールしたパッケージ名での解決
開発体験 開発サーバー、HMR(Hot Module Replacement)、ソースマップ

ネットワークの観点でも違いがあります。ブラウザは、あるモジュールを取得して初めて、そのモジュールが何をimportしているかを知ります。依存の階層が深いと「取得して解析、また取得して解析」の繰り返しになり、待ち時間が積み上がっていきます。

これは<link rel="modulepreload">で、あとから必要になるモジュールを先に知らせておけば、ある程度は緩和できます。ただし、ファイルが増えるほど、手で書き並べるのは大変です。まとめてしまえるバンドラーには、まだ分があります。

modulepreloadの例:head内で先に読み込ませておく

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>modulepreloadの例</title>
    <link rel="modulepreload" href="/js/main.js">
    <link rel="modulepreload" href="/js/gallery.js">
    <link rel="modulepreload" href="/js/utils.js">
    <script type="module" src="/js/main.js"></script>
  </head>
  <body></body>
</html>

上記の例では、main.jsを読んでからgallery.js、それを読んでからutils.js……とたどるのを待たずに、3つとも並行して取得が始まります。

ES Modulesとバンドラーの使い分け

ES Modulesとバンドラーの使い分けの目安を挙げておきます。

ES Modulesをそのまま使っても十分なケース

  • 小規模なサイトやランディングページ
  • CodePenでの検証や、動作確認用のサンプル、プロトタイプ
  • 納品先の担当者にビルド環境がなく、出力ファイルを直接編集されてしまう心配がある場合
  • ライブラリの挙動をさっと試したいとき

バンドラーを使うのが向いているケース

  • TypeScriptやJSXを使う
  • 依存パッケージが多く、階層も深い
  • パフォーマンス要件が厳しい
  • チームでの継続的な開発になる

どちらが優れているという話ではなく、案件の規模と開発体制に合わせて選べるようになりました。

まとめ

type属性にmoduleを指定すると、同じJavaScriptでも扱いが変わります。

  • import/exportで、依存関係をコードの上に書ける
  • 変数はそのモジュールの中に閉じこもり、グローバルを汚さない
  • DOMの完成を待ってから、記述順に実行される
  • 常にstrict modeで、トップレベルのawaitも使える
  • 読み込みにはCORSが適用される

import/exportによるファイル分割以外にもさまざまな利点があります。スコープの閉じ込めもDOMContentLoadedがいらない点も、type="module"と書いた時点で自動的に手に入るのです。

ファイル分割に関しても、ESM対応のCDNを組み合わせて、ビルド環境なしでnpmのパッケージまで使えます。

type="module"を明示するだけで、JavaScriptがより便利に使えるわけです。