以前、Qiitaに書いた記事ですが、いくつか現行バージョンではうまくいかないという指摘があったため、やり直してみました。
各バージョンは、2018年3月24日時点の安定版で試しています。
簡単なTODOアプリです。
TODOの管理はRailsのAPIで実施します。
Ruby、Rails、Node.jsの環境をご用意ください。
下記の記事などが分かりやすいです。
また、Webpack、およびVue.jsはyarn
でインストールされるため、yarn
もインストールしておいてください。
yarnを使ってみた
本記事を書くにあたり、下記の環境で実施しました。
複数のJSファイルをひとまとめにするモジュールバンドラーのことです。
複数ファイルに分割して管理しつつ、サーバーへのリクエスト数を削減することができるようです。
最新版で学ぶwebpack 3入門 – JavaScript開発で人気のバンドルツール
まずはもろもろインストールをしていきます。
なお、こちらの記事が動画つきで非常に分かりやすいので、もし初めて実施される方はご覧になった方が良いかと思います。
【動画付き】Rails 5.1で作るVue.jsアプリケーション ~Herokuデプロイからシステムテストまで~
はじめから--webpack
オプションを使用してプロジェクトを作成します。
$ rails new todo_sample --webpack=vue
rails s
でRailsのウェルカムページが表示されれば大丈夫です。
基本的に、Railsで用意するビューファイルは1つのみで、そこを差し替えていきます。
まずは、以下のファイルを作成、編集します。
app/controllers/home_controller.rb
config/routes.rb
app/views/home/index.html.erb
class HomeController < ApplicationController def index end end
Rails.application.routes.draw do root to: 'home#index' end
<%= javascript_pack_tag 'hello_vue' %>
javascript_pack_tag
を使用することで、app/javascript/packs
以下にあるJSファイルを探してくれます。
インストール時にhello_vue.js
というファイルが生成されているので、これをindex
にて読み込ませます。
これでrails s
して、「Hello Vue!」と表示されれば大丈夫です。
Vue.js(というよりWebpack)関連のファイルは変更したらコンパイルする必要があります。
コンパイルには、bin/webpack
というコマンドを使用する必要があります。
ただ、毎回コンパイルするのは面倒なので、変更を検出して自動コンパイルするようにします。
こちらの記事でも紹介されております。
Introducing Webpacker
まずはforeman
のGemをインストールします。foreman
はProcfile
から複数のプロセスを管理することができます。
https://github.com/ddollar/foreman
+ gem 'foreman'
これでbundle install
します。
次に下記2点のファイルを作成します。
ファイル名 | 役割 |
---|---|
bin/server | Procfile.devのコマンドを実行する |
Procfile.dev | rails s とbin/webpack-dev-server を実行する |
#!/bin/bash -i bundle install bundle exec foreman start -f Procfile.dev
web: bundle exec rails s # watcher: ./bin/webpack-watcher webpacker: ./bin/webpack-dev-server
また、bin/server
のパーミッションを変更しておきます。
$ chmod 777 bin/server
これでbin/server
を実行してみると、http://localhost:5000
で起動します。
(ポート番号が変わります。)
ここで、app.vue
の下記の部分を変えて保存すると、コンパイル処理が走り、画面がリロードされるはずです。
適当に変えて遊んでみて、問題なさそうなら大丈夫です。
export default { data: function () { return { // この文字列が画面に表示されている message: "Hello Vue!" } } }
サーバーサイドのAPI部分を実装していきます。
テーブル名はtasks
とし、下記のようにします。
カラム | 型 | |
---|---|---|
name | VARCHAR(255) | NULL: false |
is_done | BOOLEAN | NULL: false, DEFAULT: false |
created_at | DATETIME | NULL: false |
updated_at | DATETIME | NULL: false |
マイグレーションファイルとモデルはrails generate
で作成してしまいます。
$ rails generate model Task name:string is_done:boolean
マイグレーションファイルにnull: false
とdefault: false
を追記するため、下記のように編集します。
class CreateTasks < ActiveRecord::Migration[5.1] def change create_table :tasks do |t| t.string :name, null: false t.boolean :is_done, default: false, null: false t.timestamps end end end
マイグレーションを実行します。
$ rails db:migrate
ついでに、モデルのname
プロパティにバリデーションをつけておきましょう。
class Task < ApplicationRecord + validates :name, presence: true end
アクセスするURLは/api/tasks
のように名前空間を切りたいと思います。
まずはルーティングからです。
Rails.application.routes.draw do root to: 'home#index' + namespace :api, format: 'json' do + resources :tasks, only: [:index, :create, :update] + end end
次にコントローラーを作成します。
コントローラーはapp/controllers
の中にapi
というディレクトリを作成し、そこに作ります。
class Api::TasksController < ApplicationController # GET /tasks def index # 後々のため、更新順で返します @tasks = Task.order('updated_at DESC') end # POST /tasks def create @task = Task.new(task_params) if @task.save render :show, status: :created else render json: @task.errors, status: :unprocessable_entity end end # PATCH/PUT /tasks/1 def update @task = Task.find(params[:id]) if @task.update(task_params) render :show, status: :ok else render json: @task.errors, status: :unprocessable_entity end end private # Never trust parameters from the scary internet, only allow the white list through. def task_params params.fetch(:task, {}).permit( :name, :is_done ) end end
ビューのJSONファイルも同様に、views/api/tasks
以下に作成します。
json.set! :tasks do json.array! @tasks do |task| json.extract! task, :id, :name, :is_done, :created_at, :updated_at end end
json.set! :task do json.extract! @task, :id, :name, :is_done, :created_at, :updated_at end
seeds.rb
を作成し初期データを作成できるようにします。
もしレコードが増えすぎてもこれでやり直しもできます。
3.times { Task.create!(name: 'Sample Task') } 2.times { Task.create!(name: 'Sample Task', is_done: true) }
DBに適用します。
$ rails db:seed
なお、リセットする場合は、
$ rails db:setup
curl
コマンドを使用してAPIの確認をします。
$ curl localhost:5000/api/tasks {"tasks":[{"id":1,"name":"Sample Task","is_done":false,"created_at":"2017-09-14T08:12:35.454Z","updated_at":"2017-09-14T08:12:35.454Z"},{"id":2,"name":"Sample Task","is_done":false,"created_at":"2017-09-14T08:12:35.460Z","updated_at":"2017-09-14T08:12:35.460Z"},{"id":3,"name":"Sample Task","is_done":false,"created_at":"2017-09-14T08:12:35.462Z","updated_at":"2017-09-14T08:12:35.462Z"},{"id":4,"name":"Sample Task","is_done":true,"created_at":"2017-09-14T08:12:35.468Z","updated_at":"2017-09-14T08:12:35.468Z"},{"id":5,"name":"Sample Task","is_done":true,"created_at":"2017-09-14T08:12:35.475Z","updated_at":"2017-09-14T08:12:35.475Z"}]}
次にPOSTで新規作成してみます。
ここで恐らくエラーになるかと思います。
$ curl -X POST localhost:5000/api/tasks -d 'task[name]=fugafuga'
log/development.log
を確認すると、ActionController::InvalidAuthenticityToken
というエラーのようです。
CSRF対策のトークンがないため、Railsから怒られてしまいます。
application_controller.rb
の下記をコメントアウトするとエラーが出なくなります。
本来であれば、API認証のようなものをつけた方が良いとは思いますが、今回は割愛します。
【Rails】RailsでAPIの簡単なトークン認証を実装する
- protect_from_forgery with: :exception + # protect_from_forgery with: :exception
これで作成されたTODOが返却されるかと思います。
$ curl -X POST localhost:5000/api/tasks -d 'task[name]=fugafuga' {"task":{"id":6,"name":"fugafuga","is_done":false,"created_at":"2017-09-14T08:31:17.100Z","updated_at":"2017-09-14T08:31:17.100Z"}}
CSSはMaterializeというフレームワークを使用しようと思います。
マテリアルデザインを意識したものになっており、個人的に使ってみたいと思っておりました。
Gemもすでにあるので、Rails側でインストールします。
https://github.com/mkhairi/materialize-sass
+ gem 'jquery-rails' + gem 'materialize-sass' + gem 'material_icons'
bundle install
した後、下記の2ファイルに追記します。css
はscss
に変更しておきます。
+ /* Materialize */+ @import "materialize/components/color"; + $primary-color: color("teal", "accent-3") !default; + $secondary-color: color("cyan", "base") !default; + @import 'materialize'; + @import 'material_icons';
$primary-color
と$secondary-color
を設定しておくことで、よしなに色を合わせてくれます。
カラースキームはこちらを参照してください。
+ //= require jquery + //= require materialize //= require rails-ujs //= require turbolinks //= require_tree .
ここからVue.jsを中心に画面を作っていきます。
まずはヘッダーを作成します。
元となるビューファイルはRailsのindex.html.erb
になるので、こちらにVue.jsを載せられるようにします。
<div id="app"> <navbar></navbar> </div> <%= javascript_pack_tag 'todo' %>
<navbar>
というタグがありますが、Vue.js側でこのタグとコンポーネントを紐付け、表示します。
コンポーネント
また、新しくtodo.js
というファイルをapp/javascript/packs
に作成します。
(hello_vue
を修正したも良いのですが。。。)
import Vue from 'vue/dist/vue.esm.js' var app = new Vue({ el: '#app', });
ここで、import Vue from 'vue/dist/vue.esm.js'
としています。
これは、後ほどコンポーネントを使用する際に完全ビルドする必要があるからだそうです。
(すいません、まだよく分かっていません。。。)
詳しくはこちらの方が解説記事を書いてくださっています。
Rails5.1でVue.jsで単一ファイルコンポーネントのエラーがでる
これでindex.html.erb
内の<div id="app">
にマウントされます。
このまま実行しても特に何もありません。
それではコンポーネントを作成します。packs
の下にcomponents
ディレクトリを作成して、そこにheader.vue
を作成します。
コンポーネントは.vue
で作成します。
<template> <div> <ul id="dropdown" class="dropdown-content"> <li><a href="#">Top</a></li> <li><a href="#">About</a></li> <li><a href="#">Contact</a></li> </ul> <nav> <div class="nav-wrapper container"> <a href="/" class="brand-logo left">Todo Application</a> <ul class="right hide-on-med-and-down"> <li><a href="#">Top</a></li> <li><a href="#">About</a></li> <li><a href="#">Contact</a></li> </ul> <ul class="right hide-on-large-only"> <li> <a class="dropdown-button" href="#!" data-activates="dropdown"> Menu<i class="material-icons right">arrow_drop_down</i> </a> </li> </ul> </div> </nav> </div> </template>
これをtodo.js
に登録します。
import Vue from 'vue/dist/vue.esm.js' + import Header from './components/header.vue' var app = new Vue({ el: '#app', + components: { + 'navbar': Header, + } });
navbar
という名前でコンポーネントとして登録します。
(header
だと<header>
タグがすでにHTML5に存在しているため。)
これで<navbar>
タグが使用できるようになりました。
サーバーを再起動してアクセスすると下図のようなヘッダーができているのではないでしょうか?
Vue-Routerを使用することで、登録されたパスとコンポーネントで画面内を差し替えることができます。
Vue-Router
yarn
を使ってvue-router
を追加します。
$ yarn add vue-router
今回は「TODO一覧(メイン画面)」、「アバウト(おまけ)」、「コンタクト(おまけ)」を用意します。
正直、メイン画面でTODO管理はできるので、あと2つはおまけです。
まずはコンポーネントを作成します。
<template> <div> <p>Index</p> </div> </template>
<template> <div> <!-- 内容はお好みで --> <p>This is a sample of TODO application with Vue.js and Ruby on Rails.</p> <p>Sample code is <a href="https://github.com/naoki85/todo_app_with_vue_and_rails" target="_blank">here.</a></p> </div> </template>
<template> <div> <!-- 内容はお好みで --> <p>If you want to contact me, you send mail to below address.</p> <p>test@example.com</p> </div> </template>
さて、このコンポーネントとパスを登録するrouter.js
を作成します。
こちらもrouter
ディレクトリを作成してそちらに作成します。
import Vue from 'vue/dist/vue.esm.js' import VueRouter from 'vue-router' import Index from '../components/index.vue' import About from '../components/about.vue' import Contact from '../components/contact.vue' Vue.use(VueRouter) export default new VueRouter({ mode: 'history', routes: [ { path: '/', component: Index }, { path: '/about', component: About }, { path: '/contact', component: Contact }, ], })
パスとコンポーネントを結びつけます。
また、mode: 'history'
とすることで、HTMLのhistory APIを使用して、一見同じビュー内ですがURLを書き換えることができます。
HTML5 Historyモード
また、VueRouterを使用すると、<router-link>
と<router-view>
というタグが使用できます。<router-link>
は、<a>
タグとして変換されますが、画面遷移ではなくVueRouterに登録されたパスからコンポーネントを探します。
そして<router-view>
の部分に表示します。
ヘッダーの各リンクを修正します。
- <li><a href="/">Top</a></li> - <li><a href="/about">About</a></li> - <li><a href="/contact">Contact</a></li> + <li><router-link to="/">Top</router-link></li> + <li><router-link to="/about">About</router-link></li> + <li><router-link to="/contact">Contact</router-link></li>
それぞれのコンポーネントが表示される部分をindex.html.erb
に作ります。
<div id="app"> <navbar></navbar> + <div class="container"> + <router-view></router-view> + </div> </div> <%= javascript_pack_tag 'todo' %>
最後に、todo.js
に追加します。
import Vue from 'vue/dist/vue.esm.js' + import Router from './router/router' import Header from './components/header.vue' var app = new Vue({ + router: Router, el: '#app', components: { 'navbar': Header, } });
これで、ヘッダーの各リンクを押すと本文が切り替わるのではないでしょうか?
URLもHistoryモードのおかげで書き換わっています。
ただ、例えばhttp://localhost:5000/about
でリロードすると、Rails側でエラーになってしまいます。
たしかにroutes.rb
で登録していません。
とりあえず、/about
でも/contact
でもHome#index
にとぶよう記述します。
Rails.application.routes.draw do root to: 'home#index' + get '/about', to: 'home#index' + get '/contact', to: 'home#index'
これでhttp://localhost:5000/about
にアクセスするとわかりますが、ちゃんとURLからAboutのコンポーネントを表示してくれます。
axiosは、Ajax通信ライブラリです。
まずはこれをインストールします。
$ yarn add axios
Axiosを使用してindex.vue
にてAPI通信してタスク管理したいと思います。
まずは完成イメージをindex.vue
のtemplate
内に記載します。
これを書き換えていきます。
<template> <div> <!-- 新規作成部分 --> <div class="row"> <div class="col s10 m11"> <input class="form-control" placeholder="Add your task!!"> </div> <div class="col s2 m1"> <div class="btn-floating waves-effect waves-light red"> <i class="material-icons">add</i> </div> </div> </div> <!-- リスト表示部分 --> <div> <ul class="collection"> <li id="row_task_1" class="collection-item"> <input type="checkbox" id="task_1" /> <label for="task_1">Sample Task</label> </li> <li id="row_task_2" class="collection-item"> <input type="checkbox" id="task_2" /> <label for="task_2">Sample Task</label> </li> <li id="row_task_3" class="collection-item"> <input type="checkbox" id="task_3" /> <label for="task_3">Sample Task</label> </li> </ul> </div> <!-- 完了済みタスク表示ボタン --> <div class="btn">Display finished tasks</div> <!-- 完了済みタスク一覧 --> <div id="finished-tasks" class="display_none"> <ul class="collection"> <li id="row_task_4" class="collection-item"> <input type="checkbox" id="'task_4" checked="checked" /> <label v-bind:for="task_4" class="line-through">Done Task</label> </li> <li id="row_task_5" class="collection-item"> <input type="checkbox" id="'task_5" checked="checked" /> <label v-bind:for="task_5" class="line-through">Done Task</label> </li> </ul> </div> </div> </template>
こんな感じになります。
コンポーネントの中でHTML、JS、CSSをまとめて記載することを単一コンポーネントというようです。.vue
ファイルの中で、そのコンポーネントで使うJSも記述することができます。
一覧をAPIで取得するために必要なものを追記します。
<!-- 省略 --> </template> + <script> + import axios from 'axios'; + + export default { + data: function () { + return { + tasks: [], + newTask: '' + } + }, + mounted: function () { + this.fetchTasks(); + }, + methods: { + fetchTasks: function () { + axios.get('/api/tasks').then((response) => { + for(var i = 0; i < response.data.tasks.length; i++) { + this.tasks.push(response.data.tasks[i]); + } + }, (error) => { + console.log(error); + }); + }, + } + } + </script>
インスタンスにプロパティとしてtasks
とnewTask
を与えます。
メソッドとしてfetchTasks
を登録し、APIで取得してきた値をループさせてtasks
に格納します。
AxiosはJQueryと同じ感じで使えるので、使用しやすいかと思います。
mountedはVueインスタンスがマウントされたタイミングで実行されるライフサイクルフックです。
createdもあって今回の場合、あまり違いはありませんが、ライフサイクルダイアグラムについては、ちゃんと理解する必要がありそうです。
template
内の下記の部分を書き換えます。
(未完了と完了済みを両方変えます。)v-for
とv-if
を使ってtasks
プロパティの中で条件に合うものを表示しています。
条件付きレンダリング
また、v-bindで強引にid
名を作っています。
<!-- リスト表示部分 --> <div> <ul class="collection"> - <li id="row_task_1" class="collection-item"> - <input type="checkbox" id="task_1" /> - <label for="task_1">Sample Task</label> - </li> - <li id="row_task_2" class="collection-item"> - <input type="checkbox" id="task_2" /> - <label for="task_2">Sample Task</label> - </li> - <li id="row_task_3" class="collection-item"> - <input type="checkbox" id="task_3" /> - <label for="task_3">Sample Task</label> - </li> + <li v-for="task in tasks" v-if="!task.is_done" v-bind:id="'row_task_' + task.id" class="collection-item"> + <input type="checkbox" v-bind:id="'task_' + task.id" /> + <label v-bind:for="'task_' + task.id">{{ task.name }}</label> + </li> </ul> </div> <!-- 完了済みタスク一覧 --> <div id="finished-tasks" class="display_none"> <ul class="collection"> - <li id="row_task_4" class="collection-item"> - <input type="checkbox" id="'task_4" checked="checked" /> - <label v-bind:for="task_4" class="line-through">Done Task</label> - </li> - <li id="row_task_5" class="collection-item"> - <input type="checkbox" id="'task_5" checked="checked" /> - <label v-bind:for="task_5" class="line-through">Done Task</label> - </li> + <li v-for="task in tasks" v-if="task.is_done"v-bind:id="'row_task_' + task.id" class="collection-item"> + <input type="checkbox" v-bind:id="'task_' + task.id" checked="checked" /> + <label v-bind:for="'task_' + task.id" class="line-through">{{ task.name }}</label> + </li> </ul> </div>
完了済みタスクを常に表示させておく必要はないと思います。display_none
というクラスをつけているので、ここに非表示のスタイルをあてたいと思います。
単一コンポーネントではCSSも管理できます。
ただ、デフォルトの設定だと、コンパイル時に別でスタイルシートを出力してしまうので、これを無しにします。
RailsとVue.js の設計覚書
Webpack 3から、下記のようにloaders/vue.js
の中でextractCSS = false
と修正すれば良いようです。
+ environment.loaders.get('vue').options.extractCSS = false
index.vue
にスタイルを追記します。<style>
タグ中にscoped
という属性をつけておくと、そのファイルのみで有効なスタイルとして認識してくれます。
そのコンポーネントでしか使わないようなクラスはここで定義してしまえば良さそうです。
また、APIの返り値を使用してレンダリングする場合、API処理が終わるまでは、{{ task.name }}
がそのまま文字列としてレンダリングされてしまいます。
(インスタンスに値がセットされたら、その値が表示されます。)
このとき、v-cloakというディレクティブをCSSのdisplay: none;
と組み合わせて使うと、インスタンスが生成されたタイミングで表示してくれます。
// 省略 </script> + <style scoped> + [v-cloak] { + display: none; + } + .display_none { + display:none; + } + // 打ち消し線を引く + .line-through { + text-decoration: line-through; + } + </style>
これで完了済みの方は非表示になったかと思います。
まずはVueのmethods
にボタンを押されたときのメソッドを登録します。
methods: { // 省略 }, + displayFinishedTasks: function() { + document.querySelector('#finished-tasks').classList.toggle('display_none'); + }, } }
「ボタンを押されたとき」と記載するのはかなり簡潔にかけます。
テンプレートの方でv-onを使用してクリックされたタイミングでdisplayFinishedTasks
を呼んでもらいます。
<!-- 完了済みタスク表示ボタン --> - <div class="btn">Display finished tasks</div> + <div class="btn" v-on:click="displayFinishedTasks">Display finished tasks</div>
v-modelを利用することで、双方向バインディングさせることができます。
これで<input>
タグで入力された値とインスタンスのnewTask
プロパティをバインドさせます。
- <input class="form-control" placeholder="Add your task!!"> + <input v-model="newTask" class="form-control" placeholder="Add your task!!">
次に新規作成のメソッドを追加します。
APIで新規作成できた場合は、tasks
プロパティの先頭に追加するようにします。
methods: { // 省略 }, + createTask: function () { + if (!this.newTask) return; + + axios.post('/api/tasks', { task: { name: this.newTask } }).then((response) => { + this.tasks.unshift(response.data.task); + this.newTask = ''; + }, (error) => { + console.log(error); + }); + } }
まずはチェックボックスにチェックがついたら更新メソッドを呼ぶようにしたいと思います。
(この辺りは毎回呼ばずに、一定時間で同期させても良いかもしれません。)
<!-- リスト表示部分 --> <div> <ul class="collection"> <li v-bind:id="'row_task_' + task.id" class="collection-item" v-for="task in tasks" v-if="!task.is_done"> - <input type="checkbox" v-bind:id="'task_' + task.id" /> + <input type="checkbox" v-on:change="doneTask(task.id)" v-bind:id="'task_' + task.id" /> <label v-bind:for="'task_' + task.id" class="word-color-black">{{ task.name }}</label> </li> </ul> </div>
更新用のメソッドを追加します。
更新したタイミングで、未完了部分から消し、完了済みの方に追加します。
(このあたりの処理はもっと良い書き方がある気がします。。。)
methods: { // 省略 }, + doneTask: function (task_id) { + axios.put('/api/tasks/' + task_id, { task: { is_done: 1 } }).then((response) => { + this.moveFinishedTask(task_id); + }, (error) => { + console.log(error); + }); + }, + moveFinishedTask: function(task_id) { + var el = document.querySelector('#row_task_' + task_id); + // DOMをクローンしておく + var el_clone = el.cloneNode(true); + // 未完了の方を先に非表示にする + el.classList.add('display_none'); + // もろもろスタイルなどをたして完了済みに追加 + el_clone.getElementsByTagName('input')[0].checked = 'checked'; + el_clone.getElementsByTagName('label')[0].classList.add('line-through'); + el_clone.getElementsByTagName('label')[0].classList.remove('word-color-black'); + var li = document.querySelector('#finished-tasks > ul > li:first-child'); + document.querySelector('#finished-tasks > ul').insertBefore(el_clone, li); + } }
ここまでで、下図のような動きができるかと思います。
(いくつかスタイルはたしました。)
試しにテストコードを書いてみました。
RailsのSystemTestCaseで書いております。
理由は、Node.jsのテストフレームワークは(私にとって)学習コストが高いためです。
本当はフロントエンドと分けるのであれば、テストも分けた方が良いのではないかと思っています。
https://github.com/naoki85/todo_app_with_vue_and_rails/commit/4a5225395fa2fe6ba9c7a3f8a421648ab28c897c
テストの中で、within
を使用していますが、今回の場合は使用する必要はないと思います。
今後、さらに詳細なテストをする際に使用するかもと思い、残したままにしてあります。
まだ記事にできるほど試していないため、参考程度にご覧ください。
今回は、状態管理に便利なVuexは今回できなかったため、機会があればまた書きたいと思います。
もしよろしければご意見いただけると嬉しいです。
参考にさせていただいたドキュメント、記事などは下記になります。
コメントを見る
extractCSS = falseの設定ですが、こちらの記述をloader/vue.jsにするのが正しいです。
//コメントアウト
// const extractCSS = !(inDevServer && (devServer && devServer.hmr)) || isProduction
//これを追記
const extractCSS = false
匿名様
補足していただきありがとうございます。
とても参考になりました。ありがとうございます。
補足ですが、index.vue の newTask ボタンに v-on:click="createTask" の記載が漏れているようです。ご確認ください。