「レストランボード」における大規模フロントエンドの漸進的なVueリプレイスの取り組み

はじめに

こんにちは、レストランボード(以下、RB)のフロントエンドチームの石亀です。担当していた規模の大きめなプロジェクトでVueを結構触っていまして、設計含め困難と向き合いながら色々取り組ませてもらったのでそれをナレッジとして残そうと思い記事を書くことになりました。エモいですね。

RBは現在自社のフレームワークで構築されていて、徐々にVueでリプレイスをかけています。 今回、大規模なプロジェクトにてVueでさらなるリプレイスを実行しましたが、プロダクト自体がとても大きく且つ限られたリソースの中でいかに負債化させずにできるだけ安全に移行させるかを検討しました。 そこで実際に実施した施策や検討内容などを紹介します。 おそらく、多くのサービスやプロダクトで既存のコードを新しいライブラリ・フレームワークで書き換えているかと思います。 背景だったり関わる規模・コンテキストが異なるとは思いますが、1つのナレッジとして参考になればと思います。

レストランボードの現状と調査・分析

技術構成について

RBのフロントエンドでは2つのフレームワークで稼働していて、Vueと自社フレームワーク(以下、自社FW)で構成されています。
自社FWに関しては、jQuery + lodash-template + morphdomを組み合わせて作られた独自のフレームワークで、コンポーネントの考え方が取り入れられているため、ある程度部品ごとに作られています。また、MVVMのような状態管理、差分レンダリングやイベントハンドリングの機能を持っていて、ReactやVueなどのモダンなフレームワークにも通づる機能を有した高度な作りになっています。 現状、メイン機能や大部分はこのフレームワークで作られていています。

RB全体ではVueも段階的に導入されているため、MPA+SPAのような構成になっています。

レストランボード構成

各サーバーサイドテンプレートに対してそれぞれのスクリプトのエントリーポイントが存在していて、そのエントリーポイントによってどのフレームワークを使うか、あるいはモバイル用なのかデスクトップ用の資材を読むのかが変わるような構成になっています。

開発環境は、webpack, ESLint, Prettier, Storybook, デザインはAtomic Designを導入しています。
テストはjest, vue-testing-libraryを導入していて、コンポーネントやピュアなjavascriptのロジックのユニットテストから画面単位のインテグレーションテストまで網羅しています。

npmパッケージ

チーム・組織による課題について

当初からあった課題感

技術構成でも説明した通り、RBではメイン機能が自社FWで作られているので、チームメンバーがフロントエンドだけで20人前後いることもありフレームワークの習熟度や理解度などにばらつきが出てきます。 そうなると、自社FW自体が高度な機能を有してはいるものの独自フレームワークであるため学習コストが非常に高く、すぐにキャッチアップできる即戦力人員の採用も簡単でないので、運用の難易度やコストが増大します。 また、エコシステムや関連ライブラリが無く汎用性や拡張性が低いので、フレームワーク自体の性能向上や改善は見込めません。

このことから、自社FWを使い続けることはリスク且つ以降の開発効率・品質向上の観点でディスアドバンテージなので、Vueへのリプレイスをしていくことは方針として必要になるのは必然と言えます。

それを踏まえて、以下にフレームワークの仕様が各工程(製造、テスト、品質担保)とフロントエンド組織を取り巻く環境に与える今後の影響を調査し、表に洗い出しました。

種別 自社FW Vue
製造コスト 高い
・ドキュメントやオンライン情報がなく作りが複雑なので、キャッチアップ含めると見積もりが厳しくなる
普通
・ドキュメントが充実し親しみやすいので、そこまで高くない
テストのコスト 極めて高い
・独自フレームワークのため、オープンなエコシステムが利用できずテストの土台作りに時間がかかりすぎる
比較的低い
・Vue公式のテストライブラリや、その他のエコシステムが利用できる
品質 既存と同等
・ユニットテストのコストが高いためテストの実施が困難。
・フレームワークが改善される予定がないので品質リスクを抱える
高い
・ユニットテストで品質向上に貢献できる
採用・学習コスト 困難(極めて高い)
・独自フレームワークなので、ドキュメント整備が不十分
・実質既存のコードからキャッチアップしかない
・技術力のあるエンジニアが必要
容易(比較的低い)
・オープンソースのため他プロジェクト経験者であれば採用面で強い
・公式ドキュメントが充実していて、他フレームワークからの移行も比較的容易

ここからわかるのは、Vueの方がテストによる品質担保メリットが非常に大きいこと、採用の難易度と学習コストが低いことです。また、自社FWを使い続けることが開発面だけでなく採用・学習コスト見積もりの観点で懸念が残ります。
短期的に見ればリプレイスはかなりのコストになるかもしれませんが、中長期的に見てみるとコストの問題だけだけでなく事業継続性の観点(プロダクトの保守・運用、エンハンスやそれを支える技術者の採用など)にとって大きなコスト・リスクの低減になります。

