Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

YappliのCMSにおけるVuexとの付き合い方と今後の展望

この記事は Yappli Advent Calendar 2024 の13日目の記事です。

フロントエンドエンジニアのこん(@k0n_karin)です!

Yappliでは、ネイティブアプリをブラウザ上でノーコードで開発・運用・分析するためのCMSを創業当初から開発しています。

このCMSのフロントエンドは当初PHPとjQueryで作られていましたが、2019年に現在のNuxt製のCMSにリニューアルされました。リニューアル5年という節目で、誕生から現在までを振り返ってみます。

ログイン直後のYappliのCMS

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時代のmapStatemapActionsmapGettersthis.$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も有効そうです。

これらをまとめて図にするとこんな感じです。

現在と今後のVueコンポーネントとVuexの関わり図

このような設計でカバーする場合、どこをContainerにするか、どこからprovideするか、などの認識をチームで合わせる必要があります。日頃からチーム内で啓蒙活動をしたり、コードレビューで最適化していく必要がありますね。

2. ラッパーを噛ませてVuexを参照する

コンポーネントから直接VuexのAPIを使用せず、ラッパーを介して呼び出すこともできそうです。

typed-vuexを使うと、ストア内ではdispatchの引数が推論されるようになります。コンポーネントからは$accessor.someAction() のようにメソッドの形式でActionを実行できます。

https://typed-vuex.roe.dev/

// 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年と戦えるプロダクトに成長させるために日々試行錯誤や検討を重ねています。

このような課題や長期の目線でのサービス開発に興味を持たれた方は、ぜひカジュアル面談でお話しましょう!

open.talentio.com