要旨
- 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 フォームを例として考えてください。
ユーザーが「その他」と書かれたラジオボタンを選択すると、その下にテキストフィールドが現れます。
比較的単純な DOM 操作ではありますが、次の三つのことを実現するコードを jQuery を使って書かなければなりません。
- ラジオボタンの選択状態を調べ、一番下のテキストフィールドの表示・非表示を切り替える関数 f を定義する。
- HTML 文書のロード時に関数 f を呼び出す。
- ユーザーがラジオボタンをクリックしたときに関数 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 文書の一部分をテンプレートとして利用できるのですが、(筆者としては)残念なことに、フォーム要素に含まれる value
、checked
、selected
などの属性を無視します。つまり、サーバー側で生成されたフォームには値が含まれていても、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 を作りました。
Gemfile
に gem "vue-rails-form-builder"
という記述を加えて、bundle install
してください。
すると、form_for
の代わりとなる vue_form_for
と form_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 コンポーネントのデータを初期化する処理の実装例にもなっていますので、ぜひ参考にしてください。