Vuexでリレーションを持った複雑なデータ構造を上手く扱うには

Vuexは素晴らしい。ある程度複雑なアプリケーションで、ある程度複雑なことをしようと思ったら、特にSPAを作る際には使っている人も多いのではないだろうか?

しかしながら、僕はVuexを利用するにあたり、特に複雑にネストされたデータを扱うのがどうしてなかなか難しかった。この問題を解決するために、Vuex ORMというVuexのプラグインを作った。

この記事では、僕が感じたVuexの課題と、その課題にどうアプローチしているのか、そのためのVuex ORMの使い方を紹介する。

なにが問題か?

Vuexや、あるいはReactにおけるReduxなどのシングルステートツリーでアプリケーションの状態を管理しようとした時に僕が感じる問題は2つ。1つは複雑にネストされたデータ構造が扱いにくいということ。もう1つはそれらデータの活用をもっと簡単に行えるのではないかということ。

データのネスト構造

まずはデータの格納、および取り出しの問題だ。この問題についてはReduxの作者がとても良質の記事を書いている。この記事の内容をなぞることになるが、どういった問題なのかを説明する。

多くのアプリケーションでは、リレーションを持った複雑なデータ構造を扱うことになる。例えば、分かりやすくブログの記事に関する情報をサーバサイドから取得して、Vuexに保存するといったケースを考えてみよう。サーバからのレスポンスは概ね以下のようになっているだろう。

{
  "posts": [
    {
      "id": 1,
      "title": "Example title 01",
      "body": "...",
      "author": { "id": 1, "name": "John Doe" },
      "comments": [
        {
          "id": 1,
          "post_id": 1,
          "body": "...",
          "author": { "id": 3, "name": "Dave Doe" }
        },
        {
          "id": 2,
          "post_id": 1,
          "body": "...",
          "author": { "id": 4, "name": "Jack Doe" }
        }
      ]
    },
    {
      "id": 2,
      "title": "Example title 02",
      "body": "...",
      "author": { "id": 3, "name": "Dave Doe" },
      "comments": [
        {
          "id": 3,
          "post_id": 2,
          "body": "...",
          "author": { "id": 4, "name": "Jack Doe" }
        },
        {
          "id": 4,
          "post_id": 2,
          "body": "...",
          "author": { "id": 1, "name": "John Doe" }
        }
      ]
    }
    // ...
  ]
}

このデータ構造の中では同じユーザの情報であるauthorが複数回登場している。このデータを、例えばVuexにpostsといったキーを作成してそのまま保存すると、本来同じものを指しているはずのauthorのデータが複数箇所に散らばってしまう。このような状態はいくつかの点で問題になってくる。

  • 複数箇所に散らばった同じデータをきちんと全て更新するのは骨が折れる。
  • ネストされたデータ構造を処理するコードはネストされていないデータを扱うよりもややこしくなる。
  • そもそも同じ情報を散らかすのはスマートじゃない。

そのため、このネストされた構造をなんとか上手く扱えるようになりたい。

データの柔軟な活用

これは簡単に言えばクラスが欲しい、という内容だ。単純にVuexに保存したデータを取り出して活用する時に、RailsやLaravelのようなサーバサイドのフレームワークと同じく、モデルとしてデータ構造を定義し、場合によってはメソッドなんかを追加したいという場合が良くあった。

// 以下のようなユーザデータがあった時、
{
  id: 1,
  firstName: 'John',
  lastName: 'Doe'
}

// こういうことがしたい
user.fullName() // <- 'John Doe'

関数型ライクな人であれば、Userオブジェクトを操作する専用の関数群なんかを用意してUser.fullName(user)みたいな書き方の方が良いと感じるかもしれない。それはそれとして、僕は上述のようなオブジェクト指向的な書き方の方が少なくともデータモデルに対してはしっくりくる。

データのネスト構造を解決する

上述のブログ記事のようなデータ構造を上手く扱おうと考えた時、自然と思いつくのは、Vuexのステートをデータベースとして扱おうというものではないだろうか? 僕はそうだった。単純にデータがネストされているわけでなく、データはリレーションも持っている。ブログ記事の例であれば、ブログ記事(Post)は筆者であるユーザ(User)に紐づいており、Postは複数のコメント(Comment)を持ち、CommentはUserに紐づいている。そもそもサーバサイドでDBから取ってきたデータを返しているのだから、そうならざるを得ない。

