Help us understand the problem. What is going on with this article?

Rails+Vue.jsによるフォームの作例

※この記事は、まとめ記事「多人数によるRailsアプリケーション開発」の1項目にする予定です。

私の体感では、Railsアプリケーションで開発にかかる時間の半分はテンプレートとJavaScriptで、その大半はフォームです。ややこしいテンプレートはRails側で頑張らずに、Vue.jsのようなJavaScriptのフレームワークに投げてしまう、という作り方を今後のスタイルとしたい。

このサンプルは、ここ1年半ほどRails上でVue.jsをいじくって考えた、現在のところのベターなパターンです。まだ研究中なところもあり、今後変更する可能性もあります。

サンプルプログラムはこちら。簡単なブログアプリケーションです。
https://github.com/kazubon/blog-rails6-vuejs

2020-02-02: 最新のコードに合わせて記事を修正しました。
2019-12-25: 検索パラメータに付けていたキーqはやめました。

環境

  • Rails 5.2/6.0、Webpacker 4、Vue.js 2.6。
  • 非SPA、Turbolinksあり。
  • jQueryとBootstrapあり。

そこそこ大きな業務アプリケーションを想定(サンプルはブログですが)。Vue.jsではないJavaScriptを使っているなど、いろいろなスタイルのページが混じっているものとする。

ポイント

  • newとeditでは、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ受け取る。2回目はVueからAjaxでモデルのデータを受け取り、フォームの入力欄に反映する。
  • createとupdateは、Ajaxで呼ぶ。保存に成功したときはJavaScriptでリダイレクトし、失敗したらエラーメッセージを表示する。
  • 検索結果の表示ページでも、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ。2回目はVueからAjaxで検索のパラメータを送って記事リストを受け取り、一覧を表示する。

関連記事:

application.js

packs下のapplication.jsの書き方はいろいろ考えられますが、このサンプルではこんな感じです。HTML要素をid属性で探して、対応するVueアプリケーションをマウントします。

グローバル変数jsPropsは、Railsから直接データをVueのpropsに渡すのに使っています。

SessionForm(ログインフォーム、この記事では紹介なし)だけ.vueファイル内のテンプレートを使わずにRailsが出したHTMLをパースしていますが、これは比較研究したかっただけです。

app/javascript/packs/application.js
import "core-js/stable";
import "regenerator-runtime/runtime";

require("@rails/ujs").start();
require("turbolinks").start();

import Vue from 'vue';
import TurbolinksAdapter from 'vue-turbolinks'

import EntryIndex from '../entries/index';
import EntryForm from '../entries/form';
import EntryStar from '../entries/star';
import SessionForm from '../sessions/form';
import Flash from '../flash'

Vue.use(TurbolinksAdapter);

document.addEventListener('turbolinks:load', () => {
  Flash.show();

  let apps = [
    { elem: '#entry-index', object: EntryIndex },
    { elem: '#entry-form', object: EntryForm },
    { elem: '#entry-star', object: EntryStar },
    { elem: '#session-form', object: SessionForm }
  ];

  let props = window.jsProps || {};
  apps.forEach((app) => {
    if($(app.elem).length) {
      if(app.object.render) { // テンプレートあり
        new Vue({ el: app.elem, render: h => h(app.object, { props }) });
      }
      else { // HTMLをテンプレートに
        new Vue(app.object).$mount(app.elem);
      }
    }
  });
});

newとedit

編集ページのアクションです。HTMLの枠とAjaxでのデータ送信を同じアクションにしていますが、Ajax用を分けてapi/entries_controller.rbのような別コントローラにすることも考えられます。

Entries::Formは形式的に置いているもので、このサンプルの編集ページでは使ってません。

app/controllers/entries_controller.rb
  def new
    @entry = Entry.new
    @form = Entries::Form.new(current_user, @entry)
    respond_to do |format|
      format.html
      format.json { render :edit }
    end
  end

  def edit
    @entry = current_user.entries.find(params[:id])
    @form = Entries::Form.new(current_user, @entry)
    respond_to :html, :json
  end

