2017年08月24日

Rails アプリケーション内でいい感じに Vue.js を使いたい

Vue.js は普通の Web ページにもゆるふわに導入しやすい点がメリットの一つですが、(SPA ではない) Rails アプリで使う時は少し考えて書かないとつらくなってくると思います。

例えば、ある <select> 要素の値に応じて別の <select> 要素で選択可能な値をフィルタリングするという機能を実装したい場合を考えます。フィルタリングの機能を持たない、ただのフォームであれば Rails のフォームヘルパーで簡潔に書くことができます。

<% # コントローラーから渡す %>
<% @categories = [['Category 1', 1], ['Category 2', 2], ['Category 3', 3]] %>
<% @items = [['Item 1', 1], ['Item 2', 2], ['Item 3', 3]] %>

<%= form_with(model: resource, local: true) do |f| %>
  <%= f.label :category %>
  <%= f.select :category, @categories %>

  <!-- :category の値に応じて :item の値をフィルタリングしたい -->
  <%= f.label :item %>
  <%= f.select :item, @items %>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

しかし、ここに Vue.js の処理を追加しようとすると一気に複雑でめんどくさくなってきます。

<% # コントローラーから渡す %>
<% @categories = [['Category 1', 1], ['Category 2', 2], ['Category 3', 3]] %>
<% @items = [['Item 1', 1, 1], ['Item 2', 2, 1], ['Item 3', 3, 2]] %>

<%= form_with(model: resource, local: true, html: { id: 'form' }) do |f| %>
  <%= f.label :category %>
  <%= f.select :category, @categories, {}, { 'v-model': 'category' } %>

  <%= f.label :item %>
  <%= f.select :item, nil, {}, { 'v-model': 'item' } do %>
    <!-- <option> を Vue.js の機能 (v-for, v-if) でフィルタリングする -->
    <option v-for="i in all_items" v-if="i[2] == category" :value="i[1]" v-text="i[0]"></option>
  <% end %>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

<script>
  new Vue({
    el: '#form',
    data: {
      // サーバーのデータを Vue のインスタンスに渡す
      category: <%= select.category.to_json.html_safe %>,
      item: <%= select.item.to_json.html_safe %>,
      all_items: <%= @items.to_json.html_safe %>
    }
  })
</script>

今までは各 <select> に対してそれに対応するモデルの属性名と、選択肢の配列を渡すだけでよかったのが、v-model 属性をつけたり、Vue のインスタンスにデータを受け渡すコードが必要になってしまいました。しかも、フォームヘルパー、v-model、Vue のインスタンスデータの3か所で属性名 (categoryitem) を書いていてとても冗長です。

v-model を付与するヘルパーを作る

上記の問題を新しく Rails のフォームヘルパーを作ることで解決します。前提として、フォームに結びつける Rails のモデルの属性名と、Vue インスタンス上のデータ名は同一であるとします。

まずは簡単に v-model を付与するだけのヘルパーを作成します。フォームヘルパーを定義するには ActionView::Helpers::FormBuilder を継承したクラスを定義する必要があります。

class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
  # f.select が生成する HTML に `v-model` をつけるだけのヘルパー
  def select_vue(method, choices = [], options = {}, html_options = {}, &block)
    html_options = inject_vmodel(method, html_options)
    select(method, choices, options, html_options, &block)
  end

  # ... 必要な分だけ xxx_vue ヘルパーを作成する ...

  def inject_vmodel(method, html_options)
    # 与えられた属性名と同じ値を `v-model` にも指定する
    html_options.merge('v-model': method)
  end
end

これでフォームヘルパーに手動で v-model と書いていた部分を省略することができます。

v-model と結びつけたモデルを HTML 内に出力する

v-model を付与するヘルパーを書くことで、Vue 側にどのデータを渡すべきかを導出することができます。先の例を少し拡張して、v-model に渡されるデータを保持するインスタンス変数 @vue_data を作成します。

class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
  attr_reader :vue_data

  def initialize(object_name, object, template, options)
    super(object_name, object, template, options)

    # Vue の初期化時に渡すデータを保持するハッシュ
    @vue_data = {}
  end

  def select_vue(method, choices = [], options = {}, html_options = {}, &block)
    html_options = inject_vmodel(method, html_options)
    select(method, choices, options, html_options, &block)
  end

  # ... 必要な分だけ xxx_vue ヘルパーを作成する ...

  def inject_vmodel(method, html_options)
    # xxx_vue ヘルパーで参照されたデータを保持しておく
    @vue_data[method] = object[method]

    # 与えられた属性名と同じ値を `v-model` にも指定する
    html_options.merge('v-model': method)
  end
end

