uhyohyo.net

JavaScript初級者から中級者になろう

十六章第二十回 モジュール

このページの最終更新日:

第十六章は長いですね。20回に到達したのは初めてです。

最近の記事は細かい話が続いており皆さんも飽き飽きしているだろうということで、今回は新しい文法を紹介します。それはモジュールに関する文法です。

モジュールとは、プログラムを分割したそれぞれの単位のことです。どんなプログラミング言語でも、大きなものを作るときソースコードを全部ひとつのファイルに書くことはまずありません。機能ごととか、適切な単位でプログラムをファイルに分割して整理するのが普通です。

そうなると、複数のファイルを協調させて動作させる仕組みが必要になります。それがモジュールシステムです。JavaScriptにおいては、ES2015で追加されたモジュール関連の機能によってこれを成し遂げることができます。

従来の方法

このモジュール関連の機能はES2015で追加されたと述べましたが、その後実際にブラウザ等で使えるようになるにはかなり時間がかかりました。現在(2017年11月)、やっとChromeやSafariでこの機能が動くようになりました。Firefoxはまだ(フラグ無しでは)動作しません。

しかし、JavaScriptはつい最近流行りだしたような言語ではありません。これまではどのようにJavaScriptでファイル分割を行っていたのでしょうか。

ブラウザの場合、もっとも原始的な方法は、分割したJavaScriptファイルをscript要素で順番に実行することです。例えばfoo.jsとbar.jsという2つのファイルがあるとしましょう。


// foo.js
function fooFunction(){
  console.log('Hi');
}

// bar.js
fooFunction();

2つのファイルの中身がこのようになっていた場合、これらはHTMLから次のように読み込んで使用することができます。


<script src="foo.js"></script>
<script src="bar.js"></script>

この順番で読み込むことで、まずfoo.jsによりfooFunction関数が定義され、次にbar.jsがその関数を使うことができます。これは順番が大事です。逆にすると、fooFunctionが定義される前にそれを使用しようとしてエラーになります。

この例では、2つのファイルの間の協調はグローバル変数によって成されています。この例ではfooFunctionというグローバル変数が2つのファイルの間の橋渡しをしています。これまでは、事実上グローバル変数を用いるのが唯一の方法でした。

一方、近年はブラウザ以外の環境でもJavaScriptが動くようになり、そういった環境用の独自のモジュールシステムが整備されてきた歴史もあります。その代表例がnode.jsに採用されたCommonJSです。CommonJSではrequireという組み込み関数を用いて他のファイルを呼び出し、結果を得ることができます。CommonJSについてはここでは解説しませんが、上に対応する例だけ出しておきます。


// foo.js
exports.fooFunction = function(){
  console.log('Hi');
};

// bar.js
const {
  fooFunction,
} = require('./foo.js');

fooFunction();

このような独自のモジュールシステムが先に育ってきた歴史があるため、それに対比して今回紹介するモジュールシステムはES Modulesと呼ばれることがあります。ES ModulesはJavaScript (ECMAScript)の言語仕様に含まれる正統なモジュールシステムなので、今後node.jsなどの環境でもES Modulesを用いたモジュールシステムが主体となっていくでしょう。

import, export

では、今回の内容であるES Modulesの話に移りましょう。モジュールのために、新しい文法import文export文が追加されました。

import文は、他のモジュール(他のファイル)から値を読み込みます。export文は、自分が他のファイルから読みこまれるときにどんな値を提供するか宣言します。つまり、export文で提供された内容をimport文で読み込むことができるのです。

export文により値を他のモジュールに提供することをエクスポートするといい、import文で他のモジュールから値を読み込むことはインポートするといいます。

とりあえずまず例を見てみることにしましょう。上のfoo.jsとbar.jsの例はこのように書けます。


// foo.js
export function fooFunction(){
  console.log('Hi');
}

// bar.js
import {
  fooFunction,
} from 'foo.js';

export文

まずexport文から解説していきます。export文にはいくつかの種類があります。

まず、宣言系の文にはexportをつけてexport文にすることができます。上の例は、fooFunctionの関数宣言にexportをつけています。これは普通の関数ですが、ジェネレータ関数の宣言なども可能です。他に、var、let、constによる変数宣言にもexportを付けることができます。