編集ページのHTML枠です。editではグローバル変数jsPropsでEntryモデルのidを渡しています。

app/views/entries/new.html.erb
<div id="entry-form"></div>
app/views/entries/edit.html.erb
<script>
var jsProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>;
</script>
<div id="entry-form"></div>

編集ページのフォーム用のVueです。createdでAjaxを使ってEntryモデルのデータを取得し、フォームにセットします。

app/javascript/entries/form.vue
<template>
  <div>
    <form @submit="submit">
      <div v-if="alert" class="alert alert-danger">{{alert}}</div>
      <div class="form-group">
        <label for="entry-title">タイトル</label>
        <input type="text" v-model="entry.title" id="entry-title"
          class="form-control" required maxlength="255" pattern=".*[^\s]+.*">
      </div>
      <div class="form-group">
        <label for="entry-body">本文</label>
        <textarea v-model="entry.body" id="entry-body" cols="80" rows="15"
          class="form-control" required maxlength="40000">
        </textarea>
      </div>
      <div class="form-group">
        <label for="entry-tag0">タグ</label>
        <div>
          <input v-for="(tag, index) in entry.tags" :key="index" v-model="tag.name"
            class="form-control width-auto d-inline-block mr-2" style="width: 17%"
            maxlength="255" >
        </div>
      </div>
      <div class="form-group">
        <label for="entry-published_at">日時</label>
        <input type="text" v-model="entry.published_at" id="entry-published_at"
          class="form-control"
          pattern="\d{4}(-|\/)\d{2}(-|\/)\d{2} +\d{2}:\d{2}">
      </div>
      <div class="form-group mb-4">
        <input type="checkbox" v-model="entry.draft" id="entry-draft" value="1">
        <label for="entry-draft">下書き</label>
      </div>
      <div class="row">
        <div class="col">
          <button type="submit" class="btn btn-outline-primary">{{entryId ? '更新' : '作成'}}</button>
        </div>
        <div class="col text-right" v-if="entryId">
          <button type="button" class="btn btn-outline-danger" @click="destroy">削除</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
import Axios from 'axios';
import Flash from '../flash';

export default {
  props: ['entryId'],
  data() {
    return {
      entry: {},
      alert: null
    };
  },
  created() {
    Axios.get(this.path() + '.json').then((res) => {
      this.entry = res.data.entry;
      this.initTags();
    });
  },
  methods: {
    path() {
      return this.entryId ? `/entries/${this.entryId}/edit` : '/entries/new';
    },
    initTags() {
      let len = this.entry.tags.length;
      if(len < 5) {
        for(let i = 0; i < 5 - len; i++) {
          this.entry.tags.push({ name: '' });
        }
      }
    },
// 中略
  }
}
</script>

newとeditのアクションで、Vueに渡すjsonデータです。

app/views/entries/edit.jbuilder
json.entry do
  json.title @entry.title
  json.body @entry.body
  json.published_at (@entry.published_at || Time.zone.now).strftime('%Y-%m-%d %H:%M')
  json.draft @entry.draft
  json.tags do
    json.array! @entry.tags do |tag|
      json.name tag.name
    end
  end
end

createとupdate

新規作成と更新のアクションです。成功したときは、jsonでリダイレクト先のパスを返します。失敗したときは、ステータスコード422とメッセージを返します。

ここで使っているEntries::Formは、バリデーションとデータの保存を行うものです。次の記事で紹介しています。

app/controllers/entries_controller.rb
  def create
    @entry = Entry.new
    @form = Entries::Form.new(current_user, @entry, entry_params)
    if @form.save
      render json: { location: entry_path(@entry), notice: '記事を作成しました。' }
    else
      render json: { alert: '記事を作成できませんでした。' },
        status: :unprocessable_entity
    end
  end

  def update
    @entry = current_user.entries.find(params[:id])
    @form = Entries::Form.new(current_user, @entry, entry_params)
    if @form.save
      render json: { location: entry_path(@entry), notice: '記事を更新しました。' }
    else
      render json: { alert: '記事を更新できませんでした。' },
        status: :unprocessable_entity
    end
  end
