🌊

アサイン後の初仕事で Vue2 のシステムを React に置き換えた話

2024/06/03に公開

Cariot の開発チームでエンジニアをしている山田と申します。
チームに参画した最初の仕事として、Vue2 のシステムを React にリプレイスしましたので、その知見を共有したいと思います。

事の始まり

Cariot の開発チームにアサインされた初日、1 つのタスクを割り当てられました。
それは、 Cariot の機能の一つであるリアルタイムで車両の情報を共有するサブシステム DriveCast のモダナイズでした。
アサイン後の最初のタスクなんて、ちょっとしたデザインの修正等、仕様やコードを理解するための軽いものだろうと高を括っていた私にとっては寝耳に水でした。

Vue2 は 2023 年末に EOL となり、サポートを終了しているため、Vue3 やその他のフレームワークにバージョンアップが必要となっています。
それ以外にも、依存するライブラリが脆弱性のある古いバージョンを使っており、リスクのある状態でした。
Vue2 から Vue3 へは破壊的な変更も多く、移行するコストを考慮すると、React に置き換えるのと大差ないのでは?と考えました。
また React を採用することで、Vue の経験者の多い弊社において、他のフレームワーク等の知見を広め、チームの底上げになるのではという狙いもあります。
そういった経緯から、Vue2 から React へのリプレイスを目玉としつつ、古いバージョンの Node やライブラリで開発されているその機能を、最新の Node やライブラリで動くようにすることを目指しました。

方針を決める

構成

何はともあれ、作業を進めなければなりません。
元々のシステムの基本構成としては、

  • Vue CLI
  • Vue2
  • VueRouter
  • Vuex
  • Vuetify
  • Google Maps API
  • 有象無象のライブラリ達

ライブラリのバージョン等は古いとはいえ、よくある Vue アプリケーションの構成です。
最終的にこれを、以下のような構成にリプレイスしました。

  • Vite
  • React
  • ReactRouter
  • ReactHooks
  • MaterialUI
  • TailwindCSS
  • Google Maps API
  • 有象無象のライブラリ達

両者で共通したライブラリもありますが、リプレイス版はバージョンの新しいものを利用します。

リプレイス当初は、Next.js への移行を考えていました。
しかし、元のシステムとしては SPA で動作する静的ファイルを S3 にデプロイするという構成であり、SPA のアプリを Next.js に移行するのは相性が悪いと判断しました。
Next.js でも静的ファイルとして出力することは可能ですが、現在の Next.js 推奨の AppRouter では /url/{parameter}/ のようなパスパラメータを使用したものを静的出力する場合、/url/1/url/2 のように {parameter} のバリエーションの数だけ静的ファイルが出力されます。
また、 {parameter} の部分もあらかじめ判明している値を利用する必要があるなど、静的サイトとしてパスパラメータを使用した場合の柔軟性に難があると考えました。
パスパラメータを ?param={parameter} のようなクエリパラメータに変更する事も検討しましたが、既にアクセスする URL としてパスパラメータを使用したものユーザーに周知しており、URL の変更も許容できませんでした。
そのため、Next.js への移行は見送り、今回のような構成になりました。

リプレイス方針

リプレイスする上での基本方針として考えたのは、そのまま使えるロジックはなるべく使うということです。
リファクタリング等、何から何まで一気に解決しようと欲張ってはいけません。
Vue2 から React へ置き換えるだけでも骨の折れる作業です。
既存の挙動の理解も浅い状況で、不用意にリファクタリング等を考えると知らぬ間に挙動を変えてしまい収拾が付かなくなると考えました。
フレームワークは違えど、どちらも TypeScript で書かれたプログラム。
幸い、元のコードではコンポーネントから分離されたロジックも多く、そのまま利用できるコードも多くありました。

いざ、リプレイス作業開始

Vue3 はともかくとして、 Class Component が主流の Vue2 で書かれたコードと Functional Component が主流の React はコードの見た目からして何から何まで異なります。
コンポーネントの状態の持ち方一つとっても Vue2 から React にリプレイスする場合は、React の挙動をイメージしておく必要があります。
Vue では以下のようにコンポーネントに値を持ちます。

