ES6で書いたReactのアプリをTypeScriptに移行した件(+そこに至るまでの歴史)
これだけ読めばいい(かもしれない)概要
めちゃくちゃ長くなってしまったので、先に概要だけ...φ(・ω・`)
- クライアントサイドのコードが複雑化してきたので、TypeScriptの適用領域が広がっている
- いきなりTypeScriptを導入するのではなく、まずES6で整理してみる
- 汎用型「any」を使いながら、見える範囲で徐々に型を埋めていけば良い
- 外部ライブラリは型定義を追加してからimport
- jQueryとかグローバルオブジェクトはdeclareを使って宣言
- TypeScriptを書く時にVSCodeをつかうとめっちゃ便利
- interfaceで「ゆるい制約」をかけられるのが便利
- うまくinterfaceを使えば、Reactのpropsやstateの型もチェックできる
- 型が導入されても、テストが不要なわけではない
以下、めっちゃ長い話になってますので、後はお好みで(´・ω・)っ
TypeScriptを導入した経緯
先日はとある事情からGoを試したわけですが・・・
・・・その際に、もう一つ「伸びている言語」として挙げられていたのが、
「TypeScript」でございます
2012年に初めて発表された際は、まだクライアント側の開発が成熟しておらず、
ブラウザのDOMをいじる程度の用途であれば、まだ生のJavaScriptが優勢だったと思います
当時はそれこそ山のようにAltJSが存在し、クライアントのフレームワーク同様、
何もスタンダートがない状況で、トランスパイルの手間を考えても、
RailsでCoffeeScriptが便利・・・くらいだったかと(´-ω-)
「あくまでJavaScriptの拡張として型を導入したもの」という触れ込みから、
出たばかりのTypeScriptをいじってみた記憶はありますが、
やはり「当時のJSのスタンダード」からは逸脱しており、諦めた記憶が *1
しかし、今や時代は変わりました
Webアプリケーションの主体はクライアント=ブラウザとなって複雑化したり、
Babel+Webpackがほぼ主流に定まったことにより、
「AltJSをトランスパイルしてデプロイする」というのも一般化してきました(`・ω・´)
「標準的なJavaScript」も進化し、ES2015(いわゆるES6)により、
他の言語にありがちなパラダイムが、「標準的なJS」として書けるようにもなっています
(それでもまだ、古い環境向けのトランスパイルは必要ですが)
明示的に「クラス(もちろん継承も)」が存在したり、
最大のネックだったスコープ問題の解決など、最近の言語を知るプログラマからすると、
「やっとできるようになったか」という内容が満載です
現在のTypeScriptは実質、「ES6の仕様を拡張したもの」*2として考えることができます
特に「型」の概念を明確に導入していることで、大規模開発に有利なのもポイントです
そしてなにより・・・以前とは比べものにならないほど、採用事例が増えています
あの(Dartを開発していた)Googleですら、TypeScriptは「標準言語」ですΣ(・ω・ノ)ノ
先日のGoに比べると、「JavaScriptで書かれたそこそこ大きなアプリ」であれば適用できるため、
採用する基準がわかりやすいのも利点かと
少なくとも、「ES6で書かれ、Reactの採用を考えなければならないサイズのアプリ」であれば、
TypeScriptを導入する価値は十分にあると考えられます
・・・まあ、結局はやってみたかったからやったのですが(´-ω-)
ccptsにおけるクライアントアーキテクチャの変遷
長い導入はさておいて、ここから本題に対する長い前置きです...φ(・ω・`)
ということで今回は、「チェンクロパーティーシミュレーター」(以下ccpts)を、
TypeScriptで書きなおした・・・というお話なのですが、
そのためにはまず、そこに至るまでの経緯から説明しないといけません
開発に着手した2014年当時の私は、まだクライアント側の経験が浅く、
「スマホやタブレットで見ることを前提としたクライアント主体のアプリ」として、
ccptsのコンセプトを定義しました
- jQueryでイベントを定義しつつ、簡単なmodel状のもので整理する
- 初期リリース当時のコードを見たところ、Classが2つで1ファイル、450行程度のCoffeeScriptΣ(・ω・ノ)ノ
- 機能の増加に合わせて、modelの細分化やviewの複雑化が進む
- 特にチェンクロの2部実装で「絆システム」が導入され、急に複雑化した
- 2015年頃、Reactを試そうとして断念する
- Reduxの「データをどこかで一元管理する」というやり方になじめなかった
- 当時はreact-bootstrapがまだ実用レベルではなかった(あるいは私の理解が足りなかった)
- 仕事でNode.jsのサーバを書いている際、FRPの概念を知り、試したくなる
- https://parrot.hatenadiary.jp/entry/2015/11/29/175113
- 結果的にBacon.js+Lodashという組み合わせでコードが整理され、イベントがストリーム化される
- これにより「view」と「イベント→データ」の流れが分離される(とても重要)
- 2016年の始め、Bowerからnpm+Browserifyに移行する
- これをきっかけに本格的なReact化プロジェクトを開始
- https://parrot.hatenadiary.jp/entry/2016/02/28/113310
- イベント→データのストリームを、そのままsetStateにつなげられたのが成功のポイント
- この時点ではまだ、modelはCoffeeScriptで書かれていた
- クラス変数・メソッドが使えないのがどうしても・・・
- 2016年末、Webpackを導入
- https://parrot.hatenadiary.jp/entry/2017/02/07/144805
- Rails5.1でwebpackerが導入される情報を見て移行
- この時点でCoffeeScriptは排除され、完全にES6ベースに移行する
- (以降、しばらくはチェンクロの3部対応=データ周りの仕様変更に追われる)
- 2017年の夏、Rails5.1+React on Rails(RoR)の組み合わせで設計が整理される
- RoRのreact_component機能を利用し、サーバサイドとviewの関係をきれいに整理
- この時の作業をBlogにまとめるつもりが、さぼっていたΣ(゚Д゚)ガーン
- (ここからもしばらく、データ周りの仕様変更やサーバサイドのアーキテクチャ変更)
- 2018年の春、チェンクロのヒロイックスキル対応(の準備)を始める
- 2018年の夏、ES6のコードをTypeScriptに書き直す <- イマココ
要するに何が言いたいのかというと、急にTypeScriptが導入されたわけではなく、
時代にあわせて段階的にクライアントのコードが整理されていき、
TypeScript周りの情報も整理されていった結果、わりと低コストで導入できた・・・ということです
最初から「よーし、ReactをTypeScriptで書いてアプリつくっちゃうぞー」だったら破綻してます
Reactを導入するようなサイズのアプリをいきなり設計するのは難しいですし、
Reactを使いたいならまず、ES6から入った方が問題が少ないわけで(´-ω-)
TypeScript導入の実例
お待たせしました、本題です...φ(・ω・`)
TypeScriptを導入してみたときに感じたことを、
いくつかのテーマに分けて書いていってみます
(1) まずはmodelから段階導入
TypeScriptが「ES6に型を導入したもの」くらいなのはわかっていたので、
言語仕様の把握もそこそこに、model周りから移行を開始しましたΣ(・ω・ノ)ノ
modelは単体、あるいはmodel同士の関連で完結しているものが多いですし、
「型」の代表格は「Class」なわけですから、導入も簡単だろうと
実際、「model」と「lib」・・・つまり、React以外のコードを書きなおし、
ここで一度リリースしました
すでに動いている「Class」に「型」を定義していくだけなので、
作業自体はほぼ機械的です
クラスメソッドやクラス定数を使えるので、
この点でもES6のコードより厳密ですっきりします(`・ω・´)
この段階ではTypeScript自体に慣れていないのもあり、
外から受け取るオブジェクトは「any」でごまかしてしまい、
modelの文脈で型がわかるものだけを定義していきました
eslintに代わる「tslint」がありますし、型がおかしい場合は書いている時点でエラーになるので、
詰まるたびに調べて・・・という感じで言語仕様を把握していきました...φ(・ω・`)
ほぼeslintの上位互換になっていて、
eslintの項目に「型」に関わるルールを追加したもの・・・というのが私の理解です
このように、段階的に型を詳細化していくだけで十分「使える」のですが、
最終的に「anyみたいなあいまいなのは嫌!」ってなった場合、
tslintでanyをチェックすることもできるので、固まってきたら導入してもいいかもしれません
(2) デプロイ時における、グローバル変数(jQuery等)の扱い
そもそも、Rails+RoRでES6をデプロイする仕組みが存在し、
そこにTypeScriptのトランスパイルを加えるだけなので、
デプロイの手順自体は今まで通り変わりません
ES6のコードとTypeScriptのコードが混在することになりますが、
それぞれいい感じに生のJavaScriptにトランスパイルされる(つまり、型情報は全て消える)ので、
最終的なコードで競合する心配はありません(`・ω・´)
TypeScriptの「型」は、あくまでTypeScriptの文脈でチェックされるだけなので、
極端な話、TypeScript側の型定義が間違っていたとしても、
ES6側で正しいObjectを渡している限り、動作上は何の問題もありません
(実際、全く異なる型定義になっていたが、特に問題なく動いていた)
引っかかったのは、今回もやはりjQueryの扱いです(´-ω-)
ローカルで動かしているときはなんともなかったのですが、
いざ本番にデプロイし、Railsのassets:precompileを実行したところでエラーに
ccptsはReactとjQueryを共存させており、特にjQueryはグローバルに存在する必要があるのですが、
TypeScriptの中で普通にimportしてしまうと、外のjQueryと別になる・・・という、
まさに以前と同じ問題が発生します
ES6の時点では、eslintのレイヤーで問題になるだけで、
eslintcでグローバルであると指定すればよく、ES6側で何か対応する必要はありませんでした
(ただ放置しておけば参照できた)
しかし、TypeScriptは厳密なので、「外から導入される、型が不明なもの」に対して、
明確にエラーになりますΣ(゚Д゚)ガーン
これはjQueryに限った話ではなく、そもそもLodashやBacon.jsも外のライブラリであり、
何らかのimportをおこなう場合、「型定義」を導入して、型情報を与える必要があります
$ yarn add @types/baconjs
現在は主要なライブラリのほとんどに型定義が存在し、
「@types/hoge」を加えるだけで、型情報が追加されるのですが、
これが昔はめっちゃ面倒だったのです(´-ω-)
しかし、jQueryの場合はグローバルなオブジェクトなので、
結論からいえば以下のような宣言が必要になります
(実は、「@types/hoge」の中身は、ひたすらdeclareをまとめたファイルだったりします)
import * as _ from "lodash" // @types/lodashの型定義を参照してくれる declare var $ // jQueryがグローバルから導入されるという宣言
同様に、ブラウザのwindow由来のグローバルオブジェクトもdeclareを記述するようにしてます *3
以前も書いた話ですが、jQueryで何でもやっちゃうから問題なのであって、
それが必要なレイヤーにおいて、エンジニアの都合で制限をかけるのはやりたくないので、
あえてこのような方法を追求している面もあります
・・・まあ、私自身がjQueryがないと書けないからってのが大きいですがΣ(・ω・ノ)ノ
(3) ReactへのTypeScript適用
独立したmodel関連をリリースし、TypeScriptにも慣れてきたところで、
いよいよReactのjsxをtsxに差しかえる作業を開始したのですが、
基本的には拡張子を変えて、エラーになったところを修正していく感じでいけます
問題は、「型」に関してもう一歩踏み込む必要があることです
model周りは基本の型と自分のClassを見ていればよかったのですが、
React周りには多数の「型」が存在します
- HTML(というよりDOM)の型
- 例:HTMLSelectElement
- JSXの型
- 例:JSX.Element
- Reactの型
- 例:React.FormEvent
これらをガンガン適用していくわけですが、
実は私、これらの型に関するドキュメントを一切読んでいませんΣ(゚Д゚)ガーン
いや、読んだ方がいいのは確実なのですが、
それが不要なレベルで、VSCodeの支援機能が強力なのです
VSCode上で何らかのオブジェクトをポイントすると、
型の定義を推論して提示してくれるので、
それを参考に順番に埋めていっただけです
もちろん、「すでに動いているアプリ」なので、
こういった裏技(というより邪道)に近いやり方が可能なのですが、
AtomからVSCodeになんとなく移行して、初めて「VSCodeすごい」と思いました(`・ω・´)
しかも、この型推論はES6の文脈でも発揮されます
ES6の文脈で使われているオブジェクトに型情報があった場合、
同じように推論してくれるので、それを参考にして厳密な処理を書くことが可能です
実際、modelに型を導入し、React側がまだES6で書かれている時点で、
かなりの数の潜在的バグを、型定義から見つけて排除することができました(`・ω・´) b
ある程度コードの規模が大きくなってくると、
「これどこから来たんだっけ?」というのがわからなくなりますが、
IDEの支援を駆使し、型を手がかりにすることで、かなり把握しやすくなるわけです
Lodashの「_.chain」とか、Bacon.jsの「Bacon.Bus」はとても便利な仕組みですが、
つないでいくうちに何をつないでいるのかわからなくなってしまいがちです
そんな複雑な高階関数であっても、型定義のおかげで安心して書けます...φ(・ω・`)
(4) ReactのPropTypesの代替としてのinterface定義
逸れてきたので話をReactに戻すと、元々のReactのコードにはPropTypesが定義されてませんでした
これはもう、完全に私の怠慢なんですが、今回TypeScriptを導入するにあたり、
当然ながらpropsやstateにも型が欲しくなるので、きちっと定義しました
// interfaceで型を定義する interface LatestInfoAreaProps { latestInfo: LatestInfo // interfaceが定義されたObject } interface LatestInfoAreaState { visible: boolean } // React.Componentにinterfaceを渡す export default class LatestInfoArea extends React.Component<LatestInfoAreaProps, LatestInfoAreaState> { constructor(props: LatestInfoAreaProps) { super(props) this.state = { // constructor内で初期化してないと、ちゃんとコンパイルエラーになる visible: true } } // ... }
このように、Reactの機能とは無関係に、TypeScriptの文脈で細かく型を指定できるため、
PropTypesを使わなくても同じようなことができます(`・ω・´)
「PropTypes.oneOf」に相当することもTypeScriptの範囲でできます
interface SummaryMemberProps extends MemberRendererProps { // props.viewは型としてはstringだが、値はこのどちらかしかとれない view: "full" | "chain" } export default class SummaryMember extends MemberRenderer<SummaryMemberProps> { // ... }
また、componentに継承関係をつける際も、
親クラス側でpropsのinterfaceに制約をかけることができます
// 一番基底のクラスではただのジェネリクス // abstractも明示できる export default abstract class ArcanaRenderer<T> extends React.Component<T> { } // 継承する子クラスではinterfaceで制限をかける export interface MemberRendererProps { member: Member | null } // 総称型TはMemberRendererPropsを継承している必要がある // -> 「props.memberを持つ」という制約 export abstract class MemberRenderer<T extends MemberRendererProps> extends ArcanaRenderer<T> { } // SummaryMemberPropsはMemberRendererPropsを継承している=props.memberを持つ interface SummaryMemberProps extends MemberRendererProps { // 継承しているので、「member: Member | null」も定義に含まれる view: "full" | "chain" } // SummaryMemberPropsはMemberRendererの制約を満たす export default class SummaryMember extends MemberRenderer<SummaryMemberProps> { // ... }
こういう継承構造を、PropsTypes+ES6のクラス定義だけで書いていくと、
間違いなくどこかで抜けが発生するとか、理解できなくなりそうですが、
TypeScriptだと、このように厳密な定義が書けるわけです(`・ω・´) b
(5) その他、言語仕様に関わる雑多なこと
書こうと思っていたけど、流れに乗らなかったのことを最後にまとめて...φ(・ω・`)
例えば、あるプロパティにnullが設定される可能性があったとします
このままこのオブジェクトのメソッドを呼ぶと、
nullの可能性があるため「エラー」になります(警告ではありません)
その代わり、上記のようにnullチェックを通した後だと、型推論からnullが排除されます
つまり、ちゃんと文脈を見ているということなので、とても便利です(`・ω・´) b
(なお、TypeScriptでは「null」と「undefined」は別の型です)
あと、先ほど出てきた「interface」、Javaなどでも制約をかけるのに使われますが、
TypeScriptの場合、Objectそのものに「ゆるい制約」をかけるために使われます
classにinterfaceをimplementsするJavaに対し、
TypeScriptではObjectにラベルを貼るような感覚で使えます
// 「お気に入り」オブジェクトの構造(key -> boolean) export interface FavoritesParams { [key: string]: boolean } export default class Favorites { // FavoritesParams型のクラス定数(初期値は空Object) private static favorites: FavoritesParams = {} // 一見複雑なchainに見えるが、いい感じに型推論してくれる public static list(): string[] { return _.chain(Favorites.favorites) .map((s, c) => { // s: boolean c: string if (s) { return c } return "" }).compact().value().sort() } }
interfaceのポイントは、あくまでTypeScriptにおける制約でしかないので、
ES6の文脈でも、トランスパイルされたコードでも、ただのObjectとして扱えることです
そのため、クラスよりも「ゆるい制約」として気軽に使いまくれます(`・ω・´)
終わりに
というわけで、「ある程度の規模のアプリだとTypeScriptは便利だよ」って話でした
ただ、あくまでTypeScriptが確認してくれるのは「型の整合性」だけです
「型の定義が誤ったまま整合性が取れてしまっている」とか、
「渡された値そのものに問題がある」といった問題は検知できません(´-ω-)
それでも、最低限の「nullが渡されて落ちた」とか、
「全然違うObjectが渡されて落ちた」といった、
基本的なやらかしは検知して防ぐことが可能です
大規模になるほど、こういったつまらないミスを排除して、
仕様やデータレベルのテストに注力したいはずですし、
型を決めることで共同作業もやりやすくなる・・・といったメリットは十分にあります
生のJavaScriptで十分な領域にはオーバースペックですが、
ES6で細かく書きたいレベルであれば、
基本的な型のレベルから試してみるといいと思います(`・ω・´)