正直VuexやReduxをDBのように扱うことに漠然として不安があった。特に根拠はないのだが、そういう風に扱ってはいけないのではないだろうか、と。そんな僕の背中を押して来れたのが、Reduxの作者が書いた記事だったわけだ。

ともあれ、具体例を見てみよう。要は、上述のブログ記事を、以下のような形でVuexに保存したいわけだ。

{
  posts: [
    {
      id: 1,
      title: 'Example title 01',
      body: '...',
      author: 1,
      comments: [1, 2]
    },
    {
      id: 2,
      title: 'Example title 02',
      body: '...',
      author: 3
      comments: [3, 4]
    },
  ],
  users: [
    { id: 1, name: 'John Doe' },
    { id: 3, name: 'Dave Doe' },
    { id: 4, name: 'Jack Doe' }
  ],
  comments: [
    { id: 1, post_id: 1, body: '...', author: 3 },
    { id: 2, post_id: 1, body: '...', author: 4 },
    { id: 3, post_id: 2, body: '...', author: 4 },
    { id: 4, post_id: 2, body: '...', author: 1 }
  ]
}

全てのデータが綺麗に分離されている。この様な形で保存できれば、検索や更新も大分楽になるだろう。

実はこれを実現するためのライブラリであるNormalizrが存在する。これを使うと、データの分離は驚くほど簡単に行える。公式のサンプルを引用するが、以下の様な具合だ。

// こういうデータがあった時、
{
  "id": "123",
  "author": {
    "id": "1",
    "name": "Paul"
  },
  "title": "My awesome blog post",
  "comments": [
    {
      "id": "324",
      "commenter": {
        "id": "2",
        "name": "Nicole"
      }
    }
  ]
}

// こんな感じでスキーマを定義してノーマライズすると、
import { normalize, schema } from 'normalizr'

const user = new schema.Entity('users')

const comment = new schema.Entity('comments', {
  commenter: user
})

const article = new schema.Entity('articles', {
  author: user,
  comments: [ comment ]
})

const normalizedData = normalize(originalData, article);

// こうなる
{
  result: "123",
  entities: {
    "articles": {
      "123": {
        id: "123",
        author: "1",
        title: "My awesome blog post",
        comments: [ "324" ]
      }
    },
    "users": {
      "1": { "id": "1", "name": "Paul" },
      "2": { "id": "2", "name": "Nicole" }
    },
    "comments": {
      "324": { id: "324", "commenter": "2" }
    }
  }
}

実際にVuex ORMでもこのライブラリを活用させてもらっている。しかし、これをこのまま使うのはまだまだ面倒くさい。データ毎に上述の様なコードを書かなくてよくしたい。理想的には、次のサンプルぐらい簡単にデータを保存したい。

// サーバからデータを取ってきて、
const posts = await axios.get('/api/posts')

// Vuexに保存する。
store.dispatch('posts/create', { data: posts })

ここまで簡単にできれば、それこそサーバサイドのORMと同じぐらい簡単に"DB"を操作できるだろう。これを目指したのが、Vuex ORMだ。

Vuex ORMを使った場合のフロー

ここでは手前味噌になるが、Vuex ORMを利用した場合にどういうフローとなるのか、引き続き上述のブログ記事のデータ構造を元に追って行く。ところで、データの柔軟な活用に関する解決策をまだ書いていないわけだが、ここでひっくるめて出てくるので我慢して欲しい。

インストールする

当然だが、NPMでインストールする(VueやVuexも忘れずに)。

$ npm install --save vuex-orm

モデルを定義する

ここからが本番だ。まず、データ構造をモデルとして定義する。Vuex ORMではこれにクラスを用いる。基底クラスとして、Vuex ORMのモデルを継承する。

import { Model } from 'vuex-orm'

class User extends Model {
  // Vuexのステートのキー名を指定する。
  static entity = 'users'

  // ユーザデータの構造とリレーションを定義する。
  static fields () {
    return {
      id: this.attr(null),
      title: this.attr(''),
      body: this.attr(''),
      posts: this.hasMany(Post, 'user_id')
    }
  }
}

class Post extends Model {
  static entity = 'posts'