data() {
  return {
    count: 0
  }
}

一方、React では以下のように書きます。

const [count, setCount] = useState(0);

React では状態を更新する際、基本的には useState で受け取った setter を通さなければなりません。
Vue のようにこの値を素直に count = 1count++ で更新することはできません。
そして更に話をややこしくするのが、 Vue では同期的に値が更新されることに対し、React は違うということです。

例えば、初期値 が 0count を値に持つ Vue コンポーネントに以下のようなロジックがあったとします。

if (条件1) {
  this.count++;
}
if (条件2) {
  this.count++;
}

条件1条件2 がどちらも true であったとします。
Vue では当然、 count の値は 2 となります。
Vue でなくても、素直に考えればそう思うでしょう。

一方、React で以下のようなロジックに置き換えたとします。

if (条件1) {
  setCount(count + 1);
}
if (条件2) {
  setCount(count + 1);
}

先ほど同様に、条件1条件2 がどちらも true であったとします。
さて、count の値はどうなるでしょうか?答えは 1 です。
React の挙動を知らなければ 条件1 側の setCount+ 1 されて、 条件2 側の setCount+ 1 されるのだから結果は 2 だろうと誰しもが思うところだと思います。
なぜこのようなことが起きるのかと言えば、React の State が更新されるのは非同期だからです。
つまり、上記の例で言えば、 setCount により即座に count の値が更新される訳ではないのです。
そのため React 側のコードでは setCount(0 + 1) を 2 回実行しているだけで結果が 1 となるのです。
React では値はコンポーネントの再レンダリング時に更新されます。

では、どうするか。
まず 1 つ、上記のような単純な例であれば以下のようにすることもできるでしょう。

let newCount = count;
if (条件1) {
  newCount++;
}
if (条件2) {
  newCount++;
}
setCount(newCount);

元の値を別の変数に入れて、それを使い回して最後に値を設定する方法です。
ただ、この方法、let を禁止しているようなコーディング規約の厳しいプロジェクトでは使えないでしょう。

続いて、もう一つ他の方法は、 setter 第一引数に前回の値を受け取る関数を指定する方法です。
useState から受けった setter の第一引数には単純な更新値を指定するだけではなく、前回の値を受け取ってそれを返す関数を指定することができます。
それを用いた場合は以下のようになります。

if (条件1) {
  setCount((prev) => prev + 1);
}
if (条件2) {
  setCount((prev) => prev + 1);
}

prev には元々持っていた値が渡されてきます。
これは呼び出し以前に setter で更新されていた場合も更新された状態の値を受け取ることができます。
そのため prev の値を使って新しい値を作ることで正常に値の更新ができるという訳です。

まだ、他にも useRef を使うというような方法もありますが、何にせよ React では状態の値の更新をすること 1 つとっても挙動をイメージできていないと思った通りに動かすことができません。
知っていても、うっかりしていると値が更新されないなというようなミスが起こります。
Vue のロジックで状態を更新している箇所を、何も考えずに setter に置き換えたりすると思わぬバグに遭遇することとなります。
ただ、挙動を知っていればバグが起きた時に原因に当たりをつけることはできるでしょう。

このように、Vue の data の値を useState に置き換えること 1 つとっても考慮すべきことが多くあります。
それに加え、Vue のみならず Vuetify などの UI フレームワークの変更におけるデザイン調整なども重なるのですからリプレイス作業は大変な労力を必要としました。

ハマりポイント

React において問題になりやすいのは、 状態が更新されたのに反映されなかったり値がおかしい、あるいはレンダリングが無駄に呼ばれて遅いとかでしょうか。
今回のリプレイス作業でよく発生したのは前者です。

特に、Vue でのコンポーネントで mounted の処理を置き換える場合は気をつけなければなりません。
Vue の mounted はコンポーネント生成時に実行される処理ですが、React では以下のように置き換えることができます。

useEffect(() => {
  ・・・処理
}, [])