# 中略
  def entry_params
    params.require(:entry).permit(
      :title, :body, :published_at, :draft, tags: [ :name ]
    )
  end

編集ページのフォーム用のVueで、フォームを送信するメソッドです。送信時には、HTTPヘッダにmeta属性からCSRF対策のトークンを入れます。

成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。

実際のアプリケーションでは、トークンを指定する部分はコードを共通化するべきでしょう。レスポンスの処理部分も共通化したほうがいいかも。

app/javascript/entries/form.vue
<script>
export default {
// 中略
  methods: {
// 中略
    submitPath() {
      return this.entryId ? `/entries/${this.entryId}` : '/entries';
    },
    submit(evt) {
      evt.preventDefault();
      if(!this.validate()) {
        return;
      }
      Axios({
        method: this.entryId ? 'patch' : 'post',
        url: this.submitPath() + '.json',
        headers: {
          'X-CSRF-Token' : $('meta[name="csrf-token"]').attr('content')
        },
        data: { entry: this.entry }
      }).then((res) => {
        Flash.set({ notice: res.data.notice });
        Turbolinks.visit(res.data.location);
      }).catch((error) => {
        if(error.response.status == 422) {
          this.alert = error.response.data.alert;
        }
        else {
          this.alert = `${error.response.status} ${error.response.statusText}`;
        }
        window.scrollTo(0, 0);
      });
    },
// 中略
  }
}
</script>

index

検索フォームと記事一覧を表示するindexアクションです。ここでもHTMLの枠とAjaxのデータを同じアクションにしています。

ここで使っているEntries::SearchFormは、パラメータを使って検索を行うものです。これもフォームクラスを使うで紹介しています。

app/controllers/entries_controller.rb
  def index
    @user = User.active.find(params[:user_id]) if params[:user_id].present?
    @form = Entries::SearchForm.new(current_user, @user, search_params)
    respond_to :html, :json
  end
(中略)
  def search_params
    params.except(:user_id, :format).permit(:title, :tag, :offset, :sort)
  end

検索ページのHTML枠の一部です。グロバール変数jsPropsでVueに検索パラメータを渡します。

app/views/entries/index.html.erb
<script>
var jsProps = <%= { query: @form.query }.to_json.html_safe %>;
</script>
<div id="entry-index"></div>

検索フォームと記事一覧用のVueです。フォーム(search_form.vue)と一覧(list.vue)を子コンポーネントに分けています。

app/javascript/entries/index.vue
<template>
  <div>
    <search-form :query="query"></search-form>
    <list :query="query"></list>
  </div>
</template>

<script>
import Form from './search_form';
import List from './list';

export default {
  props: ['query'],
  components: { 'search-form': Form, 'list': List }
}
</script>

検索フォームのVueです。ここでは特に何もしてません。タグを入力したときに動的に候補を出すような機能を付けるときはここに追加するつもりです。

検索フォームの送信はブラウザーに任せてページ遷移します。

app/javascript/entries/search_form.vue
<template>
  <div>
    <form action="/entries" method="get" class="form-inline mb-4">
      <input type="text" name="title" class="form-control mr-3 mb-2"
        v-model="title" placeholder="タイトル">
      <input type="text" name="tag" class="form-control mr-3 mb-2"
        v-model="tag" placeholder="タグ">
      <input type="hidden" name="sort" v-model="query.sort">
      <input type="hidden" name="user_id" v-model="query.user_id">
      <button type="submit" class="btn btn-outline-primary mb-2">検索</button>
    </form>
  </div>
</template>

<script>
export default {
  props: ['query'],
  data() {
    return {
      title: this.query.title,
      tag: this.query.tag
    };
  }
}
</script>

検索結果の一覧を出すVueです。createdでAjax送信を行い、記事一覧のデータを配列entriesに入れます。「もっと読む」ボタンを押したときは、オフセットを増やして記事を取得します。

