Skip to main content

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 і emit update:modelValue
  • Vue 3 підтримує кілька іменованих v-model на одному компоненті: v-model:firstName, v-model:lastName
  • Модифікатори .lazy, .number, .trim закривають найпоширеніші потреби при роботі з інпутами

Швидкий приклад

vue
<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" під час збірки:

vue
<!-- Що пишеш ти --> <input v-model="text" /> <!-- Що генерує компілятор --> <input :value="text" @input="text = $event.target.value" />

Для кастомних компонентів результат інший:

vue
<!-- Що пишеш ти --> <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

Модифікатори

Три вбудовані модифікатори покривають найпоширеніші задачі обробки вводу:

vue
<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 у кастомних компонентах

Батько передає значення, дитина емітить зміни назад. Це весь контракт.

vue
<!-- Батьківський компонент --> <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. Це той підхід, який я найчастіше використовую в реальних компонентах: шаблон залишається простим, а потік даних очевидний.

vue
<!-- 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:

vue
<!-- Батьківський компонент --> <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.

vue
<!-- Неправильно: мутація пропа --> <template> <input v-model="modelValue" /> </template> <script setup> defineProps(['modelValue']) // Vue видасть попередження: "Avoid mutating a prop directly" </script>

Пропи передаються в одному напрямку. Батько володіє даними, і будь-яка зміна має повернутися через emit. Виправлення через computed:

vue
<!-- Правильно: 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. Не дає жодного ефекту.

vue
<!-- Не працює --> <div contenteditable v-model="text"></div>

v-model розраховує на властивість value, якої у contenteditable елементів немає. Потрібне ручне підключення:

vue
<div contenteditable="true" :innerHTML="text" @input="text = $event.target.innerText" ></div>

Неправильний тип ref для множинних checkbox. Коли кілька чекбоксів прив'язані до одного ref, він має бути масивом, не рядком.

vue
<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 для кожного поля.

Приклади

Базовий: рядок живого пошуку

vue
<template> <input v-model="query" placeholder="Пошук товарів..." /> <p>Шукаємо: {{ query }}</p> </template> <script setup> import { ref } from 'vue' const query = ref('') </script>

Кожне натискання клавіші оновлює query, параграф ре-рендериться. Жодного обробника подій не потрібно.

Середній: форма todo (патерн TodoMVC)

vue
<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-оновленням

vue
<!-- 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.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?