Олег Марков
Пользовательские директивы - полный разбор для разработчиков
Введение
Пользовательские директивы чаще всего ассоциируются с современными фронтенд‑фреймворками, такими как Vue и Angular, но сама идея шире: это механизм расширения декларативной разметки за счет собственных «мини‑правил» поведения элементов.
Другими словами, вы описываете, что элемент должен делать, через небольшие маркеры в разметке, а как именно он это делает — прячете в коде директивы. Это удобно, когда нужно переиспользовать одно и то же поведение на множестве элементов: подсказки, фокус, валидация, обработка кликов вне элемента, бесконечный скролл и так далее.
В этой статье мы будем говорить о пользовательских директивах на примере Vue (v2 и v3, но без привязки к конкретной версии API, там где это возможно). Причина простая: в Vue работа с пользовательскими директивами наиболее наглядна и хорошо показывает саму концепцию. Однако многие принципы легко переложить и на другие фреймворки.
Смотрите, я покажу вам:
- как устроен жизненный цикл директивы
- какие аргументы получают ее хуки
- как правильно очищать за собой ресурсы
- как передавать параметры и modifiers
- типичные паттерны: автофокус, click‑outside, маски ввода, бесконечный скролл
- как проектировать и тестировать директивы
Что такое пользовательская директива
Директивы на уровне разметки
Директива — это специальный «атрибут» в шаблоне, который навешивает на элемент определенное поведение. В Vue вы часто видите встроенные директивы:
- v-if
- v-for
- v-model
- v-show
Пользовательская директива работает по той же идее, только реализуете ее вы сами. Например, вам нужен автофокус при появлении поля ввода:
<input v-focus />
А вот соответствующая директива:
// Регистрируем глобальную директиву focus
Vue.directive('focus', {
// Хук inserted вызывается, когда элемент вставлен в DOM
inserted(el) {
// Здесь мы можем работать с реальным DOM элементом
el.focus() // Устанавливаем фокус на элемент
},
})
Как видите, этот код выполняет всю логику, а в шаблоне вы оставляете лишь декларативное указание: «у этого поля должен быть фокус».
Зачем нужны пользовательские директивы
Давайте разберемся, какие задачи удобно решать директивами:
Повторяющееся поведение на уровне DOM:
- автофокус
- клики вне элемента
- drag and drop
- бесконечный скролл
- анимации при появлении
- маски ввода
Инкапсуляция низкоуровневых операций:
- прямые манипуляции DOM (когда без них никак)
- подключение сторонних библиотек (например, tooltip, datepicker)
- управление слушателями событий
Улучшение читаемости шаблонов:
- вместо длинных обработчиков в шаблоне вы пишете короткий маркер
- пример: v-debounce-click="handler" вместо громоздкой логики с setTimeout
Когда лучше не использовать директивы:
- если поведение тесно связано с состоянием и логикой компонента — возможно, это логика компонента, а не директивы
- если поведение проще выразить через компонент‑обертку
- если вы не работаете с DOM напрямую (директивы как раз про DOM)
Жизненный цикл и структура пользовательской директивы
Формат объекта директивы
В Vue директива описывается объектом с набором хуков. Смотрите, я покажу вам базовый шаблон:
const myDirective = {
// Вызывается один раз при первом связывании директивы с элементом
bind(el, binding, vnode) {
// Инициализация - настройка обработчиков - начальное состояние
},
// Вызывается, когда элемент вставлен в родительский DOM
inserted(el, binding, vnode) {
// Здесь элемент уже в документе - можно измерять размеры, фокусировать и тд
},
// Вызывается перед обновлением компонента - до обновления дочерних
update(el, binding, vnode, oldVnode) {
// Здесь можно реагировать на изменение значения директивы
},
// Вызывается после обновления компонента и его потомков
componentUpdated(el, binding, vnode, oldVnode) {
// Полностью обновленный DOM - полезно для работы с содержимым элемента
},
// Вызывается при отвязывании директивы от элемента
unbind(el, binding, vnode) {
// Здесь важно очистить слушатели и сторонние ресурсы
},
}
Не обязательно реализовывать все хуки — вы можете указать только те, которые нужны.
Краткий синтаксис директивы
Если вам нужна только одна функция (например, чаще всего inserted или bind), можно использовать сокращенную запись:
Vue.directive('focus', function (el, binding, vnode) {
// В кратком виде это хук bind + update
// Но в реальных проектах лучше использовать объект - удобнее контролировать этапы
})
Для сложных директив с состоянием и очисткой ресурсов все же лучше использовать полную форму с отдельным unbind.
Аргументы хуков: el, binding, vnode
Разберем по порядку, что именно получает каждая функция.
el — реальный DOM‑элемент
Это ссылка на сам элемент, к которому применена директива.
Примеры того, что вы можете делать с el:
inserted(el) {
el.focus() // Установить фокус
// Добавить класс
el.classList.add('has-directive')
// Повесить обработчик события
el.addEventListener('click', () => {
console.log('clicked')
})
}
Важно: любые добавленные обработчики нужно снимать в unbind, чтобы не было утечек памяти.
binding — объект с данными директивы
Обратите внимание, этот объект содержит почти все, что вам нужно о директиве в конкретном месте шаблона.
У него есть поля:
- name — имя директивы без префикса v-
- value — переданное значение
- oldValue — предыдущее значение (в хуках обновления)
- expression — строка выражения из шаблона
- arg — аргумент директивы (часть после двоеточия)
- modifiers — объект модификаторов (части после точки)
Пример:
<div v-example:color.primary.bold="userName"></div>
Теперь посмотрим, что вы увидите в binding внутри директивы:
bind(el, binding) {
console.log(binding.name) // 'example'
console.log(binding.value) // значение переменной userName
console.log(binding.expression) // 'userName'
console.log(binding.arg) // 'color'
console.log(binding.modifiers) // { primary: true, bold: true }
}
Смотрите, я размещаю этот пример, чтобы вам было проще понять: через arg и modifiers удобно передавать дополнительные «флаги» поведения директивы, не усложняя объект value.
vnode и oldVnode
Это виртуальные ноды Vue. Они дают доступ к контексту компонента:
bind(el, binding, vnode) {
const vm = vnode.context // Экземпляр компонента
// Теперь можно обратиться к его методам или данным, если очень нужно
// Но лучше по возможности избегать жестких связей
}
В большинстве простых директив вам хватает el и binding. Работа с vnode полезна, если вы хотите учитывать конкретный компонентный контекст.
Регистрация пользовательских директив
Глобальная регистрация
Глобальная директива доступна во всех компонентах приложения.
В Vue 2:
// Регистрируем глобально
Vue.directive('focus', {
inserted(el) {
el.focus()
},
})
В Vue 3 с использованием createApp:
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.directive('focus', {
mounted(el) {
// В Vue 3 хук mounted заменяет inserted
el.focus()
},
})
app.mount('#app')
Преимущество глобальной регистрации — простота; недостаток — сложнее контролировать, где именно директива используется, и тяжелее поддерживать крупные проекты.
Локальная регистрация в компоненте
Если вы хотите ограничить директиву только одним компонентом, регистрируйте ее локально:
Vue 2:
export default {
directives: {
focus: {
inserted(el) {
el.focus()
},
},
},
}
Vue 3 (Composition API, но локальная регистрация в объекте компонента осталась похожей):
export default {
directives: {
focus: {
mounted(el) {
el.focus()
},
},
},
}
Теперь директива v-focus будет доступна только в шаблоне этого компонента.
Именование директив
Правила простые:
- в JavaScript вы регистрируете их в camelCase: myDirective
- в шаблоне используете в kebab-case: v-my-directive
Пример:
app.directive('lazyLoad', { /* ... */ })
// В шаблоне:
<img v-lazy-load="imageSrc" />
Vue автоматически сопоставит lazyLoad и lazy-load.
Передача значений, аргументов и модификаторов
Теперь давайте посмотрим, как директивы принимают параметры.
Передача основного значения
Самое типичное — вы передаете в директиву какое-то значение:
<input v-autofocus="shouldFocus" />
В binding.value вы увидите значение shouldFocus:
inserted(el, binding) {
if (binding.value) {
el.focus()
}
}
Вы можете передавать не только примитивы, но и объекты или функции:
<div v-scroll="onScrollHandler"></div>
<div
v-tooltip="{
text: 'Удалить элемент',
position: 'top'
}"
></div>
В директиве:
bind(el, binding) {
// binding.value может быть объектом или функцией
}
Аргументы директивы
Аргумент — это часть после двоеточия:
<div v-theme:dark="userSettings"></div>
Здесь:
- name = 'theme'
- arg = 'dark'
- value = userSettings
В реальной жизни аргумент часто определяет «основное измерение» поведения:
- v-resize:width
- v-resize:height
- v-validate:email
- v-format:currency
Пример:
<input v-validate:email="emailRules" />
<input v-validate:phone="phoneRules" />
Директива:
bind(el, binding) {
const type = binding.arg // 'email' или 'phone'
const rules = binding.value // объект с правилами
// В зависимости от типа включаем разные проверки
}
Модификаторы директивы
Модификаторы — это точечные суффиксы:
<button v-click-outside.stop.prevent="onOutsideClick">
Окно
</button>
Здесь:
- binding.modifiers = { stop: true, prevent: true }
Использовать модификаторы удобно для булевых «флажков»:
<input v-autofocus.immediate />
<input v-autofocus.delayed />
А в директиве:
inserted(el, binding) {
if (binding.modifiers.immediate) {
el.focus()
} else if (binding.modifiers.delayed) {
setTimeout(() => el.focus(), 300)
} else {
// Поведение по умолчанию
}
}
Комбинация value + arg + modifiers дает вам очень гибкий интерфейс директивы, в котором разработчикам понятно, как ею пользоваться.
Практические примеры пользовательских директив
Теперь вы увидите, как это выглядит в коде на конкретных сценариях.
Директива автофокуса
Классический пример — автофокус поля ввода.
Шаблон:
<input v-focus />
Директива:
// Глобальная регистрация
Vue.directive('focus', {
// В Vue 2
inserted(el) {
// Здесь элемент уже в DOM
el.focus() // Устанавливаем фокус
},
// В Vue 3 используйте mounted вместо inserted
// mounted(el) { el.focus() },
})
Вариант с дополнительной логикой: фокус только если value = true.
<input v-focus="shouldFocus" />
Vue.directive('focus', {
inserted(el, binding) {
// Проверяем переданное значение
if (binding.value) {
el.focus()
}
},
update(el, binding) {
// Реагируем на изменение значения
if (binding.value && !binding.oldValue) {
// Если значение стало true - устанавливаем фокус
el.focus()
}
},
})
Директива click‑outside
Очень полезный паттерн — отлавливать клик вне элемента, например, для закрытия выпадающих списков или модальных окон.
Шаблон:
<div v-click-outside="onOutside">
<!-- Выпадающее меню -->
</div>
Директива:
const clickOutsideDirective = {
bind(el, binding) {
// Создаем обработчик и сохраняем его на элементе
// чтобы потом можно было снять слушатель
el.__clickOutsideHandler__ = function (event) {
// Проверяем - кликнули ли вне элемента
if (!el.contains(event.target)) {
// Вызываем переданный обработчик
// binding.value может быть функцией
if (typeof binding.value === 'function') {
binding.value(event)
}
}
}
// Вешаем слушатель на документ
document.addEventListener('click', el.__clickOutsideHandler__)
},
unbind(el) {
// Снимаем слушатель при уничтожении элемента
document.removeEventListener('click', el.__clickOutsideHandler__)
delete el.__clickOutsideHandler__
},
}
Обратите внимание, как этот фрагмент кода решает задачу очистки: мы кладем обработчик на свойство элемента, чтобы иметь к нему доступ в unbind.
Директива debounce для событий
Иногда нужно «задерживать» частые события, например ввод текста, чтобы не дергать API при каждом символе.
Шаблон:
<input v-debounce:input.500="onSearch" />
Здесь:
- arg = 'input' — тип события
- value = onSearch — обработчик
- modifiers = { 500: true } — такой вариант не очень красив, поэтому чаще таймаут передают в value, но давайте сделаем максимально понятно через число в value.
Сделаем по‑другому, более практично:
<input v-debounce="{
event: 'input',
handler: onSearch,
delay: 500
}" />
Директива:
const debounceDirective = {
bind(el, binding) {
const config = binding.value || {}
const event = config.event || 'input' // Событие по умолчанию
const handler = config.handler
const delay = config.delay || 300
if (typeof handler !== 'function') {
// Минимальная защита - в реальном проекте можно кинуть warning
return
}
let timeoutId = null
const listener = function (eventObject) {
// Очищаем предыдущий таймер
if (timeoutId !== null) {
clearTimeout(timeoutId)
}
// Назначаем новый
timeoutId = setTimeout(() => {
handler(eventObject)
}, delay)
}
// Сохраняем данные на элементе для доступа в других хуках
el.__debounceConfig__ = {
event,
handler,
listener,
}
el.addEventListener(event, listener)
},
unbind(el) {
const config = el.__debounceConfig__
if (config) {
el.removeEventListener(config.event, config.listener)
delete el.__debounceConfig__
}
},
}
Здесь я размещаю пример с объектом конфигурации, чтобы вам было проще потом расширять директиву, не ломая ее интерфейс.
Директива маски ввода
Простейший пример маски для ввода телефона. Мы не будем реализовывать полноценную маску, а покажем базовый подход.
Шаблон:
<input v-phone-mask />
Директива:
const phoneMaskDirective = {
bind(el) {
function formatPhone(value) {
// Удаляем все нецифровые символы
const digits = value.replace(/\D/g, '')
// Формируем шаблон +7 (XXX) XXX-XX-XX
const part1 = digits.slice(0, 1) // Код страны
const part2 = digits.slice(1, 4)
const part3 = digits.slice(4, 7)
const part4 = digits.slice(7, 9)
const part5 = digits.slice(9, 11)
let result = ''
if (digits.length > 0) {
result = '+' + part1
}
if (digits.length >= 2) {
result += ' (' + part2
}
if (digits.length >= 5) {
result += ') ' + part3
}
if (digits.length >= 8) {
result += '-' + part4
}
if (digits.length >= 10) {
result += '-' + part5
}
return result
}
function onInput(e) {
const value = e.target.value
const formatted = formatPhone(value)
e.target.value = formatted // Обновляем значение поля
}
el.__phoneMaskInputHandler__ = onInput
el.addEventListener('input', onInput)
},
unbind(el) {
el.removeEventListener('input', el.__phoneMaskInputHandler__)
delete el.__phoneMaskInputHandler__
},
}
Эта реализация упрощенная, но хорошо показывает, как директива может управлять значением поля, при этом шаблон остается чистым.
Директива бесконечного скролла
Давайте посмотрим, что происходит в примере с подгрузкой данных при прокрутке к низу списка.
Шаблон:
<div
v-infinite-scroll="loadMore"
style="overflow-y: auto; max-height: 300px;"
>
<!-- Список элементов -->
</div>
Директива:
const infiniteScrollDirective = {
bind(el, binding) {
const distance = binding.arg ? Number(binding.arg) : 50
// distance - расстояние до низа - при котором нужно вызывать коллбек
const callback = binding.value
if (typeof callback !== 'function') {
return
}
const onScroll = function () {
const scrollBottom =
el.scrollHeight - el.scrollTop - el.clientHeight
if (scrollBottom <= distance) {
callback()
}
}
el.__infiniteScrollHandler__ = onScroll
el.addEventListener('scroll', onScroll)
},
unbind(el) {
el.removeEventListener('scroll', el.__infiniteScrollHandler__)
delete el.__infiniteScrollHandler__
},
}
Использование:
<div v-infinite-scroll:100="loadNextPage">
<!-- элементы -->
</div>
Здесь arg = '100' — минимальное расстояние до низа, при котором срабатывает подгрузка. В реальном проекте стоит добавить защиту от слишком частого вызова (throttle или debounce).
Отличия директив от компонентов и mixins
Когда лучше использовать директиву, а когда компонент
Директива идеальна, когда у вас:
- поведение ориентировано на DOM‑элемент (фокус, измерение, прослушивание событий)
- нет своей разметки (директиве не принадлежит верстка)
- одна и та же логика должна работать на разных типах элементов (input, div, button и т.д.)
Компонент подходит лучше, когда:
- вам нужно собственное дерево DOM
- есть внутреннее состояние и сложная логика
- требуется переиспользуемая разметка, а не только поведение
Пример, где лучше компонент: всплывающее модальное окно. Пример, где лучше директива: ловля кликов вне произвольного элемента.
Директивы и mixins / composables
Mixins и composables (в Vue 3) — это про переиспользование логики между компонентами. Они работают на уровне JavaScript‑кода: обеспечивают данные, методы, вычисляемые свойства.
Директива — это про работу с конкретным DOM‑элементом.
Иногда они хорошо дополняют друг друга:
- composable управляет состоянием, API‑запросами, хранит «когда грузить еще»
- директива следит за скроллом и вызывает функцию из composable
Управление ресурсами и избегание утечек памяти
При работе с пользовательскими директивами особенно важно корректно освобождать ресурсы: обработчики событий, таймеры, объекты сторонних библиотек.
Где и что освобождать
Главное правило: все, что вы создали при bind/inserted/mounted, нужно корректно убрать в unbind/unmounted.
Что обычно нужно чистить:
- обработчики событий (removeEventListener)
- таймеры (clearTimeout, clearInterval)
- подписки на сторонние библиотеки (unsubscribe)
- наблюдатели (IntersectionObserver.disconnect, MutationObserver.disconnect)
Пример с IntersectionObserver:
const observeVisibilityDirective = {
bind(el, binding) {
// Создаем наблюдатель за пересечением
const observer = new IntersectionObserver((entries) => {
const entry = entries[0]
if (entry.isIntersecting && typeof binding.value === 'function') {
binding.value(entry)
}
})
observer.observe(el)
// Сохраняем observer на элементе
el.__visibilityObserver__ = observer
},
unbind(el) {
const observer = el.__visibilityObserver__
if (observer) {
observer.disconnect() // Останавливаем наблюдение
delete el.__visibilityObserver__
}
},
}
Хранение состояния директивы
У директивы нет «своего this», поэтому обычно состояние привязывают к DOM‑элементу через нестандартные свойства, как мы уже делали выше:
- el.someInternalProperty
Важно:
- используйте имена, маловероятные к конфликту (двойное подчеркивание, уникальный префикс)
- не полагайтесь на эти свойства вне директивы
- обязательно чистите их в unbind
Типичные ошибки при создании пользовательских директив
Слишком сильная связь с компонентом
Ошибка: директива напрямую лезет в методы/данные конкретного компонента через vnode.context, что затрудняет переиспользование.
Рекомендация: по возможности передавайте все, что нужно, через value/arg/modifiers и не завязывайтесь на конкретную структуру компонента.
Отсутствие очистки обработчиков
Частая проблема: добавили addEventListener в bind, но забыли removeEventListener в unbind. В результате:
- утечки памяти
- «мнимые» баги, когда обработчик продолжает срабатывать после ухода со страницы
Всегда проверяйте, есть ли соответствующий код в unbind.
Логика обновления значения
Иногда разработчики забывают обрабатывать изменение значения директивы (value). Например:
<input v-focus="shouldFocus" />
Если вы хотите, чтобы фокус появлялся не только при инициализации, но и при изменении shouldFocus, нужно использовать update/componentUpdated.
Избыточная работа с DOM
Директивы дают прямой доступ к DOM, но не стоит злоупотреблять этим:
- избегайте частых перерисовок вручную, там где можно довериться реактивности
- будьте осторожны с изменением содержимого элемента (innerHTML), чтобы не сломать работу Vue с шаблоном
Рекомендации по проектированию пользовательских директив
Думайте об интерфейсе директивы
Хорошая директива:
- понятна по названию и сигнатуре (value, arg, modifiers)
- учитывает типичные сценарии использования
- минимально зависит от конкретного компонента
Полезные вопросы:
- Можно ли использовать директиву на любом элементе?
- Понятно ли из разметки, что она делает?
- Можно ли расширять поведение без изменения старого кода?
Документируйте директивы
Даже если вы пишете директиву только для внутреннего проекта, добавьте:
- комментарии в коде (что делает директива, какие значения ожидает)
- примеры использования
- список поддерживаемых аргументов и модификаторов
Это резко снизит порог входа для других разработчиков.
Тестируйте базовые сценарии
Для директив достаточно хотя бы простых тестов:
- инициализация: поведение при первом монтировании
- обновление: изменение value
- уничтожение: корректное снятие обработчиков
В большинстве случаев можно использовать unit‑тесты, взаимодействуя с DOM через тестовую обвязку.
Заключение
Пользовательские директивы — это удобный способ вынести повторяющееся поведение на уровень декларативной разметки. Они особенно полезны, когда нужно:
- работать с конкретным DOM‑элементом
- переиспользовать одно и то же поведение на разных типах элементов
- инкапсулировать интеграцию со сторонними DOM‑библиотеками
Главные моменты, на которые стоит опираться:
- понимание жизненного цикла директив (bind, inserted/mounted, update, unbind)
- грамотное использование binding (value, arg, modifiers)
- аккуратное управление ресурсами (обработчики, observers, таймеры)
- тщательное проектирование интерфейса директивы
Если вы начнете выносить в директивы повторяющиеся DOM‑шаблоны поведения, со временем шаблоны станут чище, а компоненты — проще. А грамотный набор собственных директив часто превращается в небольшой внутренний «язык» разметки, с которым комфортно работать всей команде.
Частозадаваемые технические вопросы по теме статьи и ответы на них
1. Как использовать пользовательские директивы в составе библиотечного компонента третьей стороны
Если компонент позволяет передавать атрибуты на корневой элемент (например, через v-bind="$attrs"), вы можете повесить директиву прямо на него:
<ThirdPartyInput v-focus v-bind="$attrs" />
Если же библиотека не пробрасывает атрибуты, создайте обертку:
<template>
<ThirdPartyInput ref="input" />
</template>
<script>
export default {
directives: { focus },
mounted() {
// Вызываем директиву вручную на рефе
this.$directives.focus.mounted(this.$refs.input.$el)
},
}
</script>
Важно проверить, к какому реальному DOM‑элементу вы обращаетесь.
2. Как протестировать пользовательскую директиву без реального браузера
Используйте тестовую среду с jsdom (например, Jest). В тесте:
- Регистрируете директиву локально в тестовом приложении Vue.
- Рендерите компонент с этой директивой.
- Проверяете изменения DOM (класс, атрибут, фокус, вызовы обработчиков).
При необходимости можно напрямую вызывать хуки директивы, передавая им mock‑объекты el и binding.
3. Как сделать директиву, работающую и на клиенте, и на серверном рендеринге
На сервере нет реального DOM, поэтому в SSR‑режиме:
- не обращайтесь к window, document и элементам напрямую в хуках, которые вызываются на сервере
- оборачивайте такие обращения в проверки typeof window !== 'undefined'
- основную логику, связанную с DOM, выносите в хуки, которые выполняются только на клиенте (inserted/mounted)
4. Можно ли в директиве асинхронно менять значение связанного с элементом v-model
Да, но делать это нужно через состояние компонента, а не напрямую через el.value. В директиве вы вызываете переданную функцию или изменяете реактивное свойство через binding.value (если там объект с коллбэком). Компонент же уже обновляет свое состояние, а вместе с ним и v-model.
5. Как переиспользовать одну и ту же директиву в нескольких проектах
Лучший подход — оформить набор директив как небольшой npm‑пакет:
- Вынести директивы в отдельную папку.
- Добавить install(app) функцию, которая регистрирует директивы.
- Опубликовать пакет в приватном или публичном репозитории.
- В проектах подключать через app.use() или через локальный импорт нужных директив.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев