r-yanyoのブログ

アイコン画像

Vue.js + Ruby on RailsでTodoリストアプリを作る。

  • Vue.js
  • Ruby on Rails

完成図

コンセプト

フロントエンドとサーバサイドを完全に分ける。よって Rails の Webpacker を用いた Vue.js の使用はしない。

環境

Mac なので基本 homebrew でインストールしている。 環境でこけるのが一番萎えるので慎重に。

  • Mac OS X
  • node 10.0.0
  • npm 5.6.0
  • Vue 2.9.3
  • Rails 5.1.6

早速 Todo リストアプリ作ろう。

サーバーサイドとフロントエンドを分けるので、それぞれフォルダも分かれます。

まずはサーバサイドから

まずはサーバサイドから作ります。単純な CRUD 機能(Create Read Update Delete)が出来ることを目指しましょう。但し今回は Update は省略しています。

APi モードで rails new します。

$ rails new server --api

Todo リストアプリを作るので、「task」というモデルを作り、タスクを保存したり削除したり出来るようにします。タスクが持つ column は string 型の「text」だけとします。DB のマイグレーションも忘れず行う。

$ rails generate model task text:string
$ rails db:migrate

次にコントローラーを作ります。

$ rails g controller api::tasks

tasks_controller.rb の中身を以下のようにします。

class Api::TasksController < ApplicationController
  def index
    @tasks = Task.all
    render json: @tasks
  end

  def create
    @task = Task.new(task_params)
    if @task.save
      render json: "create new task.\n", status: 200
    else
      render json: "fail to create.\n", status: 500
    end
  end

  def destroy
    @task = Task.find(params[:id])
    @task.destroy
    render json: "destroy a task.\n"
  end

  private
    def task_params
      params.require(:task).permit(:text)
    end
end

最後にルーティングを設定します。

Rails.application.routes.draw do
  namespace :api, { format: 'json' } do
    resources :tasks
  end
end

これによりルーティングは以下のようになります。

$ rake routes
       Prefix Verb   URI Pattern                   Controller#Action
    api_tasks GET    /api/tasks(.:format)          api/tasks#index {:format=>/json/}
              POST   /api/tasks(.:format)          api/tasks#create {:format=>/json/}
 new_api_task GET    /api/tasks/new(.:format)      api/tasks#new {:format=>/json/}
edit_api_task GET    /api/tasks/:id/edit(.:format) api/tasks#edit {:format=>/json/}
     api_task GET    /api/tasks/:id(.:format)      api/tasks#show {:format=>/json/}
              PATCH  /api/tasks/:id(.:format)      api/tasks#update {:format=>/json/}
              PUT    /api/tasks/:id(.:format)      api/tasks#update {:format=>/json/}
              DELETE /api/tasks/:id(.:format)      api/tasks#destroy {:format=>/json/}

ターミナルから curl でテストしてみましょう。

まず Rails サーバを立てます。

$ rails s

別ターミナルで、curl から rails サーバへリクエストを送ります。 まずは新しいタスクを作ってみましょう。

$ curl http://localhost:3000/api/tasks -X POST -d 'task[text]=洗濯する'
create new task.

次はそれを GET してみます。

$ curl http://localhost:3000/api/tasks
[{"id":1,"text":"洗濯する","created_at":"2018-05-31T15:05:34.862Z","updated_at":"2018-05-31T15:05:34.862Z"}]

最後にそれを削除してみます。

$ curl http://localhost:3000/api/tasks/1 -X DELETE
destroy a task.

これでサーバ側は(だいたい)完成です。後からちょっと修正します。

次はフロントエンド

次はフロント側を Vue.js で作っていきます。 vue-cli で雛形を作っていきましょう。今回はテンプレとして webpack-simple を使います。

$ vue init webpack-simple client
$ cd client
$ npm install
$ npm run dev

まずは GET メソッドを送ってタスクを入手できるようにしましょう。 src/app.js を以下のように変更します。

<template>
  <div id="app">
    <ul id="task-list">
      <li class="task" v-for="task in tasks"><p>{{ task.text }}</p></li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios';

const hostName = 'localhost:3000';
const path = '/api/tasks'

export default {
  name: 'app',
  data () {
    return {
      tasks: [],
    }
  },
  methods: {
    getTasks: function() {
      axios.get(`http://${hostName}${path}`)
        .then((response) => {
          this.tasks = response.data;
        })
        .catch(function(error) {
          console.log(error);
        });
    }
  },
  mounted: function() {
    this.getTasks();
  }
}
</script>

axios を使ってサーバにリクエストを送るので axios をインストールします。

npm install axios

axios で異なるオリジンに対してリクエストを送ると、クロスドメイン対応と pre-flight の対応が必要になります。OPTIONS メソッドで Pre-flight というリクエストが送られます。(あまり詳しくないのでここを参考にしました。これに対応するためにサーバ側を少し変更します。

まずクロスドメインに対応するために、 rack-cors モジュールをインストールし、

gem 'rack-cors'

config/initializers/cors.rb を以下にようにします。

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins '*'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

config/routes.rb に options リクエストが来た時のルーティングを追加します。

match '*path' => 'options_request#preflight', via: :options

controller/options_request_controller.rb に以下を追加します。

class OptionsRequestController < ApplicationController
  ACCESS_CONTROL_ALLOW_METHODS = %w(GET OPTIONS).freeze
  ACCESS_CONTROL_ALLOW_HEADERS = %w(Accept Origin Content-Type Authorization).freeze

  def preflight
    set_preflight_headers!
    head :ok
  end

  private

  def set_preflight_headers!
    response.headers['Access-Control-Max-Age'] = ACCESS_CONTROL_MAX_AGE
    response.headers['Access-Control-Allow-Headers'] = ACCESS_CONTROL_ALLOW_HEADERS.join(',')
    response.headers['Access-Control-Allow-Methods'] = ACCESS_CONTROL_ALLOW_METHODS.join(',')
  end
end

さて、これで task をサーバから GET できるようになったと思います。 データは予め curl で post して下さい。

次にデータを POST するために form を追加しましょう。 form の値を newTask というデータに bind したので、data に追加します。 form に入力した値を axios でリクエストするメソッドを methods の中に追加します。

まとめると以下のコードになります。

<template>
  <div id="app">
    <form v-on:submit.prevent="postTask">
      <input id="new-task-form" type="text" v-model="newTask" placeholder="やりたいことは...">
    </form>
    <ul id="task-list">
      <li class="task" v-for="task in tasks"><p>{{ task.text }}</p></li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios';

const hostName = 'localhost:3000';
const path = '/api/tasks'

export default {
  name: 'app',
  data () {
    return {
      tasks: [],
      newTask: '',
    }
  },
  methods: {
    getTasks: function() {
      axios.get(`http://${hostName}${path}`)
        .then((response) => {
          this.tasks = response.data;
        })
        .catch(function(error) {
          console.log(error);
        });
    },
    postTask: function() {
      axios.post(`http://${hostName}${path}`,
          `task[text]=${this.newTask}`
        )
        .then((response) => {
          this.getTasks();
          this.newTask = '';
        })
        .catch(function(error) {
          console.log(error);
        });
    },
    deleteTask: function(id) {
      axios.delete(`http://${hostName}${path}/${id}`)
        .then((response) => {
          this.getTasks();
        })
        .catch(function(error) {
          console.log(error);
        });
    }
  },
  mounted: function() {
    this.getTasks();
  }
}
</script>

最後に task を DELETE するためのボタンを追加し、ボタンが押された時に axios で DELETE を送るメソッドを追加します。ついでに scss でスタイリングを行うと以下のようなコードになります。

<template>
  <div id="app">
    <form v-on:submit.prevent="postTask">
      <input id="new-task-form" type="text" v-model="newTask" placeholder="やりたいことは...">
    </form>
    <ul id="task-list">
      <li class="task" v-for="task in tasks"><p>{{ task.text }}</p><button class="delete-button" v-on:click="deleteTask(task.id)">×</button></li>
    </ul>
  </div>
</template>

<script>
import axios from 'axios';

const hostName = 'localhost:3000';
const path = '/api/tasks'

export default {
  name: 'app',
  data () {
    return {
      tasks: [],
      newTask: '',
    }
  },
  methods: {
    getTasks: function() {
      axios.get(`http://${hostName}${path}`)
        .then((response) => {
          this.tasks = response.data;
        })
        .catch(function(error) {
          console.log(error);
        });
    },
    postTask: function() {
      axios.post(`http://${hostName}${path}`,
          `task[text]=${this.newTask}`
        )
        .then((response) => {
          this.getTasks();
          this.newTask = '';
        })
        .catch(function(error) {
          console.log(error);
        });
    },
    deleteTask: function(id) {
      axios.delete(`http://${hostName}${path}/${id}`)
        .then((response) => {
          this.getTasks();
        })
        .catch(function(error) {
          console.log(error);
        });
    },
  },
  mounted: function() {
    this.getTasks();
  }
}
</script>

<style lang="scss">

$list-item-height:   30px;

html {
  height: 100%;
}

body {
  height: 100%;
  margin: 0;
}

#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
}

#new-task-form {
  width: 100%;
  height: $list-item-height;
}

#task-list {
  width: 100%;
  list-style-type: none;
  margin: 0;
  padding: 0;
}

.task {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  height: $list-item-height;
  border-bottom: dashed 1px gray;

  p {
    margin: 0;
    padding-left: 10px;
  }
}

.delete-button {
  width: 20px;
  height: 20px;
  margin: 0 8px;
  background-color: gray;
  color: white;
  border: none;
  border-radius: 50%;
}

</style>

これで、完成です!UPDATE 機能はまだ実装していないので、実装してみると勉強になると思います。