TS v2.1 async/awaitのためのgulpfile.jsをつくる
前回の記事でTS2.1のasync/awaitの前提となるPromise/A+について学んだので、今度はいよいよ実際にasync/awaitを使ってみよう、というわけでGulpによりビルドタスクを管理するサンプル・プロジェクトを作ってみました。未だにnpmを中心としたJavaScriptのエコシステムがしっかり身についた知識となっておらず、試行錯誤にだいぶ時間をかけてしまいました。。
サンプル・プロジェクトのファイルセットはGitHub上に用意したリポジトリにコミットしました。ビルドの結果生成されるファイル(*.jsや*.mapを含む)はGit管理対象外としました。これらのファイルは後述の「ファイル構成」のセクションで確認してください。
プロジェクトの要件
- 開発にはEclipse、WebStorm、VS(Visual Studio)やVS Codeは使わずコマンドラインとAtomを使用する
- ES3(ECMAScript第3版)をビルド・ターゲットとする
- ES6(ES2015。ECMAScript第6版)のPromise/A+をポリフィルにより導入する
- AtomとGulpで同じtscondfig.jsonを使用させる
- Atomだけでも最低限TypeScriptのビルドを行えるようにする(※1)
- Atomによるコード編集とビルドにはatom-typescriptパッケージを使う
- Browserifyにより実行時依存性の自動解決を行う(※2)
- Watchifyによりインクリメンタル・ビルドを行えるようにする
※1 実行時依存性の解決(依存性のコードも含めて1つのjsファイルにまとめ上げる)まではできなくてもjsファイルからtsファイルへの変換(トランスパイル)はさせたいのです。厳密に言えばこの段階で変換の結果としてファイル出力できる必要はないのですが、とにかくAtomで編集したコードが妥当なTypeScriptコードとなっており、構文エラーや依存性解決エラーとならなないことを確認できるようにしたいのです。
※2 本題ではないのですが、GulpでWebアプリのためのビルド・タスクを定義する場合、これがとても重要な点です。Browserify(やWebpack)を使用しない場合、実行時依存性は実際上手作業で管理せざるを得なくなってしまいます。ここで「手作業で」というのは、gulpfile.jsのなかで依存性(例えばPromise/A+のポリフィルやjQueryのjsファイル)のパスを直指定してnode_modulesディレクトリからビルド成果物のディレクトリへとコピーするタスクを作ったり、そのコピーされたファイルを読み込むためにHTMLファイルに<script/>
タグを列挙したりのことです。つまりビルド時依存性と実行時依存性は次元というか世界のちがう話であり、前者はnpmやGulpがよろしく管理してくれるのですが、後者はそうではないということです。今回、後者にはBrowserifyを使用することにしました。
環境情報
OSおよびツールのバージョンは:
OS | macOS Sierra 10.12.2 |
npm | 3.10.9 |
gulp-cli | 1.2.2 |
Atom | 1.12.7 |
atom-typescript | 10.1.13 |
ビルド依存性のNode.jsパッケージのバージョンは:
@types/es6-promise | 0.0.32 |
@types/jquery | 2.0.34 |
browserify | 13.1.1 |
del | 2.2.2 |
gulp | 3.9.1 |
gulp-rename | 1.2.2 |
gulp-sourcemaps | 1.9.1 |
gulp-uglify | 2.0.0 |
gulp-util | 3.0.7 |
tsify | 2.0.7 |
typescript | 2.1.4 |
vinyl-buffer | 1.0.0 |
vinyl-source-stream | 1.1.0 |
watchify | 3.8.0 |
実行時依存性のNode.jsパッケージのバージョンは:
es6-promise | 4.0.5 |
jquery | 3.1.1 |
なお、jQueryはBrowserifyによる実行時依存性解決の動作確認のために追加しています。Gitリポジトリに登録したTypeScriptコードには先頭部にjQueryをインポートするための宣言が記述されていますが、続くビジネスロジックでは実際には使用されていないため、ビルドのなかでデッドコードとして扱われて、最終成果物であるapp.min.jsでは跡形もなく消し去られています。もちろんビジネスロジック内にjQueryを使用するコードを記述すればjQueryのコードが最終成果物のapp.min.jsの中に織り込まれます。
ファイル構成
ファイル構成は以下のようにしました。srcディレクトリ配下にHTMLおよびTypeScriptのソースコードを格納。buildディレクトリ配下にビルド成果物を格納します。
build/es/app.min.js
には開発者がmain.ts
に直接記述した内容だけでなく、そこからインポートされているPromise/A+のポリフィルその他のコードも織り込まれることに注意してください。
├── build ←Gulpのビルド・タスクにより生成(Git管理外) │ ├── es │ │ ├── app.min.js ←main.tsとPromise/A+ポリフィルのコードが含まれる │ │ └── app.min.js.map │ └── index.html ├── gulpfile.js ←Gulpのタスク定義ファイル ├── node_modules ←npmモジュールのディレクトリ(Git管理外) │ └── (...省略...) ├── package.json ←npm向けのパッケージ定義ファイル ├── src ←HTMLやTSのソースコードのディレクトリ │ ├── es │ │ ├── main.js ←tsconfig.jsonに基づき生成されたjsファイル(Git管理外) │ │ ├── main.min.js ←tsconfig.jsonに基づき生成されたsourcemapファイル(Git管理外) │ │ └── main.ts ←エントリーポイントのコードを記述するtsファイル │ └── index.html └── tsconfig.json ←TypeScriptのビルド設定ファイル
TypeScriptコード
サンプルとして今回作成したTypeScriptコードは以下の通り。先頭部で<reference/>
タグで型定義ファイル(アンビエント)を読み込み、続いてimportステートメントでjQueryとPromise/A+のポリフィルを読み込んでいます。
その後、ブラウザの開発者ツールのコンソールにメッセージを出力するだけのビジネスロジックを記述しています。Greeter#greet()
は同期的に、Greeter#greetAsync()
は非同期的にメッセージ出力を行います。後者の戻り値の型はPromise<string>
となる点に注意してください。C#のasyncメソッドであればTask<string>
となるところです。
/// <reference path="../../node_modules/@types/jquery/index.d.ts" /> /// <reference path="../../node_modules/@types/es6-promise/index.d.ts" /> // jQueryをロードする(実際にはロジック内で使用していないので最終的なビルド成果物にコードは含まれない) import * as $ from 'jquery'; // ES6より前の環境でasync/awaitを使うため前提となるポリフィルをロードする import {polyfill} from 'es6-promise'; // ポリフィルを有効化する polyfill(); // コンソールにあいさつを出力するだけのクラス class Greeter { private x : string; constructor(x : string) { this.x = x; } greet() : void { // 同期的にあいさつを出力 console.log(this.x); } async greetAsync() { // Promiseのコンストラクタに渡たす関数内のスコープでは // thisの意味が変わってしまうため、xをローカル変数にアサイン var x = this.x; // Promiseのインスタンスを生成する var p = new Promise<string>(function(resolve, reject) { // 1秒待機したのちただちにresolve(string)を呼び出す setTimeout(() => resolve(x), 1000); }); // Promise初期化直後のログ出力 console.log("#1 in greetAsync()."); // Promiseが表わす処理が終わったあと実行させたい処理を設定 p.then((d : string) => { // 非同期的にあいさつを出力 console.log(d + ' async'); }); // 事後処理設定の直後のログ出力 console.log("#2 in greetAsync()."); // Promiseが表わす処理の完了を待機する await p; // Promiseが表わす処理の完了後のログ出力 console.log("#3 in greetAsync()."); // Promiseを呼び出し元に返す return p; } } // あいさつクラスのインスタンスを取得 var g = new Greeter('bonjour'); // 同期メソッドを呼び出す前のログ出力 console.log("#1 in global."); // 同期メソッドを呼び出す g.greet(); // 同期メソッドを呼び出した後/非同期メソッドを呼び出す前のログ出力 console.log("#2 in global."); // 非同期メソッドを呼び出す g.greetAsync(); // 非同期メソッドを呼び出した後のログ出力 console.log("#3 in global.");
ビルドしたあとindex.htmlをブラウザで表示すると開発者ツールのコンソールには以下のような出力がなされます(Chromeの例):
コード上、await
キーワードのおかげで、その直後に記述されているメッセージ"#3 in greetAsync()."
の出力ロジックが、Promiseが表わす処理の完了後に起動されていることがわかります。
ビルド・タスクの定義
Gulpのビルド・タスクの定義は以下のようにしました。
'use strict'; var browserify = require('browserify'); var buffer = require('vinyl-buffer'); var del = require('del'); var gulp = require('gulp'); var gutil = require('gulp-util'); var rename = require("gulp-rename"); var source = require('vinyl-source-stream'); var sourcemaps = require('gulp-sourcemaps'); var tsconfig = require('./tsconfig.json'); var tsify = require('tsify'); var typescript = require('gulp-typescript'); var uglify = require('gulp-uglify'); var watchify = require('watchify'); gulp.task('clean', () => del(['build'])); gulp.task('copy', () => gulp.src(['src/**/*.html']).pipe(gulp.dest('build'))); gulp.task('default', ['copy'], buildTask(false)); gulp.task('watch', ['copy'], buildTask(true)); function buildTask (watch) { // タスク本体となる関数を生成して呼び出し元に返す return () => { // Browserifyのインスタンスを初期化する // Tsifyプラグインを追加する var b = browserify({ entries: 'src/es/main.ts', debug: true }) .plugin(tsify, tsconfig); // buildTaskの第1引数にtrueが渡された場合 // Watchifyプラグインを追加する if (watch) b.plugin(watchify); // Browserifyにより実行時依存性解決されたJSファイルを // UglifyJSやsourcemapsにより変換する var f = () => b .bundle() .pipe(source('app.js')) .pipe(buffer()) .pipe(sourcemaps.init({loadMaps: true})) .pipe(uglify()) .pipe(rename({suffix: '.min'})) .on('error', gutil.log) .pipe(sourcemaps.write('./')) .pipe(gulp.dest('build/es')); // buildTaskの第1引数にtrueが渡された場合 // 'update'イベントのリスナー関数としてfを追加する if (watch) b.on('update', f); // 初回の変換処理を行う // buildTaskの第1引数にfalseが渡された場合これでタスクは完了 return f(); }; }
プロジェクトのルートでgulp
コマンドを実行すると'default'
タスクが実行され、TypeScriptコードのコンパイルと実行時依存性の解決、そして難読化・圧縮(uglify)が行われた後、jsファイルがmapファイルととともにビルド成果物として出力されます。
gulp watch
コマンドを実行すると'watch'
タスクが実行され、ファイル更新の監視が始まり、直後に前述の'default'
タスクと同じ処理が行われるとともに、TypeScriptファイルに変更があるたびコンパイル〜出力の手順が繰り返されます。Ctrl + C
で監視はストップします。
TypeScriptのビルド設定
最後にTypeScriptのビルド設定ファイルも見ておきましょう。"target"
でECMAScript第3版のランタイムをビルド・ターゲットとする旨を宣言し(これは指定しなくても同じ意味になります)、"lib"でTypeScript v2.1リリースノートに示されている値を宣言しています。
{ "compilerOptions": { "noImplicitAny" : true, "sourceMap": true, "target": "es3", "lib": ["dom", "es2015.promise"] }, "include": [ "src/**/*" ], "exclude": [ "build/**/*.ts", "node_modules/**/*", "**/*.spec.ts" ] }
前述の通り、プロジェクトをAtomでオープンしてTypeScriptファイルを編集したときでもそれが(コンパイル・エラーにならず)きちんと編集できることを今回の要件としています。このファイルはGulpのタスク(で使用されているBrowserifyのプラグインであるtsify)から参照されるとともに、Atom(のatom-typescriptパッケージ)からも参照されます。