404 Not Found

404 Not Found


nginx

Vue 3 のフォーム処理と高度な v-model:defineModel、カスタムバリデーション、および複雑なフォーム

フォームは、Webアプリケーションの中核となるインタラクションです。ログイン、登録、検索、設定などはすべて、フォーム処理に依存しています。Vueのv-modelは、フォーム処理のための「シンタックスシュガー」であり、双方向バインディングのコードを極めて簡潔にします(1行対5行)。

Vue 3.4 以降では、defineModel により v-model の実装がさらに簡素化されています。複雑なフォームでは、バリデーション、シリアライズ、ファイルアップロードなどの高度な機能も必要となります。このレッスンでは、5つの主要なフォームのシナリオについて解説します。

1. 学習内容


2. ログインフォームの悪夢「5つの定型セクション」

(1) 課題:5つのフォームフィールド、10行のv-modelコード

アリスのログインフォームには5つの入力欄があり、それぞれにv-modelバインディングが設定されていました:

VUE
<!-- ❌ The "Flip" Version: 5 fields + 5 refs + 5 v-model -->
<template>
  <form @submit.prevent="handleLogin">
    <input v-model="username">
    <input v-model="password" type="password">
    <input v-model="email" type="email">
    <input v-model="phone">
    <input v-model="captcha">
    <button>Login</button>
  </form>
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
const password = ref('')
const email = ref('')
const phone = ref('')
const captcha = ref('')
</script>

5つのフィールド=10行の定型文。10つのフィールドは20行に相当する。フォームが複雑になればなるほど、状況は悪化する。

(2) Vueのv-modelに関する高度なアプローチ:リアクティブオブジェクト + defineModel

VUE
<!-- ✅ Correct Version: 1 reactive Object -->
<template>
  <form @submit.prevent="handleLogin">
    <input v-model="form.username">
    <input v-model="form.password" type="password">
    <input v-model="form.email" type="email">
    <input v-model="form.phone">
    <input v-model="form.captcha">
    <button>Login</button>
  </form>
</template>

<script setup>
import { reactive } from 'vue'
const form = reactive({
  username: '',
  password: '',
  email: '',
  phone: '',
  captcha: ''
})
</script>

5つのフィールド = 1つのオブジェクト。リアクティブプログラミングにより、コード量が50%削減されます。

(3) 収益

Vモデルの最適化後:


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
<!-- Child component:CustomInput.vue -->
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
VUE
<!-- Using Parent Components v-model -->
<CustomInput v-model="searchQuery" />

<!-- equivalent to -->
<CustomInput 
  :modelValue="searchQuery" 
  @update:modelValue="searchQuery = $event"
/>

(3) defineModel(Vue 3.4以降向けに簡略化)

VUE
<!-- Vue 3.4+: 1 line replaces the 10 lines above -->
<template>
  <input v-model="value">
</template>

<script setup>
const value = defineModel('modelValue')
// Now value is ref, Sure:
// - Read: value.value
// - Write: value.value = 'new' (Automatic emit)
// - watch:watch(value, ...)
// - Parent Component v-model Two-way binding works as usual
</script>

4. カスタムVモデルの5つのタイプ

(1) 基本的な入力

VUE
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

(2) テキストメッセージ

VUE
<template>
  <textarea :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

(3) 選択

VUE
<template>
  <select 
    :value="modelValue" 
    @change="$emit('update:modelValue', $event.target.value)"
  >
    <option value="vue">Vue</option>
    <option value="react">React</option>
  </select>
</template>

(4) チェックボックス(複数選択可)

VUE
<template>
  <div>
    <label v-for="option in options" :key="option">
      <input 
        type="checkbox" 
        :value="option" 
        :checked="modelValue.includes(option)"
        @change="onChange($event, option)"
      >
      {{ option }}
    </label>
  </div>
</template>

<script setup>
const props = defineProps({
  modelValue: Array,
  options: Array
})
const emit = defineEmits(['update:modelValue'])

function onChange(e, option) {
  const newValue = e.target.checked
    ? [...props.modelValue, option]
    : props.modelValue.filter(v => v !== option)
  emit('update:modelValue', newValue)
}
</script>

