Навигация

Кастомные директивы во 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, а также обсудим серверный рендеринг и типичные ошибки.

Содержание

  1. Что такое кастомная директива
  2. Хуки жизненного цикла директивы
  3. Сокращённая форма
  4. Объект binding: value, arg, modifiers
  5. Регистрация: локально и глобально
  6. Динамические аргументы и модификаторы
  7. Практические примеры
  8. Директива, composable или компонент
  9. SSR и директивы
  10. Лучшие практики и антипаттерны
  11. Ключевые тезисы для интервью
  12. Заключение
  13. Полезные ссылки

Что такое кастомная директива

Кастомная директива — это объект (или функция), который 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.

Полезные ссылки