Make組ブログ

Python、Webアプリや製品・サービス開発についてhirokikyが書きます。

Vue.jsでAPIにないフィールドはモデルにも作らないプラクティス - モデルとViewModelの区別の仕方

前回の記事はこちらです。先に読まれておくことをオススメします。

blog.hirokiky.org

モデルに置く値、ViewModelに置く値の区別をつけよう

Vue.jsで開発するときに、モデルに置くべき値とViewModelに置くべき値を区別できればかなりキレイに設計できます。 区別の判断は少し難しいですが、「バックエンドのAPIで保存・読み込みする値のみモデルで管理する」と考えてみると勘所が分かりやすいです。 バックエンドのAPIなどが無い場合は、例えばlocalStorageに保存する値をモデルとすると良いでしょう。

前提: 記事の読み込み、保存する画面で説明します

説明のためのプロジェクトの説明をします。

記事(Post)の編集をする画面をVue.jsで作り、バックエンドのAPIからデータを取得、保存するとしましょう。 ここではAPIから以下のレスポンスがあると想定します。

{
    "id": 1,
    "title": "タイトル",
    "body": "本文"
}

この場合、APIのレスポンスはモデルで管理すべきです(参考: JavaScript (ES6) でAPIから受け取ったデータをモデルに入れるプラクティス - Make組ブログ

export class Post {
  constructor (id, title, body="") {
    this.id = id
    this.title = title
    this.body = body
  }
}

読み込み、保存する処理は以下のようにします

import axios from 'axios'

import { Post } from './api'


export async function getPost (id) {
  let res = axios.get(`/posts/${id}/`)
  return new Post(res.data.id, res.data.title, res.data.body)
}


export async function patchPost (post) {
  let res = axios.patch(`/posts/${post.id}/`, {body: post.body})
  post.body = res.data.body
  return post}

単純にVueコンポーネントを作る

さて、ここでどのようにVueコンポーネントを書くべきでしょうか? 一番単純に作ると以下のようになります。

<template>
  <div v-if="post">
    <textarea v-model="post.body"></textarea>
    <button @click="save">Save</button>
  </div>
</template>

<script>
import * as api from './api'


export default {
  name: 'Post',
  data () { return {
    post: null
  } },
  methods: {
    async save () {
      await api.patchPost(this.post)
    }
  },
  async mounted () {
    this.post = await api.getPost(1)
  }
}
</script>

モデルに書く?

ここで「値が変更されていないときは Save ボタンを disabled にしたい」としましょう。 この場合、値が変更されたことを検知するのはどこが良いでしょうか?

せっかくJavaScriptclass が使えるので、以下のように getter, setter で書くとカッコイイ気がします。 が、結論から言うとあまりオススメしません。

export class Post {
  constructor (id, title, body="") {
    this.id = id
    this.title = title
    this._body = body
    this.changed = false
  }

  get body  () {
    return this._body
  }

  set body (value) {
    this._body = value
    this.changed = true 
  }
}

この場合、Vueコンポーネントは以下のようになるでしょう。 changed = false にする処理や、 :disabled="!post.changed" が足されています。

<template>
  <div v-if="post">
    <textarea v-model="post.body"></textarea>
    <button @click="save" :disabled="!post.changed">Save</button>
  </div>
</template>

<script>
import * as api from './api'


export default {
  name: 'Post',
  data () { return {
    post: null
  } },
  methods: {
    async save () {
      await api.patchPost(this.post)
      post.changed = false
    }
  },
  async mounted () {
    this.post = await api.getPost(1)
  }
}
</script>

この程度ではあまり問題になりませんが、 1つのコンポーネントでしか使わないような処理や状態をモデルに入れるとモデルが肥大化していきます 。 変更の検知をする部分はVueコンポーネントに書くほうが良いです。

changedAPI(データの読み込み・保存)には関係しない。モデルに書くべきでは無いかも?」と考えてみてください。

Vueコンポーネントに書こう

モデルの定義は元に戻して(getter, setterを消して)、Vueコンポーネントを以下のように書いてみましょう。 「変更されたかどうか」という状態をVueコンポーネントに持つようになっています。

<template>
  <div v-if="post">
    <textarea v-model="body"></textarea>
    <button @click="save" :disabled="!changed">Save</button>
  </div>
</template>

<script>
import * as api from './api'


export default {
  name: 'Post',
  data () { return {
    post: null,
    body: ""
  } },
  methods: {
    async save () {
      this.post.body = body
      await api.patchPost(this.post)
    }
  },
  computed: {
    changed () {
      return this.body != this.post.body
    }
  },
  async mounted () {
    this.post = await api.getPost(1)
    this.body = this.post.body
  }
}
</script>

textarea ではVueコンポーネントbody を編集するようにして、 post.body は触れないようにしています。 「変更されたかどうか」は changed というcomputedで計算しています。

changeddata で管理するフラグにしても良いです。

  methods: {
    update (value) {
      this.body = value
      this.changed = true
    }
  }

こう変更することで、モデルとしたPostクラスでは changed という実装が無くなりました。 「記事」として管理すべき値はモデルに、「編集する」という責任範囲で管理すべき値はコンポーネントに置きましょう。 そうすることで、モデルの実装を減らして、細かく分けられたVueコンポーネントに実装を分散できます。

もし複数コンポーネントで状態を共有するのであれば、Vuexを使えば済みます。 もちろん今回の説明も「こっちが正解」というわけではありません。ですが、「全てモデルに書く」、「全てViewコンポーネントに書く」のでなく、適宜コンポーネントを分離して値を管理する場所を分けることで、より良い設計が自然とできるようになるでしょう。

他のオススメ記事

blog.hirokiky.org