Кастомные директивы во Vue
Директивы — один из базовых инструментов Vue: v-if, v-for, v-model и v-bind знакомы каждому. Но когда встроенных директив не хватает, Vue позволяет писать собственные. Кастомная директива даёт прямой доступ к DOM-элементу и позволяет связать императивную логику (фокус, подписки, интеграция со сторонними библиотеками) с жизненным циклом этого элемента прямо в шаблоне.
В отличие от компонентов и composables, директивы не создают разметку и не возвращают состояние — их зона ответственности это поведение конкретного элемента. Понимание того, когда уместна директива, а когда лучше composable или компонент, отличает аккуратный код от «магического» и является частым вопросом на позицию UI Engineer.
В этой статье разберём хуки директив, объект binding, аргументы и модификаторы, регистрацию локально и глобально, напишем примеры v-focus, v-click-outside, v-intersect и v-debounce, а также обсудим серверный рендеринг и типичные ошибки.
Содержание
- Что такое кастомная директива
- Хуки жизненного цикла директивы
- Сокращённая форма
- Объект binding: value, arg, modifiers
- Регистрация: локально и глобально
- Динамические аргументы и модификаторы
- Практические примеры
- Директива, composable или компонент
- SSR и директивы
- Лучшие практики и антипаттерны
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Что такое кастомная директива
Кастомная директива — это объект (или функция), который Vue применяет к конкретному DOM-элементу. Vue вызывает хуки директивы в определённые моменты жизненного цикла элемента и передаёт в них сам элемент и объект binding с параметрами из шаблона.
Ключевое отличие от других механизмов переиспользования:
- Директива не рендерит разметку и не хранит реактивное состояние — она манипулирует уже существующим элементом.
- Компонент описывает UI: разметку, пропсы, слоты, emits.
- Composable инкапсулирует реактивную логику и возвращает значения для использования в
<script setup>.
Идея директивы — вынести императивную, привязанную к элементу логику в декларативную форму. Вместо того чтобы в каждом компоненте писать onMounted(() => input.value.focus()), можно написать один раз директиву и использовать её в шаблоне:
<script setup>
const vFocus = {
mounted: (el) => el.focus(),
};
</script>
<template>
<input v-focus />
</template>
Типичные задачи для кастомных директив:
- управление фокусом (
v-focus, focus trap для модалок); - обработка кликов вне элемента (
v-click-outside); - подписка на
IntersectionObserverилиResizeObserver; - debounce/throttle обработчиков прямо на элементе;
- инициализация сторонних DOM-библиотек (tooltip, маска ввода, drag-and-drop);
- установка атрибутов или классов, вычисляемых из значения.
Директива — низкоуровневый инструмент. Vue специально ограничивает её возможности: она не должна превращаться в «компонент без шаблона». Если логика обрастает состоянием и условным рендерингом — это сигнал, что нужен компонент или composable.
Хуки жизненного цикла директивы
Определение директивы — это объект с хуками, которые соответствуют этапам жизни элемента. Хуки принимают элемент, объект binding, а также vnode и предыдущий vnode.
const myDirective = {
// вызывается до применения директивы к элементу — до атрибутов и слушателей
created(el, binding, vnode, prevVnode) {},
// вызывается непосредственно перед вставкой элемента в DOM
beforeMount(el, binding, vnode) {},
// вызывается после вставки элемента в документ
mounted(el, binding, vnode) {},
// вызывается перед обновлением содержащего компонента
beforeUpdate(el, binding, vnode, prevVnode) {},
// вызывается после обновления содержащего компонента и его детей
updated(el, binding, vnode, prevVnode) {},
// вызывается перед удалением элемента из DOM — элемент ещё в документе
beforeUnmount(el, binding, vnode) {},
// вызывается после удаления элемента из DOM
unmounted(el, binding, vnode) {},
};
| Хук | Когда вызывается | Типичное применение |
|---|---|---|
created | До применения атрибутов и слушателей | Подготовка, не требующая DOM |
beforeMount | Перед вставкой элемента | Подготовка перед монтированием |
mounted | После вставки в документ | Фокус, подписки, init библиотек |
beforeUpdate | Перед обновлением | Синхронизация перед патчем |
updated | После обновления | Реакция на изменение binding.value |
beforeUnmount | Элемент ещё в DOM | Подготовка к удалению |
unmounted | Элемент удалён из DOM | Снятие слушателей, disconnect() |
Практически всегда используются только mounted и unmounted. Остальные нужны в специфичных случаях: например, updated — чтобы отреагировать на изменение значения директивы, а created — чтобы вычислить что-то до того, как элемент попадёт в DOM.
Важно: mounted директивы срабатывает до onMounted компонента-владельца. Если директива зависит от того, что элемент уже полностью готов, это нужно учитывать.
<script setup>
import { onMounted } from 'vue';
const vFocus = {
mounted(el) {
el.focus();
console.log('директива: элемент сфокусирован');
},
};
onMounted(() => {
console.log('компонент: onMounted');
});
</script>
В консоли порядок будет: сначала сообщение директивы, затем onMounted компонента.
Сокращённая форма
Когда поведение директивы нужно только на mounted и updated, а очистка не требуется, определение можно сократить до одной функции:
app.directive('focus', (el) => {
el.focus();
});
Такая функция эквивалентна объекту:
app.directive('focus', {
mounted: (el) => el.focus(),
updated: (el) => el.focus(),
});
Сокращённая форма удобна для идемпотентных операций: установка атрибута, пересчёт класса, применение значения binding.value. Она подходит, например, для директивы, которая выставляет цвет из значения:
const vColor = (el, binding) => {
el.style.color = binding.value;
};
<template>
<span v-color="textColor">Цветной текст</span>
</template>
Но если директива подписывается на события, создаёт обсервер или иным образом расходует ресурсы, сокращённую форму использовать нельзя — слушатели и подписки некому будет снять, что приведёт к утечке памяти. В таких случаях нужен полный объект с mounted и unmounted.
Объект binding: value, arg, modifiers
Второй аргумент любого хука — объект binding, содержащий всё, что директива «передала» из шаблона. Его поля:
| Свойство | Описание |
|---|---|
value | Значение выражения в кавычках |
oldValue | Предыдущее значение (доступно в beforeUpdate/updated) |
arg | Аргумент после двоеточия |
modifiers | Объект модификаторов после точки |
instance | Экземпляр компонента, где используется директива |
dir | Само определение директивы |
Рассмотрим на примере вызова:
<div v-demo:color.delay.throttle="500">…</div>
Разбор: color — это binding.arg, delay и throttle — binding.modifiers, 500 — binding.value.
app.directive('demo', {
mounted(el, binding) {
console.log(binding.value); // 500
console.log(binding.arg); // "color"
console.log(binding.modifiers); // { delay: true, throttle: true }
console.log(binding.instance); // экземпляр компонента
},
});
Значением может быть любое JavaScript-выражение: строка, число, объект, массив, функция. Часто директивы принимают объект-конфигурацию или колбэк:
<template>
<button v-click-outside="onClose">Закрыть при клике вне</button>
</template>
Здесь binding.value — это функция onClose.
oldValue и обновления
На изменения значения директива реагирует в updated (и beforeUpdate). В binding.oldValue лежит предыдущее значение, что позволяет выполнять работу только при реальном изменении:
const vHighlight = {
mounted(el, binding) {
el.style.background = binding.value;
},
updated(el, binding) {
if (binding.value !== binding.oldValue) {
el.style.background = binding.value;
}
},
};
Важно: updated вызывается не только при изменении значения директивы, но и при любом обновлении содержащего компонента. Поэтому сравнение с oldValue — обязательный приём, если операция дорогая.
Регистрация: локально и глобально
Директиву нужно зарегистрировать, чтобы Vue знал, как её применять. Есть два способа.
Локальная регистрация в <script setup>
В <script setup> любая переменная с именем вида vXxx автоматически становится локальной директивой и доступна в шаблоне как v-xxx. Это самый распространённый способ: директива живёт рядом с местом применения.
<script setup>
const vFocus = {
mounted: (el) => el.focus(),
};
const vClickOutside = {
mounted(el, binding) {
el._handler = (event) => {
if (!el.contains(event.target)) binding.value(event);
};
document.addEventListener('pointerdown', el._handler);
},
unmounted(el) {
document.removeEventListener('pointerdown', el._handler);
delete el._handler;
},
};
</script>
<template>
<input v-focus />
<div v-click-outside="close">Содержимое</div>
</template>
Обратите внимание на соответствие имён: vFocus → v-focus, vClickOutside → v-click-outside. Vue преобразует camelCase-имя переменной в kebab-case в шаблоне.
В компонентах на Options API директива регистрируется через опцию directives:
export default {
directives: {
focus: {
mounted: (el) => el.focus(),
},
},
};
Глобальная регистрация через app.directive
Если директива нужна во многих местах, её регистрируют глобально на экземпляре приложения:
import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
app.directive('focus', {
mounted(el) {
el.focus();
},
});
app.mount('#app');
После регистрации v-focus доступна в шаблоне любого компонента. Глобальный вариант удобен для инфраструктурных директив вроде v-focus, v-debounce, v-tooltip, которые используются повсеместно. Но у него есть недостатки: директивы не tree-shakable (регистрация происходит в рантайме), их сложнее отследить по коду, а также они увеличивают «глобальную поверхность» приложения.
Правило выбора: если директива специфична для одного компонента — регистрируйте локально; если это часть дизайн-системы или инфраструктуры — глобально. Как и с плагинами, злоупотребление глобальными сущностями усложняет поддержку.
Динамические аргументы и модификаторы
Помимо статического аргумента v-demo:color, Vue поддерживает динамические аргументы — имя аргумента вычисляется во время выполнения:
<script setup>
import { ref } from 'vue';
const attribute = ref('color');
const value = ref('red');
</script>
<template>
<div v-demo:[attribute]="value">…</div>
</template>
Здесь binding.arg будет равен значению attribute на момент вычисления. Динамические аргументы удобны, когда поведение директивы зависит от данных — например, применяемый атрибут, направление анимации или имя события.
Модификаторы нельзя сделать динамическими: v-demo.delay — всегда { delay: true }. Но можно комбинировать динамический аргумент и модификаторы:
<button v-tooltip:[placement].dark="message">Подсказка</button>
Директива получит arg: placement, modifiers: { dark: true }, value: message.
Ограничения динамических аргументов:
- выражение в квадратных скобках должно давать строку (или
nullдля условного отключения); - пробелы и кавычки внутри выражения не допускаются — используйте
computed, если нужно составить имя; - из-за этого в DOM-шаблонах (без сборки) динамические аргументы могут работать иначе, чем в SFC, поэтому их лучше использовать в проектах со сборщиком.
Пример условного аргумента:
<template>
<div v-demo:[isActive ? 'visible' : null]="value">…</div>
</template>
Если выражение вернёт null, аргумент не будет передан.
Практические примеры
Ниже — четыре директивы, которые часто встречаются в реальных проектах и хорошо демонстрируют разные приёмы.
v-focus
Простейшая директива: ставит фокус на элемент после монтирования. Актуальна для полей поиска, модалок и форм.
const vFocus = {
mounted(el) {
el.focus();
},
};
Если фокус нужно ставить и при обновлении:
const vFocus = (el) => el.focus();
v-click-outside
Обрабатывает клики вне элемента — базовый приём для дропдаунов, поповеров и модалок. Здесь обязательна очистка слушателя.
const vClickOutside = {
mounted(el, binding) {
el._clickOutsideBinding = binding;
el._clickOutside = (event) => {
// клик внутри элемента игнорируем
if (!el.contains(event.target)) {
el._clickOutsideBinding.value(event);
}
};
document.addEventListener('pointerdown', el._clickOutside);
},
updated(el, binding) {
// обновляем ссылку на binding, не пересоздавая слушатель
el._clickOutsideBinding = binding;
},
unmounted(el) {
document.removeEventListener('pointerdown', el._clickOutside);
delete el._clickOutside;
delete el._clickOutsideBinding;
},
};
<script setup>
import { ref } from 'vue';
const isOpen = ref(false);
const close = () => {
isOpen.value = false;
};
</script>
<template>
<button @click="isOpen = !isOpen">Меню</button>
<div v-if="isOpen" v-click-outside="close" class="dropdown">
…
</div>
</template>
Функцию-обработчик храним прямо на элементе (el._clickOutside), чтобы снять именно тот же слушатель, который добавили. Подчёркивание — негласное соглашение о приватном свойстве на el.
v-intersect
Ленивая загрузка, бесконечный скролл и аналитика видимости строятся на IntersectionObserver. Директива удобна, когда не нужно вытаскивать состояние в <script>.
const vIntersect = {
mounted(el, binding) {
const options = {
threshold: binding.arg ? Number(binding.arg) : 0,
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
binding.value(entry);
// модификатор .once — отписаться после первого срабатывания
if (binding.modifiers.once) observer.unobserve(el);
}
});
}, options);
observer.observe(el);
el._intersectObserver = observer;
},
unmounted(el) {
el._intersectObserver?.disconnect();
delete el._intersectObserver;
},
};
Использование с порогом и модификатором:
<template>
<div v-intersect:0.5.once="trackView">Блок для аналитики</div>
</template>
Здесь :0.5 — динамически вычисленный аргумент-порог, .once — модификатор, а trackView — колбэк, получающий запись IntersectionObserverEntry. Не забываем про disconnect() в unmounted — иначе обсервер продолжит держать ссылку на удалённый элемент.
v-debounce
Ограничивает частоту вызова обработчика. Директива принимает колбэк, а задержку получает через аргумент.
const vDebounce = {
mounted(el, binding) {
el._debounceBinding = binding;
const handler = (event) => {
const delay = Number(el._debounceBinding.arg) || 300;
clearTimeout(el._debounceTimer);
el._debounceTimer = setTimeout(
() => el._debounceBinding.value(event),
delay
);
};
el._debounceHandler = handler;
el.addEventListener('input', handler);
},
updated(el, binding) {
// при обновлении компонента обновляем ссылку на binding
el._debounceBinding = binding;
},
unmounted(el) {
el.removeEventListener('input', el._debounceHandler);
clearTimeout(el._debounceTimer);
delete el._debounceHandler;
delete el._debounceBinding;
},
};
<template>
<input v-debounce:500="onSearch" placeholder="Поиск" />
</template>
Директива навешивает debounced-обработчик на событие input, а вызываемый код получает уже «успокоенное» событие. Такой подход избавляет от ручного оборачивания каждого обработчика.
Директива, composable или компонент
Выбор инструмента — ключевой навык. Три механизма перекрываются, но решают разные задачи.
| Критерий | Директива | Composable | Компонент |
|---|---|---|---|
| Рендерит разметку | Нет | Нет | Да |
| Доступ к DOM-элементу | Да, напрямую | Через ref | Через ref |
| Возвращает состояние | Нет | Да | Через emits/slots |
| Использование вне шаблона | Нет | Да | Нет |
| Тестируемость | Ниже | Высокая | Высокая |
| Декларативный синтаксис | Да, v-xxx | Нет | Да, <Comp> |
Сравним v-intersect и useIntersectionObserver из VueUse. Оба решают задачу отслеживания видимости, но по-разному.
Директива прячет всю работу внутри и идеальна, когда нужно просто «сделать что-то, когда элемент появился»:
<template>
<div v-intersect="onVisible">…</div>
</template>
Composable возвращает реактивное состояние, которое можно использовать в логике, вычислениях и других composables:
<script setup>
import { ref, watch } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';
const target = ref(null);
const isVisible = ref(false);
useIntersectionObserver(target, ([{ isIntersecting }]) => {
isVisible.value = isIntersecting;
});
// isVisible можно использовать где угодно
watch(isVisible, (v) => {
if (v) trackImpression();
});
</script>
<template>
<div ref="target">…</div>
</template>
Практические выводы:
- Берите директиву, если логика чисто императивная, привязана к элементу, не производит разметку и не нуждается в возврате состояния. Классика: фокус, click-outside, init сторонней библиотеки, debounce.
- Берите composable, если нужно реактивное состояние, переиспользование вне шаблона, тестируемость или композиция с другой логикой.
- Берите компонент, если нужно рендерить UI, слоты или инкапсулировать разметку и поведение вместе.
Не стоит реализовывать директивой то, что должно быть компонентом: директивы не умеют управлять разметкой, и попытка «строить UI» через innerHTML в директиве быстро превращается в хрупкий код в обход реактивности Vue.
SSR и директивы
При серверном рендеринге реального DOM на сервере нет, поэтому хуки mounted, updated, beforeUnmount и unmounted не выполняются. Единственный хук, работающий на сервере, — getSSRProps, который возвращает атрибуты для серверной разметки.
const vShow = {
mounted(el, binding) {
el.style.display = binding.value ? '' : 'none';
},
updated(el, binding) {
el.style.display = binding.value ? '' : 'none';
},
getSSRProps(binding) {
// на сервере отдаём атрибут, который клиент потом отработает
return {
style: { display: binding.value ? '' : 'none' },
};
},
};
Если getSSRProps не объявлен, на сервере директива просто игнорируется — в HTML не попадут никакие изменения, которые директива вносит на клиенте. Это может привести к разнице между серверной разметкой и клиентской (hydration mismatch).
Рекомендации для SSR:
- Не обращайтесь к
document/windowвcreatedиbeforeMount: на сервере их нет. Используйтеmounted/unmounted, которые гарантированно выполняются только в браузере. - Если директива влияет на вид разметки, объявите
getSSRProps, чтобы серверный HTML совпадал с клиентским. - Помните, что на сервере не работают подписки и обсерверы — они появятся после гидратации.
- Для Nuxt большинство директив удобнее регистрировать плагином со
import.meta.client-проверкой.
Лучшие практики и антипаттерны
Делайте
- Снимайте слушатели и отключайте обсерверы в
unmounted. ЛюбойaddEventListenerиnew IntersectionObserverобязан иметь парную очистку — иначе утечка памяти. - Храните приватные данные на элементе под своим именем. Например,
el._clickOutside,el._debounceHandler. Использование общих имён вродеel.handlerрискует конфликтом с другой директивой или библиотекой. - Сравнивайте
binding.valueсbinding.oldValueвupdated.updatedсрабатывает на любое обновление компонента, а не только при изменении значения. - Держите директивы маленькими. Директива — это тонкая обёртка над DOM-API, а не место для бизнес-логики.
- Регистрируйте локально, если директива используется в одном месте. Глобальная регистрация оправдана только для инфраструктурных директив.
- Проверяйте SSR. Не трогайте
document/windowвнеmountedи объявляйтеgetSSRProps, когда директива меняет разметку.
Не делайте
- Не забывайте про очистку. Самая частая ошибка — подписка в
mountedбез снятия вunmounted.
// ❌ Утечка: слушатель никогда не снимется
const vBad = {
mounted(el, binding) {
document.addEventListener('click', binding.value);
},
};
- Не стройте UI через директиву. Если нужна разметка, слоты или состояние — это компонент. Манипуляции
innerHTMLв обход реактивности ломают Vue. - Не используйте сокращённую форму для ресурсоёмких подписок. Функция-директива выполняется на
mountedиupdated, но не даёт места для очистки. - Помните о порядке:
mountedдирективы срабатывает раньшеonMountedкомпонента. Не рассчитывайте, что к этому моменту компонент уже полностью инициализирован. - Не применяйте директиву к компоненту с несколькими корневыми элементами. Vue не сможет однозначно определить, к какому элементу применить директиву, и выдаст предупреждение.
- Не тащите в директиву реактивное состояние. Если директиве нужно возвращать данные в компонент, скорее всего, нужен composable.
Ключевые тезисы для интервью
- Кастомная директива задаёт императивное поведение конкретного DOM-элемента (фокус, подписки, init библиотек) и не рендерит разметку и не возвращает состояние.
- У директивы семь хуков, но на практике нужны
mountedиunmounted; сокращённая функция-директива выполняется наmountedиupdatedи годится только для идемпотентных операций без очистки. bindingсодержитvalue,oldValue,arg,modifiers,instanceиdir;updatedсрабатывает на любое обновление компонента, поэтому операцию защищают сравнениемvalue !== oldValue.- В
<script setup>переменнаяvXxxстановится локальной директивойv-xxx, аapp.directiveрегистрирует её глобально; динамические аргументы (v-demo:[arg]) вычисляются в рантайме, а модификаторы всегда статичны. - Директива — для императивного поведения элемента, composable — для реактивной логики с возвратом значений, компонент — для UI;
v-intersectиuseIntersectionObserverиз VueUse показывают эту границу. - При SSR DOM-хуки директивы не выполняются:
getSSRPropsотдаёт атрибуты для серверной разметки, а очистка слушателей и обсерверов вunmountedобязательна, чтобы не течь.
Заключение
Кастомные директивы — это инструмент для императивного поведения конкретного DOM-элемента: фокуса, подписок, интеграции со сторонними библиотеками. Они остаются простыми и декларативными, если помнить об их границах: директива не рендерит разметку и не хранит состояние.
Ключевые практические правила: используйте mounted/unmounted для ресурсов, обязательно снимайте слушатели и отключайте обсерверы, сравнивайте binding.value с oldValue в updated и не забывайте про getSSRProps при серверном рендеринге. Когда логика требует состояния, возврата значений или тестируемости — выбирайте composable; когда она создаёт разметку — компонент.
Попробуйте написать v-focus, v-click-outside и v-intersect самостоятельно, а затем сравните с готовыми composables из VueUse, чтобы почувствовать, где заканчивается зона ответственности директивы. В следующих статьях раздела мы разберём плагины и app-level API, а также стилизацию во Vue.