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

script要素にtype="module"を付ける利点 第1回 importとexportでファイルを分割する

script要素にtype="module"を指定すると、importとexportでJavaScriptのファイルを分割できるようになります。分割の書きかたから、必要な機能だけを取り出す指定、必要になった時点で読み込む動的なimport()までを見ていきます。

発行

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

はじめに

script要素のtype属性には、さまざまな値が指定できます。古くからもっともよく使われるのはtext/javascriptで、type属性を省略した場合もtext/javascriptと同じ扱いになります。この値を指定すると、ブラウザはそのscript要素の中身をJavaScriptとして解釈し、実行します。

これとは別に、type属性にはmoduleという値を指定することもできます。moduleを指定すると、script要素の中身はES Modules(ESM)として解釈され、JavaScriptとして実行されます。

どちらもJavaScriptとして実行される点は同じです。ただしmoduleを指定した場合には、いくつかの違いがあります。

  • importexportが使える
  • グローバル変数が作られない
  • HTMLのパースが完了するまで実行されない
  • CORSが適用される
  • strict modeで実行される
  • トップレベルでawaitが使える

moduleという名前のとおり、importexportによるモジュール分割がもっとも大きな特徴です。ただほかにも利点があります。今回はimportexportから始めて、残りは次回以降で順に見ていきましょう。

補足:import/exportの構文そのものについて

このシリーズは「script要素にtype="module"を指定すると何が変わるのか」という観点でまとめています。import/exportの構文の細部や、仕様の成り立ち、Node.jsやCommonJSとの関係は、過去のシリーズで詳しく解説しています。併せて読んでみてください。

モジュールとimport/export

まずはimportexportから見ていきます。ファイルを分割して管理したいときの、いちばんわかりやすい使いどころです。

従来、複数のJavaScriptファイルを使うときは、head要素やbody要素の末尾にscript要素を並べて読み込んでいました。

script要素を順番に並べる書きかた

<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<script src="./utils.js"></script>
<script src="./header.js"></script>
<script src="./gallery.js"></script>
<script src="./contact-form.js"></script>
<script src="./main.js"></script>

HTMLにたくさんのscript要素が並ぶと、単純に読みづらいでしょう。それに加えて、あとから手を入れるときに困ることがいくつもあります。

  • 本当に全部必要なのか、HTMLを見てもわからない
  • 今は使っていないファイルが、そのまま残っているかもしれない
  • 並べる順番を間違えると動かない
  • 値の受け渡しが、script要素の垣根を越えて、グローバル変数頼みになる

ES Modulesなら、HTMLに書くのは入口となる1ファイル(エントリーポイント)だけで済みます。

読み込むのはエントリーポイントだけ

<script type="module" src="./main.js"></script>

なぜ1つで済むのかというと、スクリプトの中からほかのファイルを読み込めるからです。それがimportです。CSSの@importのように、ファイルの先頭で必要なファイルを読み込むイメージです。

main.js:機能ごとのファイルをまとめて読み込む

import './header.js';
import './gallery.js';
import './contact-form.js';

header.jsの中でさらに別のファイルをimportしていれば、ブラウザはそれもたどって取得します。importをたどっていけば、必要なファイルはすべて集まるわけです。

たとえば、それぞれのファイルがutils.jsimportしていれば、こんなツリーの形になります。それぞれが読み込んでいるutils.jsはまったく同じファイルを参照しています。

importをたどって集まるファイル

main.js
├─ header.js
│  └─ utils.js
├─ gallery.js
│  └─ utils.js
└─ contact-form.js
   └─ utils.js

HTMLに書いたのはmain.jsだけですが、ブラウザはこのツリーをたどって、末端まで集めてくれます。utils.jsが3か所に出てきても、同じファイルなので、ブラウザは1回だけ取得して実行します。

補足:同じモジュールは一度しか評価されない

