インクリメンタルに新しい技術を取り入れる方法。TypeScriptへの移行を例にしたプロセス

こんにちはKARTE Blocksチームです。

日々プロダクトを開発していると新しい機能が増えます。
一方で古くなる機能も増えるため、負債となった部分がボトルネックとなり、新規開発へも影響してきます。

そのため、古くなった部分を解消しながら、新しい技術をインクリメンタルに取り込んでいく必要があります。

この記事は「KARTE Blocksリリースの裏側」という連載の2日目の記事です。全10回を予定しています。
これから毎日記事を更新していくため、更新をチェックしたい方は@KARTE_BlocksのTwitterアカウントをフォローしてください!

「KARTE Blocksリリースの裏側」の記事の一覧です。

  1. KARTE Blocksを支える技術
  2. インクリメンタルに新しい技術を取り入れる方法 ← イマココ
  3. セカンドパーティコンテンツをもつサードパーティスクリプトの作り方
  4. AWSが落ちてもGCPに逃がすことで落ちないシステムを作る技術
  5. ユーザーが自ら理解・学習するためのテックタッチなアプローチ
  6. 爆速で価値あるプロダクトをリリースするためのチームビルディング
  7. CSS in JSとしてVanilla-Extractを選んだ話と技術選定の記録の残し方
  8. 0→1のフェーズで複数のユーザー体験をつなぐUIデザインを考える|wagon|note
  9. リリース後に落ちないように、新規サービスで備えておいたこと
  10. KARTE Blocksにおけるポジショニングの考え方とその狙い
  11. 「KARTE Blocksリリースの裏側」の裏側 - 複数人で連載記事を書く方法

なぜインクリメンタルにやるか

現代のアプリケーションは巨大で複雑で、すべてを一度に書き換えできるサイズではないものが増えています。
一方で、放置している問題(負債)はそれ自体が広がってしまうため、よいものはちゃんと取り入れて新陳代謝していく必要があります。
代謝が悪くなると、全体が不良となり、新規開発も止まってしまう問題が発生します。

これを避けるためには、新しいものをインクリメンタルに取り入れて、交換可能な状態を意識していく必要があります。
交換可能とは、どちらか片方だけという意味ではなく、どちらも同時に動く状態であることです。
負債を見ないようにするのではなく、負債と新しいものを同時に動くようにして、その配分を変えていくイメージです。
その総量が少なければ、一括で置き換えることもできます。一方で新しいものが未知の要素なら少し試してから少しずつに置き換えます。

重要なのは、どちらか片方というわけではなく、どちらも存在しているということは認識することです。
認識した上で、その配分を変えていくのがインクリメンタルな戦略です。

この記事では、KARTE Blocksにおける「TypeSciptへの移行」を例にしたインクリメンタルな移行プロセスについて書いていきます。

記事のポイント

この記事では、KARTE Blocksにおける「TypeSciptへの移行」を例にしたインクリメンタルな移行プロセスについて紹介しますが、とても長いです。

そのため、最初にこの記事のポイントを整理します。

  • 大きな変更は、開発者とユーザーどちらにとっても心理的な負担が大きい
  • 現実的なコストで新しいものを取り入れる必要がある、そのアプローチとしてインクリメンタルな戦略をとる
  • マイグレーションの単一障害点を作らないようにして、チームで学習する仕組みを知る
  • 失敗しても取り返せる大きさが、インクリメンタルなサイズ
  • インクリメンタルな戦略を実行するにはポイントがある、それを知る

それでは、具体的に「TypeSciptへの移行」をどのようにインクリメンタルに進めたのかをみていきます。

BlocksにおけるTypeScriptのタイムライン

まず最初に、BlocksにおけるTypeScriptの採用状況を時間軸で大まかに見ていくと、次の図のようになります。

BlocksでのTypeScriptの採用状況の時間軸

Blocksは、2019年10月ごろから開発がスタートとしています。
2019年ごろから、PLAID社内の他のシステムでもTypeScriptへの移行が少しずつ行われていましたが、
Blocksはコンセプトの検証から始めたため、開始時はすでに慣れていたJavaScriptをベースにしていました。

大雑把にBlocksにおけるTypeScriptの採用状況のタイムラインをまとめると次のようになります。

  • 2019年後半: 開発開始時はJavaScript
  • 2020年前半: JavaScript + TypeScriptの混在(allowJs: true)
    • monorepo内の一部がTypeScriptになるが、JavaScriptも残ってる状態
  • 2020年後半: 型なしのTypeScript(noImplicitAny: false)
    • 型はゆるいがTypeScriptになってきた状態
  • 2021年前半: 型ありのTypeScript(noImplicitAny: true, allowJS: false)
    • 大部分にちゃんとした型がついて、型を前提にして開発できる状態
    • TypeScriptをSingle Source of Truthとした
    • 2021年6月にallowJs: falseとなっている
    • VueからReactへの移行をスタート(共存)
  • 2021年後半: 積極的に型を使うTypeScript
    • 型があるのは当然で、型によって間違えを防ぐ仕組みをもって開発できる状態
    • Type GurdeやGenericsなどの型をちゃんと使うためのコードを書くようになり、人間のミスを型エラーで気つけるようにする型を書く

最初はJavaScriptから始まり、徐々にコードベースがTypeScriptとなっていき、現在では型があることは前提の開発へとなっています。
もちろん、この移行は段階的に行われているため、特定の時期の一気にJavaScriptがTypeScriptになったりしたわけではありません。
時期によっては、JavaScriptとTypeScriptは混在していますし、一部のTypeScriptには型がちゃんとついているといった混在があります。

また、タイムライン全体でみても、TypeScriptの移行を理由に新規開発を止めた時期はありません。
逆を言えば、混在していることを認識し複数のパラダイムを共存しながら動かせることが、インクリメンタルな仕組みとして重要です。

この記事では、TypeScriptへの移行というタイムラインをテーマにして、どのようにしてインクリメンタルに新しい技術(TypeScriptやReactなど)を取り入れたのかについて書いていきます。

2020年前半: JavaScript + TypeScriptの混在(allowJs: true)

Overview 2020年前半

現状の背景

Blocksの開発から1年弱程度が経ち、Blocks全体のコードサイズはおおよそ20万から30万行程度になっていました。
開発言語の大部分はJavaScriptで、シビアなパフォーマンスが要求される部分は、スケールのしやすさなどの観点でGo言語を使っています。
フロントエンドはVue.js 2がベースとなっていて、バックエンドはNode.jsでexpressとMongoDBを使ったものがベースでした。

チーム内の開発者(デザインを含め)は4人から6人程度であり、コードサイズも大規模とも小規模とも言えないぐらいのサイズです。
一人の開発者が全体像をぎりぎり把握できるぐらいのボリュームでした。

なぜTypeScript化を進めようとしたのか

2020年の時点で、すでにTypeScriptが一部使われていたので、改めてTypeScriptを導入するべきかという議論はありませんでした。
TypeScriptの利点は、JavaScriptとの相互運用性や導入のしやすさ、型によるコードの健全性の改善などがあります。
TypeScriptの利点については、すでにいろいろな場所で語られているので、詳細について省きます。

Blocksでは、新しい機能の開発や既存の機能改善など活発に開発がされています。
その際に、型があれば防げるようなバグを見かけたり、新しくチームに入った人がコードの動きをつかみにくいという問題を見かけるようになりました。

そのため、コードベースのTypeScript化をすすめることで、今後のスケールに備えていくことにしました。

2020年後半: 型なしのTypeScriptへのマイグレーション

Overview 2020年後半

マイグレーションの2種類のアプローチ

