はじめに
これは 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 の導入に関しては、こちらが参考になります。
この内容を踏まえた上で、その先の設計周りについて考察していきます。
プロジェクトの作成
--webpack=vue
で webpack と Vue の雛形を生成します。
$ rails new sample --webpack=vue
プロジェクトの設定
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
Vue のマウント対象を #app
などに固定すると、ページごとのインスタンス管理が難しく、controller.controller_name
や controller.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>
のような場合、a
と b
は別々のインスタンスであり、なおかつマウント範囲を最小限に抑えることが期待できます。
option
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
--webpack=vue
に Vuex は含まれないため、別途インストールします。
$ yarn add vuex
store/
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
当初、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 のページ遷移に依存しない状態管理が成立します。
データバインディングの対応
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
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
前述の v-model
と data-vue-model
の探索登録をミックスインで組み込むと、
Vue.mixin({
data: function(){
let data = {}
// v-model and data-vue-model process
return data
}
})
のような形になります。
当初は created
をフックしての探索登録を検討していましたが、data
直下のキーが宣言必須だっため、data
定義内で完結させる必要があります。
Plugin
ここまでの設計方針を Vue のプラグインとして分割して、app/javascript/packs/application.js
からは、
import VueAssignModel from 'vue-assign-model'
Vue.use(VueAssignModel)
のように読み込ませます。
おまけ
以上の手順を一括で行う Rails プラグインを用意しました。
webpack=vue
の Rails プロジェクトに、
$ gem install rails_vue_melt
でインストールしたあと、
$ rails generate vue_melt
で app/javascript/packs/vue_melt
に必要なファイルを生成します。
options/users.js
と components/Hello.vue
をサンプルとして準備しているので、
<div data-vue="users">
<input value="Example" v-model="user.name">
<p>User Name: {{ user.name }}</p>
<hello></hello>
</div>
で動作確認が可能です。