Vue.js, axios, Railsでブログシステムの記事詳細ページにコメント機能を実装

## はじめに

Railsで作っているブログシステムに、記事に他ユーザーがコメントを残せるような機能をつけるために色々と試行錯誤した話です。
このシステムでは、jQueryは一切使わずに、今までjQueryでできていたことをVue.jsに置き換えるような形で作りたく、このため今回の要望は本格的にVue.jsを勉強できるいい機会でです。
そこでこの記事では、Vue.jsの概念から理解して実装を開始した旨のレポートを執筆しようと思います。と言いつつ、まだそんなに深く理解できた訳ではないのでとりあえずエビデンスを残しつつ書いていきます。

## 要望

1. 記事の詳細ページでログインユーザーがコメントを残せるようなフォームを実装
2. さらに、残したコメントを作成順に表示できるようにリスト表示する

今回も要望を細分化してひとまず上の二つを実現できるような機能をVue.js, axios, Railsで実現していきたいと思います。

## ひとまずRails側でAPI機能実装の準備

```ruby:db/migrate/******_create_comments.rb
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
```

```ruby:app/model/comment.rb
# 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
```

```ruby:config/routes.rb
(中略)
  namespace :api, format: 'json' do
    namespace :v1 do
      resources :comments, except: %i[update]
    end
  end
```

```ruby:app/api/v1/comments_controller.rb
# 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でソートするのを忘れずに!

```ruby:app/views/api/v1/comments/index.json.jbuilder
# 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/events.html)
[https://jp.vuejs.org/v2/guide/forms.html](https://jp.vuejs.org/v2/guide/forms.html)
[https://jp.vuejs.org/v2/cookbook/using-axios-to-consume-apis.html](https://jp.vuejs.org/v2/cookbook/using-axios-to-consume-apis.html)

上の参考文献より、axiosでRailsで定義したAPIリクエスト(POST)を投げる方法を作りました。

```slim:app/views/articles/show.html.slim
  - 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リクエストを投げられるようにしました。

```js:frontend/packs/comment_create.js
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として定義するだけにします。

```js
data : {
  article_id:,
  username:
}
```
本当は上のようにデータプロパティの値を定義するだけにしたかったのですが、こういう記述はできないため、`querySelector`によって、hiddenフィールドの値を初期値として返すようにしました。

```js:
    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を叩いた場合のログ

```ruby
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トークンを発行するように修正しました。

```js:frontend/packs/comment_create.js
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/cookbook/using-axios-to-consume-apis.html)
[https://jp.vuejs.org/v2/guide/list.html](https://jp.vuejs.org/v2/guide/list.html)

コメント投稿と同じく、`axios`を利用して、GETリクエストを投げられるようにします。

```slim:app/views/articles/show.html.slim
          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}'`

でエスケープするようにしています。

```js:frontend/packs/comments_index.js
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](https://jp.vuejs.org/v2/guide/instance.html)

このインスタンスは以下のURLのライフサイクルに沿って作成・DOMの更新を行います。

[https://jp.vuejs.org/v2/guide/instance.html#%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B5%E3%82%A4%E3%82%AF%E3%83%AB%E3%83%80%E3%82%A4%E3%82%A2%E3%82%B0%E3%83%A9%E3%83%A0](https://jp.vuejs.org/v2/guide/instance.html#%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B5%E3%82%A4%E3%82%AF%E3%83%AB%E3%83%80%E3%82%A4%E3%82%A2%E3%82%B0%E3%83%A9%E3%83%A0)

今回の事例では、インスタンス作成後・HTMLテンプレートのコンパイル完了後、つまり `mounted` のステータスになった時点でGETリクエストを叩きたいので、以下のコードを追記しました。

```js
    mounted: function() {
        this.get_comments()
    }
```

## 結果

記事詳細のページに飛ぶとコメント一覧が取得できるようになりました。
レイアウトは後日修正します、、、

![42826133-a9baf996-8a1e-11e8-9907-43343c25aeda.png](https://qiita-image-store.s3.amazonaws.com/0/144991/e2d431a7-fcba-14cb-941a-18f1a45a6dbf.png)

## まとめ

基本はVue.jsの公式ドキュメントを読めば時間はかかりましたが、それほど難しくはありませんでした。
どちらかと言うとslimテンプレートとvueの組み合わせに難航した感覚です。
slimとvueの組み合わせた記述に関してはissueレベルでしか、エビデンスが無さそうですが今後はWikiページなどで残されていくような気がします。それまで待ちましょう...

## その他の参考文献

1 CSRFトークンの解決方法

* [https://laracasts.com/discuss/channels/laravel/how-can-i-use-csrf-token-with-axios-post-method](https://laracasts.com/discuss/channels/laravel/how-can-i-use-csrf-token-with-axios-post-method)

2 Rails側でAPI機能の実装やjson形式のデータをレスポンスとして返す方法

* [https://qiita.com/naoki85/items/51a8b0f2cbf949d08b11](https://qiita.com/naoki85/items/51a8b0f2cbf949d08b11)

3 v-modelの要素や値を返す方法

* [https://developer.mozilla.org/ja/docs/Web/API/Document/querySelector](https://developer.mozilla.org/ja/docs/Web/API/Document/querySelector)

4 slimテンプレート文でvueのdataプロパティの結果を表示する方法

* [https://github.com/slim-template/slim/issues/722](https://github.com/slim-template/slim/issues/722)