export const someValue = 100;

export let one = 1, two = 2, three = 3;

export var foo, bar;

foo = 10;
bar = 20;

このように、var、let、const宣言にexportを付けることができます。また、export文を含むファイルもJavaScriptプログラムであることは変わりませんので、ちゃんと上から実行されます。上の例のfoo、barのように、まずexport付きvarで変数を宣言しておき、あとから変数に値を代入することも可能です。この場合、他のファイルがfooやbarを読み込むとちゃんと10、20という値になります。

また、クラス宣言もexportすることができます。


export class VeryUsefulClass{
  constructor(){
    console.log('Hello');
  }
}

他には、既に宣言されている変数をexportするための構文が用意されています。


let foo = 3;
const bar = 5;

export {
  foo,
  bar,
};

このように、この構文では、exportのあとに{ }の中に変数名をコンマで区切って書きます。これにより変数fooとbarがエクスポートされます。

また、エクスポートするときに名前を変えることができます。それには、次のようにasを使います。


let foo = 3;
const bar = 5;

export {
  foo as superFoo,
  bar,
};

この例の場合、変数fooはfooという名前でなくsuperFooという名前でエクスポートされます。

defaultエクスポート

また、defaultエクスポートという種類のexportもあります。詳しくはあとで説明しますが、defaultエクスポートによりエクスポートされたものは対応するimport構文により簡単にインポートすることができます。そのモジュールの中心的なものをdefaultエクスポートにするのがよいでしょう。defaultエクスポートは必ず用意する必要はありません。

defaultエクスポートをするには、エクスポートしたいものの前にexport defaultと書きます。先ほどと同様、関数宣言やクラス宣言はdefaultエクスポートにできます。


export default function fooFunction(){
  console.log('Hi');
}

ただし、変数宣言はだめです。よって、export default const foo = 3;のようなエクスポートはできません。

また、好きな値をdefaultエクスポートにすることができます。そのためには、export default ;とします。値の部分は式で計算することもできます。


let foo = 5, bar = 10;

export default foo + bar;

こうすると15がdefaultエクスポートになります。

なお、defaultエクスポートは1つしか作ることができません。

import文

以上でexport文は終わりです。今度は他のモジュールから値をインポートするために使う、import文を見ていきましょう。

import文の基本的な形は次の形です。


import {
  foo,
  bar,
} from "./foo.js";

このように、importというキーワードのあとに{ }の中に名前をコンマで区切ったものを書きます。そして、from "ファイル名"の部分でどのファイルから読み込むのか指定します。この例では、./foo.jsによりfooとbarがエクスポートされていれば読み込むことができます。

この場合、読み込んだものはそのまま変数foo、barに入り、使うことができます。このとき、exportのときと同様に、asを使って名前を変えることができます。


import {
  foo as superFoo,
  bar,
} from "./foo.js";

こうした場合、./foo.jsからfooという名前でエクスポートされたものはsuperFooという変数に入り使えるようになります。

さらに、あるモジュールからエクスポートされているものを全部まとめて得るための構文もあります。


import * as obj from "./foo.js";

console.log(obj.foo);

このように* as 変数名としてインポートした場合、./foo.jsからエクスポートされているものが全てまとまったオブジェクトを得ることができます。例えば、./foo.jsからfooとbarがエクスポートされている場合、それらはobj.fooおよびobj.barに入っています。

残りはdefaultエクスポートをインポートするための構文です。これは簡単です。


import foo from "./foo.js";

console.log(foo);

このようにimport 変数名 from "モジュール名";とした場合、当該モジュールにdefaultエクスポートがあればそれが指定した変数に入ります。簡単ですね。このように簡単にインポートできるようにしたいものをdefaultエクスポートに指定するとよいでしょう。

そして、defaultエクスポートとその他のエクスポートを同時に読み込む構文もあります。


import obj, {
  foo,
  bar,
} from "./foo.js";

このようにすると、defaultエクスポートがobjに入り、foo, barという名前でエクスポートされているものがfoo, barという変数にそれぞれ入ります。

他に、import obj, * as obj2 from "./foo.js";という構文も可能です。