  static fields () {
    return {
      id: this.attr(null),
      user_id: this.attr(null),
      title: this.attr(''),
      body: this.attr(''),
      author: this.belongsTo(User),
      comments: this.hasMany(Comment)
    }
  }
}

ここではCommentモデルの定義を省略しているが、なんとなくイメージは分かっていただけると思う。Userはthis.hasManyメソッドによってpostsフィールドをPostモデルに関連づけている。PostもUserやCommentと紐づいている。もしもっと詳しく知りたい場合はぜひVuex ORMのドキュメントを参照して欲しい。

VuexのModuleを作成する

次に、Vuexに登録するモジュールを作成する。このモジュールはただのVuexのモジュールなので、何か特別なことはない。Vuex ORMで使うからといって、特別な項目を作る必要はなく、実際空のオブジェクトでも構わない。

const users = {}
const posts = {}

Vuexに登録する

モデルとモジュールが準備できたら、Vuex ORMのDatabaseに登録した上で、そのDatabaseをVuexにプラグインとして登録する。

import Vuex from 'vuex'
import VuexORM from 'vuex-orm'

const database = new VuexORM.Database()

database.register(User, users) // Userモデルとusersモジュールを登録
database.register(Post, posts) // Postモデルとpostsモジュールを登録

const store = new Vuex.Store({
  plugins: [VuexORM.install(database)]
})

これで準備完了である。この直後、Vuexステートは以下の通りになる。

{
  entities: {
    posts: {
      data: {}
    },
    users: {
      data: {}
    }
  }
}

まず、Vuex ORMで管理されるデータは全て、entitiesという名前空間の下で管理される。また、各モジュールはdataというキーを持ち、この中にデータが保存されていく。

データを保存する

Vuex ORMはデフォルトでいくつかのVuexのActionを定義しており、これらのActionを通じてデータの保存、編集、削除ができる。

// 以下の様なデータがあったとする。これはブログ記事のデータ。
const post = {
  id: 1,
  user_id: 1,
  title: '...',
  body: '...',
  author: { id: 1, name: 'John Doe' }
}

// これを`create`アクションを使って保存する。
store.dispatch('entities/posts/create', { data: post })

// すると、Vuexのステートは以下の通りになる。
{
  entities: {
    posts: {
      data: {
        '1': {
          id: 1,
          user_id: 1,
          title: '...',
          body: '...',
          author: '1'
        }
      }
    },
    users: {
      data: {
        '1': {
          id: 1,
          name: 'John Doe'
        }
      }
    }
  }
}

他にもinsertupdateといったメソッドもあるが同じ形で扱うことができる。

データの取得や検索

Vuex ORMは単純にVuexにデータを保存しているだけなので、普通にVuexのステートを参照することができる。

store.state.entities.users[0]

しかし、gettersを使うことで、より柔軟にデータを取得したり、検索することができる。この時のAPIも、サーバサイドのORMと良く似た形をとる。

// 全てのユーザのデータを取得する。
store.getters['entities/users/all']()

// 特定の条件にマッチするユーザを取得する。
store.getters['entities/users/query']().where('age', 24).first()

// Userに紐付くPostも合わせて取得する。
store.getters['entities/users/query']().with('posts').get()

また、gettersで取得したデータは、クラスのインスタンスとして取得される。これにより、モデルにメソッドを定義して、それを扱うことが可能になる。

const user = store.getters['entities/users/find']()

user.fullName() // <- 'John Doe'

さいごに

当然だが、比較的シンプルなアプリケーションではこの様な手法は必要ないと思っている。正直なところ、PostとUser程度しかデータがないのであれば、わざわざこの様なプラグインを使うこともないだろう。特にモデルの定義や、Databaseへの登録など、Vuex ORMは事前準備がある程度必要になる。その分、一回作ってしまえば割とスムーズにデータを弄くり回すことができるが、単純にそのコストが見合わないケースも多くある。

しかし、複雑なアプリケーションでは、多かれ少なかれ、複雑なリレーションを持ったデータ構造に悩まされる。更新したはずのデータが別の場所で更新されていないとか、大量のデータを検索するのが大変とか、っていうかなんだかんだ言ってクラス欲しいよねとか。

そういった問題にもし悩んでいる人にとって、少しでもヒントになったり、手助けになったりする部分が、この記事に含まれていることを祈っている。