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

Railsのフォームビルダーで生成したform要素をVueコンポーネント化する

More than 1 year has passed since last update.

要旨

  • Vue.js は、サーバー側で生成された HTML 文書の一部をテンプレートとして利用できる。
  • HTML フォームに含まれるフォーム要素(input 要素、textarea 要素、select 要素等)に v-model 属性を指定すれば、フォームへの入力と Vue コンポーネントのデータが結び付けられる。
  • しかし、Vue.js はそれらの要素の value, checked または selected 属性の初期値を無視する。
  • そこで、私はそれらの値を拾い上げて Vue コンポーネントのデータを初期化するプラグインを作成した。
  • また、私はフォーム要素に設定された name 属性の値から適宜 v-model 属性に値をセットするように拡張されたフォームビルダーを作成し、Gem パッケージとして公開した。

背景

Rails のフォームビルダーで HTML フォームを生成し、jQuery で DOM 操作を行うという流儀で作られた Web アプリケーションはとても多く存在します。しかし、UI 仕様が複雑になってくると jQuery ベースでの開発は次第に困難になります。

次の図にあるような HTML フォームを例として考えてください。

new_user0.png

ユーザーが「その他」と書かれたラジオボタンを選択すると、その下にテキストフィールドが現れます。

new_user1.png

比較的単純な DOM 操作ではありますが、次の三つのことを実現するコードを jQuery を使って書かなければなりません。

  1. ラジオボタンの選択状態を調べ、一番下のテキストフィールドの表示・非表示を切り替える関数 f を定義する。
  2. HTML 文書のロード時に関数 f を呼び出す。
  3. ユーザーがラジオボタンをクリックしたときに関数 f を呼び出すようにする。

私は、これをかなり面倒だと考えています。

Vue.js のテンプレート

Vue.js の特徴のひとつは、サーバー側で生成された HTML 文書の一部をテンプレートとして利用できる、ということです。

例えば、ある HTML 文書に次のような断片が含まれていたとします。

<form id="user-form" action="/users" method="post">
  <input v-model="user.name" type="text" name="user[name]" id="user_name">
  <input type="submit" name="commit" value="Create">
</form>

このとき、次のような JavaScript コードを実行すれば、Vue.js はこの <form> 要素全体のコードをテンプレートとして解析し、Vue コンポーネント化した上で、この <form> 要素全体を再描画します。

import Vue from "vue/dist/vue.esm"

document.addEventListener("DOMContentLoaded", () => {
  new Vue({
    el: "#user-form"
  })
})

Vue コンストラクタ関数の el オプションには CSS セレクタを指定し、これが Vue コンポーネントをマウントする対象を指します。ここでは user-form という id 属性を持つ要素が対象となります。

Vue コンストラクタ関数に template オプションが指定された場合、その値が Vue コンポーネントのテンプレートとなります。しかし、そうでない場合は、el オプションで指定された HTML 要素全体のコードがそのままテンプレートとして使われます。

Vue.js のテンプレートは、ブラウザや HTML パーサによってパースできる有効な HTML の断片です。

v-model ディレクティブ

さて、さきほど例として挙げた HTML 文書に次のような記述があります。

  <input v-model="user.name" type="text" name="user[name]" id="user_name">

HTML の標準に v-model という名前の属性はありません。v- で始まる属性は Vue.js で特殊な意味を持つ属性、すなわちディレクティブです。

v-model は、HTML フォームに含まれるフォーム要素(input 要素、textarea 要素、select 要素等)とVue コンポーネントのデータを結びつけます。この結びつきは双方向(two-way)です。ユーザーがフォーム要素の触れば、コンポーネントのデータが変化します。逆に、コンポーネントのデータが変化すれば、フォーム要素の状態も変化します。

ここで、ひとつ重要なことがあります。この双方向データバインディングを利用するためには、Vue コンストラクタ関数で data オプションを指定し、コンポーネントのデータを初期化する必要がある、ということです。つまり、さきほどの JavaScript コードを次のように書き換えなければなりません。

import Vue from "vue/dist/vue.esm"