大規模リプレイスにおける課題と技術的要件

フレームワークを大きくリプレイスをしていくことは確定事項ですが、改修・新規合わせて100画面超(モーダルを含む)ほどが対象になります。プロジェクトのコストを考慮すると100画面超のすべてをVueでリプレイスすることは、工数的にかなり厳しいことがわかりました。

そこで、リプレイスのメリットを享受したまま工数・工期を圧縮する方法を見つけることが必要で、それを可能と仮定した時に技術的な現状・これからどうリプレイスをかければ良いのかを調査・分析し表にまとめました。

組み合わせ メリット デメリット
No1
画面:Vue
モーダル:自社FW
・既存モーダル分の工数は削減できそう ・つなぎこみのナレッジがなく予期せぬバグが起こりうるため品質担保の難易度が上がる
・以降のエンハンスの難易度が上がるので、自社FW部分の機能拡張は厳しい
No2
画面:自社FW
モーダル:Vue
・画面はそのままなので7、8割ほどの製造コストは抑えられる
・モーダルのテストはしやすく、画面側実装もそのままなので品質担保はNo1に比べると多少容易
・No1と同様につなぎこみ方法が確立できていないので予期せぬバグの対応が見込まれる
No3
画面:Vue
モーダル:Vue
・過去のテストの実績や実装のナレッジがあるので、もっとも品質の担保がしやすい
・技術が統一できるのでイレギュラーが起きにくい
・技術を知っている人が多いのでメンテナの確保がしやすい
・全部作り変える場合、製造コストがもっとも膨れる
No4
画面:自社FW
モーダル:自社FW
・製造コストは内容次第でもっとも低い
・品質面では、修正が少なければ少ないほど品質が落ちることはなさそう
・以降のエンハンスや保守・運用が厳しい
・新規実装経験者がほぼいない
・一定の技術が必要なので属人的になる

この表では、どの組み合わせでもメリットとデメリットが存在しています。製造コストが膨らむような短期的なリスクもあればメンテナンスやエンハンスに影響が出るように中長期的なリスクもあり、銀の弾丸などないという前提のもとどのメリットを享受しどのデメリットを保有するかを加味した上で意思決定することが必要と言えます。

Vueと自社FWがミックスしている項目がありますが少しわかりづらいのでリプレイスのイメージを図にしてみました。画面やモーダルは部品ごとに作られていて、それを組み合わせるとNo1,2が以下のようなイメージになります。

リプレイス

実装していくに当たって上記表のNo1,2案が必要になりますが、お互いのフレームワーク同士をできるかぎり安全につなぐことが欠かせません。そのために技術的に達成・確認しなければいけない要件が見えてきます。

  • できる限り負債化させないような構成と実装方法の確立
  • 異なるフレームワーク同士のデータとイベントの伝達方法
    • 画面がVueでモーダルが自社FWの場合(表No1)
    • 画面が自社FWでモーダルがVueの場合(表No2)

技術検証と構造の決定

技術課題が見えてきたところで今度はこれらの検証と実際に実装するための構造を作っていきます。

技術調査

できる限り負債化させないような構成と実装方法の確立

技術調査ではお互いにデータやイベントのやりとりをできることがわかりました。
しかし、お互いのフレームワークの中に別のフレームワークを直接取り込み動作させることは予期せぬバグの温床になったり以降のエンハンスで負債になりかねません。それを防ぐには以下を気をつけながら設計する必要があります。

  • 機能や影響範囲を限定的にする
  • 壊しやすい、捨てやすい構成にする

既存部品を使うとは言え、今回実装するUIの操作に関係のないデータやクラスメソッドがすでに実装されているので、それらと影響しあわないようにどんなデータを送りどんな振る舞いをしてもらうかを限定的かつ明示的に記載するようにします。また、本プロジェクト以降でもリプレイスしやすくするため、未来において再利用してもらうのではなく捨ててもらうことを前提として、ラッパーパターンを採用し異なるフレームワークをつなぎこむようにしてみました。

connect層

ディレクトリの構成は以下のようになっていてVueや自社FWのコンポーネントはそれぞれ別れていたので、直接お互いに部品をimportしないようにconnect層というものを作成し使用する機能や振る舞いだけを記載するようにしています。もし、必要なくなったらconnectディレクトリごと捨てられるようにしています。

1
2
3
src/client // Vueで作られたコンポーネントや画面、ストア、ルーターなど
src/js  // 自社FWで作られた部品や画面
src/connect // 今回作成した二つのフレームワークをつなぐラップモジュール群

