20. Кастомные директивы
🎨 Урок 21 — Пользовательские директивы в Vue 3
Заголовок раздела «🎨 Урок 21 — Пользовательские директивы в Vue 3»Встроенные директивы Vue (v-if, v-for, v-model) — это лишь вершина айсберга. Vue 3 позволяет создавать собственные директивы, которые дают прямой доступ к DOM-элементу. Это мощный инструмент для анимаций, аналитики, интеграции с внешними библиотеками и многого другого.
🤔 Когда использовать директивы
Заголовок раздела «🤔 Когда использовать директивы»Директивы нужны, когда требуется прямая манипуляция DOM, которую неудобно делать через обычную реактивность. Несколько примеров:
- Автофокус при монтировании (
v-focus) - Отслеживание кликов вне элемента (
v-click-outside) - Ленивая загрузка изображений (
v-lazy) - Маски ввода (
v-mask) - Ripple-эффект (
v-ripple) - Tooltip при наведении (
v-tooltip)
<!-- Без директивы — нужны ref и onMounted --><script setup>import { ref, onMounted } from 'vue'const input = ref(null)onMounted(() => input.value?.focus())</script><template> <input ref="input" /></template>
<!-- С директивой — элегантно! --><template> <input v-focus /></template>🔧 Создание простой директивы vFocus
Заголовок раздела «🔧 Создание простой директивы vFocus»Самый простой способ — определить директиву объектом с хуками:
export const vFocus = { mounted(el) { el.focus() }}Использование в компоненте:
<script setup>import { vFocus } from './directives/focus'</script>
<template> <input v-focus placeholder="Я получу фокус автоматически" /></template>В <script setup> директивы, начинающиеся с v, распознаются автоматически — не нужна регистрация через directives: {}.
🔁 Хуки жизненного цикла директивы
Заголовок раздела «🔁 Хуки жизненного цикла директивы»Директива может реагировать на все этапы жизни элемента:
const myDirective = { // Вызывается до привязки атрибутов элемента // и до монтирования дочерних элементов created(el, binding, vnode) { console.log('created: элемент создан, но не в DOM') },
// Вызывается перед вставкой элемента в DOM beforeMount(el, binding, vnode) { console.log('beforeMount: скоро в DOM') },
// Вызывается после вставки элемента в DOM mounted(el, binding, vnode) { console.log('mounted: элемент в DOM, можно работать с ним') el.style.border = '2px solid #42b883' },
// Вызывается перед обновлением компонента beforeUpdate(el, binding, vnode, prevVnode) { console.log('beforeUpdate: значение изменится с', binding.oldValue, 'на', binding.value) },
// Вызывается после обновления компонента updated(el, binding, vnode, prevVnode) { console.log('updated: новое значение =', binding.value) },
// Вызывается перед размонтированием компонента beforeUnmount(el, binding, vnode) { console.log('beforeUnmount: скоро удалим') },
// Вызывается после размонтирования unmounted(el, binding, vnode) { console.log('unmounted: почистили ресурсы') }}📦 Параметр binding
Заголовок раздела «📦 Параметр binding»Объект binding содержит всю информацию о директиве:
<div v-my-directive:color.bold.italic="'#42b883'">...</div>const myDirective = { mounted(el, binding) { console.log(binding.value) // '#42b883' — значение директивы console.log(binding.oldValue) // undefined (только в updated) console.log(binding.arg) // 'color' — аргумент после : console.log(binding.modifiers) // { bold: true, italic: true } console.log(binding.instance) // экземпляр компонента (ctx) console.log(binding.dir) // сам объект директивы }}🎨 Директива v-color с аргументом
Заголовок раздела «🎨 Директива v-color с аргументом»Пример директивы, которая устанавливает цвет:
export const vColor = { mounted(el, binding) { applyColor(el, binding) }, updated(el, binding) { applyColor(el, binding) }}
function applyColor(el, binding) { const { value, arg, modifiers } = binding
if (arg === 'background') { el.style.backgroundColor = value } else { el.style.color = value }
if (modifiers.bold) { el.style.fontWeight = 'bold' }
if (modifiers.underline) { el.style.textDecoration = 'underline' }}<template> <!-- Цвет текста зелёный, жирный --> <p v-color.bold="'#42b883'">Vue зелёный</p>
<!-- Фоновый цвет тёмный --> <p v-color:background="'#35495e'">Тёмный фон</p></template>🖱 Директива v-click-outside
Заголовок раздела «🖱 Директива v-click-outside»Очень востребованная директива — закрытие дропдаунов по клику вне:
export const vClickOutside = { mounted(el, binding) { el._clickOutsideHandler = (event) => { // Если клик внутри элемента — игнорируем if (el.contains(event.target)) return
// Вызываем переданный коллбэк binding.value(event) }
document.addEventListener('click', el._clickOutsideHandler) },
unmounted(el) { // Важно: чистим слушатель при размонтировании! document.removeEventListener('click', el._clickOutsideHandler) delete el._clickOutsideHandler }}<template> <div class="dropdown" v-click-outside="closeDropdown" > <button @click="open = !open">Меню</button> <ul v-if="open"> <li>Пункт 1</li> <li>Пункт 2</li> </ul> </div></template>
<script setup>import { ref } from 'vue'import { vClickOutside } from './directives/clickOutside'
const open = ref(false)const closeDropdown = () => { open.value = false }</script>🌍 Глобальная регистрация директивы
Заголовок раздела «🌍 Глобальная регистрация директивы»Директивы можно регистрировать глобально через app.directive():
import { createApp } from 'vue'import App from './App.vue'import { vFocus } from './directives/focus'import { vClickOutside } from './directives/clickOutside'import { vLazy } from './directives/lazy'
const app = createApp(App)
app.directive('focus', vFocus)app.directive('click-outside', vClickOutside)app.directive('lazy', vLazy)
app.mount('#app')После этого v-focus, v-click-outside и v-lazy доступны во всех компонентах без импорта.
📌 Локальная регистрация (Options API)
Заголовок раздела «📌 Локальная регистрация (Options API)»В Options API директивы регистрируются в опции directives:
<script>export default { directives: { focus: { mounted(el) { el.focus() } }, color: vColorDirective }}</script>💫 Директива v-animate (число)
Заголовок раздела «💫 Директива v-animate (число)»export const vAnimate = { mounted(el, binding) { const { value, modifiers } = binding const duration = typeof value === 'number' ? value : 300
el.style.transition = "all " + duration + "ms ease"
if (modifiers.fade) { el.style.opacity = '0' requestAnimationFrame(() => { el.style.opacity = '1' }) }
if (modifiers.slide) { el.style.transform = 'translateY(-20px)' el.style.opacity = '0' requestAnimationFrame(() => { el.style.transform = 'translateY(0)' el.style.opacity = '1' }) } }}<template> <!-- Плавное появление за 500мс --> <div v-animate.fade="500">Я появляюсь плавно</div>
<!-- Слайд + fade за 300мс (по умолчанию) --> <div v-animate.slide.fade>Я выезжаю снизу</div></template>🔢 Директива v-debounce для полей ввода
Заголовок раздела «🔢 Директива v-debounce для полей ввода»export const vDebounce = { mounted(el, binding) { const delay = binding.value || 300 let timer
el._debouncedHandler = () => { clearTimeout(timer) timer = setTimeout(() => { el.dispatchEvent(new Event('debounced-input')) }, delay) }
el.addEventListener('input', el._debouncedHandler) },
unmounted(el) { el.removeEventListener('input', el._debouncedHandler) delete el._debouncedHandler }}<template> <input v-debounce="500" @debounced-input="onSearch" placeholder="Поиск (с задержкой 500мс)" /></template>🧪 Тестирование директив
Заголовок раздела «🧪 Тестирование директив»import { mount } from '@vue/test-utils'import { vFocus } from './directives/focus'
test('vFocus фокусирует элемент при монтировании', () => { const wrapper = mount({ template: '<input v-focus />', directives: { focus: vFocus } })
expect(wrapper.element.querySelector('input')).toBe(document.activeElement)})
test('vClickOutside вызывает коллбэк по клику снаружи', async () => { const handler = vi.fn() const wrapper = mount({ template: '<div v-click-outside="handler"><span>Внутри</span></div>', directives: { 'click-outside': vClickOutside }, setup: () => ({ handler }) }, { attachTo: document.body })
document.dispatchEvent(new MouseEvent('click')) expect(handler).toHaveBeenCalled()})