V-model у Vue.js
v-model - це директива Vue для двостороннього зв'язування (two-way binding) між полями форми і станом компонента через value prop та input event.
Теорія
TL;DR
v-modelкомпілюється в:value="x" @input="x = $event.target.value"під капотом- Працює на нативних полях (
<input>,<textarea>,<select>) і кастомних компонентах - Кастомні компоненти: потрібен проп
modelValueі emitupdate:modelValue - Vue 3 підтримує кілька іменованих v-model на одному компоненті:
v-model:firstName,v-model:lastName - Модифікатори
.lazy,.number,.trimзакривають найпоширеніші потреби при роботі з інпутами
Швидкий приклад
<template>
<input v-model="message" placeholder="Введіть текст" />
<p>Live: {{ message }}</p>
</template>
<script setup>
import { ref } from 'vue'
const message = ref('Hello Vue') // Початкове значення відображається в інпуті
</script>Кожен символ в інпуті одразу оновлює message. Тег <p> ре-рендериться автоматично, бо message - реактивний ref. Жодного обробника подій не потрібно.
Як це компілюється
Компілятор шаблонів Vue трансформує v-model="text" під час збірки:
<!-- Що пишеш ти -->
<input v-model="text" />
<!-- Що генерує компілятор -->
<input :value="text" @input="text = $event.target.value" />Для кастомних компонентів результат інший:
<!-- Що пишеш ти -->
<CustomInput v-model="text" />
<!-- Що генерує компілятор -->
<CustomInput :modelValue="text" @update:modelValue="text = $event" />Система реактивності на основі Proxy (Vue 3) відстежує присвоєння і запускає ре-рендер для всього, що читає text.
Коли використовувати
<input>,<textarea>,<select>- використовуй v-model напряму- Кастомні компоненти-інпути - v-model через
modelValue+update:modelValue - Кілька полів на одному компоненті - іменовані v-model типу
v-model:firstName - Кнопки та інші не-форм елементи - v-model тут не потрібен, використовуй
@click - Складні форми зі спільним станом між багатьма компонентами - розглянь Pinia
Модифікатори
Три вбудовані модифікатори покривають найпоширеніші задачі обробки вводу:
<input v-model.lazy="text" /> <!-- Оновлюється після blur, не при кожному символі -->
<input v-model.number="age" /> <!-- Автоматично перетворює рядок на число -->
<input v-model.trim="username" /> <!-- Обрізає пробіли на початку і в кінці -->
<!-- Комбінація -->
<input v-model.lazy.trim="email" />.lazy перемикає слухач з @input на @change. Зручно для пошукових полів, де не треба запускати запит при кожному натисканні.
v-model у кастомних компонентах
Батько передає значення, дитина емітить зміни назад. Це весь контракт.
<!-- Батьківський компонент -->
<CustomInput v-model="searchText" />
<!-- Дочірній: CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>Більш чистий патерн - computed з getter і setter. Це той підхід, який я найчастіше використовую в реальних компонентах: шаблон залишається простим, а потік даних очевидний.
<!-- CustomInput.vue -->
<script setup>
import { computed } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>
<template>
<input v-model="value" />
</template>Кілька v-model (Vue 3)
Vue 3 прибрав обмеження Vue 2 на один v-model. Кожен v-model:propName відповідає пропу propName і еміту update:propName:
<!-- Батьківський компонент -->
<UserName
v-model:firstName="firstName"
v-model:lastName="lastName"
/>
<!-- Дочірній: UserName.vue -->
<template>
<input
:value="firstName"
@input="$emit('update:firstName', $event.target.value)"
placeholder="Ім'я"
/>
<input
:value="lastName"
@input="$emit('update:lastName', $event.target.value)"
placeholder="Прізвище"
/>
</template>
<script setup>
defineProps(['firstName', 'lastName'])
defineEmits(['update:firstName', 'update:lastName'])
</script>Типові помилки
Пряма мутація пропа. Це найпоширеніша помилка з v-model у компонентах Vue.
<!-- Неправильно: мутація пропа -->
<template>
<input v-model="modelValue" />
</template>
<script setup>
defineProps(['modelValue'])
// Vue видасть попередження: "Avoid mutating a prop directly"
</script>Пропи передаються в одному напрямку. Батько володіє даними, і будь-яка зміна має повернутися через emit. Виправлення через computed:
<!-- Правильно: computed як проксі -->
<script setup>
import { computed } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>v-model на contenteditable. Не дає жодного ефекту.
<!-- Не працює -->
<div contenteditable v-model="text"></div>v-model розраховує на властивість value, якої у contenteditable елементів немає. Потрібне ручне підключення:
<div
contenteditable="true"
:innerHTML="text"
@input="text = $event.target.innerText"
></div>Неправильний тип ref для множинних checkbox. Коли кілька чекбоксів прив'язані до одного ref, він має бути масивом, не рядком.
<script setup>
// Неправильно: рядок ламає логіку перемикання
const hobbies = ref('')
// Правильно: масив відстежує всі вибрані значення
const hobbies = ref([])
</script>
<template>
<input type="checkbox" v-model="hobbies" value="coding" /> Coding
<input type="checkbox" v-model="hobbies" value="skiing" /> Skiing
<p>{{ hobbies }}</p> <!-- ['coding', 'skiing'] якщо обидва вибрані -->
</template>З масивом Vue додає і видаляє значення при переключенні. З рядком - поведінка непередбачувана.
Де зустрічається в реальних проектах
- Vuetify:
<v-text-field v-model="form.email">в адмін-панелях - Quasar:
<q-input v-model="state.search">для пошуку в мобільних застосунках - Element Plus:
<el-input v-model="query">у таблицях із серверною фільтрацією - VeeValidate: обертає
v-modelщоб запустити валідацію перед емітом оновлення - Nuxt.js:
v-modelу формах профілю користувача разом зuseAsyncData
Питання на співбесіді
Q: Що є скомпільованим виводом <input v-model="msg" />?
A: :value="msg" @input="msg = $event.target.value". Для кастомного компонента: :modelValue="msg" @update:modelValue="msg = $event".
Q: Чим v-model відрізняється у Vue 2 і Vue 3?
A: Vue 2 використовує проп value і подію input, захардкоджено для всіх компонентів. Vue 3 перейшов на modelValue і update:modelValue, що дозволяє кілька іменованих v-model на одному компоненті.
Q: Яку подію слухає v-model на <select multiple>?
A: change, не input. Vue обробляє це автоматично і збирає вибрані опції у масив.
Q: Як реалізувати lazy v-model у кастомному компоненті, щоб оновлення відбувалось тільки після blur?
A: Зберігати проміжне значення в локальному ref при @input, а емітувати update:modelValue тільки в @blur. Це той самий ефект що v-model.lazy, але всередині кастомного компонента.
Q: Два інпути мають v-model="text" на один ref. Що станеться?
A: Race condition на @input. Перемагає той, хто спрацював останнім, тобто другий інпут завжди перезаписує перший. Використовуй окремі refs або computed з getter/setter для кожного поля.
Приклади
Базовий: рядок живого пошуку
<template>
<input
v-model="query"
placeholder="Пошук товарів..."
/>
<p>Шукаємо: {{ query }}</p>
</template>
<script setup>
import { ref } from 'vue'
const query = ref('')
</script>Кожне натискання клавіші оновлює query, параграф ре-рендериться. Жодного обробника подій не потрібно.
Середній: форма todo (патерн TodoMVC)
<template>
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="Додати завдання..."
/>
<ul>
<li v-for="todo in todos" :key="todo.id">{{ todo.text }}</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const newTodo = ref('')
const todos = ref([])
function addTodo() {
if (!newTodo.value.trim()) return
todos.value.push({ id: Date.now(), text: newTodo.value.trim() })
newTodo.value = '' // v-model очищає інпут коли ресетиш ref
}
</script>Натискаєш Enter - завдання з'являється у списку, інпут очищається. Достатньо newTodo.value = '', бо v-model тримає DOM синхронізованим із ref.
Просунутий: кастомний email-інпут з валідацією і lazy-оновленням
<!-- EmailInput.vue -->
<template>
<input
:value="modelValue"
@blur="handleBlur"
@input="localValue = $event.target.value"
:class="{ error: hasError }"
/>
<span v-if="hasError">Некоректний email</span>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
const localValue = ref(props.modelValue)
const hasError = ref(false)
function handleBlur() {
hasError.value = !localValue.value.includes('@')
if (!hasError.value) {
emit('update:modelValue', localValue.value) // Емітить тільки при валідному blur
}
}
</script>Дані батька оновлюються тільки коли користувач покинув поле і значення валідне. Введення тексту залишається локальним до blur. Такий патерн часто зустрічається у бібліотеках форм, зокрема у VeeValidate.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.