Rails
vue.js

[Rails4/5] Vue.jsでシングルページアプリケーションの構築を目指す(登録、一覧表示の実装編)

More than 1 year has passed since last update.

前提

普段はRuby on Railsを使いつつ、フロントエンド強化のため、Vue.jsを学習中。
Railsで実装したアプリケーションをVue.jsを使って作り変えるをお題に、シンプルなサンプルを作成します。
Vue.jsを使って極力ページ遷移しないアプリケーションを作っていきます。

やること

  • 簡易的なブログ機能を想定したアプリケーションをイメージしてRailsのScaffodコマンドでベースになる部分を作成。

  • 登録と一覧表示処理について画面遷移しない形になるようにVue.jsを利用してその機能を実現。

Vue.js初心者向けの内容になります。

サンプルアプリはこちら

vue-js-articles

下準備

Railsで土台を作成する

Scaffoldコマンドでブログ記事管理用(Article)のModel, View, Controllerを作成。

Modelにはタイトルと本文用のカラムを作成。

$ rails g scaffold article title body:text

データベース作成、migration実行

$ bin/rails db:create
$ bin/rails db:migrate

おなじみの記事のCRUDを行うアプリが完成。
スクリーンショット 2017-08-18 14.01.18.png

RailsでできたこのアプリをVue.jsを使う形に編集していく〜!

Vue.jsのインストール

Vue.js公式サイトからソースファイルをダウンロードして使用する。開発バージョンを選択。

app/assets/javascriptsに配置し、application.jsで読み込む。

app/assets/javascripts/application.js
//= require jquery
//= require jquery_ujs
//= require turbolinks
+ //= require vue
//= require_tree .

ブラウザでVue.jsが読み込まれていることを確認

Chromeの場合、右クリック 検証 > ConsoleタブでVue.jsが読み込まれているメッセージが表示されていればOK。
スクリーンショット_2017-08-18_15_06_05.png

記事の新規登録の実装

下記のステップで進める。

  • 新規作成フォームを一覧ページに表示する
  • 記事を登録する

1. 新規作成フォームを表示する

Railsのform_forを使う形ではなく、HTMLのタグを使ってフォームを描画。
Railsでは最低限のことを行い、Javascriptで実装する!

実装イメージ

New Article ボタンを押下することで、フォームの表示/非表示が切り替えれるようにする。

github.gif

一覧表示用のView, JavaScriptファイルを編集

app/views/articles/index.html.erb
<p id="notice"><%= notice %></p>

<h1>Articles</h1>

<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Body</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @articles.each do |article| %>
      <tr>
        <td><%= article.title %></td>
        <td><%= article.body %></td>
        <td><%= link_to 'Show', article %></td>
        <td><%= link_to 'Edit', edit_article_path(article) %></td>
        <td><%= link_to 'Destroy', article, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

- <%= link_to 'New Article', new_article_path %>

+ <div id="article-form">
  + <button @click="show=!show" class="btn btn-info">New Article</button>
  + <div v-show="show">
    + <div>
      + <label for="title">Title</label>
      + <input type="text" />
    + </div>
    + <div>
      + <label for="body">Body</label>
      + <textarea></textarea>
    + </div>
    + <div>
      + <button class="btn btn-primary">Submit</button>
    + </div>
  + </div>
+ </div>

今回CoffeeScriptでなく通常のJavaScriptで記述したいので、.jsファイル作成。

$ touch app/assets/javascripts/articles.js
app/assets/javascripts/articles.js
window.onload = function() {
  var vm = new Vue({
    el: '#article-form',
    data: {
      show: false
    }
  });
};

実装ポイント

  • Vueインスタンス作成
    View側でbuttonとformの上位のdivにidセレクター(article-form)を設定し、JS側でVueインスタンスnew Vueを作成。

  • 新規登録用のフォーム設置
    View側で新規作成用のフォーム要素を設置。HTMLのタグ(今回はinput, textarea)を使って追加。

  • フォームにv-showディレクティブを設定
    View側のフォーム要素の上位divv-show="show"を設定。これにより、JS側で与えているdataオブジェクトのshowの値がtrueの場合のみdivを表示する。
    v-showについて

  • ボタンにイベントハンドラを設定
    Vuew側のNew Articleリンクを削除し、buttonタグを設置
    buttonタグにイベントハンドラとしてv-on:clickを設定(@clickは省略記法)
    @clickshowの値がtrue/falseと切り変わるように記述。
    JS側でshowのデフォルト値にfalseに設定しているので通常時は表示されない。
    v-on:clickについて