JavaScriptからTypeScriptへのマイグレーションについては大きく分けて、2つのアプローチがあると考えています。

  • 最初から型をちゃんとつけたTypeScriptにする方式
    • 元が静的型のある言語(FlowやClosure compiler)などからの移行には適している
    • マイグレーションに専任の人がいる場合は、こちらのほうが適しているケースがある
    • 型に対してある程度学習が必要なため(JSでは可能だがTSでは表現できない方法があるため)、型をつけるにもコードの変更が必要
  • 一旦ゆるい型でTSに変換してから、型を徐々に付けていく方式
    • 元が静的型ではない言語(JavaScript)だったり、チームがTSに対して全員詳しいわけではないならこちらを推奨
    • 徐々に厳しい型を使っていくようにする
    • 型をより正しくするためには、型をつけるモチベーションを維持させる必要がある

簡単にまとめると、最初からきちんとした型付け(strict:trueなど)をしながら移行するか、
一度any// @ts-expect-errorなどゆるい型付けで移行してからちゃんと型を付けていくアプローチがあります。

前者のアプローチでは、最初からしっかりした型のTypeScriptを扱えるため、TypeScriptの恩恵を最大限得られます。
一方で、移行のコストが高くなりやすいため、どこから順番にやるかなどの計画や専任(TypeSriptやドメインに詳しい必要がある)でマイグレーションをやる人が必要になります。
また、JavaScriptで書いたコードの動作や構造を変更せずに、きちんとしたTypeScriptの型をつけるのは難しい場面が必ずあります。
そのため、このアプローチを採用する場合は、リファクタリングも型付けと一緒に行う必要があります。

具体的な例としては、次の事例などはリファクタリングと型付けを同時に行っています。

後者のアプローチでは、最初にany// @ts-expect-errorなどを使って、とりあえずのTypeScriptにマイグレーションします。
実際のマイグレーションは機械的な作業に近くなるため、短時間でJavaScriptをTypeScriptに変換できるのがメリットです。
一方で、変換後のTypeScriptはanyが多かったりやStrict null checkingなどが有効ではないため、TypeScriptのメリットは部分的なものとなります。
(コンパイルエラーで拾えない部分があるという意味です)

具体的には、Airbnbでは、ts-migrateというツールを使い、一括でJavaScriptをTypeScriptに変換してマイグレーションしています。

どちらのアプローチでもTypeScriptのallowJsを併用して、段階的にできるのは同じです。
(一括ですべてのファイルを変換する必要はないため、特定のディレクトリだけといったアプローチが可能です)

後者のアプローチでは、早い段階でJavaScriptファイルがなくなるため、ツールや設定が統一しやすくなるというメリットがあります。
たとえば、ESLintではJavaScriptとTypeScriptでは設定が異なるため、JSとTSがあると複数の設定が必要です。

Blocksでは、TypeScriptをまだ本格的に使ったことがない人もいたため、後者の「一旦ゆるい型でTSに変換してから、型を徐々に付けていく方式」を採用しました。

TypeScriptへのマイグレーションをしていく順番

一度ゆるい型でTSに変換してから、型を徐々に付けていく方法を取ることにしましたが、具体的にどのような順番でJavaScriptをTypeScriptにしていくかにはいくつかのポイントがあります。
コードベースのサイズが大きほど、一度のPull RequestでTypeScriptにしていくのは難しくなります。
なぜなら、一度のPull Requestでまとめて変更すると、コンフリクトの確率が高くなったり、検証のコストが高くなってしまうためです。

段階的にマイグレーションをしていくためには、どこで境界線を引くかが重要です。
JavaScriptでは、次のようなポイントを境界線とみなしやすいです。

  • ファイル
  • モジュール(JavaScriptはファイルと同義)
  • ルーティング(ルーター)

JavaScriptからTypeScriptへのマイグレーションの場合は、モジュールを切り口として変換していくと、段階的に変換していきやすいです。
そのため、TypeScriptへのマイグレーションをしていく際に、まず最初に変換するのは型ではなくモジュールです。

モジュールをECMAScript Modulesにする

JavaScriptとTypeScriptが共存するには、TypeScriptのallowJsというオプションを利用します。
allowJs: trueの場合、TypeScriptのコンパイラーはJavaScriptファイルも扱えます(具体的にはJSDocの型チェックだけを行い、そのままJavaScriptファイルを出力します)。

allowJsが有効時の場合はJavaScriptとTypeScriptが混在しているため、モジュール形式も混在しているケースが多いです。

JavaScriptで書かれたコードではCommonJS(requiremodule.exports)が混在している場合があります。
また、TypeScriptの場合も古いモジュール形式(import x = require("x"))が混在している場合があります。

これらのモジュール形式の混在は、TypeScriptへ移行していく際に問題となりやすいです。
具体的にはCommonJS形式のモジュールはインポートするとanyとなってしまうため、まずはECMAScript Module(import/export)形式に変更していくことが重要です。

Blocksのコードベースでも、Node.jsで動いてるコードではCommonJS形式が混在したままであったため、まずはモジュールをECMAScript Modulesへ変換することから始めました。

CommonJS ModulesをECMAScript Modulesへ変換する

CommonJS Modules(CJS)をECMAScript Modules(ESM)へ変換する作業自体は、ほぼ機械的な変換です。
たとえば、次のmodule.exports.fooというCommonJS形式のモジュールは、export { foo }というNamed Exportsが同等の表現です。

- module.exports.foo = foo;
+ export { foo }

CommonJS ModulesとECMAScript Modules形式の大まかな構文の対応を表にまとめると次のようになります。

CommonJS Modules ECMAScript Modules
module.exports.foo = foo export { foo }
module.exports.bar = foo export { foo as bar }
module.exports = foo export default foo
const foo = require("mod") import foo from "mod"
const { foo } = require("mod") import { foo } from "mod"
const bar = require("mod").foo import { foo as bar } from "mod"
require(moduleName) import(moduleName)
  • 📝 require(moduleName) は同期処理なのに対して、import(moduleName)は非同期処理となります。
  • 📝 tsconfig.jsonesModuleInteroptrueでないとdeafult importの意味合いは異なります。

この表はCommonJS ModulesとECMAScript Modulesで機能的に1対1で応するという意味ではありませんが、
大まかにはこの対応表にそってECMAScript Modulesの構文へと変換ができます。

エディターを使い手動で変換したり、次のようなツールを使ってある程度機械的な変換も可能です。

このモジュールの変換で重要なことは、できるだけCommonJS ModulesとECMAScript Modulesを混ぜないことです。
CommonJS ModulesとECMAScript Modulesの間には、厳密な互換性はありません(TypeScriptやBabelといったTranspilerがいい感じにしているだけです)。
一部のケースで相互運用性の問題が発生するため、混在を減らすには変換するモジュール(ファイル)の順番が重要です。

📝 以前は、module.exports = fooimport文で読み込むと相互運用性の問題がありましたが、esModuleInteropを有効にすることで緩和されています。

モジュールの依存関係の末端からTypeScript化していく

このようないろいろなファイルを書き換える必要があるリファクタリングをする際には、依存関係の末端から行うことが望ましいです。
依存の末端とは、どのモジュールにも依存していないモジュールのことを指します。
末端のモジュールなら、書き換えることでの影響範囲は、末端のモジュールを使ってるモジュールだけです。
そのため、末端のモジュールから書き換えることで、安全に書き換えることができます。

依存関係の末端を探す方法はいくつかありますが、コードベースがそこまで大きくないなら、エディターのジャンプ機能を使うことで判別できます。
JetBrains系IDEならGo to Declaration、VScodeならGo to Definitionを使って、モジュールのインポート先へジャンプし、ジャンプできなくなったらそのモジュールが末端です。

コードベースが大きい場合は、@code-dependency(Dependency cruiser)やNode File Traceなどを使って、
一度依存関係を図や表に落としてから依存が少ない部分を探すのがよいです。