具体的なフレームワーク同士のデータとイベントの伝達方法

大まかな構成が決まったところで、今度はお互いにデータの伝達やイベントのやりとりなどを調査・検証していきました。

独自FWに関しては基本的にclass構文で書かれておりインスタンス生成時にデータを受け渡すことをできることがわかりました。 さらに、モーダルに関してはPromiseベースで作られているものが多く、モーダルを表示するときはインスタンス化・モーダルを閉じるときはresolveされてthenで値を受け取りつつイベントを発火させることができます。以下が自社FWで作られた部品のサンプルです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//////////////
// 部品側のコード
//////////////
import View from 'ui/View'; //自社FW本体
...
export class SampleLegacyWindow extends View {
  // インスタンス化時に必要な値を
  constructor(id, keywords = []) {
    ...
    showWindow(this);// モーダルを開く
    ...
    this.promise = new Promise((resolve, reject) => {
      this.resolve = resolve;
      this.reject = reject;
    });
  }
}
export default (storeId, keywords) => {
  return new SampleLegacyWindow(storeId, keywords).promise;
};
////////////////////
// 部品を使用する側の例
////////////////////
import SampleLegacyWindow from 'view/modal/SampleLegacyWindow'
const openSampleLegacyWindow = props => {
  ...
  SampleLegacyWindow(id, keywords).then( e => {
    // モーダルが閉じた時にデータを受け取ったり、閉じたタイミングで親画面でイベントを発火
    // あるいは、必要な動作のコールバック関数のみを受け取る
  });
}
自社FWの画面からVueモーダルを使うパターン

次に、Vueモーダルを自社FWで使うパターンの実装になります。モーダル側は親側がVueコンポーネントであることを前提として作っているので、コンポーネントのマウントに必要な処理やemitなどの処理を使う分記載します。最後にVueインスタンスをreturnすることで、親側でemitした際の処理を走らせることもできます。

また、モーダルに関わるVuexストアのActionsなどもある場合は関数を作成しておくことで、自社FW内に直接VuexをimportせずにActionsを実行することが可能です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
////////////
// connect層
////////////
import Vue from 'vue';
import sampleVueModal from 'sampleVueModal'; // Vueコンポーネント
// Vueインスタンスをリターンし親画面側でemit処理などを受け付け取れるようにする
// 必要なデータはpropsで受け取りVueインスタンスにセットする
export const useSampleVueModal = props => {
  ...
  return new Vue({
    ...
    components: {
      SampleVueModal
    },
    ...
  })
}
// もし、関係するストアの処理がある場合はピュアファンクションでラップし親側で使えるようにする
export const openSampleVueModal = () => { ... }
export const closeSampleVueModal = () => { ... }
Vueの画面から自社FWのモーダルを使うパターン

次に自社FWのモーダルを使うパターンですが、こちらも同じように必要な処理だけを記載するようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
////////////
// connect層
////////////
import legacyModal from 'legacyModal';
export const useLegacyModal = props => {
  const { id, keywords } = props;
  return legacyModal(id, keywords);
}
////////////
// 親画面側
////////////
import { useLegacyModal } from 'useLegacyModal';
...
methods: {
  onOpenModal() {
    const props = { ... };
    useLegacyModal(props).then(keywords => {
      // モーダルがresolveされた時(閉じる)の処理
    });
  }
}

技術検証により異なるフレームワークを混同させても問題ないことわかったので、自社FWとVueを混在させる意思決定をすることができました。 あとは、実際にこれらを適用させる範囲はどこなのか、やりとりするデータと振る舞いは具体的に何があるのかを詳細設計に記載します。

また、アーキテクチャの決定に至るまでの検証事項やそもそもの話、メリットデメリットや意思決定材料の内容をフロントエンドエンジニア以外が見ても判断ができるようにドキュメントに残しておきます。

まとめ・終わりに

ここまで長々とプロジェクトにおけるリプレイスの説明をしてきましたが、取り組みから以下について気づきがありました。

  • 何にでも効くような万能な設計はないが、壊しやすい・捨てやすいコードは良い設計の助けになる
  • どの意思決定をしてもデメリットは必ず存在するので、どのデメリットを保有するかを含めた上で判断をする
  • 短期・中長期のリスクがあるので、技術的負債の返済をどこでどのくらい返していくかを考える
  • Vueがプログレッシブフレームワークなので、段階的に導入しやすかった

プロダクトの改善はここで終わりではないので今後も改善を続けていきます。また、ここで説明した内容は背景やバックグラウンドが異なるので、もちろんそのまま同じことが当てはまるなんてことはないと思います。その上で、技術的負債と向き合う人やフロントエンドとして泥臭く戦ってる人の何かしらのヒントになればいいなと思い、この記事を終わりにします。