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パッケージ)からも参照されます。
TS v2.1のasync/awaitのまえにPromises/A+について学ぶ
先日、TypeScript v2.1がリリースされ、そのリリースノートのなかの"Downlevel Async Functions"セクションで、ECMAScript v3(ES3)やv5(ES5)のランタイムでもasync/await
が使用可能になった(ただしそれらの環境にPromiseを導入するポリフィルのJSコードが読み込まれていることが前提)という記載がありました。
以前からasync/await
の機能は提供されていましたが、それはコンパイルのターゲット・ランタイムがECMAScript v6(ES6/ES2015)であることが条件でした。TS v2.1ではこの条件が緩和されたわけです。これは面白いことになってきました。
C#においてはasync/await
はTask
により実現されていますが、TypeScriptにおいてはPromise
により実現されています。なるほどわかりやすい対応関係です。ではまずPromise
についてやはりある程度きっちり知っておかなくては、ということで、Google Developersの"JavaScript Promises: an Introduction"(by Jake Archibald)という記事を読んでみました。
「お集まりの紳士淑女の皆さん、Web開発の転換点がやってきました」から始まるだいぶふざけた感じの前置きのあと、イベント・メカニズムとの比較を通じてPromise
についての解説が行われています。
イベント・メカニズムの課題
まずはイベント・メカニズムの問題点。次のようなコードではイベントリスナーの設定の前にイベントが起きてしまった場合、リスナー関数は実行されません:
var img1 = document.querySelector('.img-1'); img1.addEventListener('load', function() { // woo yey image loaded }); img1.addEventListener('error', function() { // argh everything's broken });
"load"
イベントについて対策を施したものは次のようになりますが、これでも依然として"error"
イベントには同じ問題が残ります:
var img1 = document.querySelector('.img-1'); function loaded() { // woo yey image loaded } if (img1.complete) { loaded(); } else { img1.addEventListener('load', loaded); } img1.addEventListener('error', function() { // argh everything's broken });
そしてWebページ上の画像は当然1つだけとは限りません。"load"
と"error"
を監視すべきリソースごとにこんなコードを書くのは現実的ではありません。
Promiseによる課題克服
そこでPromiseが登場します。Chrome 32、Opera 19、Firefox 29、Safari 8、そしてMicrosoft Edgeにおいてすでに、Promiseはデフォルトで使用可能になっています。Promise
をサポートしていなかったり、部分的にしかサポートしていない環境向けにはポリフィルが提供されています。
もしIMG要素がPromise
インスタンスを返すready()
メソッドを持っているとすれば(これは仮定の話ですが):
img1.ready().then(function() { // loaded }, function() { // failed }); // and… Promise.all([img1.ready(), img2.ready()]).then(function() { // all loaded }, function() { // one or more failed });
Promise
はイベントのメカニズムとはいくつかの点で異なります:
Promise
は何度も成功したり、何度も失敗したり、成功のあとに失敗したりといったことは起こりません。Promise
として表現された手続きに対して成功時と失敗時のそれぞれのコールバック関数を設定できますが、それらはいずれか片方、それもただ1回しか呼び出されません。- そして、もしコールバック関数が設定された時点で
Promise
として表現された手続きがすでに成功もしくは失敗していた場合での、コールバック関数は呼び出されます。
Promise
はQ、RSVP.jsなどいくつかのライブラリで実装されてきmしたが、それらとJavaScriptにネイティブに導入されたPromise
とが共有する標準的な挙動はPromises/A+として仕様化されています。
JavaScriptネイティブのPromise
の作成は次のように行います:
var promise = new Promise(function(resolve, reject) { // do a thing, possibly async, then… if (/* everything turned out fine */) { resolve("Stuff worked!"); } else { reject(Error("It broke")); } });
なお、明示的にreject()
を呼び出さなくても、エラーがスローされた場合はreject()
が自動的に呼び出されます。作成したPromise
は次のようにして使用できます:
promise.then(function(result) { console.log(result); // "Stuff worked!" }, function(err) { console.log(err); // Error: "It broke" });
PromiseとPromiseの合成
加えて、Promise
のthen()
呼び出しはチェインさせることで、データ変換の実行や非同期処理のシーケンシャルな実行を実現できます:
get('story.json').then(function(response) { return JSON.parse(response); }).then(function(response) { console.log("Yey JSON!", response); }) getJSON('story.json').then(function(story) { return getJSON(story.chapterUrls[0]); }).then(function(chapter1) { console.log("Got chapter 1!", chapter1); })
catch()
メソッドはthen(undefined, func)
メソッドと同じように機能します。注意が必要なのは、単にthen(func1, func2)
とした場合func1
とfunc2
のいずれか片方しか実行されないのに対して、then(func1).catch(func2)
とした場合func1
の処理が失敗するとfunc2
も呼び出されるということです(ちなみに大元のPromise
が失敗した場合func1
は呼び出されず、func2
のみが呼び出されます)。
さらに、then()
の第2引数を使用するにせよcatch()
の第1引数を使用するにせよ、一旦エラー・コールバックがエラーを処理すると、その次にチェインされているthen(func1, func2)
ではfunc1
が呼び出されます。
then()
はシーケンシャルな実行を提供する機能ですが、Promise.all([p0, p1, p2, ...])
は個々の並行に実行される処理からなる新しいPromise
を生成します。
"JavaScript Promises: an Introduction"の中では他にもいろいろなことが述べられていますが、ひとまずこんな感じでしょうか。
深緑野分『オーブランの少女』
- 作者: 深緑野分
- 出版社/メーカー: 東京創元社
- 発売日: 2016/03/22
- メディア: 文庫
- この商品を含むブログ (2件) を見る
デビュー作の「オーブランの少女」を含め5篇からなる1冊です。先日読んだ『分かれ道ノストラダムス』もそうでしたが、この作者さんは時間的な奥行きを持ったミステリーを書きます。その上作品世界の「現在」は近代だったり現代だったり、地域も英国だったり北欧だったり。その舞台のしつらえ方にちょっと米澤穂信の作品も思い出します。どのお話もまあ面白いのですが、いわゆるイヤミスとは違った意味で、どうもすっきりしない気分になる結末のものばかりでした。