社内の別のシステムでは、モジュール間の依存関係を把握するためにAcornを使いコードをパースして依存関係グラフを作っていました。
変数を使った動的なインポートを利用していたり、import mod from "@/mod"のように特殊なエイリアスを使ってる場合には、既存のツールでは難しいケースもあります。

Blocksの場合は、エディターの機能で十分把握できる深さであったため、特殊なツールは使いませんでした。
Blocksのコードはmonorepo構成となっていて、パッケージ間での依存もありました。

主なパッケージを上げると、Vueで書かれたフロントエンドのfront/、Node.jsで書かれたバックエンドのserver/、一部の共通処理やスキーマが置かれていたcommon/がありました。

packages
├── ...others...
├── common
├── front
├── server
└── system-test

front/server/common/へ依存しているので、パッケージ間での末端はcommon/です。
そのため、commonのパッケージの中から末端となるモジュールを探して、そこボトムアップする形でECMAScript Modules形式へと変換していきました。

モジュールのマイグレーションは末端のモジュールから

この際に、パッケージが別れているmonorepo構成では型定義ファイルを生成するためにビルドの依存も発生します。
たとえば、commonパッケージをTypeScriptにして、commonパッケージの型定義を使うにはcommonパッケージをビルドしてからでないと利用できません。
(Denoなどのようにmainフィールドに.tsを指定する方法もありますが、正攻法ではありません)

このパッケージ間のビルドの依存関係を明示的に表現する機能としてProject Referencesがあります。
Project Referencesを設定することで、依存してるパッケージのビルドも同時に行えるため、
複数のパッケージを同時に編集してビルドチェック(型チェック)などができます。
また、Project Referencesではtsc --buildコマンドを使いますが、
このbuildコマンドではデフォルトでインクリメンタルビルドとなりビルドキャッシュが効いた状態となります。

Project Referencestsconfig.jsonreferencesフィールドを各パッケージごとに設定する必要があります。
referencesフィールドの更新は@monorepo-utils/workspaces-to-typescript-project-referencesを使うことで自動化できます。

あとは、拡張子を.jsから.tsにして、モジュール構文をECMAScript Modulesに変換しつつ、tsc --buildでコンパイルが通るかを確認する作業を繰り返していくだけです。

モジュール構文以外の型エラーへの対処

拡張子を.jsから.tsにすると、TypeScriptファイルとして認識されるため、TypeScriptのコンパイルで型チェックエラーが発生します。

まずTypeScriptにすることを目的にしていたため、モジュール構文以外のコードはいじらずに変換して行くように気をつけて変換していきました。
最初に書いたように、リファクタリングも一緒に行ってしまうと動作が変わってしまう場合があるため、確認コストや心理的なコストが上がってしまいます。
テストなどでもコストはある程度減らせますが、リファクタリングしたことで動作が変わってしまったかの不安は消しきれません。

モジュールの構文のみの変更ならば、TypeScriptのコンパイルチェックとロードしてエラーがでるかを確認する程度でかなりの部分がカバーできます。
動的なインポートは少し気をつける必要がありますが、その部分だけ変更を分けるなどの工夫が取れます。

.tsへ変更したことで発生する型エラーに対しては、type FIXME_TYPE = anyのようなany型や// @ts-expect-errorコメントを使って型エラーを無視していきました。
// @ts-ignoreコメントに関しては、正しい型チェックも無視されてしまうため、現時点では使う理由はないでしょう)

この型エラーを無視するように変換するのはts-migrateというツールを使うことでもできます。
まとめて、変換すると不安になってしまうこともあるため、ある程度モジュールのカタマリを見つけて試しながらやってみるのがよいです。

Blocksでは、commonパッケージは手作業で変換し、serverではAPIのルーティングごとに変換、frontはts-migrateを使って一括で変換しました。
(frontでは.vueファイルはTypeScript移行の対象外としていて、それ以外の.jsファイルの数があまり多くなかったため)

これによって、パッケージ/モジュール/APIのルーティングといったある種のグループごとに変換していくことで、段階的にJavaScriptを型なしのTypeScriptへと変換できました。
他の作業もしつつ大体1ヶ月程度で、JavaScriptファイルの大部分がTypeScriptファイルへと変換できています。

2021年前半: 型なしのTypeScriptから型ありのTypeSriptへ

Overview 2021年前半

ひとまずTypeScriptファイルへの変換が済み、新しいコードは最初からTypeScriptで型をちゃんと書いて開発できる状態となりました。
しかし、JavaScriptから変換したTypeScriptは、any// @ts-expect-errorだらけの型なしのTypeScriptのままです。
(プリミティブな型はついていますが、オブジェクトや複雑な関数の型などはanyとなっているようなイメージです)

次は、この型なしのTypeScriptを型ありのTypeScriptへとマイグレーションしていくことを検討しました。

2021年前半のBlocksのTypeScript

一度すべてのJavaScriptをTypeScriptに変換して、2021年前半のBlocksのTypeScriptは次のような状態でした。

  • commonstrict: trueな型付けが完了した
  • server は、主にexpressとMongoDBで書かれていて、型なしのTypeScriptが多かった
  • front は、Vue部分は型なし、StateなどのTSも型は緩めだった

先ほども書いていたように、型のように連鎖するマイグレーションは依存関係の末端から行っていくのが基本です。
そのため、しっかりした型付けもパッケージ間の末端であるcommonから行っています。
commonパッケージは総量が少なかったためすぐに完了しましたが、serverfrontはまだnoImplicitAnyfalseであるなどanyが多い状態でした。

これは、最初の「マイグレーションの2種類のアプローチ」で紹介していたように、型をちゃんとつけるのはコストが高いためです。
最初からしっかりした型をつけるパターン、後から型をつけるパターンのどちらであっても、一番たいへんなのは型をつける部分です。

どちらのパターンでも型やドメインに詳しい人が専任で型を足していくのが一番はやく効率的です。
しかし、今回はチームの誰でもできる形を目指して、チームでTypeScriptの型をつけていく方針を選びました。

特定の誰かではなく、チームでTypeScriptの型をつけていく方針を目指した理由は次のとおりです。

  • 特定の誰かだけがやるのは仕組みにならない
  • チーム全体でTypeScriptを使っていくために、チームがTypeScriptを学習していく必要がある

特定の誰かだけがやるのは仕組みにならない

常に全員が新しいものを取り入れたり、古いコードを取り除くことに労力を支払いたいわけではありません。
しかし、プロダクトを開発して新しい機能が増えたり時間が経てば、コードは古くなるので改善しないといけません。
特定の人がこのマイグレーションをやるのは意思決定のブロッカーが少ないため早いですが、それではスケールしないし、継続的な改善が難しいです。

たとえば、特定の誰かのみが毎回マイグレーションをしていると、その人がチームから抜けた後は改善が継続しません。
特定の誰かだけでなく、チームを積極的に巻き込んでいき、チーム全体で変更への成功体験を作ることが重要だと考えています。

逆を言えば、誰でも参加できるマイグレーションは仕組み化された部分があって、分散的にできる特徴を持っています。
ここにチームで参加することで、次の改善は別の人が起因で発生する可能性が上がると考えています。

また、仕組みだけを作って渡すのはうまくいかないと考えています。
頭ではその改善が正しいとわかっていても、実際に動かしてみないと納得するのは難しいです。
そのため、仕組みだけ作って渡すだけだと、実際には行われずに放置されるケースがあります。

これを避けるには、一緒にやるための仕組みを作って一緒にやることが重要です。

一緒にやって成功体験を作ることが、実際にものごとを動かします。
また、参加する意思のある人が、その作業へ参加しやすい状態を作ることが重要です。

  • 誰でも触れるというのは、コンフリクトしない仕組み
  • 誰でも触りやすいのは、小さな単位で移行できる仕組み
  • 誰でも参加しやすいのは、失敗が許容しやすい状態

