エンジニアの西辻です。
今回の記事では、Railsプロジェクトで一部の画面のみをVue.jsを用いてSPA化するにあたって、その際に得た知見などを共有できたらと思います。
Overview
大きく以下の流れで書いていきます。
- Motivation
- RailsとVue.jsの連携方法について調査、部分的なSPAが実現可能かの検証
- 実装を進めていく中での気づき
- スマホ対応の方針決め
- 最後に
Motivation
まず、なぜRailsプロジェクトで一部の画面のみをSPA化する必要があったかの背景を説明したいと思います。
今年の5月からtoB向けの管理ツールを新規開発したのですが、その際にjQueryだとコードの見通しが悪いのでVue.jsを積極的に利用していこうという話があり、チームメンバーでVue.jsを学習しながら開発を進めていました。
管理ツール自体は無事リリースでき、稼働はしているのですがVue.jsへの知見が少なかったのとリリース日までの開発のリソース上、管理ツールのメイン機能であるメッセンジャーの大部分がRailsとVue.jsの微妙な連携で成り立っており、UXがよくない状態でした。
ローカル環境にはなりますが、実際のメッセンジャー画面が以下のスクリーンショットになります。
メッセンジャーは大きく3つのセクションに分かれています。
Topic Listで選択したTopicのメッセージがMessage List上に表示されます。
よくあるメール系のアプリの動きにかなり近いです。
それに付随して一番右のUser Summaryに対応しているユーザ情報が表示されます。
メッセンジャーは具体的に以下の部分でUXに問題がありました。
- Topic Listを選ぶ度にブラウザリロードされてしまい、ユーザの操作に連続性が保てない。
- 上記の問題も絡んで、スマホ対応がCSSのみで完結しない状態で、スマホ対応が困難な状況。
- Topic Listのページングで進んでもTopic選択時にブラウザリロードしてしまい、ユーザが操作している位置を見失う。
- メッセージ送信時にブラウザリロードされてしまう。
こうして並べてみると、ブラウザリロードされてしまうことがほとんどの問題の原因となっていることが分かります。
上記の問題を解決するために、中途半端にRailsのviewとVue.jsが連携している部分をRailsのview利用をやめて、Vue.jsでSPA化する必要があるという考えに至りました。
RailsとVue.jsの連携方法について調査、部分的なSPAが実現可能かの検証
自分自身、Vue.jsへの知識がそんなになく部分的なSPAが実現可能かという点について検証をしてみないと分からない状態でした。
考え込んでも埒が明かないので小さめのプログラムを組み、実現可能かの検証を数日行っていました。
検証のポイントとしては、以下の項目をあげていました。
- Railsの資産を活かした形でVue.jsを利用できるかどうか
- 3つのセクション(Topic List, Message List, User Summary)間でイベントによる連携ができるかどうか
- 将来的にスマホ対応することができる設計ができるかどうか
- ソースコードの見通しが悪くならないかどうか
- URLとして特定のTopicを表現できるかどうか
Railsの資産を活かした形でVue.jsを利用できるかどうか
最終的にはSPA化する判断に至ったのですが、当初の考えではRailsのviewをいい感じに再利用してできないかを模索していました。
というのも既にRailsのview templateに対してVue.jsを導入しており、考え的にその派生でいけないかという印象を強く持っていました。
Railsのview template engineとしてはhamlを利用しており、特定の要素に対してVue.jsを動作させるような実装が簡易的でRailsの恩恵を最大限受けながら開発スピードを速めて進めることができました。
haml 側が以下のようにRails側の変数とVue.jsのsyntaxが入り混じりながら記述するスタイルです。
#agent-creator-modal.agent-creator-modal.modal.fade{"aria-hidden" => "true", :role => "dialog", :tabindex => "-1"}
.modal-dialog.modal-lg{:role => "document"}
.modal-content
.modal-header
%h5.modal-title エージェントの追加
%button.close{"aria-label" => "Close", "data-dismiss" => "modal", :type => "button"}
%span{"aria-hidden" => "true"} ×
= form_with model: @agent, html: {'v-on:ajax:success': "createAgentToSuccess", 'v-on:ajax:error': "(event) => { createAgentToFailed(event) }"} do |f|
.modal-body
= "#{t('application.name')}にエージェントを招待します。招待したいエージェントのメールアドレスを入力して送信ボタンを押してください。"
.row.mt-4{'v-if': "agentEmailErrorMessage", 'v-cloak': ""}
.col
.alert.alert-danger
{{agentEmailErrorMessage}}
.row.mt-3
.col
.form-group
%label
= Agent.human_attribute_name(:email)
= f.email_field :email, class: 'form-control', placeholder: '例:testuser@company.com'
.modal-footer
%button.btn.btn-light.my-3.px-4{"data-dismiss" => "modal"}= t('application.actions.cancel')
= f.submit t('application.actions.send'), class: 'btn btn-primary my-3 px-5'
= javascript_pack_tag 'agents/index'
javascript_pack_tagで呼び出されるVue.js側のコードが以下です。
new Vue({
el: ".agent-creator-modal",
data: {
agentEmailErrorMessage: '',
},
methods: {
createAgentToSuccess: function() {
location.href = "/agents"
},
createAgentToFailed: function(event) {
this.agentEmailErrorMessage = event.detail[0]['errors'][0]
}
}
});
上記のようなコードは割とシンプルな動きをつける分には問題ないのですが、
今回対応しようとしているメッセンジャーのような機能に対しては向いてないようでした。
現に上記のような形でメッセンジャー部分をRailsのview templateで頑張って実装したのですが、
ブラウザリロードがどうしても発生してしまうため、この方法では実現したいことができないと判断しました。
3つのセクション(Topic List, Message List, User Summary)間でイベントによる連携ができるかどうか
前述したように、Railsのview templateメインに考えてしまうとUX向上が実現ができない考えに至りました。
では次に何をすべきかを考えた時にとりあえず3つのセクション(Topic List, Message List, User Summary)間でデータのやりとりは問題なくできるかという疑問でした。
これができないことにはブラウザリロードを防ぐことができないと考えたためです。
この疑問に対しては公式ドキュメントのPassing Data to Child Components with PropsとSending Messages to Parents with Eventsを読んで実際にコードで動かしてみて確認し問題ないことが分かりました。
この時点で親コンポーネントと子コンポーネントの定義が必要ということが分かり、Topic Listを親コンポーネントとして定義し、他のMessage List, User Summaryを子コンポーネントとして扱うことにしました。
将来的にスマホ対応することができる設計ができるかどうか
今回ブラウザリロードを防ぐだけが目的ではなく将来的に3つのセクションをスマホ対応させる必要があります。
GmailアプリなどのメールアプリでよくあるUIで、メールを選んで詳細に行って戻ったりが実現イメージに近いです。
なので、Topic List -> Message List -> User Summary と順に遷移していけるのが理想系になります。
この理想系が実現可能かどうかも重要なポイントだったので公式ドキュメントを読みながらコードを書いて検証を進めました。
この辺りでSingle File Componentsの存在を知りました。
Single File Componentsを動かしてみながら、これを利用して3つのセクション毎にコンポーネントを作成したら、綺麗に作れるんじゃないかという感じがしてきました。
また、Vue Router: Nested Routesのドキュメントを見る限り、コンポーネントになっていればうまくスマホの遷移にも対応できる感じがしていました。
ソースコードの見通しが悪くならないかどうか
hamlに直接Vue.jsのbindingなどを書いていくと正直見づらい感じがしていました。
今回メッセンジャーを改修するにあたり、大幅にVue.jsのコードが増えることが予想されたので、ソースコードの見通しが悪くなり
今後のメンテナンス性が落ちてしまうのではないかという懸念がありました。
しかし、この問題は前述しているSingle File Componentsが解決してくれました。
hamlとjsファイルが分かれているのはjQueryの時と似た状況でファイル間のスイッチがどうしても必要でした。
Single File Componentsは一見、前時代的な記述に見えましたが
実際templateとjsが同じファイル内にあるというのは実装する上でやりやすかったです。
URLとして特定のTopicを表現できるかどうか
管理ツールなのでブラウザリロードを防ぐ手法を取った場合に、特定のTopicで問題が発生した際に
URLとしてアクセスできないと問題の調査が困難になることが予想されました。
なので、Vue.jsメインになったとしてもRailsのルーティング同様/topics/:topic_id/messages
のような形でアクセスできることが必須でした。
この問題は前述でもありましたが、Vue Routerを利用することで解決できる目処がありました。
実装を進めていく中での気づき
一通り小さなプログラムコードで検証が終わり、プロダクトコードに組み込むイメージが大体できあがってきました。
この時点でVue.js側はSingle File ComponentsとしてTopic List, Message List, User Summaryの3つのコンポーネントを作成することにし、
Rails側はapiでデータを返し、Vue.jsはそれを受け取り表示を行う形にすることに決めました。
こうして文章に起こすとよくある当たり前の構成になりました、ただ既存のコード量が多いので足掻きたい気持ちがありこの思想までなかなかたどり着けなかったです。
ただ、既存のRails viewは全て捨てることになるのでこの時点で、「あのコード全部apiに寄せるのか・・・」という感情が湧き上がって来ました。
たぶん、チームメンバーの誰もが最終的にメッセンジャー部分は全てapi化することが必須だという空気がありましたが、今回の調査・検証でやり切ったら綺麗な世界ができる確信が持てたので気合い入れてapiへ実装し直していきました。
ここからは、実際に実装していく中で気づいたことなどを記述していきます。
RubyMineに導入するPlugin
- JetBrainsが開発しているVue.jsの公式プラグインはSingle File Componentsを扱う時にSyntax Highlightが効いて良かったです。
- Single File Componentsのtemplateはpugが利用できるのでpugのSyntax Highlightが効くプラグインも便利でした。
実際の開発の進め方について
今回はスマホ対応まであるのでレスポンシブなデザインの部分はデザイナーの稲井さんにやっていただきました。
元々スマホまで見越したBEMでの記述になっていたので基本的にはSingle File Componentsを利用してVue.js化をどんどん進めていく感じになりました。
まずは一番左のセクションにあるTopic ListをSingle File Components化し、それに伴いRails側で必要なapiを実装していきました。
Rails側でのapi実装について
まずは、apiからデータを受け取れるようにしないとVue.js側の実装はやりづらかったのでapiを作っていくことにしました。
apiはjsonapiの形式を他のプロジェクトで導入しており、クライアントサイドの実装がやりやすい印象を持っていたので今回もjsonapi形式でapiを作成していくことにしました。
これに合わせてVue.js側で利用できるjsonapiをデシリアライズするライブラリが必要になりました。 今回は以下のライブラリを利用することにしました。
SeyZ/jsonapi-serializer
このライブラリには複数のリレーションが紐づく場合にデシリアライズに失敗するバグがあり、更新が止まっている状態です。
有志の方が上記のバグを直しているのでそれを自分たちのGithubにforkしてそれをyarnから利用する形式にしました。
jsonapi自体の仕様自体はほとんど固まっているのでパフォーマンス向上などはあると思いますがデシリアライザーとして利用する分にはforkで十分と判断しました。
とりあえずサーバーサイド、クライアントサイドでjsonapi形式に則った形で連携できることがわかったので実装を進めていきます。
class Api::AgentsController < ApplicationController
before_action :set_agent, only: [:show]
def index
render json: current_agent.select_list
end
def show
authorize @agent
render json: @agent,
meta: { message_icon: helpers.image_set_tag('icons/global-sidebar-message', class: 'global-sidenav__item__btn__icon'),
calender_icon: helpers.image_set_tag('icons/global-sidebar-calender', class: 'global-sidenav__item__btn__icon'),
user_icon: helpers.image_set_tag('icons/global-sidebar-user', class: 'global-sidenav__item__btn__icon'),
setting_icon: helpers.image_set_tag('icons/global-sidebar-setting', class: 'global-sidenav__item__btn__icon')
}
end
private
def set_agent
@agent = Agent.without_soft_destroyed.find(params[:id])
end
end
実際の今回実装したapi controllerの例をあげます。
ここでのポイントがいくつかあるので書いていきます。
Vue.jsメインでの実装になるにあたって、Rails側に乗っている画像リソースに対してどのようにアクセスするかの問題がありました。
これはjsonapi形式におけるmetaを利用して解決しています。
controllerからhelpersでヘルパーメソッドが呼び出せるのでimage_set_tagでimage tagとなったコンテンツをVue.js側でv-htmlを利用して扱うといい感じでした。
Vue.js側のtemplateで以下のように呼び出します。
div(v-html="meta.calender_icon")
この方法があるので複雑なhtmlをhelperで生成していたものはhelpersで加工済みのhtmlをそのままapiで返却してVue.js側のtemplateでv-htmlディレクティブを利用した実装が楽にできました。
また、今回の管理ツールではRails側でdecoratorを利用していたので
jsonapiのserializerでdecoratorを呼び出す手法を取ると再実装コストを大幅に抑えられました。
具体的なコードで示すと以下になります。
decoratorは既存のRailsのviewでも利用できるので再利用性がかなり高まった気がします。
class AgentDecorator < ApplicationDecorator
delegate_all
def phone_number
object.phone_number.presence || I18n.t("application.not_defined")
end
end
serializerではobject.decorate
で任意のdecoratorが呼び出せるので、その性質を利用しています。
class AgentSerializer < ActiveModel::Serializer
attributes :phone_number
def phone_number
object.decorate.phone_number
end
end
上記のような感じで一通り方針が固まったので、あとはひたすらapiを作成していきました。
Vue.js側のSingle File Componentsの実装について
apiが完成したのでVue.js側の実装を次に進めていきます。
以下がエントリーポイント的な部分のindex.jsになります。
router周りは、みやっち(宮永)に手伝ってもらったのでそのうちきっといい解説ブログが書かれることでしょう。
素直にこのpathの時はこのコンポーネントを利用すると書かれているので見やすいですね。
import GlobalNavbar from '../components/GlobalNavbar'
import TopicList from '../components/TopicList'
import MessageList from '../components/MessageList'
import UserSummary from '../components/UserSummary'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
new Vue({
router: new VueRouter({
mode: 'history',
routes: [
{
path: '/topics',
component: TopicList,
name: 'topics',
props: true,
children: [
{
path: ':topic_id/messages',
component: MessageList,
name: 'messages',
children: [
{
path: 'user_summary',
component: UserSummary,
name: 'user_summary'
}
]
}
]
}
],
}),
components: {GlobalNavbar}
}).$mount('#topics-index')
Vue Routerを利用する場合はRails側のroutesをVue Routerのrootに当たる部分に向けないといけないので注意が必要です。
今回の例だと/topics
がVue Routerのrootに当たるのでRails側でtopics#index
に向かうように変更を加えています。
resources :topics do
get 'messages/user_summary', to: 'topics#index'
get 'messages', to: 'topics#index'
end
index.jsで$mount('#topics-index')
としているので実際のindex.haml
では以下のように%global-navbar
とするとコンポーネントを呼び出すことができます。
.l-wapper.h-100#topics-index
%global-navbar
= javascript_pack_tag 'topics/index'
非常にシンプルになりました。
GlobalNavbar.vueの中身を見ていきます。
実際のコードを載せると長くなるので必要な部分のみ抜き取っています。
<template lang="pug">
.col.h-100
topic-list(:isSmartPhone="isSmartPhone")
</template>
<script>
import TopicList from "./TopicList";
export default {
components: {TopicList},
data() {
return {
agent: Object,
meta: Object,
isTopicsPath: false,
isSmartPhone: false
}
}
}
</script>
GlobalNavbarの中で更にTopicListを呼び出しています。
このような形でMessageList, UserSummaryも呼び出していく形を取っています。
TopicListは以下のようになっており、MessageListとUserSummaryに対して親コンポーネントになるようになっています。
子コンポーネントであるmessage-listとuser-summaryに対しては:isTopicLoading
のような形で値を渡せます。
子コンポーネント側はさらにwatchで監視を行い親コンポーネントからの変化を受け取るという感じになります。
また、@createMessageEvent=receiveMessageEvent
という形で子コンポーネントから発火されたイベントを親コンポーネントであるTopicListで受け取ることができます。
<template lang="pug">
message-list(
:isTopicLoading="isTopicLoading"
:isSmartPhone="isSmartPhone"
@createMessageEvent="receiveMessageEvent"
)
user-summary(
:isTopicLoading="isTopicLoading"
:isSmartPhone="isSmartPhone"
@selectedAgentEvent="receiveSelectedAgentEvent"
)
</template>
具体的に子コンポーネントでの親コンポーネントの値の受け取り方と子コンポーネントから親コンポーネントへのイベント通知についてみていきます。
<script>
import axios from "axios"
import BlankSlate from './BlankSlate'
import MessageItemNairan from './MessageItemNairan'
import MessageItemAttachment from './MessageItemAttachment'
import MessageItemText from './MessageItemText'
const csrfToken = document.querySelector("meta[name=csrf-token]").content
axios.defaults.headers.common['X-CSRF-Token'] = csrfToken
var JSONAPIDeserializer = require('jsonapi-serializer').Deserializer
export default {
components: {
BlankSlate,
MessageItemNairan,
MessageItemAttachment,
MessageItemText
},
data() {
return {
messages: [],
nairans: [],
nairanBlankSlateIcon: '',
attachments: [],
attachmentBlankSlateIcon: '',
body: '',
page: 1,
isLoading: false,
isLastMessageLoaded: false,
isRequiredReply: false,
errorMessage: ''
}
},
props: {
isTopicLoading: {
type: Boolean,
default() {
return false
}
}
},
methods: {
createNewTextMessage() {
var vm = this
axios
.post(`/api/topics/${this.$route.params.topic_id}/messages`, {
headers: {
'Content-type': 'application/json',
},
topic_id: this.$route.params.topic_id,
body: this.body,
})
.then(response => {
this.initializeMessage()
new JSONAPIDeserializer({keyForAttribute: 'snake_case'}).deserialize(response.data, function (err, message) {
vm.$emit('createMessageEvent', message)
vm.body = ''
})
})
.catch(function (error) {
vm.errorMessage = error.response.data.errors
})
},
},
watch: {
isTopicLoading: function (isTopicLoading) {
if (isTopicLoading) {
// 処理を書きます
}
}
}
}
</script>
親コンポーネントからの値の受け取りはprops
を利用しています。
同時にwatchを利用して監視を行い、値が変化したら子コンポーネントにも伝達されます。
逆に$emit('createMessageEvent', message)
で親コンポーネントに値を渡しています。
上記のケースだとメッセージ送信時にTopicListに送信されたメッセージを通知して、最新のメッセージが表示されるようになっています。
Bootstrap-Vueの適用
今回の管理画面ではBootstrap4を利用しており、一部動きをつけるためにjQueryを入れていましたが
Bootstrap-Vueというライブラリを利用するとVue.jsとBootstrapの連携が可能になります。
自分たちのユースケースだとModalsやTooltipsを利用しています。
スマホ対応の方針決め
スマホ対応ですが、PCとスマホのどちらにも対応するためにroutingを適切に行う必要がありました。
色々考えて最終的に以下のようになりました。
- PC
/topics/:topic_id/messages/user_summary
でアクセスする- 上記のroutingの場合は3つのコンポーネントを全表示するようにする
- スマホ
/topics/
,/topics/:topic_id/messages/
,/topics/:topic_id/messages/user_summary
で3つのコンポーネントそれぞれに対してアクセスが可能
最終的な調整はみやっちがやってくれましたが、実現したいSPAの形に収めることができ素晴らしかったです。
最後に
長々と書きましたが、今回のメッセンジャー改修についての知見をまとめてみました。
こうして振り返ると色々考えながら実装を進める必要があり、自分自身初めてのSPA実装となったので調査、検証の連続でなかなか面白い体験でした。
実際完成したものを触ってみると非常に使い勝手があがっており、利用者からも良いフィードバックを得られているので、やってよかったという思いです。
とはいえ、まだまだ改善の余地はあるので今後も更に精進していきたいです。
今話題のReTech!業界を変えるカウルを支えるエンジニアをWanted!
今話題の不動産テック!スタンダードを創るUI/UXデザイナーをWanted