最後に、これらとは別に一風変わった構文もあります。


import "./foo.js";

これは、モジュールを読み込むけど何もインポートしないという場合に使う構文です。何もインポートしないのにモジュールを読み込む意味があるのかとお思いかもしれませんが、読みこまれると何らかの動作をするというモジュールも考えられます。そういう場合に使うとよいでしょう。

最後に、exportとimportを合わせたような構文があるのでそれも紹介します。


export {
  foo,
  bar,
} from "./foo.js";

これはいわゆる再エクスポートの構文です。つまり、./foo.jsから読み込んだfooとbarをそのまま自分のモジュールからエクスポートします。


export * from "./foo.js";

また、これは./foo.jsからエクスポートされているものを全て自分のモジュールからエクスポートします。ただし、defaultエクスポートは再エクスポートできません。

以上で構文の説明は終わりです。地味に数が多いので大変に思うかもしれませんが、使ううちに慣れていきます。後でサンプルも出てきますから、だんだん慣れていきましょう。

なお、import文やexport文はモジュールのトップレベルでしか使えませんので、注意しましょう。トップレベルというのは、プログラムの一番外側で、ブロックや関数の中ではないということです。つまり、次のように書くことはできないということです。


function foo(){
  // 関数の中でimport文を使うことはできないのでこれはだめ
  import { bar } from './bar.js';
}

if (true) {
  // ブロックの中で使うのもだめ
  import hoge from './hoge.js';
  // もちろんexport文もだめ
  export {
    foo,
  };
}

関数の中だけでしか使わないから関数の中でimport文を使う、というようなことはできません。また、特定の条件を満たしたときだけ読み込むとかいうこともできません。export文も同様に、特定の条件を満たしたときだけエクスポートするというようなことはできません。あまり無いと思いますが、もしそのようなことが必要な場合は、条件を満たさない場合は代わりにundefinedをエクスポートするなどの工夫をしましょう。

HTMLにおけるモジュールの使用法

さて、ここまでで、import文とexport文を学習しました。実際にHTMLからこれらの構文を用いてファイルの協調を行う方法をここから見ていきます。

まず、プログラムがimport文やexport文を使うためには、そのプログラムがモジュールであると宣言する必要があります。それには、script要素のtype属性"module"という値を指定します。これを忘れたままimport文やexport文を使った場合文法エラーとなります。

ちなみに、モジュールでないプログラムはスクリプトと呼ぶことになっています。スクリプトでは、importやexportは使えないのです。

例はこんな感じです。


<script type="module">
  import {
    fooFunction,
  } from "./foo.js";

  fooFunction();
</script>

実際にやってみたサンプル1を用意しています。先にも述べた通り、ブラウザによってはまだ動作しないかもしれません。記事執筆時点でEdge, Chrome, Safariは上のサンプルが動作しますが、FirefoxやOperaは設定でモジュールを有効にしないと動作しません。

なお、モジュール名に指定できるのはJavaScriptファイルですが、ファイルはURL、絶対パス("/javascript/foo.js"のような形)か相対パス("./foo.js"のような形)のいずれかで指定します。特に注意しなければいけないのは、諸事情により、同じディレクトリにファイルがあったとしても、"foo.js"と指定することはできないという点です。この場合は"./foo.js"としなければいけません。

モジュールの利点と既存の方法の欠点

HTMLでJavaScriptのモジュールを利用する場合、従来の方法に比べると利点があります。せっかくなのでそれについて解説しておきます。

まず、従来の方法の欠点を解説します。実は、普通のJavaScriptがHTML中に現れると、それはパーサーをブロックします。パーサーをブロックするというのは、そのスクリプトの実行が終わるまでHTMLの読み込みが止まるということです。例えば、


……
<p>ここはすぐ表示されます。</p>
<script>
  // 何らかのすごく時間がかかる処理
</script>
<p>ここはなかなか表示されません。</p>

というように、HTML中にscript要素でJavaScriptが書いてある場合を考えます。

HTMLは前から順番に読みこまれていきます。その過程でscript要素に行き当たった場合、HTMLの読み込みは中断されJavaScriptが即座に実行されます。もしこれに時間がかかった場合、それ以降のHTMLが読みこまれるのが遅くなってしまいます。

