はじめに
私はもともとRailsのエンジニアとしてWebエンジニアとしてのキャリアをスタートしました。しかし、Railsだけでできることは限りがあり、フロントエンドの挙動をリッチにするためには、JavaScriptの利用が欠かせません。私も最初の頃はKnockout.jsを使ったプロダクトをメンテナンスしていた時代もありましたが、最近はVue.jsを利用することがほとんどです。そこで問題となってくるのはRailsとVue.jsをいかに連携させるかということです。例えば、単純にフロントエンドをSPAとし、Railsは単なるAPIとしての役割のみを持たせるという方法もあります。しかし、開発効率を考慮するにあたって、Railsによるサーバーサイドレンダリングを完全に利用しないということは必ずしも得策ではないと思います。Railsで実現可能なことをわざわざVue.jsでやる必要はないからです。例えばサーバーサイドの処理であればクライアントに依存せずコントロールすることができますし、経験上、単純なページを作るにおいても、Vue.jsによる開発よりも、Railsによる開発の方が短時間に行うことができると思います。
私は、ここ数年、SPAじゃないVue.js開発を何度か行なってきました。そこで得られた知見を元に、Vue.jsをSPAではなく用いる際に実装した時のポイントを紹介したいと思います。
テンプレートエンジンはerbを使うかslimを使う場合はフロントエンドはpugを使う
erbの他に、haml、slimなどのテンプレートエンジンがありますが、開発効率の向上という観点では、Vue.jsとRailsを両方使う方場合、圧倒的にerbを利用することをお勧めします。理由は、hamlやslimの記法はVue.jsのコンポーネントのテンプレートを記法と異なっており、開発時に行ったり来たりする場合、脳内での変換に時間がかかってしまうこと、もともとサーバーサイドのテンプレートにあったものをフロントエンドに持っていきたいと思った場合、いちいち記法を変換しなければならなく、非常に非効率になってしまうからです。
過去に、仕事やプライベートにおいて、hamlとVue.js、slimとVue.jsの組み合わせで開発をしていたことがありますが、最初は良いものの、開発をしていくと自分はなぜ毎回コードを変換しているのだろうという不思議な気持ちになりました。
また、slimライクな記法をvueファイルの中で記述できるpugというライブラリを使えば、サーバーサイドはslim、フロントエンドはpugというこというセットも良いと思います(私自身実案件で利用したことはありません)。ただ、erbと比較すると微妙な違いがあるのでそこは開発者が吸収してあげる必要がありそうです。
サーバーサイドからフロントエンドへの値の受け渡し
サーバーサイドからフロントエンドの値の受け渡しには、
- ユーザーの操作によって動的に変わるもの
- レンダリング時に決まっていてそのページ内で変化しないもの
の2つが考えられます。1つ目は、例えば、郵便番号を入力して住所を取得するといった挙動です。データが少量であればレンダリング時に静的にフロントエンドに値を渡してあげることも可能ですが、郵便番号のような大量のデータの場合は、APIを呼ぶしか方法はありません。2つ目は、例えば設定値など、レンダリング時に決まっている値に関しては必ずしもAPIを呼ぶ必要性はありません。こうした場合の昔ながらの方法は、gonというgemを利用する方法です。JavaScriptのwindowオブジェクトにgonという属性を追加され、JavaScriptのファイルのどこからでもgonを通じてサーバーサイドから連携された値を利用することができます。
gonは、非常に便利なのですが、どこでも利用できるが故に値が変更されたり(普通そんなことはしないと思いますが)、必ずしもスマートな方法とは言えない場合があります。そこで、HTMLタグの属性として渡してあげるようにしています。
<div
id="app_<%= "#{controller.controller_name}_#{controller.action_name}" %>"
data-server-side-values="<%= @serverSideValues.to_json %>"
data-server-side-errors="<%= @serverSideErrors.to_json %>"
>
<app>
</div>
def new
@serverSideValues = {
authenticity_token: view_context.form_authenticity_token,
front_provider: { name: 'てすと株式会社' },
}.deep_stringify_keys.deep_transform_keys { |key| key.camelize(:lower) }
# ruby側ではsnake_caseを、JavaScript側ではlowerCamelCaseを使うことを想定し、ruby側で変換。
end
このとき、Vue側では、beforeMount
でstore
にセットするようにします。mounted
で同様の処理を行うこともできますが、store
にセットされる前にserverSideValues
が呼び出されることがあり、オブジェクトのネストが深いとエラーになってしまいます。1
import Vue from 'vue';
import { mapState, mapGetters, mapMutations } from 'vuex';
import store from '@/javascripts/pages/orders/new/store';
import App from '@/javascripts/pages/orders/new/app';
window.app = new Vue({
el: '#app_orders_new',
components: {
App,
},
beforeMount () {
const params = this.$el.attributes['data-server-side-values'].textContent;
if (params !== '') {
this.setServerSeideValues(JSON.parse(params));
}
},
mounted () {
},
methods: {
...mapMutations([
'setServerSeideValues'
])
},
store,
});
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const state = {
serverSideValues: {},
};
const mutations = {
setServerSeideValues: (state, val) => {
state.serverSideValues = val
},
};
const getters = {
}
const actions = {
}
export default new Vuex.Store({
state,
mutations,
getters,
actions,
});
リロード時や確認画面からの戻り時に、フォームの値を保持する
これは必ずしもRailsとは関係のない挙動ですが、入力途中でリロードを行なった場合、値が保持されていてほしいです。また、申し込み画面(newアクション)から確認画面(confirmアクション)に行った後、確認画面上の「戻る」リンクなどで申し込み画面に戻った場合も値が保持されているべきでしょう。この時、サーバーサイドだけで行おうとすると、例えばsession
の中に値を保持してあげて戻った場合に復元してあげるなどの処理が必要です。申し込み画面が複数ページにまたがる場合はかなり厄介になります。Railsにおける申し込み画面が複数ページに渡る場合の実装についての詳細は別の記事に譲るとして、今回は、window
オブジェクトのsessionStorage
に入れる実装について紹介します。sessionStorage
とは、Web Storageの1種で、ユーザーのローカル環境(ブラウザ)にデータを保存するための仕組みです。 データの保存・上書き・削除・全クリアなどの操作は、JavaScriptで行うことができる機能です。2
Web StorageにはsessionStorage
とlocalStorage
の2種類があり、それぞれ用途が異なります。localStorage
はブラウザを閉じても値が保持され、sessionStorage
はブラウザを閉じた場合、値が破棄されます。加えて、sessionStorage
の場合、複数のタブで値を共有しません。申し込み画面には往々にして、複数タブ問題というのが存在します。ユーザーが複数のタブを同時に開いて異なる申し込みをした場合、実装によっては、意図しない申し込みが行われてしまうことがあるという問題です。私はこの問題を内包したままのプロダクトをメンテナンスした経験があり、新規に作る場合は複数タブ問題が生じないように実装するように務めています。その方法の一つがsessionStorage
を使う方法です。
まず、vue
ファイルの実装例は以下のようになります。ポイントは、inputイベント(@input
)でstoreに入力された値を保持してあげます。(setFailyName
はstore
のstate
に値をセットするMutationです)
<template>
<div>
<input
class="input"
type="text"
placeholder="苗字"
:value="familyName"
@input="(e) => (setFamilyName(e.target.value))"
>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
data () {
return {
}
},
computed: {
...mapState([
'serverSideValues',
'familyName',
]),
},
methods: {
...mapMutations([
'setFamilyName',
]),
},
}
</script>
store
側の実装は以下のようになります。ここでは、vuex-persistedstateというモジュールを利用しています。Web Storageにstore
のstate
の値を保存するには、vuejs-storageもありますが、現在は、vuex-persistedstate
の方が人気が高いようです。実装としてはplugins
の中のcreatePersistedState
の部分になります。storage
にwindow.sessionStorage
を指定し、sessionStorage
に保存することを指定します。key
の部分はsessionStorage
を利用している他の部分と重複しないように設定してあげる必要があります。paths
はstate
の値で、store
に他のstoreモジュールを利用している場合は、.
(ドット)で繋げます。
import Vue from 'vue';
import Vuex from 'vuex';
import createPersistedState from 'vuex-persistedstate';
Vue.use(Vuex);
const state = {
serverSideValues: {},
familyName: '',
};
const mutations = {
setServerSeideValues: (state, val) => {
state.serverSideValues = val
},
setFamilyName: (state, val) => {
state.familyName = val
},
};
const getters = {
}
const actions = {
}
const plugins = [
createPersistedState({
storage: window.sessionStorage,
key: 'orders',
paths: [
'familyName',
// 'postcode.postcode1' // postcodeモジュールのpostcode1というstateを永続化する場合。
],
}),
];
export default new Vuex.Store({
modules: {
},
state,
mutations,
getters,
actions,
plugins: plugins,
});
サーバーサイドエラーの連携
サーバーサイドのエラーの形式は、次のようにモデルの属性のHash
を生成し、エラーメッセージを配列で持たせるようにしています。こうすることで、複数のエラーメッセージが含まれても対応できるようにしています。
{
"family_name" => [
"Family name is too long (maximum is 10 characters)"
]
}
newページからconfirmへの遷移はpostでinvalid
の場合は、new
アクションにredirect
するようにしています。ここで、flash
でエラーを渡しています。前述のエラーの形式に整える処理は、order_errors
に実装しています。invalid?
を実行すると、モデルのerrors
が取得できるのでこれを利用して処理をするのが便利です。
class OrdersController < ApplicationController
def new
@serverSideValues = {} # 省略
@serverSideErrors =
flash[:error]&.deep_stringify_keys&.deep_transform_keys { |key| key.camelize(:lower) } || ''
end
def confirm
@order = Order.new(order_params)
if @order.invalid?
flash[:error] = order_errors
redirect_to action: :new
end
end
private
# order_paramsメソッドは省略
def order_errors
@order.errors.messages.keys.map do |key|
[key.to_s, @order.errors.full_messages_for(key)]
end.to_h
end
end
サーバーサイドのエラーのフロントエンドへの受け渡しはdata-server-side-errors
を経由して取得します。これもserverSideValues
と同様にbeforeMount
で行うと良いでしょう。
beforeMount () {
// setServerSideValuesの処理は省略
const errors = this.$el.attributes['data-server-side-errors'].textContent;
if (errors !== '') {
this.setServerSeideErrors(JSON.parse(errors));
}
}
フロントエンドでエラーを表示する場合は、serverSideErrors
を利用します。例えばserverSideErros.familyName
の配列が存在すれば表示といった処理を行います。この時、エラーがない場合は、severSideErrors.familyName
の値がundefined
になってしまうので、ruby
のdig
ライクな関数を自前で用意することでエラーが発生しないようにしています。あるいはES2020以降の場合は、オプショナルチェーン(?.
)を利用すると良いでしょう。
<template>
<div>
<input
class="input"
type="text"
placeholder="苗字"
name="order[family_name]"
:value="familyName"
@input="(e) => (setFamilyName(e.target.value))"
>
<div
v-show="dig(serverSideErrors, 'familyName', 'length') > 0"
>
<span v-for="error in serverSideErrors.familyName">
{{ error }}
</span>
</div>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
import dig from '@/javascripts/modules/dig'; // digをimport
export default {
data () {
},
computed: {
},
methods: {
dig, // template内で利用できるようにmethodに追加。
},
}
export default function dig (target, ...keys) {
let digged = target;
for (const key of keys) {
if (typeof digged === 'undefined' || digged === null) {
return undefined;
}
if (typeof key === 'function') {
digged = key(digged);
} else {
digged = digged[key];
}
};
return digged;
};
他にもRailsとVue.jsの連携方法について思いつき次第逐次追記していきます。
-
その場合に独自にrubyでいう
dig
などの関数を実装して回避することも可能です。 ↩