Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

実際に作業したリポジトリはこれですが、環境構築部分だけ拾ってメモします。

以下の記事は、コードを書きながら徐々にライブラリを追加したり環境を直したりしています。完成した環境だけのリポジトリはこちらです。

前提

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

参考にしたページ

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

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

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

Node.jsとYarn

  • 参考ページの通りnvm-windowsを使った
  • 普通にインストーラでインストールした
  • nvmコマンドでNode.jsをインストール(最新安定版がどれかコマンドで確認する方法がわからなかったので、Node.jsのサイトで確認した)
$ 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を使った
  • 普通にインストーラでインストールした
$ yarn --version
0.21.3
  • yarn initするとpackage.jsonができる
$ cd my_proj_dir
$ yarn init
yarn init v0.21.3
  • Yarnでパッケージをインストールするのはyarn addコマンド
  • バージョンを固定するための情報が書かれたyarn.lockが生成される

参考ページのリポジトリを見ると、yarn.lockをバイナリファイルとしてマークしていました。
「リポジトリにコミットするけどdiffは表示されなくていい」ということだと思います。試したら快適になりました。

TypeScript

$ yarn add typescript --dev
:: (省略)
$ .\node_modules\.bin\tsc --version
Version 2.2.1

webpack

$ yarn add webpack ts-loader --dev
:: (省略)
$ .\node_modules\.bin\webpack --version
2.3.0

試しにビルドしてみる

  • 対応コミット
  • package.jsonscriptsを書き、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'
}
package.json(抜粋)
  "scripts": {
    "build": "webpack --config config/webpack.config.debug.js",
    "build:prod": "webpack --config config/webpack.config.prod.js"
  },
  • 以下のコマンドでファイルが生成されることを確認する
$ yarn build
:: bundle.jsとbundle.js.mapが生成される
$ yarn build:prod
:: bundle.jsが生成される(minifyされている)

TSLint

$ yarn add tslint --dev
:: (省略)
$ .\node_modules\.bin\tslint --version
4.5.1
package.json(scripts部分抜粋)
    "lint": "tslint ts/**/*.ts"

AVA

$ yarn add ava npm-run-all --dev
:: (省略)
$ .\node_modules\.bin\ava --version
0.18.2
  • ドキュメントによると、AVAはTypeScriptをサポートしている
  • Yarn(npm)でAVAをインストールすれば別途型定義をインストールする必要はない
  • よく読むと、package.jsonscripts"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で順次実行させるようにしました。
yarn testでテストが実行されます。

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

環境構築とは関係ないですが、AVAで非同期のテストを書くときにxoとavaでお手軽リント・テスト環境構築を参考にしました。

nyc

Isomorphic TypeScript, fetch, promises, ava and coverageTestsの項目を読んで設定しましたが、あまり深く調べていません……。

$ yarn add nyc --dev
:: (省略)
$ .\node_modules\.bin\nyc --version
10.1.2
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"

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

開発を進めるうちに、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のページ
  2. webpackでのビルド時にsrcの場所を解決できるようにする(参考:webpackのResolveのページ
  3. AVA(nyc)実行時にsrcの場所を理解できるようにする(参考:Node.jsのModulesのページ

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

tsconfig.json(抜粋)
    "baseUrl": ".",
    "paths": {
      "*": [
        "*",
        "ts/src/*"
      ]
    },
webpack.config.debug.js(抜粋)
  resolve: {
    extensions: ['.ts', '.js']
    extensions: ['.ts', '.js'],
    modules: ['.', 'ts/src/', 'node_modules']
  },
  • AVA(nyc)実行時に環境変数NODE_PATH=build/srcを渡す
package.json(scripts部分抜粋)
    "test:cover": "cross-env NODE_PATH=build/src nyc ava build/**/*.test.js",
  • .tsファイルでimportするときに相対パスをつけない('./foo'ではなく'foo'にする)

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

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

参考ページにもあったwebpack-mergeを導入し、環境変数で切り替えるように変更しました。

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",

ついでに、resolvemodulestsconfig.jsoncompilerOptions.pathsから読み込むように修正しました。

webpack-dev-server

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

$ yarn add webpack-dev-server --dev
:: (省略)
$ .\node_modules\.bin\webpack-dev-server --version
webpack-dev-server 2.4.2
webpack 2.3.0
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やテストは実行されません。個人的には、その辺は別途実行すればいいかなと思っているので、そのままにする予定です。

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

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

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

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

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

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

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

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

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

  • 解決するパスを追加するたびに、tsconfig.jsonwebpack.config.jspackage.jsonと複数ファイルを編集するのはつらい
  • NODE_PATHに複数のパスを指定する場合、Windowsではセミコロン、それ以外ではコロンで区切る仕様であるため、package.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を使ってNODE_PATHの値を環境に合わせて変える

env-cmd

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 設定ファイル コマンドとオプションのように指定します。

env-cmd envvalues.js node foo.js

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

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

まず、tsconfig.jsoncompilerOptions.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で使用できます。

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.jsonpathsに加える
  • npmパッケージ化してnode_modules以下に配置する

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

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

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

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

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

そして.tsファイルからは、importではなくTriple-Slash Directivesで参照します。

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

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

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

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

AVAのドキュメントを見ると、Configurationrequireという項目があります。

詳細な説明が見つかりませんでしたが、どうやらここに指定されたファイルはテスト実行時に自動でrequireされるようです。(使用例:Setting up AVA for browser testing

モジュール化されていないレガシーなライブラリはただ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に追記します。

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

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

まとめ

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

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

上述の環境を構築済みのboilerplateリポジトリはこちらです。

noonworks
準備中
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away