Користувацькі директиви у Vue.js
Користувацькі директиви у Vue.js - це функції, які ти реєструєш для прямої маніпуляції DOM-елементами в шаблонах, минаючи компонентну логіку для imperativних задач: управління фокусом, ініціалізація сторонніх бібліотек, виявлення кліку за межами елемента.
Теорія
TL;DR
- Директиви схожі на спеціалізовані кухонні гаджети: вбудовані Vue (
v-if,v-for) вирішують типові задачі, ти пишеш свої для специфічної роботи з DOM. - Головна різниця: компоненти управляють даними та UI декларативно, директиви безпосередньо змінюють реальний DOM-елемент.
- Дані передаються через
binding.value(якv-tooltip="'Зберегти'"), Vue викликає хуки автоматично. - Використовуй коли потрібен прямий доступ до елемента (фокус, скрол, сторонні бібліотеки).
- Для реактивного стану і бізнес-логіки - composables, не директиви.
Швидкий приклад
<script setup lang="ts">
// Локальна директива - Vue розпізнає її по префіксу 'v'
const vAutoFocus = {
mounted(el: HTMLElement) {
el.focus() // Викликається один раз, одразу після вставки в DOM
}
}
</script>
<template>
<!-- Інпут отримує фокус одразу при монтуванні - без ref -->
<input v-auto-focus placeholder="Цей елемент фокусується при завантаженні" />
</template>Директива підключається до lifecycle Vue і запускає focus() прямо на елементі. Без ref, без onMounted всередині компонента.
Ключова відмінність
Компоненти живуть в реактивному світі: вони описують як має виглядати UI залежно від стану. Директиви знаходяться на рівень нижче. Вони отримують реальний DOM-вузол і діють на нього безпосередньо. Дані передаєш через binding.value, але сама директива не має стану компонента і не має this. По суті це функція, яка запускається на елементі в конкретні lifecycle-моменти.
Коли використовувати
- Imperativні DOM-операції (фокус,
scrollIntoView,select()) там де метод компонента додав би зайвий код. - Інтеграція сторонніх бібліотек: Tippy.js, clipboard API, чарти, яким потрібен реальний DOM-вузол для ініціалізації.
- Поведінка елемента, що повторюється: скорочення тексту, lazy-load зображень, виявлення кліку поза межами.
- Уникай коли потрібен реактивний стан, обчислювані значення або взаємодія між компонентами - для цього є composables.
Як Vue обробляє директиви
Компілятор шаблонів сканує твій <template> під час білду і перетворює v-myDir в об'єкт vnode.directive, прикріплений до віртуального вузла. В рантаймі під час циклу patch() (diff та оновлення Vue 3) Vue викликає твої хук-функції на реальному DOM el, передаючи binding (value, arg, modifiers), vnode і prevVnode. Все виконується в головному потоці браузера, в тому ж планувальнику реактивності що й компоненти.
Об'єкт binding
// Шаблон: <div v-my-directive:arg.mod1.mod2="someValue">
// binding містить:
{
value: someValue, // Що ти передав у шаблоні
oldValue: ..., // Попереднє значення, доступне в хуку updated
arg: 'arg', // Рядок після двокрапки
modifiers: { mod1: true, mod2: true },
instance: ..., // Екземпляр компонента
dir: ..., // Сам об'єкт визначення директиви
}Lifecycle-хуки директиви
const myDirective = {
created(el, binding, vnode) {}, // До застосування атрибутів елемента
beforeMount(el, binding, vnode) {}, // До вставки елемента в DOM
mounted(el, binding, vnode) {}, // Після вставки елемента в DOM
beforeUpdate(el, binding, vnode, prevVnode) {}, // До оновлення батьківського компонента
updated(el, binding, vnode, prevVnode) {}, // Після оновлення батьківського компонента
beforeUnmount(el, binding, vnode) {}, // До видалення елемента
unmounted(el, binding, vnode) {}, // Після видалення елемента
}На практиці більшість директив, які тобі доведеться писати, використовують лише три хуки: mounted, updated і unmounted. Решта існує для крайніх випадків.
Типові помилки
Не очищати в unmounted:
// Неправильно: слухач подій займає пам'ять при кожній навігації
mounted(el) {
el.addEventListener('click', handler)
}
// Немає unmounted - після 1000 навігацій в SPA буде 1000 слухачів на document
// Правильно:
mounted(el) {
el.addEventListener('click', handler)
},
unmounted(el) {
el.removeEventListener('click', handler)
}Спроба звернутися до стану компонента безпосередньо:
// Неправильно: у директив немає 'this', немає стану зі setup
mounted(el, binding) {
this.myData = 'value' // ReferenceError в strict mode
}
// Правильно: передавай дані через binding.value
// <div v-my-dir="myReactiveValue">
mounted(el, binding) {
doSomething(binding.value)
}Запускати важкі операції при кожному оновленні батька:
// Неправильно: ініціалізує бібліотеку при кожному перерендері
updated(el, binding) {
initExpensiveLib(el, binding.value)
}
// Правильно: пропускай якщо значення не змінилось
updated(el, binding) {
if (binding.value !== binding.oldValue) {
initExpensiveLib(el, binding.value)
}
}Глобальна реєстрація без префіксу:
// Ризик: конфлікт з майбутніми вбудованими директивами Vue
app.directive('focus', vFocus)
// Безпечніше:
app.directive('app-focus', vFocus)Де зустрічається
- VueUse надає 50+ директив:
vAutoResize,vInfiniteScroll- використовуються в PrimeVue та Quasar. - Element Plus - директива
v-loadingдля оверлеїв зі спінером в корпоративних дашбордах. - Nuxt UI -
v-motionдля анімацій без обгортання кожного елемента в окремий компонент. - Глобальна реєстрація в
main.tsчерезapp.directive()для директив що потрібні по всьому додатку. - Локальна реєстрація в
<script setup>- будь-яка змінна з префіксомv(наприкладvAutoFocus) автоматично стає директивою.
Питання на співбесіді
Q: Який порядок виклику хуків під час оновлення компонента?
A: Спочатку beforeUpdate, потім updated. Для вкладених елементів хуки виконуються знизу вгору для дочірніх і зверху вниз для батьківських.
Q: Як модифікатори (modifiers) директив працюють зсередини?
A: Компілятор парсить v-dir.foo.bar і передає { foo: true, bar: true } як binding.modifiers. У хуку ти читаєш це як звичайний об'єкт. Динамічні аргументи v-dir:[dynamicArg] резолвляться в рядок під час виконання.
Q: Яка різниця між локальною і глобальною реєстрацією директиви?
A: Локальна - оголошуєш змінну з v в <script setup>, доступна тільки в цьому компоненті. Глобальна - app.directive('name', def) в main.ts, доступна скрізь, але додає ім'я в простір імен додатку.
Q: Чи може директива звертатися до екземпляра компонента?
A: Так, через binding.instance. Але це створює тісне зв'язування між директивою і конкретною формою компонента. Майже завжди краще передавати дані через binding.value.
Q: (Senior) Як оптимізувати директиву що використовується на 1000+ елементах списку?
A: Пропускай хуки коли binding.value === binding.oldValue. Застосовуй debounce для важких DOM-операцій. Зберігай мутабельний стан прямо на el (наприклад el._cleanup) замість зовнішніх Map, які тримають посилання і заважають GC. Уникай getCurrentInstance() всередині директив - це вимикає tree-shaking для всього модуля.
Приклади
Виявлення кліку за межами елемента
Найпоширеніший реальний юз-кейс - закривати дропдауни або модалки при кліку в іншому місці.
// directives/clickOutside.ts
export const vClickOutside = {
mounted(el: HTMLElement, binding: { value: () => void }) {
el._clickOutsideHandler = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value() // Викликаємо обробник переданий з шаблону
}
}
document.addEventListener('click', el._clickOutsideHandler)
},
unmounted(el: HTMLElement) {
// Це і є головний сенс - без цього рядка ти маєш витік пам'яті
document.removeEventListener('click', el._clickOutsideHandler)
},
}<template>
<div v-click-outside="closeDropdown" class="dropdown">
<!-- Вміст дропдауну -->
</div>
</template>Очищення в unmounted не є опціональним. Без нього кожна навігація в SPA тихо додає новий слухач на document.
Tooltip з Tippy.js
Директиви - правильна абстракція для обгортання imperativних API сторонніх бібліотек.
<script setup lang="ts">
import tippy from 'tippy.js'
const vTooltip = {
mounted(el: HTMLElement, binding) {
tippy(el, { content: binding.value, trigger: 'hover focus' })
},
updated(el: HTMLElement, binding) {
// Оновлюємо контент реактивно - без повного перемонтування
el._tippy?.setContent(binding.value)
},
unmounted(el: HTMLElement) {
el._tippy?.destroy()
}
}
</script>
<template>
<button v-tooltip="tooltipText">Зберегти</button>
</template>Це паттерн з бібліотек типу VueUse. Директива приховує складний imperativний API Tippy і дає чистий декларативний інтерфейс. Коли tooltipText змінюється, спрацьовує updated і setContent оновлює вміст без знищення і створення інстансу заново.
Ліниве завантаження зображень з IntersectionObserver
// directives/lazyLoad.ts
export const vLazyLoad = {
mounted(el: HTMLImageElement, binding: { value: string }) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value // Завантажуємо лише коли елемент видимий
observer.disconnect() // Зупиняємо спостереження після першого завантаження
}
})
observer.observe(el)
},
}<!-- Реєструємо глобально в main.ts, далі: -->
<img v-lazy-load="imageUrl" alt="Фото продукту" />Кожен екземпляр директиви отримує власний observer, який відключається після завантаження зображення. Жодних зайвих мережевих запитів для контенту нижче видимої частини сторінки.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.