TypeScriptで複数ファイル構成する2つの方法
TypeScriptで複数ファイル構成のプロジェクトを扱う方法について書いてみる。日本語の入門記事や試してみました系の記事で勘違いされてることがたまに見受けられるので、整理してみる。
公式のModules in TypeScriptを既に読んでおられるような御仁は回れ右していただいても結構です。
やりたいこと
- ソースファイルをモジュールごとに分割して管理したい
- 実行環境はNode.js or ブラウザ
例えば、こういう処理があって、
// main.ts function trimLeft(str: string): string { return str.replace(/^\s+/, ''); } var input = document.getElementsByTagName('input')[0]; input.value = trimLeft(input.value);
trimLeft()
を別のページでも使いたいから別ファイルに切り出してユーティリティ化したい、と思ったときどうするかという話。
その1: <reference>
によるグローバルモジュール
普通のJavaScriptだったら、一番単純なのはutil/strings.jsとか別ファイルに切り出して、ブラウザのscriptタグを追加すれば良い。
<script src="util/strings.js"> <script src="main.js">
TypeScriptではコンパイル時に型情報が必要なので、main.tsからtrimLeft()
の定義を削除しただけだとコンパイルエラーになってしまう。
そこで///
から始まる特殊なコメントにreferenceタグを書くことで、別ファイルに定義された型情報を参照することができるようになっている。
- util/strings.ts
function trimLeft(str: string): string { return str.replace(/^\s+/, ''); }
- main.ts
/// <reference path="util/strings.ts"> var input = document.getElementsByTagName('input')[0]; input.value = trimLeft(input.value);
これでmain.tsをtscでコンパイルすると、main.jsとその参照先のutil/strings.jsがそれぞれ生成されるので、前述の追加したscriptタグから読めるようになる。
$ tsc main.ts
ただ、手動でscriptタグを管理していくのは人間のすることではないので、普通は--out
オプションで1ファイルに連結する。
$ tsc --out out.js main.ts
連結されたout.jsは、referenceタグで指定された全てのファイルを、依存関係を解決する適切な順序で連結したファイルになっている。
- out.js(生成結果)
function trimLeft(str) { return str.replace(/^\s+/, ''); } /// <reference path="util/strings.ts" /> var input = document.getElementsByTagName('input')[0]; input.value = trimLeft(input.value);
コンパイルされたJSファイルを見れば分かる通り、これはグローバル空間を使って異なるファイル間で関数やクラスを共有しているだけなので、 TypeScriptの仕様書ではグローバルモジュール (global module) と呼ばれている。
内部モジュール
ユーティリティにtrimLeft
の他にtrimRight
などが増えていったとき、さすがに全部グローバルにぶちまけるのは忍びないので、内部モジュールというグローバル空間の汚染を減らす仕組みが用意されている。
- util/strings.ts
module app.util.strings { export function trimLeft(str: string): string { return str.replace(/^\s+/, ''); } export function trimRight(str: string): string { return str.replace(/\s+$/, ''); } }
- main.ts
/// <reference path="util/strings.ts" /> (function () { var input = document.getElementsByTagName('input')[0]; input.value = app.util.strings.trimLeft(input.value); })();
- out.js(生成結果)
var app; (function (app) { (function (util) { (function (strings) { function trimLeft(str) { return str.replace(/^\s+/, ''); } strings.trimLeft = trimLeft; function trimRight(str) { return str.replace(/\s+$/, ''); } strings.trimRight = trimRight; })(util.strings || (util.strings = {})); var strings = util.strings; })(app.util || (app.util = {})); var util = app.util; })(app || (app = {})); /// <reference path="util/strings.ts" /> (function () { var input = document.getElementsByTagName('input')[0]; input.value = app.util.strings.trimLeft(input.value); })();
この例では、このアプリはグローバル空間にapp
という変数だけを置いて、あとはそのプロパティに階層的に名前空間を定義してる。
このmodule
の使い方をTypeScriptの仕様書では内部モジュール (internal module) と呼んでる。
同種の手法はJavaScriptでも昔からやられてきた。
TypeScriptの内部モジュールのポイントは、同じモジュールを別の場所で複数回定義できるところ。
例えばutil/strings.tsとは別のファイルから、app.util.strings
モジュール内に別の関数を追加できる。定義順も関係ない。
その2: import
/export
による外部モジュール
グローバルモジュールは仕組みが単純で分かりやすいし、世に出回っている(特に初期の)入門記事だと外部ファイルを呼び出す方法についてreferenceタグのことしか触れられてないことがある。TypeScriptコンパイラ自体がグローバルモジュールで書かれているのも原因の一つかもしれない。
でもこれだと結局グローバル変数は必要だし*1、Node.jsには向かないし、ブラウザでもBower, RequireJS, Browserifyなどのエコシステムと親和性が低い。
そこで、もう一つの方法としてimport
/export
を使った外部モジュール (external module) という仕組みが用意されている。
要するに、モジュール管理をCommonJSまたはAMDで行う方法。
外部モジュールで書いておけば、将来的にECMAScript 6 Moduleがブラウザで使えるようになったとき、TypeScriptの対応次第でES6 Moduleに変換して利用できる可能性が高いのも利点。
CommonJS形式にコンパイルしてNode.jsで使う
export
で外部モジュールを定義して、import
とrequire
で外部モジュールを読み込む。
- util/strings.ts
export function trimLeft(str: string): string { return str.replace(/^\s+/, ''); } export function trimRight(str: string): string { return str.replace(/\s+$/, ''); }
- main.ts
import strings = require('./util/strings'); var input = document.getElementsByTagName('input')[0]; input.value = strings.trimLeft(input.value);
グローバルモジュールと違って、referenceタグは不要。import
/require
だけで外部ファイルの型定義を参照してくれる。
コンパイルには--module commonjs
オプションでCommonJS形式を指定する。
$ tsc --module commonjs main.ts
コンパイルするとexports
/require
を使ったCommonJS形式で書き出される。
- util/strings.js(生成結果)
function trimLeft(str) { return str.replace(/^\s+/, ''); } exports.trimLeft = trimLeft; function trimRight(str) { return str.replace(/\s+$/, ''); } exports.trimRight = trimRight;
- main.js(生成結果)
var strings = require('./util/strings'); var input = document.getElementsByTagName('input')[0]; input.value = strings.trimLeft(input.value);
Node.jsならこのまま実行できる。簡単でしょ?
$ node ./main.js
(Node.jsのサンプルなのにdocumentとか書いてあるのは気にしないでください。。)
外部モジュールの条件
細かい仕様にも触れておくと、以下のうちひとつでも存在するファイルは外部モジュールとして扱われる。
- トップレベルの
export
宣言 - 外部モジュールを読み込む
import
宣言 - Export Assignment(後述の
export =
)
逆に言うと、これらを一つも含まないファイルはグローバルモジュールになる。
ブラウザはどうする?
コンパイル時に--module amd
を指定するとrequire
/define
を使ったAMD形式になる。
- util/strings.js(生成結果)
define(["require", "exports"], function(require, exports) { function trimLeft(str) { return str.replace(/^\s+/, ''); } exports.trimLeft = trimLeft; function trimRight(str) { return str.replace(/\s+$/, ''); } exports.trimRight = trimRight; });
- main.js(生成結果)
define(["require", "exports", './util/strings'], function(require, exports, strings) { var input = document.getElementsByTagName('input')[0]; input.value = strings.trimLeft(input.value); });
ブラウザから扱う場合、CommonJS & browserifyか、AMD & RequireJSのどちらかの組み合わせを使うことになる。それぞれ特徴あるのでお好みで。
ただちょっとした書き捨てのコードのためにこれらのビルド環境を整えるのは腰が重いので、割り切ってグローバルモジュールを使うのも一つの選択肢かと。
ちょっと複雑な例
外部モジュールを初めて書いていくときこれどうやるの?ってなる系の話。
export =
こんな感じで直接呼び出し可能なモジュールを定義したいとき、
// TypeScript import trimLeft = require('./util/trimleft'); trimLeft("foo");
つまりNode.jsでいう、
// JavaScript module.exports = function() {...};
みたいなモジュールを定義したいとき、TypeScriptではexport =
っていうExport Assignments構文を使う。
// TypeScript function trimLeft(str: string): string { return str.replace(/^\s+/, ''); } export = trimLeft;
どっちかというと関数ユーティリティよりクラス定義とかで使うかな。
動的遅延ロード
Node.jsでは、ある条件のときだけif文の中でrequire
するってことがまれにある。
// JavaScript if (someCondition) { var foo = require('./foo'); foo(); }
TypeScriptでこういう動的な読み込みをする場合はこう書くらしい。
// TypeScript // CommonJSの生のrequireのために型宣言 declare var require; // fooの型情報だけをインポート。コンパイル後消える。 import foo = require('./foo'); if (someCondition) { // 実際に残る生のrequire var f: typeof foo = require('./foo'); f(); }
コンパイル結果はこうなる。
// JavaScript if (someCondition) { var f = require('./foo'); f(); }
TypeScriptのimport文は、実際にインポートした変数が変数として使われない限りコンパイル後に残らない。上記コードのtypeof foo
は変数ではなく型として使われているだけなので、コンパイルしたあとに残っていない。
このあたりはTypeScriptが変数宣言空間と型宣言空間を別に管理していることと関係していて、それについては以前に詳しく書いたのでご参考まで。
if文の中では実際にrequire
しないといけないので、(TypeScriptの構文ではない)CommonJSのrequire
を生で書くことで動的ローディングを実現している。1行目のdeclare
はJavaScriptの関数としてのrequire
を型エラーにしないための型宣言。
ただし、コンパイル後もそのまま残るCommonJSのrequire
を生で書いちゃってるので、--module amd
したら動かない。また、Browserifyなどの静的解析系ツールとの相性も悪いので、普通はやらない方が良いと思う。
まとめ
本当は型宣言ファイル(d.ts)のパターンについて書こうと思ったのだけど、さきにこっちを書かないといけない気がして書いてみました。
あと、最近発売された「わかめ本」ことTypeScriptリファレンスを読んだらもっと分かりやすく書いてあるはずなので、ぜひこちらもどうぞ。
- 作者: わかめまさひろ,井上章,丸山弘詩
- 出版社/メーカー: インプレスジャパン
- 発売日: 2014/05/16
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (2件) を見る
献本もらったのにまだ読んでないです。これから読みます。すみませんすみません><
*1:アプリならいいけどライブラリだと微妙