Урок 04

Кастомные директивы

Что это

Директива — v-xxx для работы с DOM. Встроенные: v-if, v-for, v-model. Можно делать свои.

Когда нужны

Когда есть прямая работа с DOM:

  • Фокус
  • Слушатели
  • Сторонние библиотеки
  • Анимации

Не злоупотребляй. 90% задач — компоненты и composables.

Простая директива

// main.js
const app = createApp(App);

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

app.mount('#app');

Использование:

<input v-focus>

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

  • created — до вставки в DOM
  • beforeMount — перед вставкой
  • mounted — после вставки
  • beforeUpdate — перед обновлением
  • updated — после обновления
  • beforeUnmount — перед удалением
  • unmounted — после удаления

Обычно используют: mounted, updated, unmounted.

Аргументы хуков

app.directive('demo', {
    mounted(el, binding, vnode, prevVnode) {
        // el — DOM-элемент
        // binding — объект с value, arg, modifiers
        // vnode — виртуальный узел
        // prevVnode — предыдущий (в updated)
    },
});

binding объект

<div v-demo:foo.bar="baz">
binding.value;       // 'baz'
binding.arg;         // 'foo'
binding.modifiers;   // { bar: true }
binding.instance;    // экземпляр компонента

Пример — v-focus

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

Использование:

<input v-focus placeholder="Автофокус">

v-click-outside

app.directive('click-outside', {
    mounted(el, binding) {
        el._handler = (e) => {
            if (!el.contains(e.target)) {
                binding.value(e);
            }
        };
        document.addEventListener('click', el._handler);
    },
    unmounted(el) {
        document.removeEventListener('click', el._handler);
    },
});

Использование:

<div v-click-outside="close">
    <button @click="open = !open">Меню</button>
    <ul v-if="open">...</ul>
</div>

v-debounce

app.directive('debounce', {
    mounted(el, binding) {
        const delay = binding.arg ? Number(binding.arg) : 300;
        let timeout;

        el._handler = () => {
            clearTimeout(timeout);
            timeout = setTimeout(() => binding.value(), delay);
        };

        el.addEventListener('input', el._handler);
    },
    unmounted(el) {
        el.removeEventListener('input', el._handler);
    },
});

Использование:

<input v-debounce:500="search">

Директива с модификаторами

app.directive('style', {
    mounted(el, binding) {
        el.style.color = binding.value;
        if (binding.modifiers.bold) {
            el.style.fontWeight = 'bold';
        }
        if (binding.modifiers.italic) {
            el.style.fontStyle = 'italic';
        }
    },
    updated(el, binding) {
        el.style.color = binding.value;
    },
});

Использование:

<p v-style.bold.italic="'red'">Красный жирный курсив</p>

Директива с объектом

app.directive('tooltip', {
    mounted(el, binding) {
        const opts = typeof binding.value === 'string'
            ? { text: binding.value }
            : binding.value;

        el._tooltip = document.createElement('div');
        el._tooltip.className = 'tooltip';
        el._tooltip.textContent = opts.text;
        el._tooltip.style.position = 'absolute';
        document.body.appendChild(el._tooltip);

        el._show = () => {
            el._tooltip.style.display = 'block';
        };
        el._hide = () => {
            el._tooltip.style.display = 'none';
        };

        el.addEventListener('mouseenter', el._show);
        el.addEventListener('mouseleave', el._hide);
    },
    unmounted(el) {
        el.removeEventListener('mouseenter', el._show);
        el.removeEventListener('mouseleave', el._hide);
        el._tooltip?.remove();
    },
});

Использование:

<button v-tooltip="'Кнопка сохранения'">Сохранить</button>
<button v-tooltip="{ text: 'Удалить', color: 'red' }">Удалить</button>

Локальные директивы

В <script setup> — с префиксом v:

<script setup>
import { ref } from 'vue';

const vFocus = {
    mounted(el) {
        el.focus();
    },
};
</script>

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

