はじめに
前回作成した、TODOアプリを修正したいと思います。
 完成図は下図のようなかたちになります。
サーバーサイドをRailsで作成しつつ、ビューは1ファイルのみ、そこを差し替えていってSPAっぽくしたいと思います。

せっかくなので、コンポーネントやvue-routerなどを使ってみたいと思います。
なお、環境はDockerでRails + Vue.jsの環境を作ってみるを使用するので、割愛します。
vue-routerのインストール
vue-routerを使用することで、URLから画面の一部を差し替えることができます。
まずはインストールする必要があります。
| 1 | $ npm install vue-router | 
yarn
を使用している場合は、下記です。
| 1 | $ yarn add vue-router | 
サーバーサイドの改修
APIに詳細(show)を追加
前回は、一覧、作成、更新のAPIしか用意していなかったので、
show
を追加します。
| 1 2 3 |   # GET /api/tasks/1.json + def show + end | 
| 1 2 3 4 5 6 | @return_code ||= 0 json.server_time Time.now.to_i json.return_code @return_code json.set! :task do   json.extract! @task, :id, :name, :is_done, :created_at, :updated_at end | 
元となるビューファイルを作成
一番はじめにレンダリングされるビューファイルを作成します。
 このファイル上で、コンポーネントを差し替えていきます。
| 1 2 3 4 5 6 | <div id="app">   <h1>TODO Application</h1>   <router-view></router-view> </div> <%= javascript_pack_tag 'todo' %> | 
<router-view></router-view>
の部分が差し替わっていきます。
 ハンドリングはVue.js側でやるので、これだけです。
コントローラーも修正しておきます。
| 1 2 3 4 | class TodoController < ApplicationController   def index   end end | 
ルーティングの修正
サーバーサイドのルーティングを定義しておきます。
| 1 2 3 4 5 6 7 8 | Rails.application.routes.draw do   root to: 'todo#index'   get '/task/:id', to: 'todo#index'   namespace :api, format: 'json' do     resources :tasks, only: [:index, :create, :update, :show]   end end | 
Railsの場合、ルーティングにマッチしていなかった場合、エラーになってしまいます。
 そのため、Vue.jsでルーティングするとしても登録しておいた方が良いようです。
 Has anyone look at using vue router with rails router?
Vue.jsで詳細画面と一覧を差し替えようとおもいますが、両方とも同じコントローラーのメソッドなので、紐付けておきます。
Vue.jsの実装
大元のVue.jsのファイルを作成する
Vue.jsは
app/javascript/packs
以下に記載していきます。
| 1 2 3 4 5 | import Vue from 'vue' var app = new Vue({   el: '#app', }); | 
コンポーネントの作成
コンポーネントを利用することで、再利用可能な部分を切り出したり、ファイルの肥大化を抑えることができます。
 また、単一ファイルコンポーネントというものを使用することで、HTML、CSS、JSをひとまとめでコンポーネント管理できます。
コンポーネントは
componets
ディレクトリを作成してそこで管理します。
 また、単一コンポーネントを使用する場合は、拡張子が
.vue
になります。
一覧のコンポーネント
まずは「Index」のコンポーネントですが、こちらは前回のものを流用します。
<router-link :to="path">
で
a
タグを生成してくれます。
 このリンクがよしなに
vue-router
からマッチしたコンポーネントを呼び出してくれます。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | <template>   <table class="table table-striped" v-cloak>     <thead>       <tr>         <th>Name</th>         <th></th>       </tr>     </thead>     <tbody>       <tr v-for="task in tasks" v-if="!task.is_done">         <td>{{ task.name }}</td>         <td>           <router-link :to="'/task/' + task.id" class="btn btn-default">Show</router-link>           <div class="btn btn-danger" v-on:click="doneTask(task.id)">Done!</div>         </td>       </tr>       <tr>         <td><input v-model="newTask" class="form-control"></td>         <td>           <div class="btn btn-default" v-on:click="createTask">Create Task</div>         </td>       </tr>     </tbody>   </table> </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);         });       },       createTask: function () {         axios.post('/api/tasks', { task: { name: this.newTask } }).then((response) => {           app.refreshTasks();         }, (error) => {           console.log(error);         });       },       doneTask: function (task_id) {         axios.put('/api/tasks/' + task_id, { task: { is_done: 1 } }).then((response) => {           this.refreshTasks();         }, (error) => {           console.log(error);         });       },       refreshTasks: function () {         this.tasks = [];         this.fetchTasks();       }     }   } </script> <style lang="scss" scoped>   [v-cloak] {     display: none;   } </style> | 
export default
を使用することで、Vue.js(
vue-loader
)がコンポーネントのJSと解釈してくれます。
 Vue Component の仕様
また、
<style>
タグですが、
scoped
を使用することで、そのファイル内のスタイルを定義できます。
 イレギュラーなクラスなどはここで定義してしまえばよさそうです。
