LoginSignup
253
241

More than 5 years have passed since last update.

Rails と Vue.js の設計覚書

Posted at

はじめに

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

で動作確認が可能です。

253
241
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
253
241