Rails と Vue.js の設計覚書

  • 43
    Like
  • 0
    Comment

はじめに

これは Rails の View と Vue を馴染ませるための設計指針になります。

Rails 5.1 で webpack や Vue が公式サポートされました。SPA の場合はあまり気になりませんが、スタンダードな Rails 構成に Vue を適用する場合は、Rails の View と Vue をどのように管理すべきか悩むことになると思います。本記事にはその対処例をまとめています。

環境

  • Rails 5.1+
  • Turbolinks 5.0+
  • webpack 2.0+
  • Vue 2.0+
  • Vuex 2.0+

参考

【動画付き】Rails 5.1で作るVue.jsアプリケーション ~Herokuデプロイからシステムテストまで~ - Qiita

Rails と Vue の導入に関しては、こちらが参考になります。
この内容を踏まえた上で、その先の設計周りについて考察していきます。

プロジェクトの作成

rails/webpacker

--webpack=vue で webpack と Vue の雛形を生成します。

$ rails new sample --webpack=vue

プロジェクトの設定

Foreman

ddollar/foreman

開発中は Rails と別に webpack を実行するための webpacker を立ち上げることになるので、

rails: bundle exec rails server
webpack: ./bin/webpack-dev-server

のような Procfile を作り、

$ foreman start

でプロセス管理すると楽です。

webpack

Alias

Installation — Vue.js #Explanation of Different Builds

--webpack=vue が生成する webpack 用の設定では、vue の読み込み対象はデフォルトの Runtime-only です。

このままでは Vue が Rails の View を解釈できないため、読み込み対象を Full に向ける必要があります。読み込み箇所で明示的に指定するか、webpack のエイリアスを利用するのであれば config/webpack/shared.js に対して、

  resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js'
    },

のような追記で解消します。

CSS

Extracting CSS File · vue-loader

--webpack=vue が生成する vue-loader 用の設定では、extractCSS が有効になっているため Vue のスタイル定義は別途 CSS ファイルとして出力されます。