こんなところにすごく時間がかかる処理なんて書かないよと思うかもしれませんが、次の場合はどうでしょうか。


……
<p>ここはすぐ表示されます。</p>
<script src="foo.js"></script>
<p>ここはなかなか表示されないかもしれません。</p>

何らかのJavaScriptファイルをscirpt要素で読み込む場合です。

この場合でも、スクリプトの実行が完了するまでHTMLの読み込みは進みません。しかし、今回スクリプトを実行するにはfoo.jsをサーバーから取得する必要があります。となると、サーバーからファイルを取得してそれを実行するまで待つことになるわけで、どうしてもインターネット上を1往復するだけのタイムラグが発生します。もちろん工夫の余地が無いわけではありませんが、ラグを完全に克服することはできません。

また、これは特に上で話に出たような原始的なファイル分割の際は問題となります。


<script src="ライブラリ1.js"></script>
<script src="ライブラリ2.js"></script>
<script src="ライブラリ3.js"></script>
<script>
  // ライブラリたちを使う処理
</script>

このようにファイル読み込みが並んでいた場合、インターネットを3往復しないと次に進むことができません。さすがにこれはよろしくないですね。

ただ、HTML5で追加されたscript要素のdefer属性を使うと、状況は少しよくなります。defer属性を付けたscript要素でスクリプトを読み込む場合、実行は後回しにしてHTMLの読み込みが続行されます。そして、HTMLの読み込みが終了したあとでスクリプトが実行されます。なお、script要素が出た時点でファイル読み込みは開始されます。ファイル読み込みの待ち時間でHTMLの読み込みを進めることができるわけです。


<script defer src="ライブラリ1.js"></script>
<script defer src="ライブラリ2.js"></script>
<script defer src="ライブラリ3.js"></script>
<script>
  // ライブラリたちを使う処理をここに書きたいが……
</script>

こうすることにより、ライブラリ1〜3は裏で同時に読み込むことができ、読み込み時間の短縮が期待できます。

ただし、これにはひとつ問題があります。それは、src属性を持たないscript要素にdefer属性を付けることはできないという点です。つまり、直前の例はうまく動きません。なぜなら、この例ではライブラリの処理はdeferにより後回しにされているので、それより前にライブラリを使う処理を実行してしまうからです。

これはライブラリを使う処理も外部ファイルにしてしまうことで一応解決します。

では、モジュールの話に移りましょう。実は、type="module"を指定されたscript要素は、deferと同様に実行が遅延されます。これは、たとえsrc属性がなくても有効です。このことを確かめられるサンプル2を用意しました、

このサンプルでは、モジュールを読み込んで利用するscript要素を先、その場でconsole.logを呼び出すscript要素を後に書いたのに、実際に表示される順番は逆になります。これは、前者はdefer同様の扱いになりHTMLが読み込み終わったあとに実行されるからです。

こうなる理由はやはりパーサーをブロックしないためです。モジュールであるプログラムは、import文により別のモジュールを読み込む可能性があります。別のモジュールを読み込むには当然ながらインターネットへのアクセスが必要です。ですから、パーサーをブロックしてしまうと長い時間ブロックしてしまう可能性があり好ましくありません。

モジュールを使うことで、deferを使うよりも自然にブロックしないスクリプトを書くことができます。これがモジュールの利点です。

また、読みこまれた先のモジュールがまたimport文で別のモジュールを読み込むということも、もちろん可能です。ファイル間のこのような階層的な依存関係を記述できるのはモジュールならではの魅力です。従来の方法では、読み込みたいファイルのscript要素を書き並べるしかありませんでした。(動的にscript要素を追加するとかそういう工夫はありますが。)

また、近頃実用化が進んでいるHTTP2は細かいファイルを同時に何個も受信するのが得意とされており、モジュールによるファイル分割とは相性がよくなっています。

ただし、注意すべき点は、あるモジュールがどの別のモジュールに依存しているかはファイルを読み込んでみないと分からないという点です。依存関係の階層が深い場合、次々新しい依存先が発覚して結局インターネットを何往復もすることになりかねません。Server Pushを用いるなどの工夫が必要になるでしょう。