← Курс/Кастомные директивы#238 из 257+30 XP

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

Что такое директивы

Директивы — специальные атрибуты с префиксом v-, дающие Vue-компилятору инструкции по управлению DOM. Встроенные директивы: v-if, v-for, v-model, v-show.

**Кастомные директивы** позволяют инкапсулировать логику работы с DOM-элементами в переиспользуемую единицу.

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

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

const app = createApp(App)

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

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

<script setup>
// В <script setup> директивы начинающиеся с 'v' автоматически распознаются
const vFocus = {
  mounted(el) { el.focus() }
}
</script>

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

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

app.directive('my-directive', {
  // Перед вставкой элемента в 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) {},
})

Объект binding

<!-- v-директива:аргумент.модификатор="значение" -->
<div v-highlight:background.animate="'#ff0000'"></div>
app.directive('highlight', {
  mounted(el, binding) {
    console.log(binding.value)          // '#ff0000' — переданное значение
    console.log(binding.arg)            // 'background' — аргумент
    console.log(binding.modifiers)      // { animate: true } — модификаторы
    console.log(binding.oldValue)       // предыдущее значение (в updated)
    console.log(binding.instance)       // экземпляр компонента
  }
})

Примеры реальных директив

v-focus

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

v-tooltip

app.directive('tooltip', {
  mounted(el, { value }) {
    const tip = document.createElement('div')
    tip.className = 'tooltip'
    tip.textContent = value
    el._tooltip = tip

    el.addEventListener('mouseenter', () => {
      document.body.appendChild(tip)
      const rect = el.getBoundingClientRect()
      tip.style.top = rect.top - tip.offsetHeight - 8 + 'px'
      tip.style.left = rect.left + 'px'
    })
    el.addEventListener('mouseleave', () => tip.remove())
  },
  updated(el, { value }) {
    el._tooltip.textContent = value
  },
  unmounted(el) {
    el._tooltip?.remove()
    // важно: убрать event listeners
  }
})

v-click-outside

app.directive('click-outside', {
  mounted(el, { value }) {
    el._clickOutside = (event) => {
      if (!el.contains(event.target)) {
        value(event)  // value — callback функция
      }
    }
    document.addEventListener('click', el._clickOutside)
  },
  unmounted(el) {
    document.removeEventListener('click', el._clickOutside)
  }
})
<div v-click-outside="closeMenu">Меню</div>

Аргументы объектом

<!-- Передаём объект, если нужно несколько параметров -->
<div v-tooltip="{ text: 'Подсказка', position: 'top', delay: 300 }">
  Наведи на меня
</div>

Примеры

Система директив через data-атрибуты и MutationObserver — аналог того, как Vue применяет директивы к DOM

// Мини-система директив: регистрируем директивы по имени,
// применяем к элементам через data-атрибуты.
// MutationObserver следит за появлением новых элементов.

class DirectiveSystem {
  constructor() {
    this.directives = new Map()
    this._observer = null
  }

  // Зарегистрировать директиву
  directive(name, def) {
    this.directives.set(name, def)
    return this
  }

  // Применить директивы к элементу
  applyToElement(el) {
    for (const [name, def] of this.directives) {
      const attrName = `data-v-${name}`
      if (el.hasAttribute && el.hasAttribute(attrName)) {
        const rawValue = el.getAttribute(attrName)
        const binding = {
          value: this._parseValue(rawValue),
          arg: el.getAttribute(`data-v-${name}-arg`) || null,
          modifiers: this._parseModifiers(el, name),
        }
        if (def.mounted) {
          def.mounted(el, binding)
        }
      }
    }
  }

  _parseValue(raw) {
    if (!raw) return null
    try { return JSON.parse(raw) } catch { return raw }
  }

  _parseModifiers(el, name) {
    const mods = {}
    const prefix = `data-v-${name}-mod-`
    if (el.getAttributeNames) {
      for (const attr of el.getAttributeNames()) {
        if (attr.startsWith(prefix)) {
          mods[attr.slice(prefix.length)] = true
        }
      }
    }
    return mods
  }

  // Обновить значение директивы
  update(el, name, newValue) {
    const def = this.directives.get(name)
    if (!def || !def.updated) return
    const binding = {
      value: newValue,
      oldValue: this._parseValue(el.getAttribute(`data-v-${name}`)),
      arg: null,
      modifiers: {},
    }
    el.setAttribute(`data-v-${name}`, JSON.stringify(newValue))
    def.updated(el, binding)
  }

  // Отключить директиву
  unmount(el, name) {
    const def = this.directives.get(name)
    if (def && def.unmounted) {
      def.unmounted(el)
    }
  }
}

// --- Регистрируем директивы ---
const system = new DirectiveSystem()

// v-highlight: задаёт фоновый цвет
system.directive('highlight', {
  mounted(el, binding) {
    el.style = el.style || {}
    el.style.backgroundColor = binding.value || '#yellow'
    el.style.padding = '4px 8px'
    el.style.borderRadius = '3px'
    console.log(`[v-highlight] mounted: backgroundColor = ${binding.value}`)
  },
  updated(el, binding) {
    el.style.backgroundColor = binding.value
    console.log(`[v-highlight] updated: ${binding.oldValue} -> ${binding.value}`)
  },
  unmounted(el) {
    el.style.backgroundColor = ''
    console.log('[v-highlight] unmounted: стиль сброшен')
  }
})

// v-badge: добавляет счётчик
system.directive('badge', {
  mounted(el, binding) {
    el._badge = `[${binding.value}]`
    el.textContent = (el.textContent || '') + ' ' + el._badge
    console.log(`[v-badge] mounted: badge = ${el._badge}`)
  },
  updated(el, binding) {
    const oldBadge = `[${binding.oldValue}]`
    const newBadge = `[${binding.value}]`
    el.textContent = (el.textContent || '').replace(oldBadge, newBadge)
    el._badge = newBadge
    console.log(`[v-badge] updated: ${oldBadge} -> ${newBadge}`)
  }
})

// --- Фейковый DOM-объект ---
const fakeEl = {
  attributes: { 'data-v-highlight': '#3498db', 'data-v-badge': '5' },
  style: {},
  textContent: 'Кнопка',
  hasAttribute(name) { return name in this.attributes },
  getAttribute(name) { return this.attributes[name] ?? null },
  getAttributeNames() { return Object.keys(this.attributes) },
  setAttribute(name, val) { this.attributes[name] = val },
}

console.log('=== Применяем директивы ===')
system.applyToElement(fakeEl)

console.log('\n=== Обновляем директивы ===')
system.update(fakeEl, 'highlight', '#e74c3c')
system.update(fakeEl, 'badge', 12)

console.log('\n=== Размонтируем ===')
system.unmount(fakeEl, 'highlight')