2. 記事を登録する

フォームの入力値をJS側で受け取る

View側のtitle, bodyの入力欄にv-modelを設定。v-modelについて。
Submitボタンにメソッドを実行するイベントハンドラ@clickを設定。

app/views/articles/index.html.erb
(中略)
<div>
  <label for="title">Title</label>
  <input type="text" v-model="title" />
</div>
<div>
  <label for="body">Body</label>
  <textarea v-model="body"></textarea>
</div>
<div>
  <button @click="create" class="btn btn-primary">Submit</button>
</div>

JS側でフォームの入力値が受け取れるか取得してみる。
title, bodyをdataオブジェクトに追加。
methodsを定義し、メソッド内で受け取ったものをコンソールに表示してみる。

app/assets/javascripts/articles.js
window.onload = function() {
  var vm = new Vue({
    el: '#article-form',
    data: {
      show: false,
      title: '',
      body: ''
    },
    methods: {
      create: function() {
        console.log(this.title); //入力したタイトルが表示
        console.log(this.body);  //入力した内容が表示
      }
    }
  });
};

Ajaxを使ってRails側へ通信を行う。(データベースへの保存はまだ行わない)

jQueryのおなじみの呼び出し方を使ってAjaxを行い、記事の登録処理を行いたいので、ArticleのcreateアクションをPOSTで呼び出す。
また、フォームから入力された値をパラメータとして送る。

ブラウザで通信中/完了のステータスが分かるように下記の実装も併せて行う。

  • 通信中は「通信中です」というメッセージを表示させる
  • 通信中はSubmitボタンを押下できないようにする

これらはdataオブジェクトにプロパティmessage, loadingを追加し実装する。

app/assets/javascripts/articles.js
window.onload = function() {
  var vm = new Vue({
    el: '#article-form',
    data: {
      show: false,
      loading: false,
      message: '送信中です',
      title: '',
      body: ''
    },
    methods: {
      create: function() {
        this.loading = !this.loading;
        var that = this,
            hostname = window.location.hostname,
            protocol = window.location.protocol,
            port =  window.location.port,
            baseURL = [protocol,'//', hostname,':', port, '/articles'].join('');

            var params = {
                url: baseURL,
                method: 'POST',
                data: { title: that.title, body: that.body }
            }
            $.ajax(params).done(function(response){
              that.message = '送信完了しました';
              that.show = false;
            });
      }
    }
  });
};

Controller側を修正し、saveは実行せずJSONデータを返すだけの処理に変更。(こう書くことで上記のJS側の $.ajax.done以下が実行可能になる。)

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  (他省略)
  def create
    # 既存の処理は一旦コメントアウトする。
    render json: { success: true }
  end
app/views/articles/index.html.erb
(中略)
<div id="article-form">
  <button @click="show=!show" class="btn btn-info">New Article</button>
  <div v-show="show">
    <div>
      <label for="title">Title</label>
      <input type="text" v-model="title" />
    </div>
    <div>
      <label for="body">Body</label>
      <textarea v-model="body"></textarea>
    </div>
    <div>
      <button @click="create" :class="{ disabled: loading }" class="btn btn-primary">Submit</button>
    </div>
  </div>
  <p v-show="loading">{{ message }}</p>
</div>

実行する際はブラウザのコンソールでエラーがでていないことを確認。
また、Controller側のcreateアクションが呼ばれていることを確認するためbinding.pryを使ってみたりする。

ここまででブラウザでは下記の挙動になる。

github.gif

データベースへの登録を行う

Controllerを修正し、保存を実行。

パラメータの参照部分を修正し、save実行。
save後はページ遷移したくないので、一旦下記の書き方にしておく。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  (他省略)
  def create
    @article = Article.new(title: params[:title], body: params[:body])
    if @article.save
      render json: { success: true }
    else
      render :new
    end
  end

