Rails と Vue.js の設計覚書

# はじめに

これは 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](http://qiita.com/jnchito/items/30ab14ebf29b945559f6)

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

# プロジェクトの作成

[rails/webpacker](https://github.com/rails/webpacker)

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

    $ rails new sample --webpack=vue

# プロジェクトの設定

## Foreman

[ddollar/foreman](https://github.com/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](https://vuejs.org/v2/guide/installation.html#Explanation-of-Different-Builds)

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

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

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

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

### CSS

[Extracting CSS File · vue-loader](https://vue-loader.vuejs.org/en/configurations/extract-css.html)

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

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

```javascript
  options: {
    extractCSS: false,
```

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

#### 追記

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

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

を追記で解消します。

## View

### Node modules

[rails/webpacker #From node modules folder](https://github.com/rails/webpacker#from-node-modules-folder)

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

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

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

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

### Turbolinks cache

[turbolinks/turbolinks #Opting Out of Caching](https://github.com/turbolinks/turbolinks#opting-out-of-caching)

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

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

```html
    <%= 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](https://github.com/turbolinks/turbolinks#full-list-of-events)

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

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

```javascript
import Vue from 'vue'

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

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

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

## erb

[data-* - HTML | MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/data-*)

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

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

```javascript
querySelectorAll('[data-vue]')
```

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

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

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

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

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

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

## option

[The Vue Instance — Vue.js](https://vuejs.org/v2/guide/instance.html)

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

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

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

のようになります。

## options/

[Dependency Management #require-context](https://webpack.js.org/guides/dependency-management/#require-context)

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

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

```javascript
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` にまとめると、

```javascript
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](https://vuejs.org/v2/guide/single-file-components.html)

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

### `app/javascript/packs/components/Hello.vue`

```html
<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`

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

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

### `app/views/*/*.html.erb`

```html
<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](https://github.com/vuejs/vuex)

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

    $ yarn add vuex

## store/

[Application Structure · Vuex](https://vuex.vuejs.org/en/structure.html)

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

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

```javascript
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](https://vuex.vuejs.org/en/plugins.html#taking-state-snapshots)

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

```javascript
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](https://vuex.vuejs.org/en/modules.html)

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

## application.js

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

```javascript
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](https://vuejs.org/v2/guide/forms.html)

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

例えば、

```html
<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](https://vuejs.org/v2/guide/forms.html#Modifiers)

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

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

```javascript
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` に従い、

```javascript
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](https://vuejs.org/v2/guide/forms.html#Text)

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

が対象の場合、

```javascript
data: {
  text: "edit me"
}
```

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

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

になります。

### Multiline text

[Form Input Bindings — Vue.js #Multiline text](https://vuejs.org/v2/guide/forms.html#Multiline-text)

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

が対象の場合、

```javascript
data: {
  textarea: "add multiple lines"
}
```

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

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

になります。

### Checkbox

[Form Input Bindings — Vue.js #Checkbox](https://vuejs.org/v2/guide/forms.html#Checkbox)

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

が対象の場合、

```javascript
data: {
  checked: true
}
```

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

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

になります。

### Multiple checkboxes

```html
<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>
```

が対象の場合、

```javascript
data: {
  checkedNames: ["Mike"]
}
```

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

```javascript
} 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` の重複を把握する必要があります。ここでは、

```javascript
querySelectorAll('input[type=checkbox]')
```

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

### Radio

[Form Input Bindings — Vue.js #Radio](https://vuejs.org/v2/guide/forms.html#Radio)

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

が対象の場合、

```javascript
data: {
  picked: "Two"
}
```

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

```javascript
} 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](https://vuejs.org/v2/guide/forms.html#Select)

```html
<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>
```

が対象の場合、

```javascript
data: {
  selected: "C"
}
```

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

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

になります。

### Multiple select

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

が対象の場合、

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

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

```javascript
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](https://vuejs.org/v2/guide/list.html)

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

例えば、

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

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

また、

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

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

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

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

```html
<%= 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](http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html)

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

```html
<%= 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 のような追加削除機能を付与すると、

```html
<%= 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](https://vuejs.org/v2/guide/mixins.html)

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

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

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

    return data
  }
})
```

のような形になります。

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

## Plugin

[Plugins — Vue.js](https://vuejs.org/v2/guide/plugins.html)

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

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

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

# おまけ

[midnightSuyama/rails_vue_melt](https://github.com/midnightSuyama/rails_vue_melt)

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

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

    $ gem install rails_vue_melt

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

    $ rails generate vue_melt

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

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

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

  <hello></hello>
</div>
```

で動作確認が可能です。