同じURLのモジュールを複数の場所からimportしても、ブラウザが取得・実行するのは一度だけです。2回目以降は、最初に評価した結果が使い回されます。ですから、複数のファイルから同じモジュールを読み込んでも、初期化処理が二重に走る心配はありません。

取得する順番も実行する順番も、ブラウザがimportから判断してくれます。機能ごとにファイルを分割して管理できるようになりますし、構成を変えたくなってもHTMLを触らずJavaScript側だけで済みますね。

インラインのscript要素でもimportできる

importが使えるのは、外部ファイルだけではありません。HTMLにインラインで直接書いたscript要素でも、type="module"さえ付いていればimportできます。

インラインのscript要素でimportする

<script type="module">
import './header.js';
import './gallery.js';
import './contact-form.js';
</script>

先ほどのmain.jsの中身を、そのままHTMLに書いた形ですね。エントリーポイント用のファイルを別に用意しなくてよいので、処理が数行で済むページなら手軽です。なお、このときのパスの基準になるのは、HTMLファイルの置かれている場所です。

もちろん、type="module"を付けないインラインのscript要素でimportを書くとエラーになります。これはモジュールだけの特権なのです。

必要な機能だけを取り出す

モジュールから一部の機能だけを取り出すこともできます。先ほどの「importをたどって集まるファイル」のツリーで、3つのファイルから読み込まれていたutils.jsを例にしてみましょう。取り出される側ではexportを付けます。

ここではisMobile()isTouch()を取り出す準備をしています。

utils.js:公開する機能にexportを付ける

const BREAKPOINT = 768; // exportしていないので、このモジュールの外からは使えない

export const isMobile = () => window.innerWidth < BREAKPOINT;
export const isTouch = () => 'ontouchstart' in window;

取り出す側(gallery.js)ではimportに名前を並べます。

gallery.js:必要なものだけをimportする

import { isMobile } from './utils.js';

if ( isMobile() ) {

  // 狭い画面ではスライダーを動かさない

}

gallery.jsが使うのはisMobile()だけなので、isTouch()は書いていません。必要なものだけを名指しで取り出せるわけです。

一方、exportを付けていないBREAKPOINTという変数には、モジュールの外からは触れません。つまり、公開するものと隠すものを、変数や関数といった宣言単位で明示的に決められるようになりますね。

なおimportには、名前を変えて受け取る、モジュール全体をまとめて受け取る、export defaultされたものを受け取る、といった書きかたもあります。構文のバリエーションは「import/exportの構文と仕様 | ES modules基礎知識」にまとまっていますので、そちらを参照してください。

動的なimport()

import文は一般的にファイルの先頭に書きますが、import()を関数のように呼び出せば、必要になったタイミングで読み込むこともできます。

モーダルが開かれたときに初めて読み込む

button.addEventListener( 'click', async () => {

  const { openModal } = await import( './modal.js' );
  openModal();

} );

import()はPromiseを返すので、await.then()で受け取ります。最初の表示に必要ないコードを後回しにできるため、初期表示を軽くしたいときに便利です。

ここまでのまとめ

今回は、type="module"の入口として、import/exportによるファイル分割を見てきました。

  • type="module"を指定すると、script要素の中身はES Modulesとして解釈される
  • 依存関係はHTMLに並べたscript要素の順番ではなく、JavaScript側のimportで表せる
  • インラインのscript要素でも、type="module"が付いていればimportできる
  • exportを付けた機能だけが外から使えて、必要なものを選んで取り出せる
  • import()を関数のように呼び出せば、必要になったタイミングで読み込める

次回は、importexportも書かないままtype="module"を付けたときに何が変わるのかを見ていきます。変数がほかのスクリプトとぶつからなくなること、DOMContentLoadedを書かなくてもDOMの完成後に実行されること、strict modeが自動で有効になること、トップレベルでawaitが書けること。ファイルを分割する予定がなくてもtype="module"と書きたくなる理由が見えてきます。