これにより、フォームのブロック内で f.vue_data を書くことで、v-model に渡されるデータのハッシュを取得することができるようになります。

さらに、ApplicationHelper に JavaScript へデータを渡すためのヘルパーを定義しましょう。

module ApplicationHelper
  def write_data(data)
    content_tag('script', data.to_json.html_safe, id: 'server_side_data', type: 'application/json')
  end
end

write_data は引数に与えたハッシュを JSON 文字列にし、<script> 要素として HTML 上に書き出すためのヘルパーです。

今までに定義したヘルパーを使うと、フォームのコードは以下のようになります。

<% # コントローラーから渡す %>
<% @categories = [['Category 1', 1], ['Category 2', 2], ['Category 3', 3]] %>
<% @items = [['Item 1', 1, 1], ['Item 2', 2, 1], ['Item 3', 3, 2]] %>

<% # 定義した ApplicationFormBuilder を使用する %>
<%= form_with(model: resource, local: true, builder: ApplicationFormBuilder, html: { id: 'form' }) do |f| %>
  <%= f.label :category %>
  <%= f.select_vue :category, @categories %>

  <%= f.label :item %>
  <%= f.select_vue :item do %>
    <option v-for="i in all_items" v-if="i[2] == category" :value="i[1]" v-text="i[0]"></option>
  <% end %>

  <div class="actions">
    <%= f.submit %>
  </div>

  <% # v-model に渡すデータと、@items を JavaScript 側に渡す %>
  <%= write_vue_data f.vue_data.merge(all_items: @items) %>
<% end %>

<script>
  new Vue({
    el: '#form',
    data: function() {
      // <script> 要素として書き込まれたデータを読み込む
      return JSON.parse(document.getElementById('server_side_data').textContent)
    }
  })
</script>

コードから重複がなくなり、コードがかなり簡潔になりました。

Vue 側も工夫してより簡潔にする

さらに Vue 側も工夫することでより簡潔なコードにすることができます。例えば、サーバーのデータを HTML から読み込んでいる部分はページ共通の処理としてしまっても良いでしょう。

それをするために、まず最初にページ全体を id="app" の要素で囲み、さらにレイアウトファイルに :javascripts ブロックを挿入できるようにします。

<!DOCTYPE html>
<html>
  <head>
    <!-- ... -->
  </head>

  <body>
    <div id="app">
      <%= yield %>
    </div>

    <!-- 共通の JavaScript ファイルを読み込む -->
    <%= javascript_include_tag 'application' %>
    <!-- ページ固有のスクリプトを読み込む -->
    <%= yield :javascripts %>
  </body>
</html>

次に、共通の JavaScript ファイルにサーバーのデータを読み取る処理と、id="app" の要素に Vue をマウントする処理を書きます。addVueOptions は Vue のオプションを渡すと、それを最終的にはルートのインスタンスに適用する関数です。

const mixinOptions = []

// ルートの Vue インスタンスにオプションを追加する関数
function addVueOptions(options) {
  mixinOptions.push(options)
}
// window に代入して外からでも使用できるようにする
window.addVueOptions = addVueOptions

document.addEventListener('DOMContentLoaded', () => {
  // サーバーのデータを取得
  const data = document.getElementById('server_side_data')

  // サーバーのデータが存在する場合、それをルートのインスタンスデータに追加
  if (data) {
    addVueOptions({
      data: JSON.parse(data.textContent)
    })
  }

  const app = document.getElementById('app')

  if (app) {
    // addVueOptions によって追加されたオプションを
    // ルートのインスタンスに適用し、マウント
    new Vue({
      mixins: mixinOptions
    }).$mount(app)
  }
})

ここまで書くと、先のフォームの例が <script> 要素無しで動くようになると思います。これで単純な例であれば、一見 JavaScript を一切書かずに動くようになりました。

また、ページごとに何らかの処理を追加したいときには、先ほどの :javascripts ブロックと addVueOptions 関数を使うと良いでしょう。

<% # 処理を追加したいページ %>

<% content_for :javascripts do %>
  <script>
    window.addVueOptions({
      data: {
        additional: '追加のデータ'
      }
    })
  </script>
<% end %>

まとめ

Rails と Vue の組み合わせは素朴に使おうとするとつらい部分がいくつかあります。しかし、それぞれ何らかのヘルパーを書くことによって快適にコードが書けるようになります。

この記事では Vue の v-model を対象にして、Rails のフォームヘルパーを自作することで、簡潔なコードで Rails と Vue を使えるようにしました。

実際のアプリを書く際にはその他のケースも考慮する必要があると思いますが、基本的には Rails のヘルパーを書くことができれば楽ができるのではないかと思います。Rails と Vue の両方を利用している方はぜひ試してみてください。

RELATED POSTS