はてなでアプリケーションエンジニアをしている id:t_kyt です。この記事は はてなデベロッパーアドベントカレンダーを始めます - Hatena Developer Blog の18日目です。
昨日は id:wtatsuru による「はてなで新しくWebサービスを作るときのインフラの作り方 - Hatena Developer Blog」でした。
今回は「TypeScript で実現する MVP アーキテクチャパターン」のその後、1年近く TypeScriptプロジェクトを開発運営してきた話をしたいと思います。
「TypeScript で実現する MVP アーキテクチャパターン」の要約とこの記事の概要
この記事を読む前に前述した記事を読んでいただきたいのですが、ここで簡単に要約します。
- TypeScriptを用いて既存のフレームワークを使用せずMVPアーキテクチャパターンで開発を進めた
- AngularJS は学習コスト、React は当時の情報量の少なさから見送った
- もともとは JavaScript のプロジェクトで途中から Typescript 化したが、移行は容易であった
- MVPアーキテクチャパターンを採用
という感じです。
これを踏まえて約一年でプロジェクトはどうなったのか、どうしたいのか、あるいは新規プロジェクトはどうしたのかを
- JavaScript からの移行
- TypeScript の新機能とリファクタリング
- MVPアーキテクチャとReact
という視点から見ていきたいと思います。
JavaScript からの移行
JavaScript からの移行が簡単だったとはいえ、ただ単に拡張子を .ts
に書き換えただけでは TypeScript の恩恵をフルにうけることはできません。「動くコード」から「TypeScriptのコード」にするためにはそれなりに書き換えなくてはいけないので、実際僕がやったことあるいはやっていることを紹介したいと思います。
クラス構文で書き換える
素の JavaScript には Class が存在しないので Class っぽいことをしたかったり継承っぽいことをしたかったりする場合、独自に定義する必要があると思います。僕らのプロジェクトでも継承を行う関数を独自に定義し継承を行っていました。TypeScript の導入により class
や extends
は言語機能として提供されるようになったためそちらを利用しない理由はありません。
クラス構文への書き換え、独自に定義していた inherit()
なる関数から extends
キーワードへの書き換えは思ったよりスムーズに行えました。これはもともとの Javascript が大変お行儀よく書かれていたというのが大きかったので大変助かりました。
型をつける
TypeScript というくらいなので型をつけてこその TypeScript です。とはいえ JavaScript からの移行だと数値や文字列のリテラル以外は型がついていない状態なので適切に型を指定していく必要があります。
ところで TypeScript のコンパイルオプションには --noImplicitAny
というのがあり、これをつけると暗黙的な any
は使えなくなります。
// 怒られる var a; // これはOK var a: any;
新規プロジェクトでは --noImplicitAny
オプションを絶対につけるべきですが、 JavaScript から移行してきたばかりのコードに --noImplicitAny
をつけても大量のコンパイルエラーが出てしまいます。そこでとりあえず片っ端から型をつけていき、ぱっと見判断つかない型は any
にして、とにかく --noImplicitAny
オプションを付けられることをまずは目標にしました。
--noImplicitAny
さえつけられれば新規に書き足されたコードでうっかり暗黙的に any
が使われることが防げるので、とりあえず新規に暗黙的な any
は防止しつつ重要なところからちゃんと型を指定していけば良いという方針です。こうやって JavaScript からの移行が漸進的に進められることも TypeScript のいいところだと思います。
TypeScript の新機能とリファクタリング
TypeScript へ移行した当時からこの一年でかなりの機能が追加され、より便利に記述できるようになりました。実際にプロジェクトで書き換えた部分を紹介します。
Abstract Class
TypeScript1.6 から Abstract Class (抽象クラス)がサポートされました。これによりインスタンスを生成したくない、継承元になるだけのクラスを言語の機能的に明示することができます。
たとえば
class View implements IView { presenter: Presenter; createPresenter(): Presenter { throw new Error('You must implement createPresenter'); } // ユーザーにエラー状態を通知する。 updateError(): void {} }
のようにしていた すべてのViewの基底クラスとなる View
は
abstract class View implements IView { presenter: Presenter; abstract createPresenter(): Presenter; // ユーザーにエラー状態を通知する。 updateError(): void {} }
のようにかけます。 class
そのものだけではなくメソッドにも abstract
もつけられるので、継承していなければ Error
を投げるみたいな素朴な実装も書換えることが出来ます。
union types、type guard
例えば引数の型にパターンがあり、型によって処理を変えたいみたいな場合、以下みたいに書けるようになりました
function hoge(pattern: string | RegExp) { var regexpPattern = (typeof pattern === "string") ? new RegExp('^' + pattern.replace(/\W/g, '\\$&') + '$') : pattern; ... }
この例はプロジェクトでルーティング処理に使われているコードを少し改変したものです。
少し解説を加えると、union types は typescript1.5.3 からの機能で複数の型のうちどれかみたいな指定が出来ます。またtype guard は複数の型の候補がある場合にif文などで型を絞ることができます。
var a: string | number; // union types: a は string か number if (typeof a === "string") { // type guard: このブロックでは a はstring型として扱われる }
type guard は Typescript1.6 からユーザーが定義できるようになってさらに便利になりました。
function isCat(a: any): a is Cat { return a.name === 'kitty'; } var x: Cat | Dog; if(isCat(x)) { x.meow(); // OK, x is Cat in this block }
さらに 2.0 では switch
文でも有効になる予定です。
TypeScriptに追加された機能は以下でみることができるのでチェックしてみるといいと思います。
What's new in TypeScript · Microsoft/TypeScript Wiki · GitHub
ES6 modules
現在のプロジェクトでは namespace
を使って名前空間を分割、生成された js を concat でつなぎ合わせるビルド環境です。
小規模だったらそれで良いのですが、やはりある程度規模が出てくると厳しい面が出てきます。具体的には
- 依存関係を自分で考えて concat しなくてはならない
- 外部モジュールの管理ができない
- 名前さえ知っていればどこからでもアクセスできる
などがあります。例えば
namespace Foo { function foo() {...} }
と書かれたファイルがあった場合、 Foo.foo()
はどこからでも呼ぶことができます。またファイル同士に依存関係があった場合自力でreferenceを書かなくては正しく concat できません。
これに関しては新規プロジェクトでは
import
export
を使い依存関係は browserify で解決する- 外部モジュールは npm で管理する
- 型定義ファイルは dts で管理する
という方法をとっています。
これは単なる名前空間であった namespace
と違って、正真正銘 module システムなので export
していない関数は外から見えませんし import
しなければ使えません。
外部のモジュールの管理方法については最近は npm 一択っぽさが出てきたので npm で行っています。
今のプロジェクトもこの方式になるように書き換えてる途中です。
MVP アーキテクチャ
MVP アーキテクチャを採用し、開発運営してきた感想を書きたいと思います。良かった点に関しては冒頭の記事書かれていることその通りのことが実現出来ているので特に補足はないです。
MVPの辛み
MVP採用の経緯にもありましたとおり Presenter つまりロジックのテストが容易です。 しかしながら Presenter の状態が正しく View に反映されているかのテストは簡単にはかけません。実際、僕らのプロジェクトでは View はモック化しているので View に関するテストは行っていません。
結局 Presenter のロジックが完璧でも View への反映を間違えてしまうと元も子もありませんが、View の生成は jQuery で温かみのある作業といった感じなのでミスしている恐れも十分にあります。 View のモックをやめ、 html 片から生成することも考えられますが、DOMのテストはしたくないというのが本音です。これはDOMのテストのめんどくささもそうですが、View 生成用の html 片が本番環境と同じものであると担保していくのかが難しいという面もあります。
冒頭の記事から引用しておきます。
「『DOM に触らずにロジックをテストしたい』という前提が崩れている」とは? (2015-02-17 追記)
MVP アーキテクチャパターンを採用する動機のひとつとして、DOM に触らず (特定のデータに基づく HTML を出力することなく) プレゼンテーションロジックをテストしたいというものがあった。実際、現在は DOM に触る (View に関する) テストは行っていない。
しかしながら、近年はヘッドレスブラウザを用いたテスト実行環境も広まっており、やはりそうした環境で View も含めてテストしたほうがよいのではないかとも考える。その場合、MVP アーキテクチャパターン採用の動機づけが弱くなるのではないかということである。
解決策候補としての React
React は View を生成するためのライブラリです。Reactでは state
が決まれば常に同じ View が生成されます。一方で MVP 自体はロジックと View の生成を分離したアーキテクチャなので View の生成部分を React に任せることが可能です。
そこで View への反映は React で行うことによって、本当に Presenter のみをテストすれば良い状況になるのではないかと考えています。
また TypeScript と React との相性についても、TypeScript は JSX (TSX) をサポートしています。TSX 構文内でも IDE の恩恵を受けられ、かなり捗ります。詳しくは id:nobuoka による「UWP アプリ開発に TypeScript + React を導入することの検討 (Node.MSBuild.Npm の紹介)」をお読みください。
UWP アプリ開発に TypeScript + React を導入することの検討 (Node.MSBuild.Npm の紹介) - ひだまりソケットは壊れない
React 導入の不安点
MVP の辛みを解決してくれてなんか良さそうに思える React ですが導入にあたって不安な点もあります。その中でも一番大きいのは「デザイナーさんへの負担」です。React コンポーネントは JSX で記述するため、例えばクラスを付与したいとなった場合は JSX を触る事になります。はてなではテンプレートに関してはデザイナーさんも触る文化ですが、流石に JSX までいくと厳しいのではないか?と感じています。
まとめ
一年前の発表から、JavaScript からの移行、TypeScript の新機能、MVP アーキテクチャと React という視点でプロジェクトがどう変化したかや現状抱える問題意識をまとめました。React の導入による解決はまだ個人のアイデアレベルなので、また半年後か一年後どうなったかを伝えられればいいなと考えています。
おわり
はてなでは、先人の書いた最高のコードに敬意を持って日々リファクタリングを行い、半年後一年後も最高の状態を維持したいと考えるエンジニアを募集しています。
明日のアドベントカレンダーは id:aereal です。お楽しみに!!!!