こちらは Goodpatch Advent Calendar 2020 16日目の記事です。
前置き
プロジェクトで項目数が多めなフォームを作ったので、その知見を一度まとめておきたいと思い書いています。
Vue v3 がリリースされ、エコシステムも次々に対応していく中、v2 をまとめてどうすんだという感じですが、 まあ、まだ v3 を本番環境で使わないと思いますし、そこまで変わらないだろうということで。
あくまで、筆者が実際に開発していくなかで中でうまくいった形をまとめたものでして、こうすべきだ!という感じのものではありません。(っていうかもっとよいやり方があれば教えてください、本当に知りたいです、頼むよマジで)
内容については、既にさまざまな記事で書かれているものだと思いますが、何かの参考になれば幸いです。
前提
環境
Nuxt v2.14.x
データの送信方法
form の入力内容を送信する方法として、submitでページを更新して送信するパターンがありますが、本記事では APIサーバー に対してXHRで送信する形態を想定しています。
観点
この記事での「良さ」の基準は、コードの記述のしやすさ、読みやすさ、変更のしやさです。
(もし、それらのメリットを打ち消すぐらいパフォーマス等の観点でやばいことをしてたら教えてください)
フォームの書き方
フォーム例
全体構成を図示して説明していくのですが、具体的な例があったほうがわかりやすいので、以下のようなフォームを実現することを考えます。
全体の構成とデータフロー
先に、完成状態の構成とデータフローを示します。(APIの認証関連については省略)
全体構成図
以下、細かい説明をしていきます。
コンポーネントの構成
コンポーネントの構成については、Presentational and Container Component パターンを採用しています。
このパターンを使うことによって、責務が分離され、単体テストの実行やStorybookへの登録も容易になります。
パターンに従って、以下のような親子関係にしています。
<!-- ~/pages/users/_userCode/edit-profile.vue -->
<template>
<div>
<UserProfileFormContainer />
</div>
</template>
<!-- ~/components/.../UserProfileFormContainer.vue -->
<template>
<UserProfileForm :userProfile="userProfile" @update="update"/>
</template>
<!-- ~/components/.../UserProfileForm.vue -->
<template>
<form>
...
</form>
</template>
このぐらい単純な構造だと、Pageコンポーネントに直接フォームコンポーネントを置いて、Pageコンポーネントからデータを渡す、という方法もあると思います。
ただ、NuxtではPageコンポーネントにしか記述できない機能があるため、Containerの役割も持たせると肥大化してしまいます。そのため、このように3つのコンポーネントに分離することにしています。
入力中のデータ
Presentationalコンポーネントでは一般的にdataを極力持たせないのが良いといわれていますが、特別な要件が存在しない限り、フォームの入力中のデータは、フォームコンポーネント上の data
に持つのが良いと思います。
というのも、仮に data
を使わないとしたとき、残る候補はVuex Storeなのですが、以下のような理由で、メリットがあまりないからです。
- 記述性の点で、v-modelを利用したいが、Storeを利用する場合、入力項目分のgetter/setterの定義が必要になる。
- 入力中のデータなので他のページで使う用途は考えにくい。
- リアルタイムプレビューなどが必要な場合も、プレビュー用のコンポーネントをフォーム内に配置すればよい。レンダリングされる場所は portal-vue などでいかようにもなる。
- バリデーションルールやエラー文言などの情報を Store で扱うことになり、Storeの責務が増える。
私が開発するときは、図の通り、 input
というオブジェクトを作り、その中に入力データを集約するようにしています。
この input
は、更新Actionに対してそのまま渡せるものになっていると、記述が簡単になります。
入力値の初期化
フォームを開いたとき、全て空欄の初期値のないフォームもあると思いますが、現在の設定値をフォームの初期値として表示したいことがほとんどだと思います。前項で示した通り、 input
で入力データを扱いたいので、 APIから取得したデータをここにコピーします。
Storeから、Container、props経由で渡される userProfile
オブジェクトは、StoreのState上のデータなので、直接編集はできません。なので、オブジェクトのshallow copyか、deep copyしたものを input
に格納します。
input
の初期化のタイミングに注意が必要です。親コンポーネントがどのタイミングで userProfile
を更新するかは、コンポーネントは知りませんし、知っているべきではないので、userProfileがいつ更新されても input
を更新できるようにしておく必要があります。
大きくわけて、コンポーネントが生成される前に渡されたパターンと、される前に渡されたパターンを考える必要があります。
その両方に対応できるのが watch
です。 watch
によって、userProfileがフォームコンポーネントの生成後に渡されたパターンをキャッチできます。そして watch
の immediate
オプションを trueにすることで、コンポーネントの create タイミングで初期化を実行できます。
<!-- ~/components/.../UserProfileForm.vue -->
<script>
// deep copyを実現するなんらかの関数
import { cloneDeep } from 'utils/common'
export default {
props:{
userProfile:{
type:Object,
default: undefined,
}
},
data:{
input: {}
}
watch: {
// 元データが更新される度にinputを同期
userProfile: {
handler(val) {
if (!val) {
return
}
this.input = cloneDeep(val)
},
// この指定でcreateタイミングでもhandlerが呼ばれる
immediate: true,
},
},
}
</script>
入力コンポーネント
フォームコンポーネントと入力コンポーネントは、 input
の要素との v-model
のみで繋げられるようになっていると記述が楽になります。
<input>
などの要素や、外部ライブラリこの仕様に準拠していますが、カスタムコンポーネントもそのようになっていることが望ましいです。
TextBox
は以下のように、 model
を指定し、 input
イベント時に入力値を返すことによって、 <input>
と同様にふるまいます。
<!-- ~/components/.../UserProfileForm.vue -->
<template>
<TextBox label="ユーザー名" v-model="input.userName" />
</template>
<!-- ~/components/.../TextBox.vue -->
<template>
<input
class="text-box"
v-bind="$attrs"
:value="value"
:class="{ error }"
@keydown.enter.prevent
v-on="listeners"
/>
</template>
<script>
import Vue from 'vue'
export default {
inheritAttrs: false,
model: {
prop: 'value',
event: 'input',
},
props: {
value: {
type: String,
default: undefined,
},
error: {
type: Boolean,
default: false,
},
},
computed: {
listeners(): any {
const vm = this
return {
...this.$listeners,
input(event) {
return vm.$emit('input', event.target.value)
},
}
},
},
}
コンポーネントがv-modelに対応していなかったり、対応していても値の変換が必要になったりすると、フォームコンポーネントのやることが増えてしまいます。
変換処理をユーティリティ関数や、値を変換する機能をもった専用のTextBoxに逃がすなどして、フォームコンポーネント上の記述をすっきりさせると、項目追加やレイアウト変更に柔軟に対応することができます。
バリデーション
フォームにつきもののバリデーションについて。
素朴な書き方
今回の例のような簡単なフォームであれば、Vueのクックブックにあるように、if文を繰り返す方法もあります。
https://jp.vuejs.org/v2/cookbook/form-validation.html
checkForm()
this.errors = []
if(!input.icon){
this.error.push("アイコンは必須です")
}
if(!input.userName){
this.error.push("ユーザー名は必須です")
} else if(input.user.length > 100){
this.error.push("ユーザー名は100文字以下です")
}
if(!input.prefecture){
this.error.push("県名は必須です")
}
if(!input.birthday){
this.error.push("県名は必須です")
}
}
しかし、項目が多くなってきたり、くると、チェックする関数が肥大化し、読みにくくなってきます。
checkForm()
this.errors = []
if(!input.alpha){
this.error.push('alphaは必須')
}
if(!input.beta){
this.error.push('bataは必須')
}
.
.
.
if(!input.theta){
this.error.push('thetaは必須')
} else if (/なんらかのパターン/.test(input.theta)) ({
this.error.push('thetaは必須')
}
.
.
.
.
if(!input.omega){
this.error.push('omegaは必須')
}
}
手続的に書くと、確認しにくくバグを生みやすくなるので、宣言的にルールを記述できるようにするのが望ましいです。
ライブラリの利用
一例として、VeeValidate というライブラリを用いて記述してみます。
フォームコンポーネントに肥大化した関数を配置することなく、 template
での宣言的な記述ですっきりと記述できます。
<!-- ~/components/.../UserProfileForm.vue -->
<template>
<ValidationObserver slim>
<form>
<ValidationProvider name="ユーザー名" rules="required" v-slot="{ errors }">
<TextBox label="ユーザー名" v-model="input.userName" />
<ul>
<li class="error" v-for="error in errors" v-text="error"/>
<ul />
</ValidationProvider>
.
.
.
</form>
</ValidationObserver>
</template>
// 表示時のテキスト設定
extend('required', {
...required,
// requiredに違反したとき、以下のエラーメッセージが表示される
// {_field_} は ValidationProvider の name propの値が使用される。
message: '{_field_}は必須です',
})
ページ遷移前の確認ダイアログ
フォームの入力中に、ページ遷移やタブを閉じるなどの入力内容が消えてしまう操作に対しては、ユーザーに確認を求める必要があります。
ユーザーの操作は大きく2つにわけられ、それぞれに対応が必要です。
リロード・別サイトへの移動・タブクローズ時に確認を出したいときには beforeunload
イベントを利用します。(ブラウザの機能を使うため、ブラウザによって動作が異なることがあります)
<!-- ~/pages/users/_userCode/edit-profile.vue -->
<script>
export default {
created () {
window.addEventListener("beforeunload", this.onBeforeUnload)
},
destroyed () {
window.removeEventListener("beforeunload", this.onBeforeUnload)
},
methods: {
onBeforeUnload (e) {
e.preventDefault()
e.returnValue = ''
},
}
}
</script>
サイト内の別のページへ遷移時に確認を出したいときは、 Nuxtのページコンポーネントのみで使用できる Navigation Guard フックである beforeLeavePage
を利用します。
<!-- ~/pages/users/_userCode/edit-profile.vue -->
<script>
export default {
beforeRouteLeave (to, from, next) {
const result = confirm("ページを離れると現在の編集内容が失われます。よろしいですか?")
if (result) {
next()
}
else{
next(false)
}
}
</script>
フォームの入力内容が変更されたか検知する
前項のページ遷移前の確認ダイアログは、フォームの内容を変更していないのに毎回出てくると面倒だと感じることがあります。(開発していると特に)
フォームの入力内容を変更したときだけ、確認ダイアログを出したい場合どうすれば良いか。
先に結論のコードを貼ります。
<!-- ~/components/.../UserProfileForm.vue -->
<script>
// deep copyを実現するなんらかの関数
import { cloneDeep } from 'utils/common'
export default {
props:{
userProfile:{
type:Object,
default: undefined,
}
},
data() {
return {
input: {},
unwatchInput: undefined,
dirty: false, // 入力内容を書き換えた場合 true になる
}
},
watch: {
// 元データが更新される度にinputを同期
userProfile: {
handler(val) {
if (!val) {
return
}
this.unwatchInput?.()
this.input = cloneDeep(val)
this.dirty = false // inputが同期されたので dirtyフラグをクリア
// inputが同期後に更新されたとき、一度だけdirtyをtrueにする
this.unwatchInput = this.$watch(
'input',
() => {
this.dirty = true
this.unwatchInput?.()
},
{ deep: true }
)
},
immediate: true,
},
dirty(val) {
this.$emit('update:dirty', val)
},
},
</script>
まず、フォームコンポーネントの data
として入力内容が変化したかを現わす dirty
を持たせ、dirtyの変更時には $emit('update:dirty')
を実行するようにします。
dirtyがコンテナーコンポーネントから渡されている userProfile
と input
が同期しているかどうかを現わすようにするため、userProfile
の更新時と、 input
の更新をそれぞれ監視します。ただし、 input
の更新監視は、初回のみでよいため、 $watch
を使用し、最初に変更があったタイミングで unwatchInput
を実行することによって、監視を停止しています。
あとは、 v-bind:dirty.sync
を用いて、 dirty
の値をページコンポーネントまで引き込み、ページ遷移時にフォームを表示するか否かの判定を行うようにすればOKです。
<!-- ~/pages/users/_userCode/edit-profile.vue -->
<script>
export default {
created () {
window.addEventListener("beforeunload", this.onBeforeUnload)
},
destroyed () {
window.removeEventListener("beforeunload", this.onBeforeUnload)
},
beforeRouteLeave (to, from, next) {
if (this.dirty) {
const result = confirm("ページを離れると現在の編集内容が失われます。よろしいですか?")
if(result)
{
next()
}
else{
next(false)
}
}
}
methods: {
onBeforeUnload (e) {
if(!this.dirty) return
e.preventDefault()
e.returnValue = ''
},
}
}
</script>
おわりに
入力フォームを作るときの、全体の構成から細かい設定まで、いくつかまとめてみました。
説明不足なところは書き足したり、別の記事を書いていきたいと思います。
ありがとうございました。
明日の Goodpatch Advent Calendar 2020 は @yahharo さんです。