app/javascript/entries/list.vue
<template>
  <div>
    <div class="text-right mb-3">
      {{entriesCount}}件 | 
      <a :href="sortPath('date')" v-if="query.sort == 'stars'">日付順</a>
      <template v-else>日付順</template>
      | <a :href="sortPath('stars')" v-if="query.sort != 'stars'">いいね順</a>
      <template v-else>いいね順</template>
    </div>
    <div class="entries mb-4">
      <div v-for="entry in entries" :key="entry.id" class="entry">
        <div>
          <a :href="entry.path">
            <template v-if="entry.draft">(下書き) </template>
            {{entry.title}}
          </a>
        </div>
        <div class="text-right text-secondary">
          <a :href="entry.user_path">{{entry.user_name}}</a> |
          <a v-for="tag in entry.tags" :key="tag.id" class="mr-2"
              :href="tag.tag_path">{{tag.name}}</a> |
          {{entry.published_at}} |
          <span class="text-warning" v-if="entry.stars_count > 0">★{{entry.stars_count}}</span>
        </div>
      </div>
    </div>
    <div v-if="showMore">
      <button type="button" @click="moreClicked" class="btn btn-outline-secondary w-100">もっと見る</button>
    </div>
  </div>
</template>

<script>
import Axios from 'axios';
import qs from 'qs';

export default {
  props: ['query'],
  data: function () {
    return {
      entries: [],
      entriesCount: 0,
      offset: 0
    };
  },
  computed: {
    showMore() {
      return (this.entries.length < this.entriesCount);
    }
  },
  created () {
    this.getEntries();
  },
  methods: {
    getEntries() {
      let params = { ...this.query, offset: this.offset };
      let path = '/entries.json?' + qs.stringify(params);
      Axios.get(path).then((res) => {
        this.entries = this.entries.concat(res.data.entries);
        this.entriesCount = res.data.entries_count;
      });
    },
    moreClicked() {
      this.offset += 20;
      this.getEntries();
    },
    sortPath(key) {
      let params = { ...this.query, sort: key };
      return '/entries?' + qs.stringify(params);
    }
  }
}
</script>

indexアクションで、検索結果を返すjsonです。Entries::SearchFormのメソッドを呼び出しています。

app/views/entries/index.jbuilder
json.entries do
  json.array! @form.entries do |entry|
    json.id entry.id
    json.title entry.title
    json.path entry_path(entry)
    json.user_name entry.user.name
    json.user_path user_entries_path(entry.user)
    json.draft entry.draft?
    json.published_at entry.published_at.try(:strftime, '%Y-%m-%d %H:%M')
    json.stars_count entry.stars_count
    json.tags do
      json.array! entry.tags do |tag|
        json.id tag.id
        json.name tag.name
        json.tag_path(
          @user ? user_entries_path(@user, tag: tag.name) : entries_path(tag: tag.name)
        )
      end
    end
  end
end
json.entries_count @form.entries_count

Vue.jsと関係ない補足

  • createとupdateは、Ajaxで呼ぶほうが楽です。失敗時にrender :newなどでテンプレートを作り直す手間が省けるのは大きい。また、失敗ページでのブラウザーの進む/戻る/リロードの動作が自然になります。
  • 検索フォームを送信するときは、GETメソッドでページ遷移させて、URLを変化させます。POSTメソッドを使ったりAjaxを使ったりすると、ブラウザーのリロードで検索結果が消えたり、検索結果をブックマークできなくなったりします。
  • JavaScriptでフォームの送信を扱うときは、送信ボタンのclickイベントじゃなくて、form要素のsubmitイベントを処理すること。

検討課題

  • Vueのテンプレートは.vueファイル内に入れるべきか、Railsが出力したHTMLを使うべきか。既存のアプリケーションでは、テンプレートをすべて.vueファイルに移すのは現実的に無理なので、両方ありとします。
  • フォームのAjax送信にrails-ujs(remote: trueを付けてajax:successイベントを処理)を使うべきか。rails-ujsでもいいんだけど、慣れていない人に分かりにくいのでAxiosで統一したほうがよい。
  • RailsからVueに直接データを渡す方法(このサンプルのグローバル変数jsProps)については、まだ迷い中。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした