JavaScript のモジュール管理の仕組みをおさらいする : TypeScript をトランスパイルして HTML 上で利用するための前段
tsc
を利用して TypeScript をトランスパイルする素振り環境を作ってみた。最初はコンソール上でコンパイルした JS ファイルを $ node example.js
のように動かして満足していたのだが、コンパイルした JS ファイルを HTML で読み込んで動かそうとしたら、import
・export
が解釈できないようで詰まってしまった。
すぐにモジュールの仕様が問題なのは分かったが、その話をするために、まずは JavaScript におけるモジュール管理の仕組みをおさらいするための記事を書いてみた。
目次
- 素振り環境の用意
- まずは Node.js で動かしてみる
- HTML から JS ファイルを読み込んでも動作してくれない?
- モジュール管理の仕組みをおさらいしよう
- JavaScript におけるモジュール管理の歴史
- モジュールバンドルツールの登場
- モジュールの仕様が複数生まれた
- モジュールの定義方法をブラウザに合わせてやれば TypeScript コードもトランスパイルして動かせる
- 参考文献
素振り環境の用意
まずは素振り環境をどのように作ったか紹介する。
# 素振り用のディレクトリを作る
$ 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つのファイルを作ってみる。
./src/parent.ts
import { Child } from './child';
console.log('Parent Start');
const child = new Child();
child.echo('Neo');
console.log('Parent End');
./src/child.ts
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 形式で生成させているが、これではブラウザ上で import
・export
の関係を解釈できないのだ。
モジュール管理の仕組みをおさらいしよう
このあたりの話は頭の中では理解できているので、「あぁコレがこうだからこうすればイケるよね」とは思っているのだが、それを文面に落とし込んだことがなかったので、整理する。
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();
コレなら単一ファイルにまとまって、import
も export
もしなくなったのでブラウザで動く、というワケ。
最近は 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 上でも export
・import
している TypeScript コードが解釈できるようにする。
参考文献
- JavaScriptのモジュール管理(CommonJSとかAMDとかBrowserifyとかwebpack) | tsuchikazu blog
- ブラウザーで ECMAScript Modules を使うことを調べました 4(Universal Module Definition) – Espresso & Onigiri
- TypeScript超入門(3) : モジュール - Qiita
- ES6 (ES2015) ModulesのUMD化。HTMLのScript要素とES6 importでの同時読み込みに対応させる方法 | maesblog
- .mjs とは何か、またはモジュールベース JS とエコシステムの今後 | blog.jxck.io
- JavaScript モジュールの現状 | POSTD