(※この記事は 別媒体に投稿した記事 のバックアップです。 canonical も設定しています)
2021-12-11
これは コネヒト Advent Calendar 2021 11日目の記事です。
こんにちは! フロントエンドエンジニアのもりやです。
今回はママリのアプリ内で使われている WebView を JavaScript + Flow から TypeScript に移行した事例を紹介します。
今までママリ内で使われている WebView は JavaScript + Flow で実装されていました。
しかし State of JS 2020 の結果からも分かるように現在は TypeScript の人気が高く、実際コネヒトでも新規プロジェクトでは TypeScript が使われています。
開発体験としても TypeScript の方がよく、ツールチェインやライブラリの型定義の充実度も圧倒的です。現在、新規で何かを作るなら Flow を選ぶ積極的な理由はないと私は思います。
また Flow の問題ではないですが、以前から // @flow
の漏れなどで Flow のチェックが上手く機能してなさそうという課題もありました。
ここだけ直すこともできますが、全体を直すなら合わせて TypeScript にしたいとなりました。
これらの理由により、WebView を JavaScript + Flow から TypeScript 化する流れになりました。
2021年4月〜12月にかけて実施したプロジェクトです。
ただし @ts-ignore
や any
などでエラーを抑制している部分もあり、完全に移行が終わったわけではありません。
まだ Storybook など一部 JavaScript ファイルが残っている状況です。
TypeScript 移行中でも、開発は並行して行っていました。
対応する開発者も、メインとなる開発業務とは別に、業務時間の 10% 程度の時間をとって進めていました。
全部で4名の開発者が関わっています。
私はその中でも中心的な役割で、作戦を立てたり初期設定を主導して進め、TypeScript 化の作業も半分以上をやっていました。
tokei で計測してます。
言語 | ファイル数 | 行数 |
---|---|---|
JavaScript | 387ファイル | 20,546行 |
TypeScript | 0ファイル | 0行 |
言語 | ファイル数 | 行数 |
---|---|---|
TypeScript | 448ファイル | 30,140行 |
JavaScript | 31ファイル | 1,175行 |
まず最初に、拡張子と変換が必要な特定の型定義パターン(例: ?string
を string | null | undefined
に変更するなど)を機械的に変換する方法を試しました。
結論から言うと、これは失敗に終わりました。
原因としては、Flow がきちんと機能していない、という課題に起因しています。
一見定義されて動きそうに見えても @flow
の定義漏れなどで any
のように扱われてしまっている箇所がいくつもありました。
そのため TypeScript 化し、きちんとチェックが走ることによってエラーが多発してしまうという状況でした。
ある程度機械的に置き換えられるものを置き換えた後でも数百件のエラーがありました。
また型を外すということも考えましたが、Flow の定義が役に立つ場面もあり、これまでの資産がなくなってしまうのもやめたい、という事情もありました。
(Flow を導入していない、純粋な JavaScript であればもっと簡単だったと思います)
そういった事情から、一度に全部を変換することもそれをレビューすることも難しいですし、リリースしても何かが起きれば一気にふりだしに戻ってしまうので、この作戦は諦めました。
最終的に開発者が一つずつ JavaScript を TypeScript に変換していく作戦にしました。
数も多いので大変なことは予想していましたが、これが現状取れる手の中で最善と判断しました。
ファイルを一つずつ TypeScript 化していく方針を立てたので、次は TypeScript と JavaScript + Flow を共存していくための対応をしました。
WebView はアプリ内で変更が多い画面などによく使われており、長期間開発を止めるのは難しいので、混在した状態でもビルドができるようにしなければならないためです。
まずビルドで使っている Webpack の設定を更新します。
依存パッケージに typescript と ts-loader を追加し、webpack.config.js
に以下の設定を追加します。
+ {
+ test: /\\.tsx?$/,
+ include: path.resolve(__dirname, 'assets'),
+ use: ['babel-loader', 'ts-loader']
+ },
これで .ts
.tsx
ファイルをビルドできるようになりました。
JavaScript + Flow ↔ TypeScript 間でファイルを import
しようとするとそれぞれ型エラーが出ます。
それぞれ以下の方法でエラーを抑制しました。
(抑制しただけで、それぞれの間で型情報を引き継げるわけではありません)
この場合は Flow がエラーを出します。
対応としては、まず TSFlowStub.js.flow
というスタブ用のファイルを配置して、中身を以下のようにします。
export default {};
そして .flowconfig
に以下の指定を追加します。
+ module.name_mapper.extension='ts' -> '<PROJECT_ROOT>/TSFlowStub.js.flow'
+ module.name_mapper.extension='tsx' -> '<PROJECT_ROOT>/TSFlowStub.js.flow'
最後に JavaScript ファイルを読み込む時に、拡張子 .ts
または .tsx
を参照します。
すると Flow は自動的に TSFlowStub.js.flow
の型を参照してくれるので、エラーが出なくなります。
import foo from './foo.ts'
このパターンは @ts-ignore
で抑制しました。
(これで Webpack はエラー無くビルドしてくれました)
// @ts-ignore
import foo from './foo'
一旦テスト自体を止める、という判断をしました。
共存する設定も試してみたのですが、大掛かりになって時間がかなり掛かりそうでした。
もともと10ファイル程度しかなく、あまり変更が入らないものが多かったので、共存する設定をするよりも止めておくほうがよいと判断しました。
先に書いたとおり、ママリの WebView のフロントエンドのテストはほとんど無く、開発者による手動テストに頼っていました。
なので、TypeScript 化による意図しない変更に気付けるようなテストを入れておきたいと考えました。
ただテストの導入にコストをかけすぎると TypeScript 化が進まないので、なるべくコスパ的に良いテストを探していました。
検討した結果、主要な画面のスクリーンショットをとって、変更がないかをチェックするものであれば導入のコストが低く、効果も高いと考えました。
具体的には cypress と cypress-image-diff-js というライブラリを使って、Chrome で主要な画面をスクリーンショットでの比較をするテストを、Pull Request ごとに GitHub Actions 上で実行するようにしました。
(キャンペーン用ページなど一時的に使って、今は使わない画面などはチェックの時間が増えるだけなので除外しました)
これにより、主要な画面が表示できて変更がないことを自動でチェックできるようになり、一定の安心感ができました。
また、テストが失敗した場合でもスクショと差分をアーティファクトとしてアップロードするようにしたので、どこが失敗したのかも見れるようにしています。
ちなみに WebView なので、当初は TestCafe を使って iOS の環境に近い Safari 上でのテストもしたかったのですが、時々テストが止まってしまうという不具合が起きていたので、一旦諦めました。
毎回ではなく時々止まる、という症状で原因の特定が難しく、あまり時間もかけたくなかったので、詳しい原因までは探れていないです。
弊社では @connehito/eslint-config という ESLint の設定を OSS として公開していて、Flow 用の v1 系から、TypeScript に対応した v2 系にアップデートしました。
Flow と TypeScript が共存していて書き方が違うものなので、そのあたりを考えてやりました。
1つの Pull Request でやってしまい、かなり Diff が大きくなってしまったのは反省ポイントです・・・。(ほんとすみません。レビューありがとうございました)
↓ Pull Request に大まかな変更のポイントを書いていましたので、ついでに載せておきます。
ちなみに、今までは CI で ESLint によるチェックが行われていなかったので、CI でチェックする対応もしました。
最初の設定が終わってしまえば、あとは JavaScript + Flow のファイルを TypeScript に変更していくだけです。
ここでは実際に作業する中でできた方針について書いていきます。
他のファイルへの依存がない・少ないファイルの方がやりやすいので、分担してそういったファイルから進めてていきました。
具体例としては、API リクエストやユーティリティ関数などを最初に進めました。
当初は any
や object
などで曖昧になっていた部分を、なるべく型定義を追加しつつ TypeScript 化していきました。
特に API レスポンスは、今まで Markdown でドキュメント化はしていたものの、コード上では何の型定義もできていませんでした。
ここの定義を追加することで、アプリ内で使われるデータのチェックや IDE による補完も効くようになり、かなり開発体験が向上しました。
最初は型定義を拡充していましたが、本丸である UI 系のコードに入ってくると型定義的に不整合が起きる場面が増えてきました。Flow がきちんとチェックされていない影響は、特にこの辺りで大きかったです。
具体例として、例えば配列が渡ってくる場合 JavaScript のプリミティブな配列なのか Immutable.js のリストなのかが合致していない、などがありました。
極端な場合、どちらが来ても動くようになってて、動かしてログに出さないとどちらを期待しているのかがわからないという状況などもありました。
UI のコードは一番ファイル数的に大きいので、あまりコストを掛けてやると終わらない見通しになってきました。
また影響範囲もその画面、その部分でとどまる場合が多いので、一旦は動いているものを正として、 eslint-disable
や @ts-ignore
でのチェック抑制をして進めました。
きちんとした対応をしようとすると、おそらく今年度中には終わっていなかったと思います。
WebView から FireBase の機能を呼び出す際は、ネイティブ側と連携して動作しています。
その呼び出し関数を、一度変数への代入を使うとエラーが発生するという不具合がありました。
// iOS の例
const { postMessage } = window.webkit?.messageHandlers?.firebase ?? {}
if (postMessage != null) {
postMessage(...)
}
推測ですが window.webkit.messageHandlers.firebase.postMessage
という関数が呼ばれた時にネイティブ側がハンドリングしているものと思われます。
しかし ?.
を使うと、一度変数に入れるようなコードに変換されてしまいます。
そのため window.webkit.messageHandlers.firebase.postMessage
が呼ばれたとネイティブ側で判断できず、エラーになってしまうのだと考えられます。
解決策としては、以下のように順にチェックをしておくと特にエラーにならず正常に動作するようになりました。
if (
window.webkit &&
window.webkit.messageHandlers &&
window.webkit.messageHandlers.firebase &&
window.webkit.messageHandlers.firebase.postMessage
) {
window.webkit.messageHandlers.firebase.postMessage(/* ... */)
}
アプリ内で使う WebView ならではの不具合でした・・・。
ちなみに以下のようにすれば大丈夫そうな気もするんですが、試してはいません。
if (window.?webkit.?messageHandlers.?firebase.?postMessage) {
window.webkit.messageHandlers.firebase.postMessage(/* ... */)
}
Flow 関連のパッケージの除去や設定ファイル、スタブ用ファイル ( TSFlowStub.js.flow
) を削除しました。
これで完全に Flow への依存がなくなりました。
TypeScript 化が終わったことで不要になった eslint-disable
や @ts-ignore
を除去していきました。
やった人はすぐ分かりますが、あとから見る人は何のためのものなのか判断に困るので、こういうお掃除はなるべく早く対応しておきたいですね。
一時的に止めていたテストを TypeScript 化し、動かせるようにしました。
また WebView ではテストフレームワークとして ava
を使っていたのですが、コネヒトでは基本的に jest
を使っていたので、統一して jest
に移行しました。
(State of JS 2020 でみても Jest の人気が高いというのもあります)
長い戦いでした(まだ終わってないけど)が、ようやく終わりが見えてホッとしています。
ちゃんとやりたい部分もありつつ、時間との兼ね合いがあるので、どこまでやってどこはやらないかを判断するのが大変でした。
TypeScript に統一できたことによって、開発体験としてはかなり良くなりました。Flow でも一定サポートはしてくれますが、やはり TypeScript のツールチェインは充実しています。
まだ型定義がちゃんとできていない部分なども残っていますが、今後を開発を進めながら地道に健全な状態にしていきたいと思います。
コネヒトでは、フロントエンド開発のモダン化に挑戦したいエンジニアも募集中です!