Skip to content

Пользовательские директивы во Vue 3

1. Что такое директивы?

Директивы — это специальные атрибуты с префиксом v-, которые применяют реактивное поведение к DOM-элементам. Vue 3 позволяет создавать собственные директивы для повторного использования логики работы с DOM.

2. Хуки директивы

typescript
const myDirective = {
  // Вызывается до привязки атрибутов элемента
  created(el, binding, vnode) {},

  // Вызывается перед монтированием элемента в DOM
  beforeMount(el, binding, vnode) {},

  // Вызывается после монтирования элемента в DOM
  mounted(el, binding, vnode) {},

  // Вызывается перед обновлением родительского компонента
  beforeUpdate(el, binding, vnode, prevVnode) {},

  // Вызывается после обновления родительского компонента
  updated(el, binding, vnode, prevVnode) {},

  // Вызывается перед размонтированием элемента
  beforeUnmount(el, binding, vnode) {},

  // Вызывается после размонтирования элемента
  unmounted(el, binding, vnode) {},
};

3. Аргумент binding

typescript
interface DirectiveBinding {
  value: any;        // Значение, переданное в директиву (v-my="value")
  oldValue: any;     // Предыдущее значение (только в updated)
  arg: string;       // Аргумент директивы (v-my:arg)
  modifiers: object; // Модификаторы (v-my.mod1.mod2)
  instance: object;  // Экземпляр компонента
  dir: object;       // Объект определения директивы
}

4. Регистрация директив

Локальная регистрация (script setup)

vue
<script setup>
// Любая переменная с префиксом v — автоматически директива
const vFocus = {
  mounted: (el: HTMLElement) => el.focus(),
};
</script>

<template>
  <input v-focus />
</template>

Глобальная регистрация

typescript
// main.ts
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

app.directive('focus', {
  mounted(el: HTMLElement) {
    el.focus();
  },
});

app.mount('#app');

5. Практические примеры

v-click-outside — клик за пределами элемента

typescript
// directives/clickOutside.ts
import { DirectiveBinding } from 'vue';

export const vClickOutside = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    el._clickOutside = (event: MouseEvent) => {
      if (!el.contains(event.target as Node)) {
        binding.value(event);
      }
    };
    document.addEventListener('click', el._clickOutside);
  },
  unmounted(el: HTMLElement) {
    document.removeEventListener('click', el._clickOutside);
  },
};
vue
<script setup>
import { ref } from 'vue';
import { vClickOutside } from './directives/clickOutside';

const isOpen = ref(false);

function close() {
  isOpen.value = false;
}
</script>

<template>
  <div v-click-outside="close" v-if="isOpen" class="dropdown">
    Выпадающее меню
  </div>
</template>

v-tooltip — тултип

typescript
// directives/tooltip.ts
export const vTooltip = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const tooltip = document.createElement('div');
    tooltip.className = 'v-tooltip';
    tooltip.textContent = binding.value;
    tooltip.style.cssText = `
      position: absolute;
      background: #333;
      color: white;
      padding: 4px 8px;
      border-radius: 4px;
      font-size: 12px;
      display: none;
      z-index: 9999;
    `;
    document.body.appendChild(tooltip);

    el.addEventListener('mouseenter', () => {
      const rect = el.getBoundingClientRect();
      tooltip.style.top = `${rect.top - 30}px`;
      tooltip.style.left = `${rect.left + rect.width / 2}px`;
      tooltip.style.transform = 'translateX(-50%)';
      tooltip.style.display = 'block';
    });

    el.addEventListener('mouseleave', () => {
      tooltip.style.display = 'none';
    });

    el._tooltip = tooltip;
  },
  updated(el: HTMLElement, binding: DirectiveBinding) {
    if (el._tooltip) {
      el._tooltip.textContent = binding.value;
    }
  },
  unmounted(el: HTMLElement) {
    if (el._tooltip) {
      document.body.removeChild(el._tooltip);
    }
  },
};
vue
<template>
  <button v-tooltip="'Нажмите для сохранения'">Сохранить</button>
</template>

v-permission — отображение по правам доступа

typescript
// directives/permission.ts
export const vPermission = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const userPermissions: string[] = getUserPermissions();
    const required = binding.value;

    if (Array.isArray(required)) {
      const hasPermission = required.some((p) =>
        userPermissions.includes(p)
      );
      if (!hasPermission) {
        el.parentNode?.removeChild(el);
      }
    }
  },
};
vue
<template>
  <button v-permission="['admin', 'editor']">Удалить</button>
</template>

v-debounce — задержка ввода

typescript
// directives/debounce.ts
export const vDebounce = {
  mounted(el: HTMLInputElement, binding: DirectiveBinding) {
    let timeout: ReturnType<typeof setTimeout>;
    const delay = binding.arg ? parseInt(binding.arg) : 300;

    el.addEventListener('input', () => {
      clearTimeout(timeout);
      timeout = setTimeout(() => {
        binding.value(el.value);
      }, delay);
    });
  },
};
vue
<template>
  <!-- Задержка 500мс -->
  <input v-debounce:500="handleSearch" placeholder="Поиск..." />
</template>

v-lazy — ленивая загрузка изображений

typescript
// directives/lazy.ts
export const vLazy = {
  mounted(el: HTMLImageElement, binding: DirectiveBinding) {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            el.src = binding.value;
            observer.unobserve(el);
          }
        });
      },
      { threshold: 0.1 }
    );

    el.src = 'placeholder.png';
    observer.observe(el);

    el._observer = observer;
  },
  unmounted(el: HTMLImageElement) {
    el._observer?.disconnect();
  },
};
vue
<template>
  <img v-lazy="'/images/photo.jpg'" alt="Фото" />
</template>

6. Сокращённая запись

Если нужна логика только при mounted и updated, можно использовать функцию:

typescript
app.directive('color', (el, binding) => {
  el.style.color = binding.value;
});
vue
<template>
  <p v-color="'red'">Красный текст</p>
</template>

7. Модификаторы

typescript
const vFormat = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    let text = binding.value;

    if (binding.modifiers.uppercase) {
      text = text.toUpperCase();
    }
    if (binding.modifiers.trim) {
      text = text.trim();
    }
    if (binding.modifiers.bold) {
      el.style.fontWeight = 'bold';
    }

    el.textContent = text;
  },
};
vue
<template>
  <p v-format.uppercase.bold="'привет мир'"><!-- ПРИВЕТ МИР --></p>
</template>