Правило: переменная начинается с v → Vue считает её директивой.

Локальная с параметрами

<script setup>
const vColor = {
    mounted(el, binding) {
        el.style.color = binding.value;
    },
    updated(el, binding) {
        el.style.color = binding.value;
    },
};
</script>

<template>
    <p v-color="'red'">Красный</p>
</template>

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

main.js:

const app = createApp(App);

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

app.mount('#app');

В отдельном файле — directives/index.js:

import focus from './focus';
import clickOutside from './clickOutside';

export default {
    install(app) {
        app.directive('focus', focus);
        app.directive('click-outside', clickOutside);
    },
};

main.js:

import directives from './directives';
app.use(directives);

Async директивы

app.directive('chart', {
    async mounted(el, binding) {
        const { Chart } = await import('chart.js/auto');
        el._chart = new Chart(el, binding.value);
    },
    unmounted(el) {
        el._chart?.destroy();
    },
});

Библиотека грузится только когда нужна.

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

app.directive('lazy', {
    mounted(el, binding) {
        const observer = new IntersectionObserver(([entry]) => {
            if (entry.isIntersecting) {
                el.src = binding.value;
                observer.unobserve(el);
            }
        });
        observer.observe(el);
        el._observer = observer;
    },
    updated(el, binding) {
        if (binding.value !== binding.oldValue) {
            el.src = binding.value;
        }
    },
    unmounted(el) {
        el._observer?.disconnect();
    },
});

Использование:

<img v-lazy="user.avatar" alt="Аватар">

Утечки памяти

⚠️ Обязательно очищай в unmounted:

  • removeEventListener
  • clearInterval
  • clearTimeout
  • observer.disconnect()
  • DOM-элементы, созданные директивой

Плохо:

mounted(el) {
    document.addEventListener('scroll', () => {});   // не убрать
}

Хорошо:

mounted(el) {
    el._handler = () => {};
    document.addEventListener('scroll', el._handler);
},
unmounted(el) {
    document.removeEventListener('scroll', el._handler);
}

Использование vnode

mounted(el, binding, vnode) {
    // vnode.props
    // vnode.children
    // vnode.component
}

Редко нужно.

Полный пример — v-tooltip

// directives/tooltip.js
import './tooltip.css';

export default {
    mounted(el, binding) {
        const { text, position = 'top' } = typeof binding.value === 'string'
            ? { text: binding.value }
            : binding.value;

        const tooltip = document.createElement('div');
        tooltip.className = `custom-tooltip custom-tooltip--${position}`;
        tooltip.textContent = text;
        document.body.appendChild(tooltip);

        const show = () => {
            const rect = el.getBoundingClientRect();
            tooltip.style.top = `${rect.top - 40}px`;
            tooltip.style.left = `${rect.left + rect.width / 2}px`;
            tooltip.classList.add('visible');
        };

        const hide = () => tooltip.classList.remove('visible');

        el._show = show;
        el._hide = hide;
        el._tooltip = tooltip;

        el.addEventListener('mouseenter', show);
        el.addEventListener('mouseleave', hide);
    },

    updated(el, binding) {
        const text = typeof binding.value === 'string'
            ? binding.value
            : binding.value.text;
        el._tooltip.textContent = text;
    },

    unmounted(el) {
        el.removeEventListener('mouseenter', el._show);
        el.removeEventListener('mouseleave', el._hide);
        el._tooltip?.remove();
    },
};

Практическое правило

Используй директивы только для:

  • Работы с DOM напрямую
  • Обёрток над библиотеками (Chart.js, Select2)
  • Глобальных эффектов (focus, tooltip, lazy)

Всё остальное — компоненты или composables.

Итог

  • app.directive или локально через vXxx
  • Хуки: mounted, updated, unmounted
  • binding.value, arg, modifiers
  • Очистка в unmounted — обязательна
  • Не злоупотребляй — большинство задач — компоненты

Дальше — плагины и миксины.