この記事は Yappli Advent Calendar 2024 の13日目の記事です。
フロントエンドエンジニアのこん(@k0n_karin)です!
Yappliでは、ネイティブアプリをブラウザ上でノーコードで開発・運用・分析するためのCMSを創業当初から開発しています。
このCMSのフロントエンドは当初PHPとjQueryで作られていましたが、2019年に現在のNuxt製のCMSにリニューアルされました。リニューアル5年という節目で、誕生から現在までを振り返ってみます。
Nuxtを採用した理由
新しいCMSの実装にあたり、フロントエンドフレームワークとしてVueとReactのどちらを採用するか検討しました。最終的に、実際に開発に携わるメンバーに適していると判断されたVueと、そのメタフレームワークであるNuxt(当時は2.x)が採用されました。
Nuxtを使っていますが、いたってシンプルなSPAです。Plugin、Layout、Middlewareなどを活用しています。Pagesコンポーネントのルーティングや組み込みのVuexがあったのも良かったです。
Nuxt3に移行を終えた今でも、Nuxtを採用したのは非常に良かったと感じています。
当時のアーキテクチャの方針
PoCの段階で、Vueコンポーネントの定番であるprops
/emit
を用いた状態管理で作ると、コンポーネントツリーのネストが深い場合のprops drillingの課題感が大きくなりました。
これを解決するために、全ての状態管理をVuexに集約し、子孫コンポーネントからも直接Vuexを参照する設計が採用されました。これにより、Vuexを見ればだいたいのことが分かるようになりました。
APIリクエストもVuexのActionで実行できるため、Actionにビジネスロジックも記述され、そのままstateに格納されています。
ディレクトリ構成はpagesとstoreで同じ階層構造で実装されています。
pagesとstoreのディレクトリを同じにすることで、実際の画面上のルーティングパスとVuexのモジュール単位を揃え、画面のパスと同じモジュールパスでActionをdispatch
できるようにしました。
// https://example.com/apps/featureA の場合 const path = useRoute().path.slice(1) // -> apps/featureA dispatch(`${path}/init`) // -> apps/featureAのinitというActionが実行される
/ ├─ components │ ├─ common │ │ └─ ... │ └─ local │ ├─ featureA │ │ └─ ... │ ├─ featureB │ │ └─ ... │ └─ ... ├─ grpc │ ├─ featureA.ts │ ├─ featureB.ts │ └─ ... ├─ pages │ ├─ index.vue │ ├─ apps │ │ ├─ featureA │ │ │ └─ index.vue │ │ ├─ featureB │ │ │ └─ index.vue │ │ └─ ... │ └─ ... └─ store ├─ index.ts ├─ apps │ ├─ featureA │ │ └─ index.ts │ ├─ featureB │ │ └─ index.ts │ └─ ... └─ ...
この方針のお陰で開発の粒度が統一され、Vuexを中心に開発できるようになりました。ロジックはVuex、見た目や振る舞いに関することはVueコンポーネントという形で関心の分離もできていました。
現在の課題
しかし、5年の運用期間を経ていくつかの課題が浮上してきました。
1. アプリケーション全体がVuexと密結合になっている
親から末端の子孫コンポーネントまで全てがVuexを直接参照しており、Options API時代のmapState
やmapActions
、mapGetters
、this.$store.~
といった記述が散在しています。
親コンポーネントがストアへの依存を持つことは良くありますが、子コンポーネントが親とストアの2つに依存を持つようになっており、依存関係が複雑化しました。
そして、前項の通り全てのpages
コンポーネントから同じパスのVuex Actionがdispatch
される仕組みになっているため、新規メンバーが初期化処理がどこで実行されているのかを把握するのが難しい状況です。
コード例
// composables/useInitStore.ts export function useInitStore() { const { $store } = useNuxtApp() useAsyncData('init', async () => { // ルートストアのinit await $store.dispatch('init') // -> apps/featureA/init がdispatchされる await $store.dispatch(`${useRoute().path.slice(1)}/init`) }) } // pages/apps/featureA/index.vue <script setup> useInitStore() </script> // store/apps/featureA/index.ts export const actions = { // 必ずinitという名前のアクションを用意する init() { // 最初に必ず実行するGETリクエスト } // ... }
(今はcomposable化されたので明示的に実行されますが、以前はmixinで暗黙的に実行されていたため本当にわかりにくかったです😭)
また、その他の処理でもVuexの特定の名前のActionが必ず呼ばれる仕様になっており、Vuexモジュールを作らずに新機能を作るのが難しいくらい、強い制約を生んでいます。
この制約は開発粒度の統一の観点では素晴らしいですが、一方でVuexの強力なロックインを生んでしまいました。
2. Vuexの移行コストが高い
Vue3になってからVuex自体はまだEOLを迎えていませんが、遅かれ早かれPiniaやComposableへの移行が必要になると思います。
現在のCMSでは、各画面に対して必ず1つのVuexモジュールが存在し、合計100モジュールを超えています。これに加えて前述の通り、アプリケーション全体がVuexと密結合なので、移行コストはかなり高い状態です。
3. グローバルで使わない状態もVuexに存在している
100以上のVuexモジュールの中で、複数の画面で使用されるものは数個程度なので、そもそもグローバルである必要はありません。
また、開発中にVuexモジュールのファイルを保存するたびに、バンドラーのHMR(Hot Module Replacement)でブラウザのタブがリロードされます。ローカルな状態管理であればこの問題は発生しません。変更のたびにページリロードが走るのは開発者体験も悪くなります。
/ └─ store ├─ index.ts # グローバルで使う ├─ featureA │ └─ index.ts # グローバルで使わない ├─ featureB │ └─ index.ts # グローバルで使わない └─ ...
4. 肥大化したVuexモジュールの認知負荷が高い
Vuexはモジュールという形で分割こそできるものの、最終的には1つのストアとして存在します。そのため、肥大化しても部分的に切り出すことができないです。
ある機能では、Vuexモジュールが肥大化し、全部で8000行を超えています。1ファイルではとても管理できないので、無理やりファイルを切り出して擬似的に分割され、場所によっては型の安全性も失っています。
/ └─ store ├─ featureC │ ├─ index.ts # ↓のすべてファイルがindex.tsにマージされて1つのVuexモジュールとして扱う │ ├─ config.ts # featureCのうちconfigの領域 │ ├─ style.ts # featureCのうちstyleの領域 │ └─ ... └─ ...
5. dispatch
が型安全でない
Vuexのdispatchはdispatch('someAction', payload)
のように文字列でActionを呼び出すため、引数の型やエディタの補完機能が十分に活用できません。
これに対しては、有志によって型付けのためのラッパーが作られていたり、頑張れば自前で型付けもできるので、前項までの問題ほどは大きくないです。
どうするのが良さそうだったか
これまでを振り返って考えてみると、多くのVueコンポーネントからVuexを直接参照するのが問題の根幹になっていました。これに対するアプローチは2つくらい考えられます。
1. Vuexを参照する層を限定する
状態管理ストアとコンポーネントを疎結合にするためには、ある程度のprops drillingは避けられないです。Container/Presentational patternなどでいくつかのContainerコンポーネントだけでVuexを参照するように制限をすることが一つの手だと思います。
コンポーネントツリーのネストが深い場合、親以外の中間層のコンポーネントをContainerに見立てるのも良いでしょう。とにかくVuexを参照する箇所を絞るのが良いと思います。
# As Is 親(Vuex参照) → 子(Vuex参照) → 孫(Vuex参照) → ひ孫(Vuex参照) → 玄孫(Vuex参照) → ... # To Be 親(Vuex参照) → 子 → 孫(Vuex参照) → ひ孫 → 玄孫 → ...
また、Vue3から使いやすくなったprovide
/inject
も有効そうです。
これらをまとめて図にするとこんな感じです。
このような設計でカバーする場合、どこをContainerにするか、どこからprovide
するか、などの認識をチームで合わせる必要があります。日頃からチーム内で啓蒙活動をしたり、コードレビューで最適化していく必要がありますね。
2. ラッパーを噛ませてVuexを参照する
コンポーネントから直接VuexのAPIを使用せず、ラッパーを介して呼び出すこともできそうです。
typed-vuex
を使うと、ストア内ではdispatch
の引数が推論されるようになります。コンポーネントからは$accessor.someAction()
のようにメソッドの形式でActionを実行できます。
// in Vuex export const action = { someAction({ dispatch }) { dispatch('anotherAction') // anotherActionがサジェストされる }, anotherAction() { /* ... */} }
// in Vue component -$store.dispatch('someAction') +$accessor.someAction()
Yappliでは一部の機能では取り入れているものの、すべての機能で使っている状態ではないので、早期に取り入れていたら状況は違っていたかもしれないです。
他の状態管理に移行する場合でも、互換性をもたせながら$accessor
の参照先を変更すれば、Vueコンポーネント側のコードを書き換えずに移行できます。
フロントエンドの技術は移り変わりが早いため、ラッパーを用いて柔軟に対応できるように設計するのは重要な視点だと思います。
これからの旅路
必要な分だけグローバルな状態管理をする
すべての状態をグローバルなVuexに集約せず、必要なところだけグローバルにして、それ以外はローカルな状態管理への移行を考えています。いまのところはVue3のComposition APIを使ったComposableやコンポーネントのローカルな状態でprops
/emit
、あるいはprovide
/inject
が良さそうです。
新しい機能には、すでにこれらのアプローチで実装を開始しています。
グローバルで持ちたい状態やローカル移行が難しいモジュールはPiniaなどに移行する
グローバルで共有したい状態や、ローカル移行が難しいモジュールについては、Vue3のデファクトスタンダードの状態管理ライブラリのPiniaへの移行を検討しています。
自動化された移行スクリプトを作成し、リファクタリングコストを削減しようと試行錯誤しています。
おわりに
現在取り組んでいる課題は一部に過ぎず、まだまだたくさんの課題があり、次の5年、10年と戦えるプロダクトに成長させるために日々試行錯誤や検討を重ねています。
このような課題や長期の目線でのサービス開発に興味を持たれた方は、ぜひカジュアル面談でお話しましょう!