v-cloakというディレクティブを使用しました。
 例えば、APIの返り値を使用してレンダリングする場合、API処理が終わるまでは、
{{ task.name }}
がそのまま文字列としてレンダリングされてしまいます。
 (インスタンスに値がセットされたら、その値が表示されます。)
 
<table v-cloak>
とし、CSSにて、
| 1 2 3 4 5 | <style lang="scss" scoped>   [v-cloak] {     display: none;   } </style> | 
としておくことで、Vueインスタンスが作成されるまで非表示にしておけます。
詳細のコンポーネント
一覧のときと同じように、APIにてそのIDの値を取得してレンダリングします。
先ほども使用しましたが、ライフサイクルフックのmountedのタイミングで実施しています。
 今回の場合であれば、createdでもあまり変わらないような感じです。
 このあたりはもっと理解する必要があります。。。
 ライフサイクルダイアグラム
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | <template>   <div>     <router-link to="/" class="btn btn-default">Index</router-link>     <table class="table table-striped table-bordered margin-default" v-cloak>       <tbody>         <tr>           <th>ID</th>           <td>{{task.id}}</td>         </tr>         <tr>           <th>Name</th>           <td>{{ task.name }}</td>         </tr>         <tr>           <th>Status</th>           <td>{{ task.is_done ? "finished" : "unfinished" }}</td>         </tr>       </tbody>     </table>   </div> </template> <script>   import axios from 'axios';   export default {     data: function () {       return {         task: []       }     },     mounted: function() {       this.fetchTaskDetail();     },     methods: {       fetchTaskDetail: function() {         var id = this.$route.params.id;         axios.get('/api/tasks/' + id).then((response) => {           this.task = response.data.task;         }, (error) => {           console.log(error);         });       }     }   } </script> <style lang="scss" scoped>   [v-cloak] {     display: none;   }   .margin-default {     margin-top: 20px;   } </style> | 
ルーティングファイルの作成
コンポーネントの準備はできたので、このコンポーネントとメインの
todo.js
を結びつける、ルーティングのファイルを作成します。
ルーティングも専用のディレクトリを作成してそこで管理しようと思います。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 | import VueRouter from 'vue-router' import Vue from 'vue' import Index from '../components/index.vue' import Show from '../components/show.vue' Vue.use(VueRouter) export default new VueRouter({   mode: 'history',   routes: [     { path: '/', component: Index },     { path: '/task/:id', component: Show },   ], }) | 
基本的には
routes
以下で、パスとコンポーネントを結びつけます。
また、
mode: 'history'
とすることで、HTMLの
history API
を使用して、一見同じビュー内ですがURLを書き換えることができます。
 HTML5 History モード
さて、これをメインの
todo.js
に追記します。
| 1 2 3 4 5 6 7 |  import Vue from 'vue' +import router from './router/router' var app = new Vue({ + router,   el: '#app', }); | 
これで、はじめの画像のようになるかと思います。


個人的に詰まったこと
コンポーネントの
methods
内で、
this
にアクセスできなくなりました。
 色々調べたところ、バージョンに基づいた書き方をしないと、
this
を認識しなくなるようです。
 ‘This’ object inside vue instance methods is undefined
私の場合は、APIの返りの部分を下記のように書いており、
| 1 2 3 4 5 | axios.get('API path').then(function(response) {     // success   }, function(error) {     //error }); | 
これを下記のように書き換えたところ、うまく
this
にアクセスできました。
| 1 2 3 4 5 | axios.get('API path').then((response) => {     // success   }, (error) => {     //error }); | 
ここはいまいちよく分かりませんね。。。
9/12 追記
migi様よりコメントをいただきました。
 上記の件は、アロー関数を使用することで、
this
を束縛せずに渡せることが原因とのことです。
 ここですね。
| 1 2 3 4 5 | axios.get('/api/tasks').then((response) => {   for(var i = 0; i < response.data.tasks.length; i++) {     that.tasks.push(response.data.tasks[i]);   } }, | 
今まで通り
function(response) {
で書こうとするならば、
| 1 2 3 4 5 6 7 8 9 10 11 12 | fetchTasks: function () {   // あらかじめ変数宣言   var that = this;   axios.get('/api/tasks').then(function(response) {     for(var i = 0; i < response.data.tasks.length; i++) {       that.tasks.push(response.data.tasks[i]);     }   }, function(error) {     console.log(error);   }); }, | 
あらかじめ変数宣言しておく必要があるそうです。
さいごに
今回のソースコードはこちらになります。
 https://github.com/naoki85/rails-vue-practice/tree/add_components_and_router_to_todo_app
コンポーネントは、今までCSSの方でモジュール管理してたものを移せそうです。
 
  
 
 
 
 
 
 
 
 
「個人的に詰まったこと」に書いてある現象の原因は「アロー関数はthisを束縛しない」ことにあります。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/arrow_functions
前者のパターンの場合は事前にlet that = thisを宣言しておいて、then以下でthisの代わりにthatを呼べば問題なく動くかと思います。
ご指摘いただき、ありがとうございます!
migi様からのご指摘を記事内に追加いたしました。
よろしければご覧ください。