ES5 + Facebook JSXで書かれたJavaScriptコードベースをTypeScriptに移行させる

皆さんこんにちは。adingoにてFluctという広告配信システムの管理画面を中心にクライアントサイドの開発を行っております、大関です。

今回は、表題の通り、実際にプロダクトとして動いている既存のコードベースを、ES5ベースからTypeScriptに段階的に移行させた話について書こうと思います。

移行前のコードベース及び直面した課題

今年の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言語の教本でおなじみのコンパイルプロセスは以下の通りです。

  1. compile: Cのソースコードをオブジェクトファイルに変換する
  2. link: 1で生成したオブジェクトファイルをリンクし、実行形式のバイナリを生成する

これを応用することで、以下のようなビルドプロセスが導き出されます。

  1. compile: TypeScriptのコードをコンパイルすることで、JavaScriptコードを生成する
    • 型チェックに失敗すれば、この時点で検知できる
    • TypeScriptに移行していないJavaScriptのコードは、ここでLintする
  2. 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水準のコードとして出力できます。

最終形態

最終的なビルドチェーンは、このようになりました。

  1. compile: TypeScript or ESLint or その他の変換器でES6水準のコードを検証・生成する
  2. link: browserify + babelifyで、1で生成されたコードをES5水準に変換しつつ、リンクして実行可能なJavaScriptに落とし込む

概念としては特に複雑でも何でもないですね。

移行推移

それでは、実際にどのようにコードを移行したのか見てみましょう.

Step 1. babelifyの導入

まず、先にも述べたbabelify(babel)を導入する事でビルド基盤を整備することにしました。 同時に、browserify向けの以下のプラグインを削減・統合します。

このフェーズの時点では、特にどうのこうのと言う事はありません。ただ置き換えるだけです。

babel自体は非常に高サイクルでリリースされている為、babelifyが変換に用いているbabel-coreのバージョンを固定したい場合があると思います。これもpackage.jsondevDependenciesに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との境界を連想してもらえれば、わかりやすいと思います。

テストコードの移行

テストコードについては、TypeScriptへの積極的な移行は行っていません。

これは、一度書いたテストコードは、プロダクトコードよりも往々にしてライフサイクルが長く、できるだけポータビリティが高い方が、長期的に見て無難であると判断した為です。

将来的にTypeScriptでテストを記述する需要が高まった場合でも、ビルドチェーンとしては、プロダクトコードに対して用いた物の応用で構築できるため、特に問題はないと判断しています。

移行にかかった日数

今回は通常の開発作業を行いつつ、合間を縫ってstep 1~4の移行作業を行いました。よって、5月初頭のbabelへの移行開始から6月中旬のTypeScript化の完了まで、全体としては一ヶ月ほどかかっています。

各フェーズに要した時間は概算で以下のようになります(いずれも作業着手からレビューを経て、リポジトリのmaster/trunkへのmerge完了までの日数となります):

  • step 1: 1時間ほど
  • step 2: よく覚えていないが、そんなに苦労した記憶はない
  • step 3: 1日ほど
  • step 4: 4~5日くらい

他のツールを使わなかった理由

Closure Compiler

Closure Toolsに含まれるClosure Compilerによる、JSDocを用いたコードの静的解析と最適化も検討しましたが、コードベースの移行に伴う労力をTypeScript採用時のそれと比較検討した結果、TypeScriptへの移行の方がコストが低いと判断し、断念しました。

最初期からClosure Toolsを用いて開発を行うのであれば、(独自体系の趣はありますが)非常に強力かつ魅力的な選択肢であるとは思います。

Flowtype

FacebookによるFlowtypeの採用も同様に検討しました。段階的な移行など、私たちの要求に足るだけの機能を備えていましたが、

  • ドキュメントの整備が弱く、クライアントサイドへの習熟度がまちまちなチーム開発に採用するには難がある
  • IDE支援がTypeScript比で弱い
  • ツール群としての完成度の点で、TypeScriptに一日の長がある

といった理由から、これも見送りました。「将来性はあるとは感じるが、少なくとも現時点では、TypeScriptを差し置いて採用する積極的な理由は無い」といった温度感です。

数年後に、TypeScriptのメリットを上回った場合に移行することがあり得るとは感じています。

未解決の問題

TypeScriptへのLintの適用

ESLintではTypeScriptのコードをパースできないため、TypeScriptへのLintは薄くなっています。 babelはFlowtypeの構文をサポートしており、Flowtypeの構文はTypeScriptのそれと類似しているので、babel-eslintを用いることで解決できないかと淡い期待を抱いたのですが、解決には至りませんでした。

ですが、従来のコードベースに於けるESLintに私たちが求めていた物は、未使用変数解析などの静的解析ツールとしての機能であり、コードスタイルなどについては、そこまで重要度を高くは設定していませんでしたので、短期的にはTypeScript向けのLintが無いことを許容することにしました(混在しているJSコード向けに、ESLintの実行パス自体は残っています)。実行時エラーの実行前検知については、TypeScriptのコンパイルパスに任せる事で一応の代替を取れるため、今のところは大きな問題にはなっていません。

tslintというツールがあるにはあるので、これを導入すれば良いのですが、Lintルールの二重管理となってしまう点と、先述の理由から、現段階では導入していません。

この件については、問題の原因と解決方法の両方を見つけた上で「敢えてやっていない」だけであるので、(私個人としては)さして重要な問題ではないと考えています。

まとめ

今回は、「既存のプロジェクトに対して段階的に、TypeScriptを導入する」というテーマでお送りしました。 段階的に移行する事で、自プロジェクトの優先順位やペースに合わせて移行できるため、取りうる選択肢の幅が広がります。同種の問題に悩む皆さんのヒントとなれば幸いです。

会社のブログですので

お約束となりますが、弊社には AJITO という社内バーがあり、毎夜のようにエンジニアが現れ、酒を嗜み、軽食をつまみつつ、エンジニアリング談義を行っています( #ajitingで既にご存知の方も多いと思います)。「私も一緒にエンジニアリング談義をしたい!」という方がいらっしゃいましたら、是非とも弊社エンジニア or @tech_voyageに、お声掛けいただければと思います。

また、adingoを含むVOYAGE GROUPアドテクユニットでは、一緒に働いてくださる仲間を募集しております。VOYAGE GROUPや広告配信に興味を持たれましたら、お気軽にご連絡ください。