Перейти к содержимому

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>

Самый простой способ — определить директиву объектом с хуками:

directives/focus.js
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 содержит всю информацию о директиве:

<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) // сам объект директивы
}
}

Пример директивы, которая устанавливает цвет:

directives/color.js
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>

Очень востребованная директива — закрытие дропдаунов по клику вне:

directives/clickOutside.js
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():

main.js
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 директивы регистрируются в опции directives:

<script>
export default {
directives: {
focus: {
mounted(el) { el.focus() }
},
color: vColorDirective
}
}
</script>

directives/animate.js
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>

directives/debounce.js
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()
})