JavaScript のモジュール管理の仕組みをおさらいする:TypeScript をトランスパイルして HTML 上で利用するための前段

tsc を利用して TypeScript をトランスパイルする素振り環境を作ってみた。最初はコンソール上でコンパイルした JS ファイルを $ node example.js のように動かして満足していたのだが、コンパイルした JS ファイルを HTML で読み込んで動かそうとしたら、importexport が解釈できないようで詰まってしまった。

すぐにモジュールの仕様が問題なのは分かったが、その話をするために、まずは JavaScript におけるモジュール管理の仕組みをおさらいするための記事を書いてみた。

目次

素振り環境の用意

まずは素振り環境をどのように作ったか紹介する。

# 素振り用のディレクトリを作る
$ mkdir ts-on-html-practice && cd $_
# package.json を用意する
$ npm init -y
# TypeScript をインストールし devDependencies に追記する。v2.9.2 がインストールされた
$ npm i -D typescript
# ローカルインストールした TypeScript (tsc) を使って tsconfig.json を生成させる
$ $(npm bin tsc)/tsc --init

この状態で、tsconfig.json は以下のように設定されている。

{
  "compilerOptions": {
    /* Basic Options */
    "target": "es5",       /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */
    "module": "commonjs",  /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */

コンパイル後の JavaScript が準拠するバージョン (target) は ES5、インポート構文の解釈 (module) は CommonJS ということになる。

ここで、ソースファイルの所在を src/、生成ファイルの格納場所を dist/ とするため、以下のオプションの設定を変更しておく。

{
  "compilerOptions": {
    // …中略…
    "outDir": "./dist",  /* Redirect output structure to the directory. */
    "rootDir": "./src",  /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */

続いて、開発を進めやすくするため、package.json の npm-scripts を以下のように設定する。

"scripts": {
  "start": "tsc -w",
  "tsc": "tsc"
}

コレで準備完了。$ npm start と打つと tsc のファイル監視が始まる。src/ ディレクトリを作ってその中に .ts ファイルを置くと、dist/ 配下に .js ファイルを生成してくれる。

まずは Node.js で動かしてみる

まずは Node.js 上でトランスパイルしたファイルが動くか試してみる。お試しで以下のような2つのファイルを作ってみる。

import { Child } from './child';

console.log('Parent Start');

const child = new Child();
child.echo('Neo');

console.log('Parent End');
export class Child {
  echo(str: string): void {
    console.log(`Hello ${str}!`);
  }
}

export した別クラスを import して生成し、その関数を利用するだけのシンプルなもの。

tsc によって ./dist/parent.js./dist/child.js が生成されていると思うので、ターミナルで以下のように叩いてみると、結果が見られるはずだ。

$ node dist/parent.js
Parent Start
Hello Neo!
Parent End

ココまでは良い。問題はココから。

HTML から JS ファイルを読み込んでも動作してくれない?

先程生成した ./dist/parent.js を読み込む ./html/test-1.html という HTML ファイルを作ってみる。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>TypeScript on HTML Practice 1</title>
    <script src="../dist/parent.js"></script>
  </head>
  <body></body>
</html>

この HTML ファイルをブラウザで表示すれば、管理者コンソールに console.log() の内容が出力されそうだが、以下のようなエラーが出てしまった。

Uncaught ReferenceError: exports is not defined
  at parent.js:2

問題があるのは TypeScript コードではなく、それを JavaScript にトランスパイルする時のモジュールの決め方だ。現状はデフォルトのまま、CommonJS 形式で生成させているが、これではブラウザ上で importexport の関係を解釈できないのだ。

モジュール管理の仕組みをおさらいしよう

このあたりの話は頭の中では理解できているので、「あぁコレがこうだからこうすればイケるよね」とは思っているのだが、それを文面に落とし込んだことがなかったので、整理する。

JavaScript におけるモジュール管理の歴史

ブラウザ上で動作することが前提だった JavaScript の世界は、元々「モジュール」という概念がなかった。jQuery$ をグローバル変数に定義するから、後続のコードで $ を使える、というそれだけだった。

サーバサイドで JavaScript が動作する Node.js が登場し、当初は ServerJS という名前でモジュール管理の仕組みが策定された。これは module.exports によるエクスポートと require() によるインポートで、現在は名称変更して CommonJS という仕様で策定されている。

モジュールバンドルツールの登場

Node.js 上で実現される、CommonJS によるモジュール管理の仕組みは便利なので、ウェブサイトやライブラリを開発する時は使いたい。しかし、このままでは当然ブラウザ上では動作しない。ということで、ブラウザ上で動作させる時にファイルを上手いことまとめる (「バンドルする」) ツールが登場した。

まず有名なのは Browserify で、これは平たく言うと、require() 構文の部分に対象のファイルのコードをブチ込む、ということをやっている。

// 厳密なところは抜きにして、概念的にはこんなイメージ

// 【元ファイル】エクスポートする方
module.exports = {
  echo: function() {
    console.log('Hello');
  }
};

// 【元ファイル】インポートして使う方
const myModule = require('my-module');
myModule.echo();

// コレを Browserify は以下のように変換することで動かしてくれるイメージ
var myModule = {
  echo: function() {
    console.log('Hello');
  }
};
myModule.echo();

コレなら単一ファイルにまとまって、importexport もしなくなったのでブラウザで動く、というワケ。

最近は Browserify よりも Webpack というバンドルツールが広まっている。こちらも結局やっていることとしては、このように複数に分かれているファイル (モジュール) を1つにバンドルする (まとめる) ということをやっている。ちなみに、Grunt や Gulp といったビルドツールは、プラグインを組み合わせてこうした処理を自分で構築するツールで、それ自体がバンドルを行ってくれるワケではない。

モジュールの仕様が複数生まれた

AMD

実質的に Node.js 向けの仕様でしかない CommonJS はブラウザ上では動かないので、新たな仕様が策定された。まず生まれたのはモジュールを非同期でロードする、AMD (Asynchronous Module Definition) というモノ。AMD は define() でモジュールを定義し、読み込むモジュールを define() の第1引数で指定する、という書き方をする。

// 先程の CommonJS 形式で書いたモジュール読み込みの処理を書き直すとこうなる

// エクスポートする側
define(function() {
  return {
    edho: function() {
      console.log('Hello');
    }
  };
});

// インポートする側
define(['my-module', function(myModule) {
  myModule.echo();
});

この AMD の仕様で作られたモジュールをブラウザで動かせるようにしてくれたのが RequireJS というライブラリ。このライブラリを利用すれば、AMD 形式の JS ファイルが複数分散している状態でも、うまく非同期でロードしてくれる。このあと紹介する「方法1」はコレを使う。

UMD

Webpack は、CommonJS 形式のファイルも AMD 形式のファイルも両方サポートしている。この「CommonJS でも AMD でも」というところがベースとなって、「CommonJS 仕様が求められる環境では CommonJS モジュールとして、AMD 仕様が求められる環境では AMD モジュールとして」振る舞えるようにした仕組みを UMD (Universal Module Definition) と呼ぶ。

jQuery のソースコードの最初のところで、以下のような作りを見たことがあるかもしれない。コレが UMD パターンに即した成果ファイル。

(function(global, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD に対応した部分
    define(['myModule', 'myOtherModule'], factory);
  }
  else if (typeof exports === 'object') {
    // CommonJS に対応した部分
    module.exports = factory(require('myModule'), require('myOtherModule'));
  }
  else {
    // それ以外のブラウザでの利用に対応した部分 (引数 global に window を指定することで対応する)
    global.returnExports = factory(global.myModule, root.myOtherModule);
  }
}(this, function(myModule, myOtherModule) {
  // Methods
  function notHelloOrGoodbye() {}; // A private method
  function hello() {}; // A public method because it's returned (see below)
  function goodbye() {}; // A public method because it's returned (see below)
  
  // Exposed public methods
  return {
    hello: hello,
    goodbye: goodbye
  };
}));

ESModules

そしてついにブラウザが直接 JavaScript モジュールの仕組みを解釈できるようになった、ES2015 Modules (ESModules) という仕様が策定された。.mjs という拡張子でファイルを用意することが望まれ (ブラウザ上で動作させる場合は必須ではないみたい)、 <script type="module"> で読み込むような仕組みだ。

実はもう IE11 以外の主要なブラウザで動作するようになっている。

モジュールの定義方法をブラウザに合わせてやれば TypeScript コードもトランスパイルして動かせる

…ココまでが、JavaScript における「モジュール」の概念が生まれた歴史と、その発展の様子だ。

対象環境 モジュール管理の仕組み ブラウザで動かすには
ブラウザ (昔の話) (モジュールの概念がないので) グローバル変数で頑張って管理 JS ファイルを読み込ませる順番 (グローバル変数の定義順) に注意
主に Node.js CommonJS Browserify や Webpack でバンドル
ブラウザ向け AMD RequireJS を使うか、Webpack でバンドル
Node.js・ブラウザ両方 UMD (≒ CommonJS & AMD) RequireJS を使うか、Webpack でバンドル (中身はほぼ AMD と同じなので)
ブラウザ (最近) ESModules <script type="module"> で読み込む

次回は実際に、TypeScript のトランスパイル方法を変えたりして、HTML 上でも exportimport している TypeScript コードが解釈できるようにする。

参考文献