はじめに
Railsで作っているブログシステムに、記事に他ユーザーがコメントを残せるような機能をつけるために色々と試行錯誤した話です。
このシステムでは、jQueryは一切使わずに、今までjQueryでできていたことをVue.jsに置き換えるような形で作りたく、このため今回の要望は本格的にVue.jsを勉強できるいい機会でです。
そこでこの記事では、Vue.jsの概念から理解して実装を開始した旨のレポートを執筆しようと思います。と言いつつ、まだそんなに深く理解できた訳ではないのでとりあえずエビデンスを残しつつ書いていきます。
要望
- 記事の詳細ページでログインユーザーがコメントを残せるようなフォームを実装
- さらに、残したコメントを作成順に表示できるようにリスト表示する
今回も要望を細分化してひとまず上の二つを実現できるような機能をVue.js, axios, Railsで実現していきたいと思います。
ひとまずRails側でAPI機能実装の準備
class CreateComments < ActiveRecord::Migration[5.2]
def change
create_table :comments do |t|
t.text :body, null: false
t.string :username, null: false
t.references :article, foreign_key: true
t.timestamps
end
end
# frozen_string_literal: true
class Comment < ApplicationRecord
belongs_to :article
end
さて、Vue.jsにせよjQueryにせよフォームに入力されたデータをパラメータとして渡して動的に処理するような形を取る方法としては、Railsシステム側はAPIサーバとして受け取ったデータからDBにデータを保存し結果をjsonのようなデータ形式で渡すような形を取らなければいけません。
そのため、Rails側でAPI機能を実装するために以下のようなコントローラを作りました。
bundle exec rails g controller api/v1/comments
(中略)
namespace :api, format: 'json' do
namespace :v1 do
resources :comments, except: %i[update]
end
end
# frozen_string_literal: true
module Api
module V1
class CommentsController < ApplicationController
def index
@article = Article.find(params[:article_id])
@comments = @article.comments.order('created_at DESC')
end
def create
@comment = Comment.new(comment_params)
if @comment.save
render :index, status: :created
else
render json: @comment.errors, status: :unprocessable_entity
end
end
private
def comment_params
params.require(:comment).permit(:body, :username, :article_id)
end
end
end
end
基本的にはコメント一覧(ただし、該当記事に紐づいているコメントのみリストとして表示)とコメント作成機能さえあれば、良いのでindexとcreateメソッドのみの実装にしました。
作成日時の降順にしたいため、取得したコメントはorderでソートするのを忘れずに!
# frozen_string_literal: true
json.set! :comments do
json.array! @comments do |comment|
json.extract! comment, :id, :body, :username, :article_id, :created_at, :updated_at
end
end
indexのjsonレスポンスは上のようにしました。
コメントを作成する機能を実装
https://jp.vuejs.org/v2/guide/events.html
https://jp.vuejs.org/v2/guide/forms.html
https://jp.vuejs.org/v2/cookbook/using-axios-to-consume-apis.html
上の参考文献より、axiosでRailsで定義したAPIリクエスト(POST)を投げる方法を作りました。
- if user_signed_in?
form.form-inline#comment
.row
.form-group.col-sm-3
span.mdi.mdi-person.user-icon
.form-group.col-sm-6
input type='textarea' name='comment[body]' v-model='body'
input type='hidden' name='comment[username]' value="#{current_user.username}" v-model='username'
input type='hidden' name='comment[article_id]' value="#{@article.id}" v-model='article_id'
.col-sm-3
button class='btn btn-primary' @click='comment_create'
= 'コメントを送信'
javascript:
= javascript_pack_tag 'comment_create'
slimファイルでは、コメントはテキストのみデータ入力でvueにbindしたいため、どの記事に対するコメントなのか(article_id)、及びそのコメントは誰が投稿したものなのか(user_id)のデータはhiddenで持たせるようにしました。また、Vue側でデータを参照するために各フォームは v-model
でバインディングするようにしました。
更に、本来はv-on:click
という記述でボタンがクリックされたイベントをキャッチしVue.jsを実行するハンドラを作るのですが、 slim
との組み合わせの記述を考慮して、 v-onを省略できる記述方法として @click
を採用しました。
最後に、APIサーバーへアクセスするために、 axios
を利用してPOSTリクエストを投げられるようにしました。
import Vue from 'vue'
import axios from 'axios'
axios.defaults.headers.common = {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN' : document.querySelector('meta[name="csrf-token"]').getAttribute('content')
};
new Vue({
el: '#comment',
data: {
body: '',
username: document.querySelector("[v-model='username']").value,
article_id: document.querySelector("[v-model='article_id']").value
},
methods: {
comment_create: function() {
axios
.post('http://0.0.0.0:3000/api/v1/comments.json', {
comment: {
body: this.body,
username: this.username,
article_id: this.article_id
}
})
.then(response => (this.info = response))
}
}
})
Vue側では、data
プロパティで定義したCommentモデルへカラムを挿入するためのデータを取得するためにが押された場合に各データをparamsでPOSTリクエスト時に渡せるようにしました。
また、hiddenで定義したデータは定数として扱いたいため、dataとして定義するだけにします。
data : {
article_id:,
username:
}
本当は上のようにデータプロパティの値を定義するだけにしたかったのですが、こういう記述はできないため、querySelector
によって、hiddenフィールドの値を初期値として返すようにしました。
data: {
body: '',
username: document.querySelector("[v-model='username']").value,
article_id: document.querySelector("[v-model='article_id']").value
},
これだけでは、うまくいきません。何故ならCSRFトークンを発行していないためです。
この例では、上記のaxiosからapiを叩く挙動ですがAPI側からすれば、セキュリティ上変なデータが入ってこないようにcurl
と言った、開発者の身に覚えのない他のHTTPクライアントから無造作にPOSTリクエストを叩いて欲しくないわけです。
- CSRFトークンを発行せずにPOSTを叩いた場合のログ
Started POST "/api/v1/comments.json" for 172.20.0.1 at 2018-07-16 10:40:48 +0000
Processing by Api::V1::CommentsController#create as JSON
Can't verify CSRF token authenticity.
Completed 422 Unprocessable Entity in 12ms (ActiveRecord: 0.0ms)
ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken):
そのため、このコードの中で有効なCSRFトークンを発行するように修正しました。
import Vue from 'vue'
import axios from 'axios'
axios.defaults.headers.common = {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN' : document.querySelector('meta[name="csrf-token"]').getAttribute('content')
};
コメント一覧の取得
https://jp.vuejs.org/v2/cookbook/using-axios-to-consume-apis.html
https://jp.vuejs.org/v2/guide/list.html
コメント投稿と同じく、axios
を利用して、GETリクエストを投げられるようにします。
button class='btn btn-primary' @click='comment_create'
= 'コメントを送信'
ul#comments_index
li v-for="comment in comments"
.col-sm-3
span.mdi.mdi-person.user-icon
== '{{ comment.username }}'
.col-sm-6
== '{{ comment.body }}'
javascript:
= javascript_pack_tag 'comments_index'
= javascript_pack_tag 'comment_create'
v-for
というリストレンダリングでGETリクエストで返ってきた結果を格納した配列をループで回し、各コメントの値を表示するようにしています。
この項目に関しても、slimとvueレンダリングの記述方法が正しく表示されるように、
== '{{comment.username}'
でエスケープするようにしています。
import Vue from 'vue'
import axios from 'axios'
new Vue({
el: '#comments_index',
data: {
comments: [],
article_id: document.querySelector("[v-model='article_id']").value
},
methods: {
get_comments: function() {
axios
.get('https://www.medical-talk.net/api/v1/comments.json', {
params: {
article_id: this.article_id
}
})
.then(response => {
for(var i = 0; i < response.data.comments.length; i++) {
this.comments.push(response.data.comments[i])
}
})
}
},
mounted: function() {
this.get_comments()
}
})
今回のVue.jsではコメント一覧を取得するGetリクエストを叩き、レスポンスデータをpush形式でデータを格納するようにしています。
基本的な配列の格納方法やGETリクエストは他の言語と変わらず。
さて、このGETリクエストを叩くメソッドをいつ実行するか、そのタイミングを決めなければいけません。
Vue.jsでは new Vue
と定義した際に実行可能なVueインスタンス
が一つ作成されます。
https://jp.vuejs.org/v2/guide/instance.html
このインスタンスは以下のURLのライフサイクルに沿って作成・DOMの更新を行います。
今回の事例では、インスタンス作成後・HTMLテンプレートのコンパイル完了後、つまり mounted
のステータスになった時点でGETリクエストを叩きたいので、以下のコードを追記しました。
mounted: function() {
this.get_comments()
}
結果
記事詳細のページに飛ぶとコメント一覧が取得できるようになりました。
レイアウトは後日修正します、、、
まとめ
基本はVue.jsの公式ドキュメントを読めば時間はかかりましたが、それほど難しくはありませんでした。
どちらかと言うとslimテンプレートとvueの組み合わせに難航した感覚です。
slimとvueの組み合わせた記述に関してはissueレベルでしか、エビデンスが無さそうですが今後はWikiページなどで残されていくような気がします。それまで待ちましょう...
その他の参考文献
1 CSRFトークンの解決方法
2 Rails側でAPI機能の実装やjson形式のデータをレスポンスとして返す方法
3 v-modelの要素や値を返す方法
4 slimテンプレート文でvueのdataプロパティの結果を表示する方法