useEffect は第二引数に依存する値の配列を指定して、その配列内の値が変化した場合に中身の処理を実行するものです。
Vue の watch を置き換えるのにも利用できます。
第二引数の配列を空にした場合、Vue の mounted と同じようにコンポーネントの生成時だけに実行される処理となります。
問題は、この処理がコンポーネントの状態に依存していた場合です。

Vue で以下のようになっている場合を考えます。

data() {
  return {
    id: null,
    count: 0
  }
},
mounted() {
  this.id = setInterval(() => {
    console.log(this.count);
  }, 1000);
},
beforeDestroy() {
    clearInterval(this.id);
},
methods: {
  countup() {
    this.count++;
  }
}

何かボタンのようなもので countup が実行されて count 値が更新されるとします。
そして、1 秒ごとに count の状態をログ出力するというものです。
Vue であれば、何も考えずにこれでボタンを押した回数だけ count が増加したものが出力されます。

では、React で次のように書き直したとします。

const [count, setCount] = useState(0);

const countup = () => {
  setCount((prev) => prev + 1);
};

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => {
    clearInterval(id);
  };
}, []);

useEffect の戻り値として返している処理は、 useEffect が更新される時に呼ばれる後処理で、今回の例で言えば beforeDestoryの代わりです。
こちらも同様にボタンなどで countup が実行されるものとします。
こちらでは何が起こるかと言えば、ボタンを何度押しても出力される count の値はいつまで経っても初期値の 0 となります。

useEffect(() => {
  console.log(count);
}, [count]);

のようなものを追加して count 変化時の挙動を確認すれば分かりますが、 countup による値の更新は正常に行われていることが分かります。
なぜこのような現象が起きるのか。
それは、クロージャーが生成された時の値を保持しているためです。
setInterval を設定している useEffect はコンポーネント生成時の最初に実行され、以降は実行されることはありません。
useEffect の再実行がないため、最初に setInterval が実行された時の count の値を、 setInterval は持ち続けているということです。
そのため、 setInterval の処理は初期値の 0 を出力し続けることになります。

では、どのようにするのか。
コンポーネントの初期化処理を状態に依存しないように書き直すことが可能であれば、それが良いと思われますが、現実としてそうもいかない場合もあるでしょう。
まず、1 つの方法として次のようにすることができます。

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);

  return () => {
    clearInterval(id);
  };
}, [count]);

useEffect で最初に実行した setInterval の処理が更新されないのが問題なので、 依存配列に count を追加して値が変更された時に setInterval も再生成する方法です。

他にも、 useRef を使用する方法もあります。

const countRef = useRef(0);

const countup = () => {
  countRef.current++;
};

useEffect(() => {
  const id = setInterval(() => {
    console.log(countRef.current);
  }, 1000);
  return () => {
    clearInterval(id);
  };
}, []);

useRef を用いれば、current により常にその時の値を取得することができます。
ただし、 useRefsetState とは異なり、状態が変化したときの再レンダリングが行われません。
useRef を使用する場合はその辺りも考慮する必要があります。

今回の例のような小さい処理なら気付きやすいとは思います。
しかし、setInterval にあたる処理が比較的複雑なサブスクリプション処理だったりするとすぐに気付くのは難しいかも知れません。
私の場合、既存のロジックを流用しているので、そちらの置き換えのミスを疑い、なかなか気付きませんでした。

まとめ

紆余曲折ありながらも、どうにかリプレイス作業をこなす事ができました。
何気に一番大変だったのは、Vue2 を React に置き換えるよりも、Vuetify のデザインから MaterialUI に変更する際のデザインの調整だったように思います。
デザインの調整はそこまで悩むような作業自体はあまりありませんが、細かい調整が多く必要なので物量で攻めてくる感じです。
今回のリプレイス対象では画面の大部分が Google マップとなっており、 UI 部分はそこまで多くはありませんでした。
もしも、 UI 部分が多くあったら作業量の比重的にはデザインの調整がかなり高くなるかなと感じました。

Cariot開発チーム

Discussion