Posted at

Rails と Vue.js の設計覚書

More than 1 year has passed since last update.


はじめに

これは 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>

で動作確認が可能です。