具体的には、ファイル単位やコンポーネント単位など、ひとつの移行に数時間かからないような小さな単位で移行できる仕組みを作ることです。
また、その移行に失敗した場合もすぐに修正できたり、影響範囲が限定的であったり、代替策を用意したりなど失敗が許容しやすい状況を作ることが重要です。

Blocksでは、frontserverパッケージの型なしのTypeScriptに型を付けていくという作業が必要でした。
この型付けの作業に意味をもたせて、誰もが参加しやすく、また失敗しても影響範囲が限定的となるような単位で作業できる方法を考える必要がありました。

その中で、serverへのAPIのリクエスト/レスポンスのバリデーションと型付けについての課題を見つけ、この課題を解決する形で型付けを促進する方針を見つけました。

APIへのリクエストのバリデーションと型付けの課題

型なしのTypeScript時点でのexpressのAPIを疑似的なコードにしてみると次のようなものです。

router.ts:

import express from 'express';
import wrap from 'express-async-handler'; // asyncを使うためのwrapper
import BlockModel from './BlockModel'; // monngose Model

const router = express.Router();
router.post(
  '/updateBlock',
  wrap(async (req, res) => {
    const projectId = req.project.id; // sessionに紐づくprojectのid
    const { blockId, block } = req.body;
    // ...logics.. 
    // Warning: 危険なコードなので真似しないように(わざと問題を詰め込んでいます。)
    // bodyのbockIdを元に、そのデータをblockというオブジェクトで更新する
    const updatedModel = await BlockModel.findOneAndUpdate(
      {
        blockId,
        projectId
      },
      {
        $set: {
          ...block,
        },
      },
      { new: true },
    );
    // ...logics..
    // 更新したModelをそのまま返してる
    res.json(updatedModel);
  }),
);
export default router;

この疑似コードを見ると、次のような問題が見つかります。

  • reqresにTypeScriptの型がついていない
  • mongooseのモデルに対して、バリデーションしていないreq.bodyが渡されている
  • mongooseのモデルをそのままJSONとして返しているため、不要な値がレスポンスに入ってしまっている

特に後者のバリデーションせずにmongooseのモデルにリクエストオブジェクトを渡すコードは、NoSQL Injection呼ばれる脆弱性につながることがあります。
具体的には、{ blockId: { $ne: null }, block: {}} ようなrequest bodyを投げると、nullではないblockIdに一致するため、どれかひとつのBlockを書き換えるという意味になります。

また、res.json(updatedModel);でモデルそのものをレスポンスとして返してしまっています。
この場合、実際には必要ない値もレスポンスに含まれる可能性が多いため、あまり良くありません。

Blocksのserverのコードを見ているとAPIのルーティングの定義で、この3つの問題のどれかを抱えているものがありました。
そのため、この3つの問題をうまく解決する方法がないかを考えました。

もちろん、TypeScriptの型定義を書いて、assert(typeof blockId === "string")のようにチェックするコードを手で書いていくのも問題ありません。
ただ、TypeScriptの型定義とバリデーションのコードはやや重複している部分があるため、この2つを同時に解決できれば型付けのモチベーションとなりそうだなと考えました。

また、少し観点は異なりますが、frontserverが同じリポジトリ(monorepo)にいるにもかかわらず、うまく連携できていない部分がありました。
たとえば、frontserverに同じようなバリデーションコードがあったり、frontからserverのAPIを実際に叩くまで、そのリクエストbodyが正しいのかチェックできてないという課題もありました。

チームでこれらの課題を話し合い、次のようなアプローチでこの課題を段階的に解決していくことにしました。

まずは、開発の効率の改善のために型定義だけを入れます。
追加するのを型定義だけに絞ることで、実際の動作は変わらないため、比較的安全に追加できます。

  • RequestとResponseのTypeScriptの型を定義する
  • Expressで必ず型を使ってRequest、Responseを扱うようにする
    • Mongoモデルをそのまま返さないという制約になる
    • Responseはただのオブジェクトなのでres.json(model)はエラーとなり、不要なレスポンスが入りにくくなる
  • serverで定義した型定義をFrontに共有して活用する
    • 開発時のミスや効率などを改善できる

型定義が追加されてきたら、TypeScriptの型定義からJSON Schemaを生成するツールを使い、JSON Schemaを元にバリデーションをします。

  • TypeScriptの型定義から、バリデーションコードを生成する
  • バリデーションコードを使ってreq.bodyなどリクエストのバリデーションを行う

バリデーションは不正なリクエストが送られると、実際にブロックするため、動作を変更してしまいます。
そのため、APIごとにバリデーションコードを追加していき、1つの変更で影響があるのは1つのAPIという形で段階的に変更する必要があります。

実際に行った作業は次のような形です。
大きく2つの段階に分かれています。

  1. RequestとResponseのTypeScriptの型を定義する
  2. TypeScriptの型定義から、バリデーションコードを生成する

1. RequestとResponseのTypeScriptの型を定義する

たとえば、https://example.com/api/blocks/updateBlockのAPIは、serverパッケージで次のようなディレクトリ構造のrouter.tsに定義されていました。
このblocks/ディレクトリに、APIのリクエストとレスポンスの型定義だけをするapi.d.tsを追加します。

server/
└── api/
    └── blocks/
        ├── api.d.ts
        └── router.ts

api.d.tsは次のように、typeだけをもつ型定義ファイルです。

export type Block = { id:string; value:string; };
export type UpdateBlockRequestBody = {
    blockId: string;
    block: Block;
};
export type UpdateBlockResponseBody = {
    id: string;
};

先ほどのrouter.tsに、このapi.tsで定義した型を使うように変更します。
型定義に合わせてレスポンスボディーも修正します。

import express from 'express';
import wrap from 'express-async-handler'; // asyncを使うためのwrapper
import BlockModel from './BlockModel'; // monngose Model
import type { UpdateBlockRequestBody, UpdateBlockResponseBody } from "./api";
const router = express.Router();
router.post(
  '/updateBlock',
  wrap(async (
      req: Request<{}, {}, UpdateBlockRequestBody, {}>,
      res: Response<UpdateBlockResponseBody>,
 ) => {
    const projectId = req.project.id; // sessionに紐づくprojectのid
    const { blockId, block } = req.body; // TODO: ここはまだバリデーションの問題あり
    const updatedModel = await BlockModel.findOneAndUpdate(
      // ...
    );
    // 型に合わせてidだけを返す
    res.json({
        id: updatedModel.id
    });
  }),
);
export default router;

このserverパッケージに定義した型を、frontパッケージからも利用します。

本来はTypeScriptの型定義はビルドしたものを参照するべきですが、frontパッケージをビルドするためにserverパッケージのビルドする必要があるのは、CI的にも微妙なところです。
正攻法としてはリクエストとレスポンスの型定義だけを別のapiのようなパッケージとして切り出すのがよいのですが、
実際のAPIの実装と型定義が離れ過ぎるのも認識しにくくなる問題があります。

そのため妥協案として、rcpに型定義はおきつつ、tsconfig.jsonpathsを使ったPath mappingで、直接frontからserver型定義だけを参照するようにしました。

具体的にはfrontパッケージのtsconfig.jsonで次のような設定をします。

{
  "compilerOptions": {
    "paths": {
      "api": [
        "../server/api/api.d.ts"
      ]
    }
  }
}

これでfrontからリクエストを送る際に、serverで定義したapi.tsの型だけを参照できます。
注意点としては、import typeexport typeなどを使って型定義のみを扱わないと壊れる可能性があります。

// pathsによってserverの型定義を参照している
import type { UpdateBlockRequestBody, UpdateBlockResponseBody } from "api";

