実際に作業したリポジトリはこれですが、環境構築部分だけ拾ってメモします。
以下の記事は、コードを書きながら徐々にライブラリを追加したり環境を直したりしています。完成した環境だけのリポジトリはこちらです。
前提
- 旧石器時代の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
- 対応コミット
- TypeScript用loaderの
ts-loader
も一緒に追加
$ yarn add webpack ts-loader --dev
:: (省略)
$ .\node_modules\.bin\webpack --version
2.3.0
試しにビルドしてみる
- 対応コミット
-
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の有無
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'
}
"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
- 対応コミット
- TypeScript用のLintツールTSLint
- LintルールはTypeScript再入門覚書 ① Atom環境構築編を参考にした
-
package.json
にscripts
を書き、yarn lint
で実行する
$ yarn add tslint --dev
:: (省略)
$ .\node_modules\.bin\tslint --version
4.5.1
"lint": "tslint ts/**/*.ts"
AVA
- 対応コミット
- 後で使う予定のnpm-run-allも入れておいた
$ yarn add ava npm-run-all --dev
:: (省略)
$ .\node_modules\.bin\ava --version
0.18.2
- ドキュメントによると、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
に書く
{
"compilerOptions": {
"module": "commonjs",
"target": "es2015",
"noImplicitAny": true,
"inlineSourceMap": true,
"outDir": "build"
}
}
package.json
ではtsc
のタスクとAVA実行のタスクを別に作って、npm-run-allで順次実行させるようにしました。
yarn test
でテストが実行されます。
"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 coverageのTestsの項目を読んで設定しましたが、あまり深く調べていません……。
$ yarn add nyc --dev
:: (省略)
$ .\node_modules\.bin\nyc --version
10.1.2
"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
のパスを見てくれるようにします。そのために必要なのは以下の設定です。
-
tsc
ビルド時にsrc
の場所を解決できるようにする(TSLintもこの設定を見てくれるはず)(参考:TypeScriptのModule Resolutionのページ) - webpackでのビルド時に
src
の場所を解決できるようにする(参考:webpackのResolveのページ) - AVA(nyc)実行時に
src
の場所を理解できるようにする(参考:Node.jsのModulesのページ)
実際に修正したのは以下の通りです。
-
tsconfig.json
ではbaseUrlとpathを設定する
"baseUrl": ".",
"paths": {
"*": [
"*",
"ts/src/*"
]
},
- webpackではresolveのmodulesを設定する
resolve: {
extensions: ['.ts', '.js']
extensions: ['.ts', '.js'],
modules: ['.', 'ts/src/', 'node_modules']
},
- AVA(nyc)実行時に環境変数
NODE_PATH=build/src
を渡す
"test:cover": "cross-env NODE_PATH=build/src nyc ava build/**/*.test.js",
-
.ts
ファイルでimport
するときに相対パスをつけない('./foo'
ではなく'foo'
にする)
webpack設定ファイルが2つあるのが気に入らない
テストのディレクトリを分ける作業中、webpack.config.debug.js
とwebpack.config.prod.js
の2つを編集するのが嫌になりました。
参考ページにもあったwebpack-mergeを導入し、環境変数で切り替えるように変更しました。
"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
環境がほとんど出来上がったので、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
"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の対象外にしました。
"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
が環境依存になってしまう
"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つの環境変数を設定するファイルとして使用できます。
ENV1=abc
ENV2=def
{
"ENV1": "abc",
"ENV2": "def"
}
exports.ENV1 = "abc";
exports.ENV2 = "def";
実行時はenv-cmd 設定ファイル コマンドとオプション
のように指定します。
env-cmd envvalues.js node foo.js
つまり、設定ファイルを.js
にして、その中でOSをチェックしexports
を分岐させれば、OSごとに異なる環境変数NODE_PATH
を設定することが可能になります。
モジュール解決パスの設定一元化
まず、tsconfig.json
のcompilerOptions.paths
を読んで、モジュールのパスをいい感じに取り出す.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
で使用できます。
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で参照します。
/// <reference path="../typings/legacy_library.d.ts" />
これはモジュールのimport
とは異なり、型定義ファイルを参照するだけです。
.js
へのトランスパイルの際にrequire
になったり、webpackでファイルが1つにまとめられたりしません。
<script>
タグで別途読み込むためbundle.js
にまとめられないのは都合がよいのですが、このままではテスト実行時にもファイルが読み込まれません。
これをなんとかする方法は、テストフレームワークごとに異なります。長くなったので次の段落で説明します。
AVA実行時にレガシーなJavaScriptライブラリを読み込ませる
AVAのドキュメントを見ると、Configurationにrequire
という項目があります。
詳細な説明が見つかりませんでしたが、どうやらここに指定されたファイルはテスト実行時に自動でrequire
されるようです。(使用例:Setting up AVA for browser testing)
モジュール化されていないレガシーなライブラリはただrequire
しても使えないので、ファイルの内容をeval
して必要なオブジェクトをglobal
に追加するヘルパースクリプトを作成しました。
// ここでファイルパスと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
に追記します。
"ava": {
"powerAssert": true,
"require": [
"./config/require_helper.js"
]
},
以上で、レガシーなJavaScriptライブラリを使用した.ts
ファイルのテストができるようになりました。
eval
を使っているところから見てもやはりダーティハック感は否めないので、特別な事情がない限りは、レガシーライブラリをモジュール化した方が手っ取り早いし、今後の応用も利くと思います。
まとめ
モジュール解決のあたりで、TypeScript/webpack/Node.jsそれぞれの解決方法を混同してちょっとハマりました。解ってしまえば簡単なのですが……。
レガシーなライブラリの読み込み方法などはかなり我流です。もっとスマートなやり方があるかもしれません。
上述の環境を構築済みのboilerplateリポジトリはこちらです。