M12i.

学術書・マンガ・アニメ・映画の消費活動とプログラミングについて

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の例):

f:id:m12i:20161224164719p:plain

コード上、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パッケージ)からも参照されます。

m12i.hatenablog.com