async function post<Body = any, Response = any>(
  url: string,
  body: Body = {} as Body,
): Promise<Response> {
  return fetch(url, {
    method: "POST",
    body
  });
}

post<UpdateBlockRequestBody, UpdateBlockResponseBody>("/api/blocks/updateBlock", {
    id: "xxx",
    value: "xxx",
});

このAPIのリクエストとレスポンスの型定義を書くことによって、コンパイル時にチェックできることが飛躍的に増えました。
レスポンスで、mongooseのモデルをそのまま返してしまうケースが減ります。
また、型の補完がしやすくなりフロントエンドとバックエンドを同時に開発しやすくなりました(BlocksではFrontとserverは同時にいじれる人が多いです)。

型定義ファイル1つで複数のメリットがあり、また型定義もAPI単位で追加できるため、インクリメンタルに進めやすくなっています。
このとき、チームの誰でも参加しやすいように、この変換をするSlackのスレッドを立てて、毎日PRとともにコメントするようにしました。
作業の見本はPRとしてどんどん増えていき、PRはAPI単位なので、数十行程度の変更で済みます(大部分は型定義を追加するだけです)。

Slackのスレッド

100以上のAPIがありましたが、チームで話し合いながらAPIを修正していき、他の機能開発をしながらも1ヶ月ほどですべてのAPIに型定義が追加できました。

2. TypeScriptの型定義から、バリデーションコードを生成する

先ほどの、APIに型定義を追加するのとやや平行的に、TypeScriptの型定義からバリデーションコードを生成して利用することも始めました。

TypeScriptのコードまたは型定義からJSON Schemaを生成するツールとしてts-json-schema-generatorなどがあります。
これらのツールを使って先ほどのapi.d.tsからJSON Schemaを生成して、Ajvというバリデーションライブラリでバリデートすることにしました。

このTypeScriptからJSON Schemaを生成するアプローチにはメリットとデメリットがあります。

メリットは、次の点になります。

  • TypeScriptがコード、型定義、スキーマのSingle Source of Truthとなること
  • 型定義とバリデーションコードを二重に書かなくて済むこと

デメリットは、次の点になります。

  • ts-json-schema-generatorなどは公式のツールではないためメンテナンスの問題があること
  • TypeScriptの型定義とJSON Schemaに表現の違いがあること

メリットは明確で、開発者はTypeScriptだけを意識すればよくなることです。
デメリットは、将来この方法を使えなくなる可能性がある点です。

別のアプローチとしては、OpenAPI Spec/JSON Schema/JSON Type Definitionなどのスキーマを書いて、スキーマからTypeScriptの型定義やクライアントを生成するアプローチです。
こちらのパターンは、TypeScriptを書き慣れてきたチームにとって、コードとの距離が離れていて煩わしい部分があったため選択されませんでした。

一般公開するAPIや複数の言語のクライアントが必要な場合は、スキーマからコードを生成したほうがメリットは大きくなります(社内の別の仕組みではこのアプローチを利用しています)。
しかし、BlocksではTypeScriptが大部分を占めていたため、TypeScriptの型定義からJSON Schemaを生成するようにしました。

デメリットもいくつかありますが、ts-json-schema-generatorが使えなくなったとしても、TypeScriptの型定義自体は資産として残ります。
そのため、使えなくなった場合はそのときに移行方法を考えれば問題なさそうということで、TypeScriptからJSON Schemaを生成するアプローチを選択しました。

APIの型定義の追加ができたものから、順番に生成したJSON Schemaベースのバリデーションを追加していきました。
何度か、型定義(スキーマ)に載せてなかったパラメータのリクエストがブロックされることで、APIが動かないケースもありました。
しかし、基本的にAPI単位で進めており、管理画面全体が動かなくなるケースはなく、影響範囲が限定的であったため、失敗しながらすすめることができました。

最終的には、APIの型定義の追加とほとんど同時にバリデーションの追加も完了しました。

このマイグレーションによって、最初に課題として上げていた3つの部分が解決されました。

  • ✅ reqとresにTypeScriptの型がついていない
  • ✅ mongooseのモデルに対して、バリデーションしていないreq.bodyが渡されている
  • ✅ mongooseのモデルをそのままJSONとして返しているため、不要な値がレスポンスに入ってしまっている

また、Input(リクエスト)とOutput(レスポンス)の型定義がしっかりしてくると、その間のロジックの型定義も自然と増えてくるため、
このマイグレーション中にserverパッケージの大部分にちゃんとした型がつくようになりました。

2021年後半: 型ありのTypeScriptから積極的に型を使うTypeScriptへ

Overview 2021年後半

2021年後半のBlocksのTypeScript

2021年後半のBlocksのTypeScriptは、次のような状態でした。

  • commonstrict: trueな型付けが完了した
  • server は、APIの型付けによって型が付き、noImplicitAny: trueとなった
  • front は、Vue部分は型なし、StateなどのTSも型は緩めだった

front以外は、型があるのは前提で開発できる状態になっていました。
一方で、frontはReactやAngularに比べると、TypeScriptと組み合わせて使うのがメジャーではないVue(2系)であったため、TypeScriptの導入が進んでいませんでした。

ちょうどこの時期に、新しいエディターというUI的に複雑性のある機能開発を予定していました。
複雑な機能を持つ新しいエディター画面のロジックやデータ構造には、TypeScriptを使うのが効率のよいことはわかっていました。
この新しい機能をVue(2 or 3)で引き続き開発するか、別のUIフレームワークを使うのかを検討することにしました。

VueとReactのTypeScriptサポートを比較する

別のUIフレームワークとして候補に上がったのはTypeScriptと相性がよいReactです。
SvelteAngularなどについても簡単に議論しましたが、候補としては上がりませんでした。
管理画面というUIに対してSvelteは特徴が活かしきれないこと、AngularはVueとReactからパラダイムが少し異なることなどがあります。

そのため、Vueをそのまま使うかReactを採用するかを議論しました。
最終的には、Reactを採用することにしました。

どちらもTypeScriptをサポートしているのですが、エコシステムとしてのTypeScriptの親和性の高さなどが主な決め手です。

具体的に、VueとReactのTypeScriptのエコシステムについて見ていきます。

定量的なデータとして、ReactとVueでnpmを検索して、typesフィールドまたは@typesパッケージがあるパッケージの数を比較してみます。
それぞれ1000個のパッケージをみると、型定義があるのはReactで429個、Vueで223個でした。
2000、3000と増やしてみても、だいたい倍程度の差がある傾向は同じでした。

npm registryを検索する検証用のコード(クリックで開く)
import fetch from "node-fetch";
import pAll from "p-all";
import fs from "fs/promises";
import react from "./react.json";

const fetchTypes = async (keyword) => {
    const results = [];
    const SIZE = 250;
    for (let i = 0; i < 4; i++) {
        const searchAPI = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(keyword)}&size=${SIZE}&from=${i * SIZE}`;
        const result = await fetch(searchAPI).then(res => res.json());
        const typesResults = await pAll(result.objects.map((o) => {
            return async () => {
                const pkgAPI = `https://registry.npmjs.org/${o.package.name}/${o.package.version}`
                const pkg = await fetch(pkgAPI).then(res => {
                    if (res.ok) {
                        return res.json();
                    }
                    throw new Error("not found");
                })
                // types field
                if ("types" in pkg) {
                    return [pkg.name, "types" in pkg];
                }
                // @types/{pkg}
                const typesPkgAPI = `https://registry.npmjs.org/@types/${o.package.name}/${o.package.version}`
                try {
                    await fetch(typesPkgAPI).then(res => {
                        if (res.ok) {
                            return res.json();
                        }
                        throw new Error("not found");
                    });
                    return [pkg.name, true];
                } catch {
                    return [pkg.name, false];
                }
            };
        }), {
            concurrency: 5
        });
        results.push(...typesResults);
    }
    return results;
}