vue-loader のデフォルト動作にしたい場合は config/webpack/loaders/vue.js に対して、

  options: {
    extractCSS: false,

のような変更で解消します。

追記

webpacker 3.0 で設定方法が変更されました。config/webpack/environment.js に対して、

environment.loaders.get('vue').options.extractCSS = false

を追記で解消します。

View

Node modules

rails/webpacker #From node modules folder

webpack の出力結果は javascript_pack_tag で読み込みます。vue-loader で CSS を分離している場合は stylesheet_pack_tag も必要になります。

今回は app/views/layouts/application.html.erb で、

    <%= javascript_pack_tag 'application' %>
  </head>

のようにして、全ページで汎用的な application.js を読み込ませる方針で進めます。

Turbolinks cache

turbolinks/turbolinks #Opting Out of Caching

構造的に Turbolinks のキャッシュは Vue のイベントまで復元できないため、キャッシュだけは無効化しておく必要があります。

app/views/layouts/application.html.erb に追記すると、

    <%= javascript_pack_tag 'application' %>
    <meta name="turbolinks-cache-control" content="no-cache">
  </head>

のようになります。

Vue インスタンスの管理

webpack の対象となる app/javascript/packs 配下については、

  • application.js - (エントリーポイント)
  • options/ - (Vue インスタンスのオプションオブジェクト)

の構成で考えます。

Mount

turbolinks/turbolinks #Full List of Events

全ページで読み込む application.js にて、Turbolinks イベントに応じて Vue のマウントを管理します。

turbolinks:load でいつも通り Vue をマウントして、turbolinks:visit で Vue をアンマウントすれば、Turbolinks と Vue は共存できるので app/javascript/packs/application.js は、

import Vue from 'vue'

document.addEventListener('turbolinks:load', () => {
  // Vue mount
})

document.addEventListener('turbolinks:visit', () => {
  // Vue unmount
})

のような構成になります。

erb

data-* - HTML | MDN

Vue のマウント対象を #app などに固定すると、ページごとのインスタンス管理が難しく、controller.controller_namecontroller.action_name で切り分けたとしても、同ページ内の複数インスタンス管理は煩雑になります。

そのため、カスタムデータ属性 data-vue をマウント対象の目印として turbolinks:load イベント時に、

querySelectorAll('[data-vue]')

で探索することを考えます。

具体的には app/views/*/*.html.erb が、

<div data-vue="a">
  <p>{{ message }}</p>
</div>

<div>
  <p>message</p>
</div>

<div data-vue="b">
  <p>{{ message }}</p>
</div>

のような場合、ab は別々のインスタンスであり、なおかつマウント範囲を最小限に抑えることが期待できます。

option

The Vue Instance — Vue.js

erb で定義した data-vue の値をファイル名として、options/ に Vue インスタンスのオプションオブジェクトを用意します。

例えば、app/javascript/packs/options/a.js は、

export default {
  data: () => ({
    message: 'A'
  })
}

のようになります。

options/

Dependency Management #require-context

各 option ファイルは application.js から読み込むことになりますが、option ファイルを追加するたびに application.js を修正する事態は好ましくありません。

配置場所を options/ に定め、webpack の require.context を利用して、

var options = {}
var requireContext = require.context('./options', false, /\.js$/)
requireContext.keys().forEach(key => {
  let name = key.split('/').pop().split('.').shift()
  options[name] = requireContext(key).default
})

のように読み込むことにします。

application.js

ここまでの設計方針を app/javascript/packs/application.js にまとめると、

import Vue from 'vue'

var vms = []

var options = {}
var requireContext = require.context('./options', false, /\.js$/)
requireContext.keys().forEach(key => {
  let name = key.split('/').pop().split('.').shift()
  options[name] = requireContext(key).default
})

document.addEventListener('turbolinks:load', () => {
  let templates = document.querySelectorAll('[data-vue]')
  for (let el of templates) {
    let vm = new Vue(
      Object.assign(options[el.dataset.vue], { el })
    )
    vms.push(vm)
  }
})

document.addEventListener('turbolinks:visit', () => {
  for (let vm of vms) {
    vm.$destroy()
  }
  vms = []
})

のような形になり、この時点で動作確認が可能です。

Vue コンポーネントの管理

app/javascript/packs 配下に components/ を加えて、

  • application.js - (エントリーポイント)
  • options/ - (Vue インスタンスのオプションオブジェクト)
  • components/ - (*.vue ファイル)

の構成で考えます。

Component

Single File Components — Vue.js

Vue インスタンスの管理が整っているのであれば、コンポーネントはいつも通り使うことができます。以下に例を示します。

app/javascript/packs/components/Hello.vue

<template>
  <div>
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  data: () => ({
    message: 'Hello Vue!'
  })
}
</script>

<style scoped>
p {
  color: #f00;
  font-size: 2em;
}
</style>

app/javascript/packs/options/a.js

import Hello from '../components/Hello.vue'

export default {
  components: {
    Hello
  },
  data: () => ({
    message: 'A'
  })
}

app/views/*/*.html.erb

<div data-vue="a">
  <p>{{ message }}</p>
  <hello></hello>
</div>

Vuex の管理

app/javascript/packs 配下に store/ を加えて、

  • application.js - (エントリーポイント)
  • options/ - (Vue インスタンスのオプションオブジェクト)
  • components/ - (*.vue ファイル)
  • store/ - (Vuex 関連)

の構成で考えます。

Install

vuejs/vuex

--webpack=vue に Vuex は含まれないため、別途インストールします。

$ yarn add vuex

store/

Application Structure · Vuex

app/javascript/packs/store/ はスタンダードな Vuex 構成で問題ありません。

getters.js, actions.js, mutations.js mutation-types.js などを用意した上で index.js は、

import Vue from 'vue'
import Vuex from 'vuex'
import * as getters from './getters'
import * as actions from './actions'
import * as mutations from './mutations'

Vue.use(Vuex)

const state = {
}

export default new Vuex.Store({
  state,
  getters,
  actions,
  mutations
})

になります。

Snapshot

Plugins · Vuex #Taking State Snapshots

Vuex を利用する app/javascript/packs/application.js は、

import Vue from 'vue'
import store from './store'

...

    let vm = new Vue(
      Object.assign(options[el.dataset.vue], { el, store })
    )

...

のような形になります。

ただし、このままでは Turbolinks によるページ遷移後も store の状態が残ってしまいます。直接アクセスと Turbolinks 経由のアクセスで異なる状態を生じさせないためには、事前に store のスナップショットを撮っておき、turbolinks:visit イベント時に store を復元する必要があります。

スナップショットにはディープコピーを適用するため、ここでは公式サンプルに従って clonedeep を導入します。

$ yarn add lodash.clonedeep

Module

Modules · Vuex

当初、index.js で定義している state を切り出しておいて、これを復元情報として使うことも検討しましたが、この方法では Vuex モジュールの state が復元対象から抜けてしまうため要注意。

application.js

ここまでの設計方針を app/javascript/packs/application.js に反映させると、

import Vue from 'vue'
import store from './store'
import cloneDeep from 'lodash.clonedeep'

const storeState = cloneDeep(store.state)

var vms = []

var options = {}
var requireContext = require.context('./options', false, /\.js$/)
requireContext.keys().forEach(key => {
  let name = key.split('/').pop().split('.').shift()
  options[name] = requireContext(key).default
})

document.addEventListener('turbolinks:load', () => {
  let templates = document.querySelectorAll('[data-vue]')
  for (let el of templates) {
    let vm = new Vue(
      Object.assign(options[el.dataset.vue], { el, store })
    )
    vms.push(vm)
  }
})

document.addEventListener('turbolinks:visit', () => {
  for (let vm of vms) {
    vm.$destroy()
  }
  vms = []
  store.replaceState(cloneDeep(storeState))
})

になります。これで Turbolinks のページ遷移に依存しない状態管理が成立します。

データバインディングの対応

Form Input Bindings — Vue.js

Rails の View では、フォームの値は value 属性などに埋め込まれることが一般的です。一方、Vue でバインディングする場合は value 属性ではなく、v-model の示す Vue の Model がフォームの値になります。そのため、Rails の Model を初期値として埋め込んだ View に対して、Vue は期待通りのバインディングができません。

例えば、

<div data-vue="users">
  <input value="<%= @user.name %>" v-model="user.name">
  <p>User Name: {{ user.name }}</p>
</div>

のような View では、Rails の Model である <%= @user.name %> は無視され、Vue の Model である user.name が初期値として表示されます。また、Vue 側で user.name を定義していない場合は Vue のエラーでレンダリングが失敗します。

つまり、これを解決するためにはレンダリング前に Rails の Model を Vue に伝えればよいことがわかります。

v-model

Form Input Bindings — Vue.js #Modifiers

Vue のマウント直後に v-model を探索し、対象の DOM が保持している初期値を v-model が示す名前で登録する方針で進めます。

探索対象の v-model には修飾子が用意されており、lazy, number, trim を考慮すると、

const modelAttributes = [
  'v-model',
  'v-model.lazy',
  'v-model.number',
  'v-model.trim'
]

document.querySelectorAll(
  modelAttributes.map(attr => `[${attr}]`.replace('.', '\\.')).join(',')
)

のようになります。属性名に対しての曖昧表現ができないため , で連結しています。

これで対象の DOM が見つかるので、内容に応じて value, checked, selected の値を v-model の名前で登録していきます。登録の際には v-model のドット記法でネストさせるため、dot-prop などを使うと楽です。

内容に応じた登録は Vue の data に従い、

if (tag === 'select') {
  if (e.multiple) {
    // Multiple select
  } else {
    // Select
  }
} else if (tag === 'input' && type === 'checkbox') {
  if (isMultipleCheckboxes(model)) {
    // Multiple checkboxes
  } else {
    // Checkbox
  }
} else if (tag === 'input' && type === 'radio') {
  // Radio
} else if (tag === 'input') {
  // Text
} else if (tag === 'textarea') {
  // Multiline text
}

のように 7 種類に分類されます。

Text

Form Input Bindings — Vue.js #Text

<input type="text" value="edit me" v-model="text">

が対象の場合、

data: {
  text: "edit me"
}

が期待値であり、登録処理は、

} else if (tag === 'input') {
  dotProp.set(data, model, e.value)
}