(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. フォームの検証

(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>

6. 複雑なフォームの設計

(1) ネストされたフォーム

JS
const form = reactive({
  user: {
    name: '',
    email: '',
    age: 0
  },
  address: {
    city: '',
    country: ''
  }
})
VUE
<template>
  <input v-model="form.user.name" placeholder="Name">
  <input v-model="form.user.email" placeholder="Email">
  <input v-model="form.address.city" placeholder="City">
</template>

(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)
}
VUE
<template>
  <div v-for="(item, index) in form.items" :key="index">
    <input v-model="item.name" placeholder="Item name">
    <input v-model.number="item.quantity" type="number">
    <input v-model.number="item.price" type="number">
    <button @click="removeItem(index)">Remove</button>
  </div>
  <button @click="addItem">Add Item</button>
</template>

(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) 単一ファイルのアップロード

VUE
<template>
  <input type="file" @change="handleFile" accept="image/*">
  <img v-if="preview" :src="preview" alt="Preview">
</template>

<script setup>
import { ref } from 'vue'

const preview = ref(null)

function handleFile(event) {
  const file = event.target.files[0]
  if (file) {
    // Local Preview
    const reader = new FileReader()
    reader.onload = (e) => {
      preview.value = e.target.result
    }
    reader.readAsDataURL(file)
    
    // Upload to the server
    const formData = new FormData()
    formData.append('file', file)
    fetch('/api/upload', { method: 'POST', body: formData })
  }
}
</script>

(2) 複数のファイル + ドラッグ&ドロップ

VUE
<template>
  <div 
    @dragover.prevent
    @drop.prevent="handleDrop"
    :class="{ dragging: isDragging }"
  >
    <input type="file" multiple @change="handleFiles" accept="image/*">
    <p>Drag files here or click to upload</p>
    
    <div v-for="file in files" :key="file.name">
      <img :src="file.preview" :alt="file.name">
      <p>{{ file.name }} ({{ file.size }} bytes)</p>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const files = reactive([])
const isDragging = ref(false)

function addFile(file) {
  if (!file.type.startsWith('image/')) return
  
  const reader = new FileReader()
  reader.onload = (e) => {
    files.push({
      name: file.name,
      size: file.size,
      preview: e.target.result
    })
  }
  reader.readAsDataURL(file)
}

function handleFiles(e) {
  Array.from(e.target.files).forEach(addFile)
}

function handleDrop(e) {
  isDragging.value = false
  Array.from(e.dataTransfer.files).forEach(addFile)
}
</script>

8. 完全な例:5つの主要なフォームのシナリオ

(1) ▶ サンプル:. 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>
▶ 試してみよう

(2) ▶ サンプル:. カスタム v-model の 5 つの種類

VUE
<!-- Child component:CustomInput.vue -->
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)">
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

<!-- Parent Component -->
<CustomInput v-model="search" />
<!-- equivalent to -->
<CustomInput :modelValue="search" @update:modelValue="search = $event" />
▶ 試してみよう

(3) ▶ サンプル:. 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'
▶ 試してみよう

(4) ▶ サンプル:. 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" />
▶ 試してみよう

(5) ▶ サンプル:. よくある5つの間違いに関するクイックリファレンス

エラー 症状 解決策
v-model.number は依然として文字列です 加算エラー type="number" + .number
v-model が更新されない reactive に変更すると ref が非アクティブになる .value または toRefs に変更する
FormData を使用したファイルのアップロードに失敗しました クロスドメインまたはメソッドのエラー fetch + FormData
検証のタイミングが不適切 フォーカスが外れたときのみ表示 @blur を使用
リアクティブオブジェクトを使用した 冗長なコード 多くのフィールドの定型コード

(6) ▶ サンプル:. 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 文字列として読み取り、それを &lt;img src="..."&gt;. Use FormData に割り当ててアップロードします。
Q v-model の .lazy と .debounce 修飾子の違いは何ですか?
A .lazy は change イベント(フォーカスを失った後)によってトリガーされ、リアルタイム検証のオーバーヘッドを軽減します。.debounce は子コンポーネントで実装する必要があるカスタム修飾子であり、300 ms 以内に発生した直近のイベントをトリガーします。
Q 送信のためにフォームデータをJSON形式でシリアライズするにはどうすればよいですか?
A JSON.stringify(form) を使用するだけで十分です(ファイルを送信する場合を除き、FormDataは必要ありません)。バックエンド側では、そのJSONを受け取るだけで済みます。
Q defineModelv-model の構文全体を置き換えるのですか?
A はい。defineModel('modelValue') is equivalent to the 10 lines of code in props.modelValue + emit('update:modelValue') です。完全な下位互換性があります。

📖 まとめ


📝 練習問題

(1) 基礎問題(難易度:⭐)

簡単なログインフォームを実装する:

(2) 上級問題(難易度:⭐⭐)

フォームの検証の実装:

(3) チャレンジ問題(難易度:⭐⭐⭐)

完全な「ユーザー登録」フォームシステムを実装する:

  1. 10個のフィールド(ネストされた address オブジェクトおよび動的な tags 配列を含む)
  2. VeeValidate + Yupスキーマ
  3. ファイルのアップロード(プロフィール写真)
  4. 5つの修飾子の完全網羅
  5. defineModel は子コンポーネントに使用されます
  6. 5 エラー状態
  7. TypeScriptの厳格な型付け
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%