document.addEventListener("DOMContentLoaded", () => {
  new Vue({
    el: "#user-form",
    data: {
      user: {
        name: ""
      }
    }
  })
})

v-show ディレクティブ

では、ラジオボタンの選択状態によってテキストフィールドの表示・非表示を切り替える UI を Vue.js を用いて実現してみましょう。

まず、HTML のコードはこうなります。わかりやすくするため、ラベルを除くなどの簡略化を行っています。

<form id="user-form" action="/users" method="post">
  <input v-model="user.name" type="text" name="user[name]" id="user_name">
  <input v-model="user.language" type="radio" value="ruby" name="user[language]">
  <input v-model="user.language" type="radio" value="php" name="user[language]">
  <input v-model="user.language" type="radio" value="other" name="user[language]">
  <div v-show="user.language === 'other'">
    <input v-model="user.other_language" type="text" name="user[other_language]">
  </div>
  <input type="submit" name="commit" value="Create">
</form>

注目すべきは、6 行目にある v-show ディレクティブです。このディレクティブに指定された文字列は JavaScrpt コードとして評価され、それが「真(truthy)」であるかどうかで、この要素の表示・非表示が決まります。ここでは user.language の値が 'other' と等しい場合にのみ、この div 要素が表示されます。

そして、JavaScript のコードはこうなります。

import Vue from "vue/dist/vue.esm"

document.addEventListener("DOMContentLoaded", () => {
  new Vue({
    el: "#user-form",
    data: {
      user: {
        name: "",
        language: undefined,
        other_language: ""
      }
    }
  })
})

これらの変更により、もともと jQuery で実現されていた UI が Vue.js ベースで動くようになりました。JavaScript コードの中にイベントを扱っている部分がありませんね。この点がとても重要です。Vue.js の開発でもイベントを扱う必要は出てくるのですが、頻度は格段に減ります。

Rails のフォームビルダーを使う

次に、さきほどの HTML 断片のコードを Rails のフォームビルダーに生成させてみましょう。次のように書き換えます。

<%= form_for @user, html: { id: "user-form" } do |f| %>
  <%= f.text_field :name, "v-model" => "user.name" %>
  <%= f.radio_button :language, "ruby", "v-model" => "user.language" %>
  <%= f.radio_button :language, "php", "v-model" => "user.language" %>
  <%= f.radio_button :language, "other", "v-model" => "user.language" %>
  <div v-show="user.language === 'other'">
    <%= f.text_field :other_language, "v-model" => "user.other_language" %>
  </div>
  <%= f.submit "登録" %>
<% end %>

フォームビルダーの text_field メソッドや radio_button メソッドに v-model オプションを加えています。オプション名にダッシュ記号(-)が含まれているので => 記号を使う必要があります。

この form_for メソッドによって生成される form 要素の id 属性には user-form という値がセットされているので、この form 要素のコード全体が Vue コンポーネントのテンプレートとして使われることになります。

DOM ツリーからフォーム要素の値を拾う

ここからが本題です。

ここまで説明してきたように Vue.js は HTML 文書の一部分をテンプレートとして利用できるのですが、(筆者としては)残念なことに、フォーム要素に含まれる valuecheckedselected などの属性を無視します。つまり、サーバー側で生成されたフォームには値が含まれていても、Vue.js によって再描画されると全部消えてしまうのです。

実は、Vue.js 1 ではそうではありませんでした。Vue.js 2 での変更点のひとつです。

しかし、Vue インスタンスが生成される時点では、もともとの DOM ツリーはそのまま存在していますので、v-model 属性を持つ要素の value 属性等を調べれば、フォーム要素の値を拾い上げることが可能です。

import Vue from "vue/dist/vue.esm"

document.addEventListener("DOMContentLoaded", () => {
  const language = document.querySelector("[v-model='user.language']:checked")

  new Vue({
    el: "#user-form",
    data: {
      user: {
        name: document.querySelector("[v-model='user.name']").value,
        language: language ? language.value : undefined,
        other_language: document
          .querySelector("[v-model='user.other_language']").value
      }
    }
  })
})

document.querySelector は CSS セレクタを引数に取り、合致する最初の HTML 要素を返します。