になります。

Multiline text

Form Input Bindings — Vue.js #Multiline text

<textarea v-model="textarea">add multiple lines</textarea>

が対象の場合、

data: {
  textarea: "add multiple lines"
}

が期待値であり、登録処理は、

} else if (tag === 'textarea') {
  dotProp.set(data, model, e.value)
}

になります。

Checkbox

Form Input Bindings — Vue.js #Checkbox

<input type="checkbox" value="1" v-model="checked" checked>

が対象の場合、

data: {
  checked: true
}

が期待値であり、登録処理は、

} else if (tag === 'input' && type === 'checkbox') {
  dotProp.set(data, model, e.checked)
}

になります。

Multiple checkboxes

<input type="checkbox" value="Jack" v-model="checkedNames">
<input type="checkbox" value="John" v-model="checkedNames">
<input type="checkbox" value="Mike" v-model="checkedNames" checked>

が対象の場合、

data: {
  checkedNames: ["Mike"]
}

が期待値であり、登録処理は前項目の Checkbox を修正して、

} else if (tag === 'input' && type === 'checkbox') {
  if (isMultipleCheckboxes(model)) {
    if (e.checked) {
      dotProp.set(data, model, [...dotProp.get(data, model, []), e.value])
    } else if (!dotProp.has(data, model)) {
      dotProp.set(data, model, [])
    }
  } else {
    dotProp.set(data, model, e.checked)
  }
}