await fetchTypes("react").then(results => {
    console.log(`React: ${results.filter(r => r[1]).length}/${results.length}`);
});
await fetchTypes("vue").then(results => {
    console.log(`Vue: ${results.filter(r => r[1]).length}/${results.length}`)
});
  • React: 429/1000
  • Vue: 223/1000

定性的データとして、毎年行われている開発者アンケートであるState of JS 2020アンケートデータを使い、
{React or Vue} と TypeScriptをともに使いたい("would_use")の割合を出してみます。

State of JS 2020から抽出するスクリプト(クリックで開く)
const state = require("./state_of_js_2016_2020.json");

const state2020 = state.filter(item => item.year === 2020);
const reactWouldUse = state2020.filter(item => item.tools?.["react"]?.["experience"] === "would_use");
const reactAndTypeScriptWouldUse = reactWouldUse.filter(item => {
    return item.tools?.["typescript"]?.["experience"] === "would_use";
});
console.log(`React + TS: ${reactAndTypeScriptWouldUse.length}/${reactWouldUse.length}${Math.trunc((reactAndTypeScriptWouldUse.length / reactWouldUse.length) * 100)}%`);
const vueWouldUse = state2020.filter(item => item.tools?.["vuejs"]?.["experience"] === "would_use");
const vueAndTypeScriptWouldUse = vueWouldUse.filter(item => {
    return item.tools?.["typescript"]?.["experience"] === "would_use";
});
console.log(`Vue + TS: ${vueAndTypeScriptWouldUse.length}/${vueWouldUse.length}${Math.trunc((vueAndTypeScriptWouldUse.length / vueWouldUse.length) * 100)}%`);
  • React + TS: 11331/15071 → 75%
  • Vue + TS: 6163/9029 → 68%

ReactとTypeScriptをどちらも使いたいのは、Reactを使いたい人のうち75%ほどでした。
VueとTypeScriptをどちらも使いたいのは、Vueを使いたい人のうち68%ほどでした。(Vue 3のリリースは2020年9月なので、Vue 3リリース後のデータとなります。)

この結果からわかるのは、どちらの場合もTypeScriptと組み合わせて使いたいユーザーが7割程度います。
一方で、実際に関連するパッケージには型定義があるかどうかは、ReactとVueで2倍程度の開きがあります。

これは感覚とも一致していて、VueのコンポーネントではTypeScriptが前提となっているものはまだまだ少ない状態です。
一方で、Reactの場合はメンテナンスされているコンポーネントに関しては、TypeScriptの型定義がある状態です。

2021年時点だとTypeScriptを前提として開発する場合には、Reactが有利となり、この傾向は簡単には変わらないと考えています。
また、React 18ではこの記事の目的と同じように、ひとつのアプリケーション内でも部分的にインクリメンタルなアップデートを前提にした設計が追加されています。
そのため、今回の新規開発でReactを試してみることにしました。

まずは、Reactで書いてみるのは新しいエディター画面だけに絞り、そこでうまくいくかどうかを確認します。
もし、うまく行かない場合は、Vueでやり直すという合意をチームで取りすすめることにしました。

Reactを新規機能で利用してみることを決めましたが、既存のVueで書かれたものが急になくなるわけではありません。
インクリメンタルなアップデートとは、複数のものが同時に動くのを認めることから始まります。

VueとReactが共存するRouterの仕組み

Blocksの管理画面は、典型的なSingle Page Application(SPA)となっています。
そのため、VueとReactで書かれたページがある場合に、ページでHTML自体を分けてしまうと、ページをまたぐ際にページを読み直してしまいます。
スムーズにページ移動できるSPAらしいページ遷移ではなくなってしまうと体験が悪くなってしまいます。

そのため、VueとReactが共存し、2つのフレームワーク間での移動もスムーズにできる仕組みを作ることにしました。

最初に、マイグレーションをやるためには境界線を見るという話をしていました。

  • ファイル
  • モジュール(JavaScriptはファイルと同義)
  • ルーティング(ルーター)

この中で、今回の2つのUIフレームワークを共存させるために見るべき境界線は、ページ遷移を扱うルーティングです。

今回のケースだとVue RouterReact Routerがこのルーティングを扱うルータです。

ひとつのページに複数のルーターがあるとHistoryオブジェクトを取り合ってしまい、ルーティングが壊れてしまいます。
そのため、複数のルーター(複数のUIフレームワーク)を動かす場合には、Historyオブジェクトを実際に操作するルーターはひとつだけにして、他のルーターはその変更に同期するだけという仕組みが必要です。

今回は、React Routerを正として、Vue RouterはReact Routerの変更に追従するだけという仕組みにしました。
そして、Reactで作られているページ(新しいエディター)はReactで表示して、Vueで作られているページはVueを含むbundleを読み込んで表示します。
React Router、Vue RouterどちらもLazy Loadingによる実際に関連するページのchunkだけを読み込む仕組みを持っているため、必要なページのchunkだけを読み込むことも可能です。

具体的なコードを見ていきます。

まずは、Vueで書かれたページをまとめたblocks-vue-app.jsというbundleを用意します。
Reactで書くページとVueで書いたページのbundleを分けることで、Reactのページを開発時にはVueのページをビルドしないで済むようにしています。

次に、Vueで書いたページのbundleを読み込んでVUE_APP_IDに対してmountしておき、ルーティングにマッチしたタイミングでのみ表示します。
VueAppコンポーネントは、読み込みとmountの処理、VueAppPageは表示の切り替え処理をしています。

VueApp.tsx:

import { useEffect, FC } from 'react';

const VUE_APP_ID = 'vue-app-id';
function showVueApp() {
  const vueApp: HTMLElement | null = document.querySelector(`#${VUE_APP_ID}`);
  if (!vueApp) return;
  vueApp.style.display = '';
}

function hideVueApp() {
  const vueApp: HTMLElement | null = document.querySelector(`#${VUE_APP_ID}`);
  if (!vueApp) return;
  vueApp.style.display = 'none';
}

/**
 * vuejs を読み込むための page component.
 */
const VueApp: FC = () => {
  useEffect(() => {
    const builderUrl = '/blocks-vue-app.js';
    const script = document.createElement('script');
    script.src = builderUrl;
    script.setAttribute('id', 'blocks-vue-app');
    document.body.appendChild(script);

    return () => {
      const script = document.querySelector('#blocks-vue-app');
      if (script) script.remove();
    };
  }, []);

  return (
    <div id={VUE_APP_ID} style={{ display: 'none' }}>
      <div id="wrapper" />
    </div>
  );
};

/**
 * vueapp の表示非表示を制御するための page component.
 */
const VueAppPage: FC = () => {
  useEffect(() => {
    showVueApp();
    return () => {
      hideVueApp();
    };
  }, []);
  return null;
};

export { VueApp, VueAppPage };

合わせて、先ほども説明したようにReact RouterのみがHistoryオブジェクトを操作するようにします。
そのためには、Vue Router側のHistory処理は邪魔となるため、Vue Routerの処理を上書きする仕組みを書きます。

少し長いですが、Blocksで利用しているコードをほぼそのまま紹介します。

React側には、次のようにRouter Routerを遷移をCustom Eventとして発火します。
Vue側で、このCustom Eventを見るようにするのが目的です。

VueRouterHook.ts:

import { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
const vueRouterEvent = 'vue-router-event';
type VueRouterGoHookDetail = { type: 'go'; n: number };
type VueRouterReplaceHookDetail = { type: 'replace'; location: string };
type VueRouterPushHookDetail = { type: 'push'; location: string };
type VueRouterBeforeEachHook = { type: 'before-each' };
type VueRouterAfterEachHook = { type: 'after-each' };
type VueRouterDetail =
  | VueRouterGoHookDetail
  | VueRouterReplaceHookDetail
  | VueRouterPushHookDetail
  | VueRouterBeforeEachHook
  | VueRouterAfterEachHook;

const reactRouterEvent = 'react-router-event';
type ReactRouterDetail = { type: 'transition-to'; location: string };

const logEnability = localStorage.getItem('blocks-vue-react-integration-debug-log');
const debugLog = (...args: unknown[]) => {
  logEnability && console.log(...args);
};

/**
 * /packages/front で管理されてる vuejs 内で動いている vue-router との integration
 * vue-router から event が送られてきたらときに、こちらで history api を使用する
 */
function VueRouterHook(): null {
  const hisotry = useHistory<{ fromVueRouter?: boolean }>();
  const onGoHook = useCallback(
    (detail: VueRouterGoHookDetail) => {
      debugLog('[React] onGo', detail.n);
      hisotry.go(detail.n);
    },
    [hisotry],
  );
  const onReplaceHook = useCallback(
    (detail: VueRouterReplaceHookDetail) => {
      debugLog('[React] onReplace', detail.location);
      hisotry.replace(detail.location, { fromVueRouter: true });
    },
    [hisotry],
  );
  const onPushHook = useCallback(
    (detail: VueRouterPushHookDetail) => {
      debugLog('[React] onPush', detail.location);
      hisotry.push(detail.location, { fromVueRouter: true });
    },
    [hisotry],
  );
  const onHandleVueRouterEvent = useCallback(
    (event: CustomEvent<VueRouterDetail>) => {
      if (event.detail.type === 'go') {
        onGoHook(event.detail);
      } else if (event.detail.type === 'push') {
        onPushHook(event.detail);
      } else if (event.detail.type === 'replace') {
        onReplaceHook(event.detail);
      }
    },
    [onGoHook, onPushHook, onReplaceHook],
  );

  useEffect(() => {
    window.addEventListener(vueRouterEvent, onHandleVueRouterEvent as never);
    /**
     * vue router に event を送る。
     */
    hisotry.listen((location, action) => {
      /**
       * push, replace かつ、vue router 起因の場合は skip する。
       */
      if (action !== 'POP' && location.state?.fromVueRouter) return;
      const transitionTo = location.pathname + location.search + location.hash;
      // console.log('[React] dispatch', transitionTo);
      dispatchReactRouterEvent({
        type: 'transition-to',
        location: transitionTo,
      });
    });

    return () => {
      window.removeEventListener(vueRouterEvent, onHandleVueRouterEvent as never);
    };
  }, [hisotry, onHandleVueRouterEvent]);

  return null;
}

function dispatchReactRouterEvent(detail: ReactRouterDetail) {
  window.dispatchEvent(
    new CustomEvent(reactRouterEvent, {
      detail: detail,
    }),
  );
}

export default VueRouterHook;

一方のVue側(vueのbundleに含まれる)は、次のようにReact側からのCustom Eventを受け取り、Vue Routerのstateを書き換えます。
このとき、Vue RouterがHistoryオブジェクトを触れないように、Vue Routerの処理を上書きします。

vue-router-hack.ts:

import Router, { Route, RawLocation } from 'vue-router';

/**
 * see https://github.com/vuejs/vue-router/blob/90cd2690d59c6bd56e5bc12b5b752166a1d35e98/src/history/base.js
 */
type BaseHistory = {
  router: Router;
  base: string;
  current: Route;
  pending?: Route;
  cb: (r: Route) => void;
  ready: boolean;
  readyCbs: Array<Function>;
  readyErrorCbs: Array<Function>;
  errorCbs: Array<Function>;

  listen: (cb: Function) => void;
  onReady: (cb: Function, errorCb?: Function) => void;
  onError: (errorCb: Function) => void;
  transitionTo: (
    location: RawLocation,
    onComplete?: (route: Route) => void,
    onAbort?: Function,
  ) => void;
  confirmTransition: (route: Route, onComplete: Function, onAbort?: Function) => void;
  updateRoute: (route: Route) => void;
};

/**
 * see https://github.com/vuejs/vue-router/blob/90cd2690d59c6bd56e5bc12b5b752166a1d35e98/src/history/base.js#L27-L31
 */
type SubHistory = BaseHistory & {
  push: (location: RawLocation, onComplete?: Function, onAbort?: Function) => void;
  go: (n: number) => void;
  replace: (location: RawLocation, onComplete?: Function, onAbort?: Function) => void;
  getCurrentLocation: () => string;
  ensureURL: (push?: boolean) => void;
};

/**
 * see https://github.com/vuejs/vue-router/blob/90cd2690d59c6bd56e5bc12b5b752166a1d35e98/src/history/abstract.js
 */
type AbstractHistory = SubHistory & {
  stack: string[];
  index: number; // default := -1;
};

type VueRouter = Router & {
  history: AbstractHistory;
};

const vueRouterEvent = 'vue-router-event';
type VueRouterGoHookDetail = { type: 'go'; n: number };
type VueRouterReplaceHookDetail = { type: 'replace'; location: string };
type VueRouterPushHookDetail = { type: 'push'; location: string };
type VueRouterBeforeEachHook = { type: 'before-each' };
type VueRouterAfterEachHook = { type: 'after-each' };
type VueRouterDetail =
  | VueRouterGoHookDetail
  | VueRouterReplaceHookDetail
  | VueRouterPushHookDetail
  | VueRouterBeforeEachHook
  | VueRouterAfterEachHook;

const reactRouterEvent = 'react-router-event';
type ReactRouterDetail = { type: 'transition-to'; location: string };

const logEnability = localStorage.getItem('blocks-vue-react-integration-debug-log');
const debugLog = (...args: any[]) => {
  logEnability && console.log(...args);
};

/**
 * vue router を react router と sync されるための hack.
 *
 * react router の history をベースにするために、
 * こちらの history は react router へ event を送る形式にしている。
 */
function hackHistoryInVueRouter(vueRouter: VueRouter) {
  /**
   * before/after each hook の integration
   */
  vueRouter.beforeEach((_to, _from, next) => {
    debugLog('[Vue] before each hook');
    dispatchVueRouterEvent({
      type: 'before-each',
    });
    next();
  });
  vueRouter.afterEach(() => {
    debugLog('[Vue] after each hook');
    dispatchVueRouterEvent({
      type: 'after-each',
    });
  });

  /**
   * react router の変更が走ったら、vue router で transition する。
   */
  const onHandleReactRouterEvent = (event: CustomEvent<ReactRouterDetail>) => {
    vueRouter.history.transitionTo(
      event.detail.location,
      route => {
        debugLog('[Vue] transitioned from react to', event.detail.location);
      },
      (err: any) => {
        if (err) {
          console.error(
            `[Vue] cannot transitioned from react to ${event.detail.location}, since: ${err}`,
          );
        }
      },
    );
  };
  window.addEventListener(reactRouterEvent, onHandleReactRouterEvent as any);

  /**
   * history の動作を変更する
   *
   * go: event を送るだけ
   * push, replace: page をレンダーしつつ、event を送る
   */
  vueRouter.history.getCurrentLocation = () => {
    return window.location.pathname + window.location.search + window.location.hash;
  };
  vueRouter.history.go = n => {
    dispatchVueRouterEvent({ type: 'go', n });
  };
  vueRouter.history.push = (location, onComplete, onAbort) => {
    vueRouter.history.transitionTo(
      location,
      route => {
        dispatchVueRouterEvent({
          type: 'push',
          location: route.fullPath,
        });
        onComplete && onComplete(route);
      },
      onAbort,
    );
  };
  vueRouter.history.replace = (location, onComplete, onAbort) => {
    vueRouter.history.transitionTo(
      location,
      route => {
        dispatchVueRouterEvent({
          type: 'replace',
          location: route.fullPath,
        });
        onComplete && onComplete(route);
      },
      onAbort,
    );
  };

  /**
   * https://github.com/vuejs/vue-router/blob/90cd2690d59c6bd56e5bc12b5b752166a1d35e98/src/index.js#L102
   * mode = abstract だと初期化されないので、ここでする。
   */
  vueRouter.history.transitionTo(vueRouter.history.getCurrentLocation());
}

function dispatchVueRouterEvent(detail: VueRouterDetail) {
  window.dispatchEvent(
    new CustomEvent(vueRouterEvent, {
      detail: detail,
    }),
  );
}

export { hackHistoryInVueRouter };

Vue側は、Vue RouterのインスタンスをこのhackHistoryInVueRouterに渡して処理を書き換えて準備します。
これで、Vue RouterはHistoryを扱わなくなり、現在の位置はReact Routerと同期するようになります。

const router = new Router({
  /**
   * react router と vue router の html history を sync されるために必要。
   */
  mode: 'abstract',
  base: `/`,
  routes,
});

hackHistoryInVueRouter(router as any);

最後に、React側で、Router Routerのルーティング処理を書いていくだけです。
これで、ReactのページはReactで、VueのページはVueで描画し、ルーティング処理をReactに寄せることができました。

import React, { lazy } from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";
import VueRouterHook from "./VueRouterHook"
// Vueの表示切り替えのみを行う
import { VueAppPage, VueApp } from "./components/VueApp";
const Editor = lazy(() => import("./components/Editor"));


const Public: React.FC = () => {
  return <>
    <Router>
      <Route exact path="/react-editor" component={Editor} />
      {/* ルーティングに一致したときのみVueAppを表示する */}
      <Route exact path="/vue-page" component={VueAppPage} />
      {/* vue app integration */}
      <VueRouterHook />
      <VueApp />
    </Router>
  );
};

