🔗
Vue3のv-modelを調べてみた
はじめに
Vue3のv-modelは、フォーム要素とコンポーネント間で双方向データバインディングを実現するための強力なディレクティブです。この記事では、v-modelの基本概念から、カスタムコンポーネントでの実装方法、そして実用的なパターンまで詳しく解説します。
この記事で学べること
-
v-modelの基本概念と仕組み - フォーム要素での
v-modelの使い方 - カスタムコンポーネントでの
v-model実装 - 複数の
v-modelの活用方法 - 実用的なパターンとベストプラクティス
v-modelとは?
v-modelは、Vue.jsが提供する双方向データバインディングのためのディレクティブです。フォーム要素の値とコンポーネントのデータを自動的に同期させることができます。
基本的な仕組み
v-modelは以下の糖衣構文(シンタックスシュガー)です:
<!-- v-modelの糖衣構文 -->
<input v-model="message" />
<!-- 実際の展開形 -->
<input
:value="message"
@input="message = $event.target.value"
/>
つまり、v-modelは:
-
:valueでデータを要素に渡す(データ → 要素) -
@inputで要素の変更をデータに反映する(要素 → データ)
フォーム要素でのv-model
基本的な使い方
<script setup>
import { ref } from 'vue'
const message = ref('')
const email = ref('')
const age = ref(0)
const isChecked = ref(false)
const selectedOption = ref('')
</script>
<template>
<div>
<!-- テキスト入力 -->
<input v-model="message" type="text" placeholder="メッセージを入力" />
<p>入力内容: {{ message }}</p>
<!-- メール入力 -->
<input v-model="email" type="email" placeholder="メールアドレス" />
<!-- 数値入力 -->
<input v-model.number="age" type="number" placeholder="年齢" />
<!-- チェックボックス -->
<input v-model="isChecked" type="checkbox" id="checkbox" />
<label for="checkbox">同意する</label>
<!-- セレクトボックス -->
<select v-model="selectedOption">
<option value="">選択してください</option>
<option value="option1">オプション1</option>
<option value="option2">オプション2</option>
</select>
</div>
</template>
修飾子の活用
v-modelには便利な修飾子が用意されています:
<script setup>
import { ref } from 'vue'
const message = ref('')
const lazyMessage = ref('')
const numberValue = ref(0)
const trimmedText = ref('')
</script>
<template>
<div>
<!-- .lazy: changeイベントで更新(デフォルトはinputイベント) -->
<input v-model.lazy="lazyMessage" placeholder="フォーカスアウト時に更新" />
<!-- .number: 文字列を数値に変換 -->
<input v-model.number="numberValue" type="number" />
<!-- .trim: 前後の空白を削除 -->
<input v-model.trim="trimmedText" placeholder="空白は自動削除" />
<!-- 複数の修飾子を組み合わせ -->
<input v-model.lazy.trim="message" placeholder="遅延更新+空白削除" />
</div>
</template>
カスタムコンポーネントでのv-model
カスタムコンポーネントでv-modelを使用するには、defineModelマクロまたはpropsとemitを組み合わせて実装します。
defineModelマクロを使用(Vue 3.4+)
components/CustomInput.vue
<script setup>
// defineModelマクロでv-modelを定義
const modelValue = defineModel()
// カスタムロジックを追加
const handleInput = (event) => {
// 入力値を大文字に変換
const upperValue = event.target.value.toUpperCase()
modelValue.value = upperValue
}
</script>
<template>
<div class="custom-input">
<label>カスタム入力:</label>
<input
:value="modelValue"
@input="handleInput"
class="input-field"
/>
<p class="preview">プレビュー: {{ modelValue }}</p>
</div>
</template>
<style scoped>
.custom-input {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
margin: 8px 0;
}
.input-field {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
}
.preview {
margin-top: 8px;
color: #666;
font-style: italic;
}
</style>
propsとemitを使用(従来の方法)
components/CustomInput.vue
<script setup>
// propsの定義
const props = defineProps<{
modelValue: string
}>()
// emitの定義
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 入力処理
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value.toUpperCase()
emit('update:modelValue', value)
}
</script>
<template>
<div class="custom-input">
<label>カスタム入力:</label>
<input
:value="props.modelValue"
@input="handleInput"
class="input-field"
/>
<p class="preview">プレビュー: {{ props.modelValue }}</p>
</div>
</template>
親コンポーネントでの使用
components/ParentComponent.vue
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const inputValue = ref('')
</script>
<template>
<div>
<h2>カスタムコンポーネントのv-model</h2>
<!-- カスタムコンポーネントにv-modelを適用 -->
<CustomInput v-model="inputValue" />
<p>親コンポーネントの値: {{ inputValue }}</p>
</div>
</template>
複数のv-model
Vue 3.3以降では、1つのコンポーネントで複数のv-modelを使用できます。
複数v-modelの実装
components/UserForm.vue
<script setup>
// 複数のv-modelを定義
const name = defineModel('name', { default: '' })
const email = defineModel('email', { default: '' })
const age = defineModel('age', { default: 0 })
// バリデーション関数
const validateForm = () => {
const errors = []
if (!name.value.trim()) {
errors.push('名前は必須です')
}
if (!email.value.includes('@')) {
errors.push('有効なメールアドレスを入力してください')
}
if (age.value < 0) {
errors.push('年齢は0以上である必要があります')
}
return errors
}
// フォーム送信処理
const handleSubmit = () => {
const errors = validateForm()
if (errors.length === 0) {
console.log('フォーム送信:', {
name: name.value,
email: email.value,
age: age.value
})
} else {
console.error('バリデーションエラー:', errors)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="user-form">
<div class="form-group">
<label for="name">名前:</label>
<input
id="name"
v-model="name"
type="text"
placeholder="お名前を入力"
class="form-input"
/>
</div>
<div class="form-group">
<label for="email">メールアドレス:</label>
<input
id="email"
v-model="email"
type="email"
placeholder="メールアドレスを入力"
class="form-input"
/>
</div>
<div class="form-group">
<label for="age">年齢:</label>
<input
id="age"
v-model.number="age"
type="number"
min="0"
class="form-input"
/>
</div>
<button type="submit" class="submit-button">
送信
</button>
</form>
</template>
<style scoped>
.user-form {
max-width: 400px;
margin: 0 auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: bold;
}
.form-input {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
}
.submit-button {
width: 100%;
padding: 12px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.submit-button:hover {
background-color: #0056b3;
}
</style>
親コンポーネントでの複数v-model使用
components/ParentComponent.vue
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const userName = ref('')
const userEmail = ref('')
const userAge = ref(0)
</script>
<template>
<div>
<h2>複数v-modelの例</h2>
<!-- 複数のv-modelを指定 -->
<UserForm
v-model:name="userName"
v-model:email="userEmail"
v-model:age="userAge"
/>
<div class="preview">
<h3>入力内容のプレビュー:</h3>
<p>名前: {{ userName }}</p>
<p>メール: {{ userEmail }}</p>
<p>年齢: {{ userAge }}</p>
</div>
</div>
</template>
<style scoped>
.preview {
margin-top: 20px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 8px;
}
</style>
実用的なパターン
1. 検索コンポーネント
components/SearchBox.vue
<script setup>
import { ref, watch } from 'vue'
const searchQuery = defineModel('query', { default: '' })
const isSearching = ref(false)
// 検索クエリの変更を監視
watch(searchQuery, (newQuery) => {
if (newQuery.length > 2) {
isSearching.value = true
// 実際の検索処理をここに実装
setTimeout(() => {
isSearching.value = false
}, 500)
}
})
const clearSearch = () => {
searchQuery.value = ''
}
</script>
<template>
<div class="search-box">
<div class="search-input-container">
<input
v-model="searchQuery"
type="text"
placeholder="検索..."
class="search-input"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="clear-button"
>
×
</button>
</div>
<div v-if="isSearching" class="searching-indicator">
検索中...
</div>
</div>
</template>
<style scoped>
.search-box {
position: relative;
}
.search-input-container {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 12px 40px 12px 12px;
border: 2px solid #e0e0e0;
border-radius: 25px;
font-size: 16px;
outline: none;
transition: border-color 0.3s;
}
.search-input:focus {
border-color: #007bff;
}
.clear-button {
position: absolute;
right: 12px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
}
.clear-button:hover {
color: #333;
}
.searching-indicator {
position: absolute;
top: 100%;
left: 0;
right: 0;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #e0e0e0;
border-top: none;
border-radius: 0 0 8px 8px;
text-align: center;
color: #666;
}
</style>
2. スライダーコンポーネント
components/CustomSlider.vue
<script setup>
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: number
min?: number
max?: number
step?: number
label?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const sliderValue = ref(props.modelValue)
// スライダーの値が変更されたときの処理
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
const value = Number(target.value)
sliderValue.value = value
emit('update:modelValue', value)
}
// 外部からの値の変更を監視
watch(() => props.modelValue, (newValue) => {
sliderValue.value = newValue
})
</script>
<template>
<div class="slider-container">
<label v-if="label" class="slider-label">
{{ label }}: {{ sliderValue }}
</label>
<div class="slider-wrapper">
<input
type="range"
:min="min || 0"
:max="max || 100"
:step="step || 1"
:value="sliderValue"
@input="handleInput"
class="slider"
/>
<div class="slider-values">
<span>{{ min || 0 }}</span>
<span>{{ max || 100 }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.slider-container {
margin: 16px 0;
}
.slider-label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #333;
}
.slider-wrapper {
position: relative;
}
.slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: #ddd;
outline: none;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #007bff;
cursor: pointer;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #007bff;
cursor: pointer;
border: none;
}
.slider-values {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-size: 12px;
color: #666;
}
</style>
3. トグルスイッチコンポーネント
components/ToggleSwitch.vue
<script setup>
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: boolean
label?: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isOn = ref(props.modelValue)
const toggle = () => {
if (props.disabled) return
isOn.value = !isOn.value
emit('update:modelValue', isOn.value)
}
// 外部からの値の変更を監視
watch(() => props.modelValue, (newValue) => {
isOn.value = newValue
})
</script>
<template>
<div class="toggle-container">
<label v-if="label" class="toggle-label">
{{ label }}
</label>
<div
class="toggle-switch"
:class="{
'toggle-on': isOn,
'toggle-disabled': disabled
}"
@click="toggle"
>
<div class="toggle-thumb"></div>
</div>
</div>
</template>
<style scoped>
.toggle-container {
display: flex;
align-items: center;
gap: 12px;
}
.toggle-label {
font-weight: 500;
color: #333;
}
.toggle-switch {
position: relative;
width: 50px;
height: 24px;
background-color: #ccc;
border-radius: 12px;
cursor: pointer;
transition: background-color 0.3s;
}
.toggle-switch.toggle-on {
background-color: #007bff;
}
.toggle-switch.toggle-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-on .toggle-thumb {
transform: translateX(26px);
}
</style>
ベストプラクティス
1. 型安全性の確保
// 良い例:厳密な型定義
interface UserData {
name: string
email: string
age: number
}
const userData = defineModel<UserData>('userData', {
default: () => ({
name: '',
email: '',
age: 0
})
})
// 悪い例:any型の使用
const userData = defineModel<any>('userData')
2. バリデーションの実装
<script setup>
import { computed } from 'vue'
const email = defineModel('email', { default: '' })
// リアクティブなバリデーション
const emailError = computed(() => {
if (!email.value) return 'メールアドレスは必須です'
if (!email.value.includes('@')) return '有効なメールアドレスを入力してください'
return ''
})
const isValid = computed(() => emailError.value === '')
</script>
<template>
<div>
<input
v-model="email"
type="email"
:class="{ 'error': emailError }"
/>
<p v-if="emailError" class="error-message">
{{ emailError }}
</p>
</div>
</template>
3. パフォーマンスの最適化
<script setup>
import { ref, watch, nextTick } from 'vue'
const searchQuery = defineModel('query', { default: '' })
const searchResults = ref([])
const isSearching = ref(false)
// デバウンス処理
let searchTimeout: NodeJS.Timeout
watch(searchQuery, async (newQuery) => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
if (newQuery.length < 2) {
searchResults.value = []
return
}
searchTimeout = setTimeout(async () => {
isSearching.value = true
try {
// 実際の検索処理
const results = await performSearch(newQuery)
searchResults.value = results
} catch (error) {
console.error('検索エラー:', error)
} finally {
isSearching.value = false
}
}, 300) // 300ms後に実行
})
</script>
よくある問題と解決方法
1. v-modelが更新されない
<!-- 問題:オブジェクトのプロパティを直接変更 -->
<script setup>
const user = ref({ name: '', email: '' })
</script>
<template>
<!-- これは動作しない -->
<input v-model="user.name" />
</template>
<!-- 解決方法:オブジェクト全体を更新 -->
<script setup>
const user = ref({ name: '', email: '' })
const updateName = (newName: string) => {
user.value = { ...user.value, name: newName }
}
</script>
<template>
<input :value="user.name" @input="updateName($event.target.value)" />
</template>
2. カスタムコンポーネントでv-modelが動作しない
<!-- 問題:emitの名前が間違っている -->
<script setup>
const emit = defineEmits<{
'input': [value: string] // 間違い
}>()
</script>
<!-- 解決方法:正しいemit名を使用 -->
<script setup>
const emit = defineEmits<{
'update:modelValue': [value: string] // 正しい
}>()
</script>
3. 数値の型変換
<!-- 問題:文字列として扱われる -->
<input v-model="age" type="number" />
<!-- 解決方法:.number修飾子を使用 -->
<input v-model.number="age" type="number" />
まとめ
Vue3のv-modelは、双方向データバインディングを実現するための強力な機能です。適切に使用することで、フォーム処理やコンポーネント間の通信を効率的に実装できます。
重要なポイント
-
基本概念:
v-modelは:valueと@inputの糖衣構文 -
カスタムコンポーネント:
defineModelマクロまたはpropsとemitで実装 -
複数v-model: Vue 3.3以降で複数の
v-modelを同時に使用可能 - 型安全性: TypeScriptを使用して厳密な型定義を行う
- パフォーマンス: デバウンスや適切なイベント処理で最適化
今後の学習
- Vue3のComposition APIの詳細
- 状態管理ライブラリ(Pinia)との連携
- フォームバリデーションライブラリの活用
- カスタムディレクティブの作成
この記事で学んだ内容を活用して、より効率的で保守性の高いVue3アプリケーションを構築してください!
Discussion