になります。

Multiple を判定するためには、対象となる全ての DOM を調べて v-model の重複を把握する必要があります。ここでは、

querySelectorAll('input[type=checkbox]')

に対する重複結果を返す isMultipleCheckboxes() を定義して判定しています。

Radio

Form Input Bindings — Vue.js #Radio

<input type="radio" value="One" v-model="picked">
<input type="radio" value="Two" v-model="picked" checked>

が対象の場合、

data: {
  picked: "Two"
}

が期待値であり、登録処理は、

} else if (tag === 'input' && type === 'radio') {
  if (e.checked) {
    dotProp.set(data, model, e.value)
  } else if (!dotProp.has(data, model)) {
    dotProp.set(data, model, null)
  }
}

になります。

Select

Form Input Bindings — Vue.js #Select

<select v-model="selected">
  <option value="" disabled>Please select one</option>
  <option value="A">A</option>
  <option value="B">B</option>
  <option value="C" selected>C</option>
</select>

が対象の場合、

data: {
  selected: "C"
}

が期待値であり、登録処理は、

if (tag === 'select') {
  dotProp.set(data, model, e.value)
}

になります。

Multiple select

<select v-model="selectedMultiple" multiple>
  <option value="A">A</option>
  <option value="B" selected>B</option>
  <option value="C" selected>C</option>
</select>

が対象の場合、

data: {
  selectedMultiple: ["B", "C"]
}

が期待値であり、登録処理は前項目の Select を修正して、

if (tag === 'select') {
  if (e.multiple) {
    dotProp.set(data, model, [...e.selectedOptions].map(option => option.value))
  } else {
    dotProp.set(data, model, e.value)
  }
}

になります。

v-for

List Rendering — Vue.js

v-model 以上に困ることになるのが v-for によるデータバインディングです。

例えば、

<ul>
  <li v-for="item in items">
    {{ item }}
  </li>
</ul>

のような View から items の初期値を回収することは不可能です。

また、

<ul>
  <% items.each do |item| %>
    <li>
      <%= item %>
    </li>
  <% end %>
</ul>

のような View から初期値を回収し、無理やり v-for ディレクティブを構築することは可能かもしれませんが、コストや副作用が心配です。

以上のことから一つの解決策として、カスタムデータ属性 data-vue-model を定義して、ここに埋め込んだ JSON を Vue に伝える方針で進めます。

data-vue-model の仕組みを使うと View は、

<%= content_tag :ul, 'data-vue-model': "{ \"items\": #{items.to_json} }" do %>
  <li v-for="item in items">
    {{ item }}
  </li>
<% end %>

のように書くことが可能になります。

accepts_nested_attributes_for

ActiveRecord::NestedAttributes::ClassMethods

参考として、Active Record の accepts_nested_attributes_for を使用している場合は、

<%= content_tag :div, 'data-vue-model': "{ \"items\": #{user.items.to_json} }" do %>
  <div v-for="(_item, i) in items">
    <input type="text" :name="'user[items_attributes][' + i + '][name]'" v-model="_item.name">
    <input type="hidden" :name="'user[items_attributes][' + i + '][id]'" :value="_item.id" v-if="_item.id">
  </div>
<% end %>

のような形になります。

これに nested_form や cocoon のような追加削除機能を付与すると、

<%= content_tag :div, 'data-vue-model': "{ \"items\": #{user.items.to_json}, \"items_destroy_ids\": [] }" do %>
  <div v-for="(_item, i) in items">
    <input type="text" :name="'user[items_attributes][' + i + '][name]'" v-model="_item.name">
    <input type="hidden" :name="'user[items_attributes][' + i + '][id]'" :value="_item.id" v-if="_item.id">
    <button type="button" @click="_item.hasOwnProperty('id') ? items_destroy_ids.push(items.splice(i, 1)[0].id) : items.splice(i, 1)">Remove</button>
  </div>

  <template v-for="(id, i) in items_destroy_ids">
    <input type="hidden" :name="'user[items_attributes][' + (items.length+i) + '][id]'" :value="id">
    <input type="hidden" :name="'user[items_attributes][' + (items.length+i) + '][_destroy]'" value="1">
  </template>

  <button type="button" @click="items.push({ name: '' })">Add</button>
<% end %>

のような形になります。

Mixin

Mixins — Vue.js

前述の v-modeldata-vue-model の探索登録をミックスインで組み込むと、

Vue.mixin({
  data: function(){
    let data = {}

    // v-model and data-vue-model process

    return data
  }
})

のような形になります。

当初は created をフックしての探索登録を検討していましたが、data 直下のキーが宣言必須だっため、data 定義内で完結させる必要があります。

Plugin

Plugins — Vue.js

ここまでの設計方針を Vue のプラグインとして分割して、app/javascript/packs/application.js からは、

import VueAssignModel from 'vue-assign-model'
Vue.use(VueAssignModel)

のように読み込ませます。

おまけ

midnightSuyama/rails_vue_melt

以上の手順を一括で行う Rails プラグインを用意しました。

webpack=vue の Rails プロジェクトに、

$ gem install rails_vue_melt

でインストールしたあと、

$ rails generate vue_melt

app/javascript/packs/vue_melt に必要なファイルを生成します。

options/users.jscomponents/Hello.vue をサンプルとして準備しているので、

<div data-vue="users">
  <input value="Example" v-model="user.name">
  <p>User Name: {{ user.name }}</p>

  <hello></hello>
</div>

で動作確認が可能です。