Vue.js: フォームとv-model
最終更新:2026-10-20
フォームは、Webアプリケーションの中核となるインタラクションです。ログイン、登録、検索、設定などはすべて、フォーム処理に依存しています。Vueのv-modelは、フォーム処理のための「シンタックスシュガー」であり、双方向バインディングのコードを極めて簡潔にします(1行対5行)。
Vue 3.4 以降では、defineModel により v-model の実装がさらに簡素化されています。複雑なフォームでは、バリデーション、シリアライズ、ファイルアップロードなどの高度な機能も必要となります。このレッスンでは、5つの主要なフォームのシナリオについて解説します。
1. 学習内容
- Vモデルの仕組み(基本的には、「props + emit」の構文糖衣です)
- カスタムVモデルコンポーネント
- defineModel の簡略化 (Vue 3.4 以降)
- フォームの検証(VeeValidate / カスタム)
- 複雑なフォーム設計(ネストされたフィールド/動的フィールド)
- フォームのシリアライズと送信
- ファイルのアップロードとプレビュー
2. ログインフォームの悪夢「5つの定型セクション」
(1) 課題:5つのフォームフィールド、10行のv-modelコード
アリスのログインフォームには5つの入力欄があり、それぞれにv-modelバインディングが設定されていました:
5つのフィールド=10行の定型文。10つのフィールドは20行に相当する。フォームが複雑になればなるほど、状況は悪化する。
(2) Vueのv-modelに関する高度なアプローチ:リアクティブオブジェクト + defineModel
5つのフィールド = 1つのオブジェクト。リアクティブプログラミングにより、コード量が50%削減されます。
(3) 収益
Vモデルの最適化後:
- コード行数:10行 → 5行(-50%)
- 新規フィールド:1行(リアクティブフィールドが追加されました)
- フォーム全体を送信する:フォームオブジェクトを送信するだけです
- 検証:一元的な処理
3. Vモデルの仕組み
(1) Vモデルの本質
VUE
<!-- v-model is props + emit syntax sugar -->
<input v-model="searchQuery">
<!-- equivalent to -->
<input
:value="searchQuery"
@input="searchQuery = $event.target.value"
>
(2) コンポーネントにおけるVモデル
VUE
<!-- Using Parent Components v-model -->
<CustomInput v-model="searchQuery" />
<!-- equivalent to -->
<CustomInput
:modelValue="searchQuery"
@update:modelValue="searchQuery = $event"
/>
(3) defineModel(Vue 3.4以降向けに簡略化)
▶ サンプル:Vモデルの5つの基本原則
VUE
<!-- 1. text input -->
<input v-model="text">
<!-- 2. textarea -->
<textarea v-model="description"></textarea>
<!-- 3. checkbox -->
<input v-model="agreed" type="checkbox">
<!-- 4. radio -->
<input v-model="gender" type="radio" value="male">
<input v-model="gender" type="radio" value="female">
<!-- 5. select -->
<select v-model="selected">
<option value="vue">Vue</option>
<option value="react">React</option>
</select>
出力:
TEXT
text / textarea は入力した文字列がそのまま反映される
checkbox はチェックで true、radio は male / female のどちらか 1 つ
select は選んだ option の value(例: vue)が入る
4. カスタムVモデルの5つのタイプ
(1) 基本的な入力
(2) テキストメッセージ
(3) 選択
(4) チェックボックス(複数選択可)
(5) カスタム修飾子 (trim)
VUE
<!-- Parent Component:v-model.trim -->
<CustomInput v-model.trim="username" />
<!-- Child component:Processing trim Modifiers -->
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
function onInput(e) {
let value = e.target.value
if (props.modelModifiers.trim) {
value = value.trim()
}
emit('update:modelValue', value)
}
</script>
▶ サンプル:5つの主要な形式修飾子
VUE
<!-- 1. .lazy:change Event -->
<input v-model.lazy="email">
<!-- 2. .number:Automatically Convert to Numbers -->
<input v-model.number="age" type="number">
<!-- 3. .trim:Automatic trim -->
<input v-model.trim="username">
<!-- 4. Combinations of Multiple Modifiers -->
<input v-model.lazy.trim="email">
<!-- 5. Custom Modifiers(Child component)-->
<CustomInput v-model.trim="text" />
出力:
TEXT
.lazy は change(フォーカスを外す)まで値を反映しない
.number は 25 という入力を数値 25 に変換、.trim は前後の空白を除去
.lazy.trim は両方、カスタムコンポーネントでも .trim が効く
5. フォームの検証
(1) カスタム検証
JS
// utils/validators.js
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
export function isPhone(value) {
return /^1[3-9]\d{9}$/.test(value)
}
export function minLength(value, min) {
return value.length >= min
}
VUE
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.email" @blur="validateField('email')">
<p v-if="errors.email" class="error">{{ errors.email }}</p>
<input v-model="form.password" type="password">
<p v-if="errors.password">{{ errors.password }}</p>
<button :disabled="!isValid">Submit</button>
</form>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { isEmail, minLength } from '@/utils/validators'
const form = reactive({
email: '',
password: ''
})
const errors = reactive({})
const isValid = ref(false)
function validateField(field) {
if (field === 'email') {
if (!form.email) {
errors.email = 'Email is required'
} else if (!isEmail(form.email)) {
errors.email = 'Invalid email format'
} else {
delete errors.email
}
}
if (field === 'password') {
if (!form.password) {
errors.password = 'Password is required'
} else if (!minLength(form.password, 8)) {
errors.password = 'Password must be 8+ characters'
} else {
delete errors.password
}
}
isValid.value = Object.keys(errors).length === 0
}
function handleSubmit() {
Object.keys(form).forEach(validateField)
if (isValid.value) {
console.log('Submit:', form)
}
}
</script>
(2) VeeValidate ライブラリ(推奨)
BASH
npm install vee-validate @vee-validate/rules
VUE
<template>
<Form @submit="handleSubmit" :validation-schema="schema">
<Field name="email" type="email" v-model="form.email" />
<ErrorMessage name="email" />
<Field name="password" type="password" v-model="form.password" />
<ErrorMessage name="password" />
<button>Submit</button>
</Form>
</template>
<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required()
})
const form = reactive({ email: '', password: '' })
function handleSubmit(values) {
console.log('Valid:', values)
}
</script>
▶ サンプル:5 検証シナリオ
JS
// 1. Required
if (!form.email) errors.email = 'Required'
// 2. Email
if (!isEmail(form.email)) errors.email = 'Invalid email'
// 3. Cell Phone
if (!isPhone(form.phone)) errors.phone = 'Invalid phone'
// 4. Length
if (form.password.length < 8) errors.password = 'Too short'
// 5. Confirm Password
if (form.password !== form.confirm) errors.confirm = 'Mismatch'
出力:
TEXT
メール未入力 → Required、形式が不正 → Invalid email
電話番号が不正 → Invalid phone、8 文字未満 → Too short
確認用パスワードと不一致 → Mismatch
6. 複雑なフォームの設計
(1) ネストされたフォーム
JS
const form = reactive({
user: {
name: '',
email: '',
age: 0
},
address: {
city: '',
country: ''
}
})
(2) 動的フィールド (v-for)
JS
const form = reactive({
items: [
{ name: '', quantity: 1, price: 0 }
]
})
function addItem() {
form.items.push({ name: '', quantity: 1, price: 0 })
}
function removeItem(index) {
form.items.splice(index, 1)
}
(3) 5つの主要な形式修飾子
VUE
<!-- 1. .lazy:change Event Trigger(No input) -->
<input v-model.lazy="email">
<!-- 2. .number:Automatically Convert to Numbers -->
<input v-model.number="age" type="number">
<!-- 3. .trim:Automatically Remove Spaces -->
<input v-model.trim="username">
<!-- 4. Custom Modifiers(CustomInput) -->
<CustomInput v-model.trim="text" />
<!-- 5. .debounce(Custom):300ms Image Stabilization -->
<DebouncedInput v-model:debounce="500" />
7. ファイルのアップロード
(1) 単一ファイルのアップロード
(2) 複数のファイル + ドラッグ&ドロップ
8. 総合演習:カスタム v-model の5つの種類
▶ サンプル:カスタム v-model の 5 つの種類
▶ 試してみよう出力:
TEXT
子の入力欄に親の search の値が表示(初期は空)
入力すると update:modelValue が発火し親の search も同じ文字列に
v-model 1 行が :modelValue + @update:modelValue の 2 行と同じ動作
(1) 📊 よくある5つの間違いに関するクイックリファレンス
| エラー | 症状 | 解決策 |
|---|---|---|
| v-model.number は依然として文字列です | 加算エラー | type="number" + .number |
| v-model が更新されない | reactive に変更すると ref が非アクティブになる | .value または toRefs に変更する |
| FormData を使用したファイルのアップロードに失敗しました | クロスドメインまたはメソッドのエラー | fetch + FormData |
| 検証のタイミングが不適切 | フォーカスが外れたときのみ表示 | @blur を使用 |
| リアクティブオブジェクトを使用した | 冗長なコード | 多くのフィールドの定型コード |
(2) 📊 5つの主要なパフォーマンス比較
| パターン | シンプルさ | 性能 | 適用性 |
|---|---|---|---|
| 複数の参照 | ❌ 重複 | ⭐⭐⭐⭐ | 1~2つのフィールド |
| リアクティブオブジェクト | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 5つ以上のフィールド |
| FormData (マニュアル) | ❌ | ⭐⭐⭐ | ファイルのアップロード |
| VeeValidate | ⭐⭐⭐⭐ | ⭐⭐⭐ | 複雑な検証 |
| defineModel | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Vue 3.4+ コンポーネント |
❓ よくある質問
Q カスタムコンポーネントで v-model をどのように使用しますか?
A コンポーネント内で
defineProps(['modelValue']) + defineEmits(['update:modelValue']) を使用します。親コンポーネントでは v-model を使用します。Vue 3.4 以降では、defineModel を使用することで、10 行のコードをたった 1 行に置き換えることができます。Q v-model.number が機能しないのはなぜですか?
A
type="number" と組み合わせて使用する必要があります。あるいは、parseFloat() を手動で指定してください。Q フォームの検証には、カスタム検証とVeeValidateのどちらを使うべきですか?
A シンプルなフォーム(フィールド数が5つ未満)の場合はカスタム検証を、複雑なフォーム(フィールド数が5つ以上、ネストされたフィールドがある、または非同期検証が必要な場合)の場合はVeeValidateを使用してください。
Q ファイルをアップロードしてプレビューを表示するにはどうすればよいですか?
A
FileReader.readAsDataURL() を使用してファイルを Base64 文字列として読み取り、それを <img src="...">. Use FormData に割り当ててアップロードします。Q v-model の .lazy と .debounce 修飾子の違いは何ですか?
A .lazy は
change イベント(フォーカスを失った後)によってトリガーされ、リアルタイム検証のオーバーヘッドを軽減します。.debounce は子コンポーネントで実装する必要があるカスタム修飾子であり、300 ms 以内に発生した直近のイベントをトリガーします。Q 送信のためにフォームデータをJSON形式でシリアライズするにはどうすればよいですか?
A
JSON.stringify(form) を使用するだけで十分です(ファイルを送信する場合を除き、FormDataは必要ありません)。バックエンド側では、そのJSONを受け取るだけで済みます。Q
defineModel は v-model の構文全体を置き換えるのですか?A はい。
defineModel('modelValue') is equivalent to the 10 lines of code in props.modelValue + emit('update:modelValue') です。完全な下位互換性があります。📖 まとめ
v-modelはpropsおよびemitの構文糖衣であり、Vue 3.4 以降では、defineModelを使用した 1 行のコードに置き換えられています。- カスタム V モデルの 5 種類:input / textarea / select / checkbox / modifiers
- 5つの修飾子:.lazy / .number / .trim / カスタム / 複数の修飾子
- フォームの検証:カスタム(簡易)/VeeValidate(高度)
- 複雑なフォームの5つの種類:ネスト型/動的型/シーケンシャル型/バリデーション型/ファイル型
- リアクティブオブジェクト + Vモデルにより、定型コードを50%削減
- ファイルのアップロード:FileReader + FormData
📝 練習問題
-
基礎問題(難易度:⭐)
簡単なログインフォームを実装する:
- 5つの入力欄(ユーザー名/パスワード/メールアドレス/電話番号/キャプチャ)
- Vモデル + リアクティブ
- 送信時にフォームオブジェクト全体を出力する
-
上級問題(難易度:⭐⭐)
フォームの検証の実装:
- 5つの入力項目 + カスタム検証(メールアドレスの形式、電話番号の形式、パスワードの長さ)
- @blur がバリデーションをトリガーする
- エラーメッセージが表示された
isValidプロパティの設定に応じて、送信ボタンの有効/無効が切り替わります
-
チャレンジ問題(難易度:⭐⭐⭐)
完全な「ユーザー登録」フォームシステムを実装する:
- 10個のフィールド(ネストされた
addressオブジェクトおよび動的なtags配列を含む) - VeeValidate + Yupスキーマ
- ファイルのアップロード(プロフィール写真)
- 5つの修飾子の完全網羅
defineModelは子コンポーネントに使用されます- 5 エラー状態
- TypeScriptの厳格な型付け
- 10個のフィールド(ネストされた