これでデータベースへの登録が実装できた!

ただ、現時点では登録後の記事一覧はページを更新しないと内容が更新されないので、その点については以降のステップで行う。

また、このままでは登録後にNewArticleボタンを押すとフォームに値が残ってしまっているので、最後に空文字を代入しておく。

app/assets/javascripts/articles.js
(中略)
    methods: {
      create: function() {
        this.loading = !this.loading;
        var that = this,
            hostname = window.location.hostname,
            protocol = window.location.protocol,
            port =  window.location.port,
            baseURL = [protocol,'//', hostname,':', port, '/articles'].join('');

            var params = {
                url: baseURL,
                method: 'POST',
                data: { title: that.title, body: that.body }
            }
            $.ajax(params).done(function(response){
              that.message = '送信完了しました';
              that.show = false;
              that.title = ''; //title, bodyへ空文字代入
              that.body = '';
            });
      }

記事の一覧表示の実装

下記のステップで進める。

  • 記事一覧を表示
  • 登録後に記事一覧を更新

3. 記事一覧を表示する

Railsのインスタンス変数articlesを表示している一覧部分をVue.jsで実装する。
実装の流れは下記のイメージ

  • Rails側では記事一覧をJSONデータで返すAPIを作成する。
  • JS側では上記のエンドポイントへ通信し、一覧データを取得。
  • View側ではJSで作成されたデータを描画する。

Railsで記事一覧をJSONデータで返すAPIを作成する

返ってくるJSONデータのイメージはこのような感じ。
articlesという配列に各レコードのオブジェクトが入っている。IDも取得する。

{
  "articles":[
    {"id":1,"title":"red","body":"apple"},
    {"id":2,"title":"blue","body":"sky"},
    {"id":3,"title":"yellow","body":"banana"}
  ]
}

Railsのjbuilderを利用し、上記のデータを返すJSONファイルを作成

app/views/articles/index.json.jbuilder
json.articles do
  json.array! @articles do |article|
    json.id article[:id]
    json.title article[:title]
    json.body article[:body]
  end
end

これで、下記のエンドポイントにアクセスするとイメージ通りのJSONデータが返却されている。ブラウザで確認してみる。
スクリーンショット 2017-08-22 11.50.18.png

JavaScriptで記事一覧データを取得する

Vue.jsの beforeMountというライフサイクルフックを設定し、そのタイミングで記事一覧のJSONデータへ通信を行う。

app/assets/javascripts/articles.js
window.onload = function() {
  var vm = new Vue({
    el: '#article-form',
    data: {
      show: false,
      loading: false,
      message: '送信中です',
      title: '',
      body: ''
    },
    beforeMount: function () {
      var that = this,
          hostname = window.location.hostname,
          protocol = window.location.protocol,
          port =  window.location.port,
          baseURL = [protocol,'//', hostname,':', port, '/articles.json'].join('');

          var params = {
              url: baseURL,
              method: 'GET'
          }
          $.ajax(params).done(function(response){
            console.log(response);
          });
    },
    methods: {
      create: function () {
       以下省略
      }
    }
  });
};

Ajax後、取得した結果(response変数)をコンソールに表示してみて中身を確認。
配列articlesに記事一覧のデータが入っていることが確認できる!
スクリーンショット_2017-08-22_11_01_15.png

View側でJSで取得した記事一覧データを表示する

  • tableタグにIDセレクターを設定。(Vueのインスタンス生成時に必要)
  • thタグにIDを追加
  • tbodyタグ以下のデータ表示部分を書き換える。Vue.jsのv-forディレクティブを使用。
app/views/articles/index.html.erb
<p id="notice"><%= notice %></p>

<h1>Articles</h1>

<table id="articles-list">
  <thead>
    <tr>
      <th>ID</th>
      <th>Title</th>
      <th>Body</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <tr v-for="article in articles">
      <td>{{ article.id }}</td>
      <td>{{ article.title }}</td>
      <td>{{ article.body }}</td>
    </tr>
  </tbody>
</table>

<br>

<div id="article-form">
  以下省略
</div>

Viewに合わせてJS側も修正する。

  • Vueインスタンスを記事一覧表示用と、登録用に分割して処理を書く (対象としているView側のIDセレクターが異なるので)
  • articles変数を配列として設定(articles: [])
app/assets/javascripts/articles.js
window.onload = function() {
  var articlesListViewModel = new Vue({
    el: '#articles-list',
    data: {
      articles: []
    },

    beforeMount: function () {
      var that = this,
          hostname = window.location.hostname,
          protocol = window.location.protocol,
          port =  window.location.port,
          baseURL = [protocol,'//', hostname,':', port, '/articles.json'].join('');

          var params = {
              url: baseURL,
              method: 'GET'
          };
          $.ajax(params).done(function(response){
            that.articles = response.articles;
          });
    }
  });

  var articleViewModel = new Vue({
    el: '#article-form',
    data: {
      show: false,
      loading: false,
      message: '送信中です',
      title: '',
      body: ''
    },
    methods: {
      create: function () {
        以下省略
      }
    }
  });
};

この状態でブラウザを更新すると、記事一覧の部分がJavascriptを使って実装できたことを確認できる。IDが表示された!
既存の登録処理も変わらずできることを確認。
スクリーンショット 2017-08-22 11.45.44.png
登録データはページを更新しないと現時点では一覧に反映されない。次ステップで実装する。

4. 登録後に記事一覧を更新する

方法自体は、JS側のcreateメソッドのAjax後にarticlesプロパティ(配列)へデータを追加するイメージ。
(下記のデータはサンプル)

$.ajax(params).done(function(response){
  that.articles.push({ id: 100, title: 'test_title', body: 
 'test_body' });
});

これを実装するにあたり、View側で記事一覧と登録フォームの上位にdivを設定し、そのIDセレクターに対してVueインスタンスを作成し、記述する必要がある。

<div id="articles-view">を設置し、一覧のtableタグ、登録フォームのdivタグのidの設定は不要になるので削除。

app/views/articles/index.html.erb
<div id="articles-view" class="container">
  <p id="notice"><%= notice %></p>
  <h1>Articles</h1>

  <table>
    <thead>
      <tr>
        <th>ID</th>
        <th>Title</th>
        <th>Body</th>
        <th colspan="3"></th>
      </tr>
    </thead>

    <tbody>
      <tr v-for="article in articles">
        <td>{{ article.id }}</td>
        <td>{{ article.title }}</td>
        <td>{{ article.body }}</td>
      </tr>
    </tbody>
  </table>

  <br>

  <div>
    <button @click="show=!show" class="btn btn-info">New Article</button>
    <div v-show="show">
      <div>
        <label for="title">Title</label>
        <input type="text" v-model="title" />
      </div>
      <div>
        <label for="body">Body</label>
        <textarea v-model="body"></textarea>
      </div>
      <div>
        <button @click="create" :class="{ disabled: loading }" class="btn btn-primary">Submit</button>
      </div>
    </div>
    <p v-show="loading">{{ message }}</p>
  </div>
</div>

Controllerを修正し、登録完了後に登録したデータを返すように設定。

app/controllers/articles_controller.rb
(中略)
  def create
    @article = Article.new(title: params[:title], body: params[:body])

    if @article.save
      render json: @article
    else
      render :new
    end
  end

統合したVueインスタンスを作成し、処理をまとめる。
登録完了後に記事一覧の配列へ登録データをpushする

app/assets/javascripts/articles.js
window.onload = function() {
  var articlesListViewModel = new Vue({
    el: '#articles-view',
    data: {
      articles: [],
      show: false,
      loading: false,
      message: '送信中です',
      title: '',
      body: ''
    },
    beforeMount: function () {
      var that = this,
          hostname = window.location.hostname,
          protocol = window.location.protocol,
          port =  window.location.port,
          baseURL = [protocol,'//', hostname,':', port, '/articles.json'].join('');

          var params = {
              url: baseURL,
              method: 'GET'
          };
          $.ajax(params).done(function(response){
            that.articles = response.articles;
          });
    },
    methods: {
      create: function () {
        this.loading = !this.loading;
        var that = this,
            hostname = window.location.hostname,
            protocol = window.location.protocol,
            port =  window.location.port,
            baseURL = [protocol,'//', hostname,':', port, '/articles'].join('');

            var params = {
                url: baseURL,
                method: 'POST',
                data: { title: that.title, body: that.body }
            };
            $.ajax(params).done(function(response){
              that.articles.push({ id: response.id, title: 
response.title, body: response.body });
              that.message = '送信完了しました';
              that.show = false;
              that.title = '';
              that.body = '';
            });
      }
    }
  });
};

これで登録後、データが一覧に反映されることを確認!

github.gif

その他の実装

通信部分を別ファイルへ切り出す

実装自体は完成しているが、通信部分が冗長なので別ファイルへ切り出してすっきりさせる。

$ mkdir app/assets/javascripts/models
$ touch app/assets/javascripts/models/articleModel.js
app/assets/javascripts/models/articleModel.js
var ArticleModel = (function () {
  function ArticleModel() {
    var hostname = window.location.hostname,
        protocol = window.location.protocol,
        port = window.location.port;
    this.baseURL = [protocol, '//', hostname, ':', port].join('');
  }

  ArticleModel.prototype = {
    index : function () {
      var params = {
          url: this.baseURL + '/articles.json',
          type: "GET",
          action: "index"
      };
      return this._request(params);
    },
    create : function (data) {
      var params = {
          url: this.baseURL + '/articles',
          type: "POST",
          data: { title: data['title'], body: data['body'] },
          action: "create"
      };
      return this._request(params);
    },
    _request : function (params) {
      this.deferred = $.ajax(params);
    }
  };
  return ArticleModel;
}());
app/assets/javascripts/articles.js
window.onload = function() {
  var articleModel = new ArticleModel();
  var articlesListViewModel = new Vue({
    el: '#articles-view',
    data: {
      articles: [],
      show: false,
      loading: false,
      message: '送信中です',
      title: '',
      body: ''
    },
    beforeMount: function () {
      var that = this;
      articleModel.index();
      articleModel.deferred.done(function(response) {
        that.articles = response.articles;
      });
    },
    methods: {
      create: function () {
        this.loading = !this.loading;
        var that = this,
            data = { title: that.title, body: that.body };
        articleModel.create(data);
        articleModel.deferred.done(function(response) {
          that.articles.push({ id: response.id, title: response.title, body: response.body });
          that.message = '送信完了しました';
          that.show = false;
          that.title = '';
          that.body = '';
        });
      }
    }
  });
};

これで修正前と同様の動きができる確認する。

入力内容が正しく登録されているかチェックを追加する

app/assets/javascripts/articles.js
// createメソッド以外は省略
    methods: {
      create: function () {
        this.loading = !this.loading;
        var that = this,
            data = { title: that.title, body: that.body };
        articleModel.create(data);
        articleModel.deferred.done(function(response) {
          if (that.title === response.title && that.body === response.body){
            that.message = '登録しました';
            that.articles.push({ id: response.id, title: response.title, body: response.body });
          } else {
            that.message = '正しく登録できませんでした';
          }
          that.show = false;
          that.title = '';
          that.body = '';
        });
      }

これでメッセージの表示が正しく出来るか確認する。

Vue.jsのコードが直に表示されることを防ぐ

ページ読み込み時などView側でVue.jsのコードが直に表示されてしまうことがあるので、それを防ぐためにView側とcssにv-cloakを追加する

app/views/articles/index.html.erb
<div id="articles-view" v-cloak class="container">
(省略)
app/assets/stylesheets/scaffolds.scss
[v-cloak] {
  display: none;
}

まとめ

実際にやってみて、Railsではコマンド一つでできていたためか、Vue.jsでは各ステップがボリュームが大きく感じ、書き換えるのは難しかった。
フロントエンド特有の思考に切り替える必要があり、簡単そうな事でも実装方法が思いつかない事が多く悩んだ〜(。´・д・)

とはいえ、使っていく過程でだんだんVue.jsのお作法に慣れてきて、考え方はシンプルなので入って行きやすかった。
(きっとコンポーネントに手を出し時始めるとさらに複雑になるんだろうな。とかとか。)
引き続き学習を進めて慣れていこうと思います〜v(。・ω・。)
(記事の編集や削除も追って追加予定です!)