ルーティング自体はReactが担当していますが、これによってReactからVueへ、VueからReactへの遷移も問題なく行なえます。

VueからReactの画面へ遷移

VueのページからReactのページへと遷移しています

このアプローチの大きなメリットは、既存のVueで書いたページをそのままReact上で動かせることです。
また、ルーティング単位でインクリメンタルにVueからReactへの移行が可能になります。

TypeScriptとReactで開発する

ルーティングを境界線として、ReactとVueどちらも動く状態になったため、Reactで新しいエディター機能を開発しました。

予想していたように、エディター機能は複雑性が高いものであっため、TypeScriptを採用したことで複雑性に対処でき生産性は向上しました。
大きなツリー構造のデータを扱う必要があったため、再描画の最適化も求める必要があり、細かな調整がしやすいReactをつかうことでパフォーマンスを維持できました。

また、VueとReactのスタイルの差をどうするかについてですが、厳密なピクセルパーフェクトは要求せず、
テイストが同じとなるようにコンポーネントを作ることで使い勝手に大きな差が出ないようにしています。

スタイルのテーマ自体は、KARTEではBaisuというUIフレームワークに依存しないCSSのスタイルフレームワークとそれに合わせたデザインガイドがあります。
Reactでは、TypeScriptで型チェックが効いたCSS in JSとしてvanilla-extractを使ったり、クラス名指定を使ってBaisuのスタイルをあてたりすることで、大きなスタイルの違いは出ませんでした。

このVueからReactへの移行は、TypeScriptの導入を手動したメンバーとは別のチームメンバーが中心に行いました。
これはチームでのインクリメンタルに新しい仕組みを取り入れる流れがうまく動いた例といえます。

2021年後半では、その他にもインクリメンタルな改善が行われています。
Blocksではブラウザの拡張機能を使うことで、Blocksの管理画面を拡張しています。
このブラウザの拡張機能もVueとJavaScriptで書かれていましたが、ReactとTypeScriptへの移行を段階的に行っています。

このブラウザの拡張機能は、基本的に管理画面と拡張機能間でメッセージパッシングしています。
そのため、このメッセージのやり取りを境界線して、メッセージの種類ごとにVueからReactへの移行をインクリメンタルに行っています。

インクリメンタルに新しい技術を取り入れるためのポイント

最後にこの記事の目的であったインクリメンタルに新しい技術を取り入れて改善していく方法のポイントを見ていきましょう。

インクリメンタルにやるためには境界線を見つけて、作業する順番が重要

この記事では、次のような境界線となるものについて紹介しました。

  • ファイル
  • モジュール
  • ルーティング
  • メッセージ

インクリメンタルに改善をしていくためには、どこを境界にするかは重要です。
その境界で分けられるものが作業の単位となるので、分けられる単位は小さい方が望ましいです(作業自体はまとめてやることもできます)。

また、実際に変更していく場合には、その作業の順番も重要です。
モジュールでは、依存関係をみて、依存関係の末端からやることで影響範囲を小さく保てます。

影響範囲を小さく保つことで、変更へのリスクや心理的な負荷が減ります。
一方で影響範囲を最大化するために、逆方向からやるという方法もあります。

「誰かがやる」は仕組みではない

インクリメンタルな改善とは、継続的にやる必要があるということを意味しています。

チームで継続的に改善していくためには、「誰かがやる」を仕組みとして扱わないことが重要です。
それを仕組みとして扱ってしまうと、その人がチームからいなくなった場合に改善が継続しないことを意味しています。
話し合った結果、専任で作業をすすめるのは問題ありませんが、特定の誰かに過負荷な状態を作ることを仕組みとしてはいけません。

機能開発も開発の改善もセキュリティの問題など、ものごとに対する本当のリスクはBurnout(燃え尽き症候群)です。

成功体験を作るためには失敗できる状態を作る

改善を成功しやすくするためには、失敗できる状態を作ることです。
やってみないとわからないことは多いため、できるだけ早く失敗して、実際の問題を見つけることが重要です。

今回のTypeScriptでのマイグレーションでは、境界線を見つけて小さな作業単位をつくっていました。
この作業単位を作る目的は、誰でも参加しやすくなる点と失敗できる状態を作る点です。
境界線が見極められて小さな作業に分けられている場合、仮に失敗しても大きな影響にはなりません。

今回の移行でも、失敗した場合のケースについても触れていました。

  • TypeScriptならコンパイルだけなので、失敗はいつでもできる
  • API単位でのバリデーションを導入したことで、失敗しても動かないのは特定のAPIだけ
  • Reactを採用したときも、失敗したらVueで書きなおそうという合意を取ることで、失敗しても大丈夫にした

おわりに

この記事では、KARTE BlocksのコードベースをJavaScriptからTypeScriptへとインクリメンタルに書き換えた方法について紹介しました。

今現在のウェブアプリケーションのコード量は増え続けているため、今まで書いたものをすべて一度に置き換えるのは現実的に難しくなってきています。
たとえば、Sourcegraphが2020年に調査したThe Emergence of Big Codeでは、半数以上の開発者は2010年の100倍以上のコードを扱っていると答えています。
これはウェブアプリケーションに限らないソフトウェア開発の傾向だと考えられます。

そのため、新しいものを開発しつつ既存のコードも改善していく、インクリメンタルな戦略が重要だと考えています。
このインクリメンタルな方法は、リファクタリングだけではなく、新しい技術を取り入れる方法として利用できます。
インクリメンタルな方法はあらゆるものへの最適な解決方法ではありませんが、新しいものごとへ挑戦するスタート地点となります。

この記事は「KARTE Blocksリリースの裏側」という連載の2日目の記事です。全10回を予定しています。
これから毎日記事を更新していくため、更新をチェックしたい方は@KARTE_BlocksのTwitterアカウントをフォローしてください!

最後に、KARTE Blocks自体の開発に興味がある!というエンジニア(インターンも!)を募集しています!
詳しくは弊社エンジニア採用ページ採用スライドをご覧ください!