皆さんこんにちは。adingoにてFluctという広告配信システムの管理画面を中心にクライアントサイドの開発を行っております、大関です。
今回は、表題の通り、実際にプロダクトとして動いている既存のコードベースを、ES5ベースからTypeScriptに段階的に移行させた話について書こうと思います。
型定義ファイル︵
DefinitelyTypedにて公開されている
移行前のコードベース及び直面した課題
今年の1月頃から、アプリケーションのクライアント側の一部を、以下の構成で実際に開発しています。 ●言語 ●ECMAScript 5 ●主要な依存ライブラリ ●UI開発にReactおよびFacebook JSX syntax ●統合イベントシステムとしてのRxJS ●テストコードのアサーションにpower-assert ●ビルドチェーン ●モジュール連結にbrowserify ●環境変数に基づくビルドフラグ用途でenvify ●コードの解析とLintにESLint ●未使用変数や未定義変数の検出などに非常に有用 ●基本指針 ●Fluxパターンの踏襲 ●Multilayered architectureのエッセンスを軽く振りかける ●TypeScript移行直前のコード規模 ●行数: 5600強︵テストコード、ビルド設定、モックサーバー除く︶ ●ファイル数: 70︵テストコード、ビルド設定、モックサーバー除く︶ 繰り返しになりますが、クライアントサイドのJavaScriptファイルだけで、この状態となります。CSSやHTMLファイルなどは含んでいません。 この状況に於いて、以下のような課題に直面する事になりました。 ●JSDocには強制力が無い ●関数のシグニチャが変わった場合、レビュー時に指摘し忘れるとそのまま素通り ●テストコードを読めばわかる場合もあるが、フラストレーションが溜まる ●引数の数が違うなどの、本当にしょうもないエラーも実際に動かすまでは検知できない ●単純な引数ミスでエラーになると、開発時のフラストレーションを溜めてしまって、精神衛生上よろしくない ●IDE支援が薄い ●引数のシグニチャの型までは提示してくれない 以上の課題を解決する為、コードを静的解析し、実行前に静的に解決できる︵はずの︶型の整合性などの問題を検出するべく、TypeScriptへの移行を行う事にしました。移行に伴う要求と、それを満たすビルドチェーンの構築
前述の状況より、移行するに際しては、以下の要求を満たす必要があります‥ ●強制力のある型チェック ●インクリメンタルな移行の実現 ●流石に70ファイルをエイヤッで移行するのは、あまり現実的ではない︵コードレビュープロセス的にも︶ ●Facebook JSX構文の存在の容認 ●既に書かれているJSXベースのReactComponentを、直接React.createElement
に移行するのはやりたくないし、メンテナンス性の点から許容し難い
これらの要求を満たすため、私たちはC言語のコンパイルプロセスまで立ち戻ることにしました。
初心者向けのC言語の教本でおなじみのコンパイルプロセスは以下の通りです。
(一)compile: Cのソースコードをオブジェクトファイルに変換する
(二)link: 1で生成したオブジェクトファイルをリンクし、実行形式のバイナリを生成する
これを応用することで、以下のようなビルドプロセスが導き出されます。
(一)compile: TypeScriptのコードをコンパイルすることで、JavaScriptコードを生成する
●型チェックに失敗すれば、この時点で検知できる
●TypeScriptに移行していないJavaScriptのコードは、ここでLintする
(二)link: 1で生成したJavaScriptコードを、browserifyでリンクする
●1で生成したコードを、さながらCのビルドプロセスにおけるオブジェクトファイルのように取り扱う
これにより、以下のように要求を満たす事ができました。
●インクリメンタルな移行の実現
●TypeScriptに移行していないファイルも、工程2でうまく混ぜてリンクできる
●Facebook JSX構文の存在の容認
●工程1もしくは工程2で通常のJavaScriptコードに変換してしまえば問題ない
デバッガビリティの確保
このように複数の変換工程を経た後のコードは、往々にして変換前のコードとかけ離れた姿となっているので、デバッグを容易に行うためには変換の前後での対応を取る必要があります。ですが、JavaScriptの世界にはsource mapと呼ばれるデバッグシンボルに相当する仕組みが存在しますので、これを使えばデバッガビリティが確保できる、というのは世のJavaScripterにとっては周知の事実だと思います。 しかしながら、一般論として、二段階以上の変換を越えてsoruce mapを引き継ぐには少々手間がかかります。このTypeScriptとJSX syntaxの混合という分野には先行事例が存在しており、”JSX と TypeScript の混合 Flux または悪魔合体 ::ハブろぐ”にて、二段階変換時のsource mapの引き継ぎによる解決法が示されていますが、ビルドチェーンの複雑化を避ける観点から、このような前処理を自前で用意するのは避けたいところです。 そこで、私たちは、TypeScriptをES6 targetでコンパイルする事により、source mapの引き継ぎ問題を回避することにしました。TypeScriptを、ES6 targetでコンパイルした場合に生成されるコードは、以下のような特徴を持ったJavaScriptコードになります‥ ●TypeScriptの型注釈が落ちただけのTypeScriptに見える、ES6なJavaScriptコード ●TypeScriptの独自拡張が変換された、ES6なJavaScriptコード これで、可読性が高い状態 かつ 元のTypeScriptに近しい JavaScriptのコードを得る事ができました。あとはこれを、browserifyでリンクすれば、source mapの複数段引き継ぎ処理を行う事無く、デバッガビリティを担保した実行形式ファイルを得る事ができます。 この手順を踏む事で生成されるsource mapには、TypeScriptの時点の情報が存在しないため︵引き継いでいないため︶、ブラウザのデバッガに表示されるコードには、大元のTypeScriptに存在する型注釈やインターフェースの宣言は存在しません。ですが、どうせデバッグの際に気にするのはコードのロジックの流れだけですし、手元に元となったTypeScriptのソースコードがあるので、特に問題はないと判断しました。 ES6 targetで生成したコードは、ES6水準のJavaScriptのため、そのままでは現在のブラウザで実行できないコードが多数混じっています。これは、babelifyを用いることで、browserifyでのリンク中に、babelによるES6 -> ES5への変換を加えることができるので、特に気にする事なくES5水準のコードとして出力できます。最終形態
最終的なビルドチェーンは、このようになりました。 (一)compile: TypeScript or ESLint or その他の変換器でES6水準のコードを検証・生成する (二)link: browserify + babelifyで、1で生成されたコードをES5水準に変換しつつ、リンクして実行可能なJavaScriptに落とし込む 概念としては特に複雑でも何でもないですね。移行推移
それでは、実際にどのようにコードを移行したのか見てみましょう.Step 1. babelifyの導入
まず、先にも述べたbabelify︵babel︶を導入する事でビルド基盤を整備することにしました。 同時に、browserify向けの以下のプラグインを削減・統合します。 ●reactify ●babelはJSXをReactの関数に変換する機構を持っているので、統合 ●結果として、Reactプロジェクトがbabelの使用を推奨するようになったので、今後は必須になると思われる ●envify ●babelのutility.inlineEnvironme
ntVariables
で代用
このフェーズの時点では、特にどうのこうのと言う事はありません。ただ置き換えるだけです。
babel自体は非常に高サイクルでリリースされている為、babelifyが変換に用いているbabel-coreのバージョンを固定したい場合があると思います。これもpackage.json
のde
vDependencies
にbabel-coreの任意のバージョンを指定することで解決できます。
Step 2. class syntaxなどのES6機能の解禁
babelを導入した事でES6の機能が使用できるようになりました。これに伴い、TypeScriptなどで型表現のキーコンポーネントとして用いられているclass syntaxや、単純に便利なarrow functionなどの機能を解禁し、TypeScript移行前に、既存のES水準のコードに対して、一通りの下準備︵class syntaxへの移行など︶を行います。 ESLintの設定で、ES6の機能の解禁の可否を管理できますので、お好みに応じて調整が可能です。 このフェーズは、あくまでもTypeScript移行時の負荷を長期に分けて分散する意味合いしか無いので、スキップする事例もあり得るとは思います。Step 3. TypeScriptとの混合ビルドプロセスを作る
先述のビルドチェーン設計を元にビルドプロセスを作ります。 ここで気をつけたいのは、2015年6月23日現在でnpmで公開されているTypeScriptのバージョン︵typescript@1.
5.0-beta
︶では、ES6 targetの使用時に、d.ts
ファイルとの総合運用性に難があるという点です。
ですが、この問題はgithub上のmasterブランチ上では解決されている問題ですので、開発版をサクッと使って解決しましょう。適当に安定してそうなリビジョンを選んでpackage.jsonに書けば終わりですし、ソフトウェアの安定版などは誰かが安定したと思っているリビジョンでしかないので、何も躊躇するところはありません。
Step 4. TypeScriptに移行できるファイルを全てTypeScriptに変える
基本は気合いで頑張りましょう。5000行 + 70ファイル程度ですが、うち15ファイルはReact用のJSX記載ファイルなので50ファイル強だけ移行すれば良い計算です。十分に何とかなる規模ですね。型定義ファイル︵d.ts
ファイル︶の取り扱い
DefinitelyTypedにて公開されているd.ts
ファイルの中には、ES6 targetでのコンパイルに失敗する物がありますが、短期的には自リポジトリ内に含めてhackを入れつつも、長期的にはpull requestを送り、upstreamで解決する方針としました。
非TypeScriptなファイルをTypeScriptからimportするには
これは、例えば、JSXを含んだJSファイルが該当します。 github:Microsoft/TypeScriptのwikiのModuleに関するページに書かれているように、importしたいファイルと同名のd.ts
ファイルを作って解決すれば良いのは同じです。
外部ライブラリなどによくある問題として、マイナーなライブラリだと型定義ファイルが無いケースがありますが、これに関しては、型定義ファイルを作成する以外の方法として、any
型で対象のライブラリを読み込みつつ、アダプタとなるラッパーモジュールをTypeScriptで記述し、戦術的な設計の問題で解決することで、上手く混ぜ込む事に成功しました。RustのC FFI bindingにおけるunsafeとの境界を連想してもらえれば、わかりやすいと思います。