TypeScript+webpack+TSLint+AVA+nyc (2017.03)

実際に作業したリポジトリは[これ](https://github.com/noonworks/Dokabenizer)ですが、環境構築部分だけ拾ってメモします。

以下の記事は、コードを書きながら徐々にライブラリを追加したり環境を直したりしています。完成した環境だけのリポジトリは[こちら](https://github.com/noonworks/ts-boilerplate-201703)です。


## 前提

* 旧石器時代のJSで書かれたごく小さなアプリがある
* TypeScriptで書きたい
* テストがないのでつけたい
* Lintがないのでつけたい
* Windowsでも開発できるようにする
* （今回は）タスクランナーは使わない
* （今回は）UI関連のライブラリは使わない


## 参考にしたページ

* [Modern JavaScript概観、そしてElectronへ](http://blog.satotaichi.info/modern-javascript_201701/)

各要素の役割と選定理由が順番に説明されており、理路整然としていて平易なのでご一読をお勧めします。（この記事は、参考ページに書いてあることは省いて説明しています。）
以降の説明内で「参考ページ」と出てきたらここです。

この記事が参考ページと異なるのは以下の点です。

* flowではなくTypeScriptを使う
* babelは使わない
* ESLintではなくTSLintを使う
* webpackが2系である


## Node.jsとYarn

* 参考ページの通り[nvm-windows](https://github.com/coreybutler/nvm-windows)を使った
* 普通にインストーラでインストールした
* `nvm`コマンドでNode.jsをインストール（最新安定版がどれかコマンドで確認する方法がわからなかったので、[Node.jsのサイト](https://nodejs.org/ja/)で確認した）

```bat
$ nvm version
1.1.2
$ nvm install 6.10.0
:: （省略）
$ nvm use 6.10.0
Now using node v6.10.0 (64-bit)
$ nvm list
  * 6.10.0 (Currently using 64-bit executable)
```

* 参考ページの通り[Yarn](https://yarnpkg.com/)を使った
* 普通にインストーラでインストールした

```bat
$ yarn --version
0.21.3
```

* `yarn init`すると`package.json`ができる

```bat
$ cd my_proj_dir
$ yarn init
yarn init v0.21.3
```

* Yarnでパッケージをインストールするのは`yarn add`コマンド
* バージョンを固定するための情報が書かれた`yarn.lock`が生成される

[参考ページのリポジトリを見ると、`yarn.lock`をバイナリファイルとしてマーク](https://github.com/taichi/js-boilerplate/blob/frontend/.gitattributes)していました。
「リポジトリにコミットするけどdiffは表示されなくていい」ということだと思います。試したら快適になりました。


## TypeScript

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/896b94bc4dcc730a51455b074d2fc2b93c462595)
* 今回はプロジェクトローカルにインストールした

```bat
$ yarn add typescript --dev
:: （省略）
$ .\node_modules\.bin\tsc --version
Version 2.2.1
```


## webpack

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/577e4d0f27cc1a4acaabd658fc8b98a1da400e7c)
* TypeScript用loaderの`ts-loader`も一緒に追加

```bat
$ yarn add webpack ts-loader --dev
:: （省略）
$ .\node_modules\.bin\webpack --version
2.3.0
```


## 試しにビルドしてみる

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/4349c49c48cee56543c670bf1a5bfbf0d1a93edc)
* `package.json`に`scripts`を書き、`yarn コマンド`で実行する
* この時点では`tsconfig.json`はテキトー
* この時点ではディレクトリ構成もテキトー
* webpackの設定は別ディレクトリにして、リリース用と開発用で分けてみる

```
my_proj_dir/
　│
　├ config/
　│　├ webpack.config.prod.js <- 設定ファイル（リリース用）
　│　└ webpack.config.debug.js <- 設定ファイル（開発用）
　│
　├ ts/
　│　└ *.ts <- ソース
　│
　├ bundle.js <- これが生成される
　│ 
　└ （package.json 等）
```

* webpackの設定で`resolve`に`.ts`の拡張子を追加しないと、`import Foo from "./foo";`みたいな書き方が出来ない
* リリース用設定と開発用設定の違いはsource mapとminifyの有無

```webpack.config.debug.js
var webpack = require('webpack');
var path = require('path');

module.exports = {
  context: path.join(__dirname, ".."),
  entry: './ts/index.ts',
  output: {
    filename: 'bundle.js'
  },
  resolve: {
    extensions: ['.ts', '.js']
  },
  module: {
    loaders: [
      {
        test: /\.ts$/,
        loader: 'ts-loader'
      }
    ]
  },
  devtool: 'source-map'
}
```

```json:package.json（抜粋）
  "scripts": {
    "build": "webpack --config config/webpack.config.debug.js",
    "build:prod": "webpack --config config/webpack.config.prod.js"
  },
```

* 以下のコマンドでファイルが生成されることを確認する

```bat
$ yarn build
:: bundle.jsとbundle.js.mapが生成される
$ yarn build:prod
:: bundle.jsが生成される（minifyされている）
```


## TSLint

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/92fa460b28a755892dcaea721595dcd7e19f4513)
* TypeScript用のLintツール[TSLint](https://palantir.github.io/tslint/)
* Lintルールは[TypeScript再入門覚書 ① Atom環境構築編](http://qiita.com/junkjunctions/items/5938a6d706548cd91218)を参考にした
* `package.json`に`scripts`を書き、`yarn lint`で実行する

```bat
$ yarn add tslint --dev
:: （省略）
$ .\node_modules\.bin\tslint --version
4.5.1
```

```json:package.json（scripts部分抜粋）
    "lint": "tslint ts/**/*.ts"
```


## AVA

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/14966568d0c18bfd59fa55351ddc4d89d6f87c9d)
* 後で使う予定の[npm-run-all](https://www.npmjs.com/package/npm-run-all)も入れておいた

```bat
$ yarn add ava npm-run-all --dev
:: （省略）
$ .\node_modules\.bin\ava --version
0.18.2
```

* [ドキュメント](https://github.com/avajs/ava/blob/master/docs/recipes/typescript.md)によると、AVAはTypeScriptをサポートしている
* Yarn（npm）でAVAをインストールすれば別途型定義をインストールする必要はない
* よく読むと、`package.json`の`scripts`に`"test": "tsc && ava"`と書いてあるので、TypeScriptをそのまま実行しているわけではない（そりゃそうか）

ということで、AVAでテストするために一度`tsc`して`.js`ファイルを生成することになります。

* 最終的に生成される`bundle.js`にはテストコードを含めたくない
* AVAがサポートする`tsc`のオプションは`"module": "commonjs", "target": "es2015"`のみなので、`bundle.js`生成時のコンパイルオプションとは異なる
* 実質的にsource map必須（テストがエラーになったとき`.ts`の何行目かがわからなきゃやってられない）なので、`bundle.js`生成時のコンパイルオプションとは異なる

以上から、今回は以下のようにしました。

* テスト用のファイルは`bundle.js`とは別に生成する
* `tsconfig.json`にはテスト用の`tsc`設定を書く
* `bundle.js`生成用の設定と違う値は`webpack.config.xxx.js`に書く

```tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es2015",
    "noImplicitAny": true,
    "inlineSourceMap": true,
    "outDir": "build"
  }
}
```

`package.json`では`tsc`のタスクとAVA実行のタスクを別に作って、[npm-run-all](https://www.npmjs.com/package/npm-run-all)で順次実行させるようにしました。
`yarn test`でテストが実行されます。

```json:package.json（scripts部分抜粋）
    "test:tsc": "tsc",
    "test:run": "ava build/**/*.test.js",
    "test": "run-s test:tsc test:run"
```

環境構築とは関係ないですが、AVAで非同期のテストを書くときに[xoとavaでお手軽リント・テスト環境構築](https://t32k.me/mol/log/xo-and-ava/)を参考にしました。


## nyc

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/cbc977541f3eb48c03f418cc47545916e65e2b19)

[Isomorphic TypeScript, fetch, promises, ava and coverage](http://source.coveo.com/2016/05/11/isomorphic-typescript-ava-w-coverage/)の[Tests](http://source.coveo.com/2016/05/11/isomorphic-typescript-ava-w-coverage/#tests)の項目を読んで設定しましたが、あまり深く調べていません……。

```bat
$ yarn add nyc --dev
:: （省略）
$ .\node_modules\.bin\nyc --version
10.1.2
```

```json:package.json（scripts部分抜粋）
    "test:tsc": "tsc",
    "test:run": "ava build/**/*.test.js",
    "test:cover": "nyc ava build/**/*.test.js",
    "test": "run-s lint test:tsc test:cover"
```


## ディレクトリ構成が気に入らない

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/c30ab89cb005a04d998f728c797e1e914f307cd8)

開発を進めるうちに、`ts`ディレクトリ内にテストコードが混在することが嫌になってきました。以下のようにディレクトリを分けます。

```
my_proj_dir/
　│
　├ ts/
　│　├ src
　│　│　└ *.ts <- アプリのソース
　│　└ test
　│　　　└ *.test.ts <- テストコード
```

`.test.ts`ファイルで`import * as Foo from '../src/font';`などと書くのは嫌なので、`src`のパスを見てくれるようにします。そのために必要なのは以下の設定です。

1. `tsc`ビルド時に`src`の場所を解決できるようにする（TSLintもこの設定を見てくれるはず）（参考：[TypeScriptのModule Resolutionのページ](https://www.typescriptlang.org/docs/handbook/module-resolution.html)）
2. webpackでのビルド時に`src`の場所を解決できるようにする（参考：[webpackのResolveのページ](https://webpack.js.org/configuration/resolve/)）
3. AVA（nyc）実行時に`src`の場所を理解できるようにする（参考：[Node.jsのModulesのページ](https://nodejs.org/api/modules.html)）

実際に修正したのは以下の通りです。

* `tsconfig.json`では[baseUrlとpath](https://www.typescriptlang.org/docs/handbook/module-resolution.html#base-url)を設定する

```json:tsconfig.json（抜粋）
    "baseUrl": ".",
    "paths": {
      "*": [
        "*",
        "ts/src/*"
      ]
    },
```

* webpackでは[resolveのmodules](https://webpack.js.org/configuration/resolve/#resolve-modules)を設定する

```js:webpack.config.debug.js（抜粋）
  resolve: {
    extensions: ['.ts', '.js']
    extensions: ['.ts', '.js'],
    modules: ['.', 'ts/src/', 'node_modules']
  },
```

* AVA（nyc）実行時に環境変数`NODE_PATH=build/src`を渡す

```json:package.json（scripts部分抜粋）
    "test:cover": "cross-env NODE_PATH=build/src nyc ava build/**/*.test.js",
```

* `.ts`ファイルで`import`するときに相対パスをつけない（`'./foo'`ではなく`'foo'`にする）


## webpack設定ファイルが2つあるのが気に入らない

* [対応コミット1](https://github.com/noonworks/Dokabenizer/commit/bfd87e7738755dc205b0fb4a38cb00a89b0b184f)、[対応コミット2](https://github.com/noonworks/Dokabenizer/commit/8dc762e31331ddf4021cc32c9e7bee5694cff79a)

テストのディレクトリを分ける作業中、`webpack.config.debug.js`と`webpack.config.prod.js`の2つを編集するのが嫌になりました。

参考ページにもあった[webpack-merge](https://www.npmjs.com/package/webpack-merge)を導入し、環境変数で切り替えるように変更しました。

```json:package.json（scripts部分抜粋）
    "build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.config.js",
    "build:prod": "cross-env NODE_ENV=production webpack --config config/webpack.config.js",
```

ついでに、`resolve`の`modules`を`tsconfig.json`の`compilerOptions.paths`から読み込むように修正しました。


## webpack-dev-server

* [対応コミット1](https://github.com/noonworks/Dokabenizer/commit/13e1f298d339d59061268b707d87b7c4784cd3b2)、[対応コミット2](https://github.com/noonworks/Dokabenizer/commit/4eaf66f1f0ec4f4efdd681547a785f23e2d6825d)

環境がほとんど出来上がったので、[webpack-dev-server](https://github.com/webpack/webpack-dev-server)を使って効率化します。
特に難しいことはしておらず、開発用のwebpack設定を使ってwebpack-dev-serverを起動しています。

```bat
$ yarn add webpack-dev-server --dev
:: （省略）
$ .\node_modules\.bin\webpack-dev-server --version
webpack-dev-server 2.4.2
webpack 2.3.0
```

```json:package.json（scripts部分抜粋）
    "start": "cross-env NODE_ENV=development webpack-dev-server --inline --hot --config config/webpack.config.js",
```

`yarn start`でローカルサーバーが起動するので、ブラウザで`localhost:8080`を開くことができます。あとは`.ts`ファイルを保存するたびに自動で`bundle.js`が（メモリ内で）更新されるため、開発がスムーズに進みます。

ただし、Lintやテストは実行されません。個人的には、その辺は別途実行すればいいかなと思っているので、そのままにする予定です。


## 自分で書いた型定義ファイル

* [対応コミット](https://github.com/noonworks/Dokabenizer/commit/165264f8ba7a04edb806807d37083aa6080bfe6e)

今回のアプリで[gif.js](https://github.com/jnordberg/gif.js)というライブラリを使ったのですが、これにはTypeScript用の型定義ファイルがありません。
そのため、自分で雑な型定義ファイルを作って置く場所がほしくなりました。

```
my_proj_dir/
　│
　├ ts/
　│　├ src
　│　│　└ *.ts
　│　├ test
　│　│　└ *.test.ts
　│　└ typings
　│　　　└ *.d.ts <- 雑な型定義ファイル
```

まず、雑な型定義を置きたいのでTSLintの対象外にしました。

```json:package.json（scripts部分抜粋）
    "lint": "tslint ts/**/*.ts -e ts/typings/*.d.ts",
```

次に、モジュール解決のパスについてです。以下の条件が加わります。

* `tsc`、webpackでのビルド時に解決されるようにする
* AVA実行時には`.js`ファイルを解決できないといけないので、`typings`ディレクトリではなく、`.js`ファイルのあるディレクトリを教えてやる


## モジュール解決パスに関係する問題

今回のプロジェクトでは特に問題にならなかったのですが、より汎用的な構成を考えると、ここで2つ問題が発生します。

* 解決するパスを追加するたびに、`tsconfig.json`、`webpack.config.js`、`package.json`と複数ファイルを編集するのはつらい
* `NODE_PATH`に複数のパスを指定する場合、**Windowsではセミコロン、それ以外ではコロンで区切る**仕様であるため、`package.json`が環境依存になってしまう

```json:package.json（scripts部分抜粋・Windows専用！）
    "test:cover": "cross-env NODE_PATH=build/src;js/lib nyc ava build/**/*.test.js",
```

これらをまとめて解決するために、以下の方法を採りました。

* 解決するパスは`tsconfig.json`を基本として、`webpack.config.js`および`package.json`の設定はそれを読んで使うようにする
* 読むときにWindowsとそれ以外の環境の違いを吸収する
* [env-cmd](https://www.npmjs.com/package/env-cmd)を使って`NODE_PATH`の値を環境に合わせて変える


## env-cmd

[env-cmd](https://www.npmjs.com/package/env-cmd)は、簡単に言えば「cross-envの設定をファイルでできるようにしたもの」です。
引数に渡されたファイル（もしくはディレクトリ内の`.env-cmdrc`というファイル）で指定された環境変数を設定して、コマンドを実行します。

プレーンな`KEY=VALUE`形式のテキストファイルの他に、JSONや`.js`ファイルを指定することができます。
以下のファイルはすべて、`ENV1=abc` `ENV2=def`の2つの環境変数を設定するファイルとして使用できます。

```envvalues.txt
ENV1=abc
ENV2=def
```

```envvalues.json
{
  "ENV1": "abc",
  "ENV2": "def"
}
```

```envvalues.js
exports.ENV1 = "abc";
exports.ENV2 = "def";
```

実行時は`env-cmd 設定ファイル コマンドとオプション`のように指定します。

```bat
env-cmd envvalues.js node foo.js
```

つまり、設定ファイルを`.js`にして、その中でOSをチェックし`exports`を分岐させれば、OSごとに異なる環境変数`NODE_PATH`を設定することが可能になります。


## モジュール解決パスの設定一元化

まず、`tsconfig.json`の`compilerOptions.paths`を読んで、モジュールのパスをいい感じに取り出す`.js`ファイルを作成します。

```read_tsconfig_lib_path.js
var tsconfig = require('../tsconfig.json');
var pathes = tsconfig['compilerOptions']['paths']['*'];
var outdir = tsconfig['compilerOptions']['outDir']; // ビルド後のjsが格納されるディレクトリ
var libPathes = [];
var nodePathes = [];
if (pathes !== undefined) {
  for (var i = 0; i < pathes.length; i++) {
    // 末尾の*を消す
    var p = pathes[i].replace(/\*$/g, '');
    if (p.length > 0) {
      libPathes.push(p);
      // NODE_PATHでは
      // * 末尾の/も不要
      // * tsディレクトリ→ビルド後のjsが格納されるディレクトリに書き換え
      nodePathes.push(p.replace(/^ts\//, outdir + '/').replace(/\/$/, ''));
    }
  }
}
// webpackで使う配列
exports.pathes = libPathes;
// NODE_PATHはOSごとに異なるセパレータでつなげた文字列
if (process.platform === 'win32') {
  exports.NODE_PATH = nodePathes.join(';');
} else {
  exports.NODE_PATH = nodePathes.join(':');
}
```

結果を`exports.NODE_PATH`に代入しているので、前述のenv-cmdではそのまま設定ファイルとして使用できます。
また、`webpack.config.js`ファイルからは`require`で使用できます。

```js:webpack.config.js（抜粋）
var lib = require('./read_tsconfig_lib_path.js');
var resolveModules = lib.pathes;
resolveModules.unshift('.');
resolveModules.push('node_modules');
// 中略
  resolve: {
    extensions: ['.ts', '.js'],
    modules: resolveModules
  },
```


## JavaScript製ライブラリを使うときの問題

TypeScriptでJavaScript製のライブラリを使うときには、ライブラリがモジュール化されているかどうかで読み込み方法が変わります。

### モジュール化されているライブラリ

Node.jsで使用できるようモジュール化されたライブラリは、特に難しいことを考えずに使用できます。

npmパッケージ化されているものには、型定義ファイルが付属していたり、別途`@types/モジュール名`として型定義ファイルがパッケージ化されているものもあります。
型定義ファイルが存在しない場合でも、自分で雑に型定義を書いて`ts/typings`に置けば問題ありません。

「モジュール化されているがnpmパッケージ化されていない」ものについては、npmパッケージとは異なり、ビルド時やテスト時に読み込めるように考える必要があります。

* ライブラリのパスを`tsconfig.json`の`paths`に加える
* npmパッケージ化して`node_modules`以下に配置する

今後の応用が利くので、npmパッケージ化してしまうのがいいかもしれません。

### モジュール化されていないレガシーなライブラリ

HTMLの`<script>`タグで読み込むことを前提に作られているライブラリなどが該当します。
これらのファイルは、通常`.ts`ファイルから`import`できません。

このタイプのライブラリを使う場合、ファイルを修正しモジュール化するのが手っ取り早いと思われますが、ライセンスの都合等でそうもいかない場合もあるかと思います。

モジュール化せずHTMLの`<script>`タグ読み込みで使用する場合、まずは型定義ファイルを作成し`ts/typings`に配置します。

そして`.ts`ファイルからは、`import`ではなく[Triple-Slash Directives](https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html)で参照します。

```use_legacy.ts
/// <reference path="../typings/legacy_library.d.ts" />
```

これはモジュールの`import`とは異なり、型定義ファイルを参照するだけです。
`.js`へのトランスパイルの際に`require`になったり、webpackでファイルが1つにまとめられたりしません。

`<script>`タグで別途読み込むため`bundle.js`にまとめられないのは都合がよいのですが、このままでは**テスト実行時にもファイルが読み込まれません。**
これをなんとかする方法は、テストフレームワークごとに異なります。長くなったので次の段落で説明します。


## AVA実行時にレガシーなJavaScriptライブラリを読み込ませる

AVAのドキュメントを見ると、[Configuration](https://github.com/avajs/ava#configuration)に`require`という項目があります。

詳細な説明が見つかりませんでしたが、どうやらここに指定されたファイルはテスト実行時に自動で`require`されるようです。（使用例：[Setting up AVA for browser testing](https://github.com/avajs/ava/blob/master/docs/recipes/browser-testing.md#setup-browser-env)）

モジュール化されていないレガシーなライブラリはただ`require`しても使えないので、ファイルの内容を`eval`して必要なオブジェクトを`global`に追加するヘルパースクリプトを作成しました。

```require_helper.js
// ここでファイルパスとexportするオブジェクトを指定している
// /// <reference path="..." /> を自動で読むようにした方が
// いいとは思うけれど面倒でやってない……
var scripts = [
  {
    file: 'js/lib/legacy_lib.js',
    exports: ['LegacyClass']
  }
];

var fs = require('fs');

for (var i = 0; i < scripts.length; i++) {
  var src = fs.readFileSync(scripts[i].file, 'utf8');
  eval(src);
  for (var j = 0; j < scripts[i].exports.length; j++) {
    var name = scripts[i].exports[j];
    eval('global.' + name + ' = ' + name);
  }
}
```

これを`require`するよう、設定を`package.json`に追記します。

```json:package.json（抜粋）
  "ava": {
    "powerAssert": true,
    "require": [
      "./config/require_helper.js"
    ]
  },
```

以上で、レガシーなJavaScriptライブラリを使用した`.ts`ファイルのテストができるようになりました。
`eval`を使っているところから見てもやはりダーティハック感は否めないので、特別な事情がない限りは、レガシーライブラリをモジュール化した方が手っ取り早いし、今後の応用も利くと思います。


## まとめ

モジュール解決のあたりで、TypeScript/webpack/Node.jsそれぞれの解決方法を混同してちょっとハマりました。解ってしまえば簡単なのですが……。

レガシーなライブラリの読み込み方法などはかなり我流です。もっとスマートなやり方があるかもしれません。

上述の環境を構築済みのboilerplateリポジトリは[こちら](https://github.com/noonworks/ts-boilerplate-201703)です。