vue-data-scooper プラグイン

もちろん、部品を多く含むフォームの場合、ひとつひとつ値を拾い上げていくのは煩雑です。そこで、筆者は汎用的な Vue プラグイン vue-data-scooper を作成しました。

使い方はとても簡単です。Webpacker を使っているのであれば、まず yarn add vue-data-scooper でインストールしてください。そして、Vue インスタンスを生成している JavaScript コードを次のように書き換えます。

import Vue from "vue/dist/vue.esm"
import VueDataScooper from "vue-data-scooper"

Vue.use(VueDataScooper)

document.addEventListener("DOMContentLoaded", () => {
  new Vue({
    el: "#user-form"
  })
})

Gem パッケージ vue-rails-form-builder

以上で、Rails 側の ERB テンプレートを大きく変更せずに jQuery ベースのコードを Vue.js で書き換える道が開けました。

しかし、筆者はもう少し Rails 側の修正量を減らしたいと考え、勝手に v-model 属性をセットしてくれる Gem パッケージ vue-rails-form-builder を作りました。

Gemfilegem "vue-rails-form-builder" という記述を加えて、bundle install してください。

すると、form_for の代わりとなる vue_form_forform_with の代わりとなる vue_form_with というふたつのヘルパーメソッドが ERB テンプレート内で使えるようになり、name 属性の値から v-model 属性に値が自動的にセットされるようになります。

したがって、さきほどの例は次のように書き換えられます。

<%= vue_form_for @user, html: { id: "user-form" } do |f| %>
  <%= f.text_field :name %>
  <%= f.radio_button :language, "ruby" %>
  <%= f.radio_button :language, "php" %>
  <%= f.radio_button :language, "other" %>
  <div v-show="user.language === 'other'">
    <%= f.text_field :other_language %>
  </div>
  <%= f.submit "登録" %>
<% end %>

おわりに

以上で紹介した手法は、あくまで「伝統的な Rails + jQuery ベースの Web アプリケーション」をあまり手間を掛けずに「Rails + Vue.js ベースの Web アプリケーション」に書き換えたいという状況を想定しています。

いわゆる「シングル・ページ・アプリケーション(SPA)」ではなく、ユーザーがフォームを送信した後でページ遷移が発生するタイプの Web アプリケーションです。

SPA を作りたいのであれば、おそらくは Vue コンポーネントのデータを Ajax 呼び出しで初期化することになります。もちろん、フォームデータの送信も Ajax で行うことになります。それぞれの Ajax 呼び出しを受ける API も用意しなければならないので、コード記述量はかなりのものになります。

アプリケーションの仕様が SPA であることを要求するのであれば仕方がありませんし、SPA であることが UX を大きく向上させるのであれば果敢に挑戦すべきでしょう。

しかし、jQuery による複雑な DOM 操作をやめたい、Rails アプリケーションの保守性を上げたい、というのがメインの課題であるのなら、本稿で説明したような手法が効果的かもしれません。

補足

本稿は、2017年5月22日に株式会社オイアクス主催のイベント「Rails 5.1 + Webpacker + Vue.js 入門」で筆者がお話した内容がベースになっています。

本稿で紹介した手法を用いて作られた Rails アプリケーションのソースコードは、https://github.com/oiax/tamachi_vue で公開されています。ソースコードにはいくつかのタグが設定されています。最初の ver0 は、jQuery ベースで構築されています。

ここから ver1, ver2, ver3, ver4 とソースコードの変化を追いかければ、本稿の内容をより深く理解できるでしょう。

なお、ブランチ sfc では、Vue.js の「単一ファイルコンポーネント(single file component)」を用いた実装を行っています。こちらのブランチでは、Rails フォームビルダーの利用をやめて Vue.js 側でフォームのためのテンプレートを用意しています。axios を用いた Ajax 呼び出しによって Vue コンポーネントのデータを初期化する処理の実装例にもなっていますので、ぜひ参考にしてください。

kuroda@github
Teamgenik https:/teamgenik.com というWebサービスを開発しています。
http://tkrd.hatenablog.com/
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
ユーザーは見つかりませんでした