自定义指令:为 DOM 操作提供高效的抽象入口

前言

在 Vue 开发中,我们通常遵循 数据驱动视图 的理念,很少直接操作 DOM。但有些场景却绕不开 DOM 操作,比如:输入框自动获取焦点、点击外部区域关闭下拉框、图片懒加载、权限控制等。这些场景如果写在组件内部,会导致代码冗余、逻辑分散。

自定义指令正是为解决这类问题而生,它提供了一种优雅的方式来封装 DOM 操作逻辑,让代码更加简洁、可复用。本文将深入探讨自定义指令的生命周期、钩子参数,并通过大量实战案例,帮我们掌握这一强大的抽象工具。

什么时候需要自定义指令?

直接操作 DOM 的场景

虽然 Vue 团队官方推崇数据驱动,而且建议不要在 Vue 项目中直接操作 DOM ,但有些操作又必须直接操作 DOM:

html 复制代码
<template>
  <input ref="inputRef" />
</template>

<script setup>
import { ref, onMounted } from 'vue'

const inputRef = ref(null)

onMounted(() => {
  // 每次都要写这个逻辑
  inputRef.value?.focus()
})
</script>

此时,如果用自定义指令,就可以优雅地解决这个问题:

html 复制代码
<template>
  <!-- ✅ 声明式的指令 -->
  <input v-focus />
</template>

<script setup>
// 指令只定义一次,到处使用
</script>

与第三方库集成

当我们需要集成非 Vue 的第三方库时,自定义指令是理想的桥梁:

typescript 复制代码
// 集成 Clipboard.js 复制功能
app.directive('clipboard', {
  mounted(el, binding) {
    const clipboard = new ClipboardJS(el, {
      text: () => binding.value
    })
    
    clipboard.on('success', () => {
      alert('复制成功')
    })
  }
})

复用 DOM 相关的逻辑

有些 DOM 操作逻辑需要在多个组件中重复使用,比如:

  • 点击外部关闭:下拉菜单、模态框
  • 滚动加载更多:无限滚动列表
  • 拖拽调整大小:可调整尺寸的面板
  • 权限控制:根据权限隐藏元素

将这些逻辑封装成指令,就可以实现 一次定义,到处使用

指令的生命周期钩子

七个钩子函数详解

自定义指令提供了七个钩子函数,覆盖了指令从创建到销毁的完整过程:

typescript 复制代码
const myDirective = {
  // 在绑定元素的 attribute 或事件监听器被应用之前调用
  created(el, binding, vnode) {
    console.log('created')
  },
  
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {
    console.log('beforeMount')
  },
  
  // 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
  mounted(el, binding, vnode) {
    console.log('mounted')
  },
  
  // 在包含组件的 VNode 更新之前调用
  beforeUpdate(el, binding, vnode, prevVnode) {
    console.log('beforeUpdate')
  },
  
  // 在包含组件的 VNode 及其子组件的 VNode 更新之后调用
  updated(el, binding, vnode, prevVnode) {
    console.log('updated')
  },
  
  // 在绑定元素的父组件卸载前调用
  beforeUnmount(el, binding, vnode) {
    console.log('beforeUnmount')
  },
  
  // 在绑定元素的父组件卸载后调用
  unmounted(el, binding, vnode) {
    console.log('unmounted')
  }
}

每个钩子的适用场景

钩子函数 适用场景 示例
created 初始化只在 JS 层面工作的内容 添加事件监听前的准备
beforeMount 第一次 DOM 渲染前的修改 设置初始样式
mounted 最常用,操作真实 DOM 获取焦点、监听事件、初始化第三方库
beforeUpdate 基于更新的响应式数据修改 DOM 更新前的数据验证
updated 响应式数据更新后的操作 更新第三方库的配置
beforeUnmount 清理前的最后操作 保存状态、执行动画
unmounted 清理工作 移除事件监听、销毁实例

钩子函数的参数详解

每个钩子函数都接收相同的参数:

typescript 复制代码
interface Binding {
  value: any      // 传递给指令的值
  oldValue: any   // 之前的值,仅在 beforeUpdate 和 updated 中可用
  arg: string     // 传递给指令的参数
  modifiers: { [key: string]: boolean } // 修饰符对象
  instance: ComponentPublicInstance // 使用该指令的组件实例
  dir: Object     // 指令的定义对象
}

function hook(
  el: HTMLElement,           // 指令绑定的元素
  binding: Binding,          // 包含指令所有信息的对象
  vnode: VNode,              // 元素的虚拟节点
  prevVnode: VNode | null    // 上一个虚拟节点,仅在 update 钩子中可用
) {}

钩子函数的参数使用示例

html 复制代码
<template>
  <div 
    v-demo:foo.bar.baz="value"
    @click="value++"
  >
    点击增加
  </div>
</template>

<script setup>
const value = ref(1)
</script>

<!-- 指令实现 -->
<script>
app.directive('demo', {
  mounted(el, binding) {
    console.log(binding.value)     // 1
    console.log(binding.arg)       // 'foo'
    console.log(binding.modifiers) // { bar: true, baz: true }
    console.log(binding.instance)  // 当前组件实例
  },
  
  updated(el, binding) {
    console.log(binding.value)     // 更新后的值
    console.log(binding.oldValue)  // 更新前的值
  }
})
</script>

实用自定义指令实战

v-focus:自动获取焦点

最简单的实用指令:

typescript 复制代码
// focus.ts
export const vFocus = {
  mounted: (el: HTMLElement) => {
    el.focus()
  }
}

// 或者支持延迟获取焦点
export const vFocus = {
  mounted(el: HTMLElement, binding: { value?: number }) {
    if (binding.value) {
      setTimeout(() => el.focus(), binding.value)
    } else {
      el.focus()
    }
  }
}

使用

html 复制代码
<template>
  <!-- 立即获取焦点 -->
  <input v-focus />
  
  <!-- 延迟500ms获取焦点 -->
  <input v-focus="500" />
</template>

v-click-outside:点击外部关闭

这是最常用的指令之一,用于下拉菜单、模态框等:

typescript 复制代码
// click-outside.ts
export const vClickOutside = {
  mounted(el: HTMLElement, binding: { value: () => void }) {
    // 使用自定义属性保存处理函数,方便移除
    el._clickOutsideHandler = (event: Event) => {
      // 点击的元素不是目标元素本身也不是它的子元素
      if (!el.contains(event.target as Node)) {
        binding.value()
      }
    }
    
    // 使用 setTimeout 确保不捕获触发绑定的事件
    setTimeout(() => {
      document.addEventListener('click', el._clickOutsideHandler)
    }, 0)
  },
  
  unmounted(el: HTMLElement) {
    document.removeEventListener('click', el._clickOutsideHandler)
    delete el._clickOutsideHandler
  }
}

增强版:支持排除特定元素

typescript 复制代码
export const vClickOutside = {
  mounted(el: HTMLElement, binding: { 
    value: () => void,
    arg?: string  // 排除的选择器
  }) {
    el._clickOutsideHandler = (event: Event) => {
      const target = event.target as HTMLElement
      
      // 检查是否点击了排除元素
      if (binding.arg) {
        const excludeEl = document.querySelector(binding.arg)
        if (excludeEl?.contains(target)) {
          return
        }
      }
      
      if (!el.contains(target)) {
        binding.value()
      }
    }
    
    setTimeout(() => {
      document.addEventListener('click', el._clickOutsideHandler)
    }, 0)
  },
  
  unmounted(el: HTMLElement) {
    document.removeEventListener('click', el._clickOutsideHandler)
  }
}

使用

html 复制代码
<template>
  <div class="dropdown">
    <button ref="toggleBtn">菜单</button>
    
    <div 
      v-click-outside="closeDropdown"
      v-click-outside:".toggle-btn"="closeDropdown"
      class="dropdown-menu"
    >
      <ul>
        <li>选项1</li>
        <li>选项2</li>
      </ul>
    </div>
  </div>
</template>

v-debounce:输入防抖

处理输入框的高频事件:

typescript 复制代码
// debounce.ts
export const vDebounce = {
  mounted(el: HTMLInputElement, binding: { 
    value: (...args: any[]) => void,
    arg?: string  // 事件类型,默认 'input'
  }) {
    const eventType = binding.arg || 'input'
    let timer: ReturnType<typeof setTimeout> | null = null
    
    el._debounceHandler = (event: Event) => {
      if (timer) clearTimeout(timer)
      
      timer = setTimeout(() => {
        binding.value(event)
        timer = null
      }, 300) // 固定300ms,也可以从 binding.value 获取
    }
    
    el.addEventListener(eventType, el._debounceHandler)
  },
  
  unmounted(el: HTMLInputElement) {
    const eventType = binding?.arg || 'input'
    el.removeEventListener(eventType, el._debounceHandler)
    delete el._debounceHandler
  }
}

可配置版本

typescript 复制代码
interface DebounceBinding {
  value: (...args: any[]) => void
  arg?: 'input' | 'change' | 'keyup'
  modifiers?: {
    [key: string]: boolean  // 使用修饰符指定延迟时间
  }
}

export const vDebounce = {
  mounted(el: HTMLInputElement, binding: DebounceBinding) {
    const eventType = binding.arg || 'input'
    
    // 从修饰符中获取延迟时间,如 v-debounce:input.500
    let delay = 300 // 默认
    for (const mod in binding.modifiers) {
      const num = parseInt(mod)
      if (!isNaN(num)) {
        delay = num
        break
      }
    }
    
    el._debounceHandler = (event: Event) => {
      if (el._debounceTimer) clearTimeout(el._debounceTimer)
      
      el._debounceTimer = setTimeout(() => {
        binding.value(event)
        el._debounceTimer = null
      }, delay)
    }
    
    el.addEventListener(eventType, el._debounceHandler)
  },
  
  unmounted(el: HTMLInputElement) {
    const eventType = binding?.arg || 'input'
    el.removeEventListener(eventType, el._debounceHandler)
    if (el._debounceTimer) {
      clearTimeout(el._debounceTimer)
    }
  }
}

使用

html 复制代码
<template>
  <input 
    v-debounce:input.500="handleSearch" 
    placeholder="搜索..."
  />
</template>

<script setup>
function handleSearch(event) {
  console.log('搜索:', event.target.value)
  // 调用 API
}
</script>

v-permission:权限控制

根据用户权限显示/隐藏元素:

typescript 复制代码
// permission.ts
import { useUserStore } from '@/stores/user'

export const vPermission = {
  mounted(el: HTMLElement, binding: { 
    value: string | string[],  // 需要的权限
    modifiers?: {
      and?: boolean  // 需要同时满足多个权限
    }
  }) {
    const userStore = useUserStore()
    const permissions = userStore.permissions || []
    
    const requiredPermissions = Array.isArray(binding.value) 
      ? binding.value 
      : [binding.value]
    
    let hasPermission = false
    
    if (binding.modifiers?.and) {
      // 需要同时拥有所有权限
      hasPermission = requiredPermissions.every(p => permissions.includes(p))
    } else {
      // 拥有任意一个权限即可
      hasPermission = requiredPermissions.some(p => permissions.includes(p))
    }
    
    if (!hasPermission) {
      el.style.display = 'none'
      // 或者移除元素
      // el.parentNode?.removeChild(el)
    }
  },
  
  // 当权限更新时重新检查(如用户切换角色)
  updated(el: HTMLElement, binding: { value: string | string[] }) {
    const userStore = useUserStore()
    const permissions = userStore.permissions || []
    
    const requiredPermissions = Array.isArray(binding.value) 
      ? binding.value 
      : [binding.value]
    
    const hasPermission = requiredPermissions.some(p => permissions.includes(p))
    
    if (!hasPermission) {
      el.style.display = 'none'
    } else {
      el.style.display = ''
    }
  }
}

使用

html 复制代码
<template>
  <!-- 需要 admin 权限 -->
  <button v-permission="'admin'">管理用户</button>
  
  <!-- 需要 admin 或 manager 权限 -->
  <button v-permission="['admin', 'manager']">高级操作</button>
  
  <!-- 需要同时拥有 admin 和 finance 权限 -->
  <button v-permission:and="['admin', 'finance']">财务操作</button>
</template>

v-lazy:图片懒加载

图片懒加载是性能优化的常用手段:

typescript 复制代码
// lazy.ts
export const vLazy = {
  mounted(el: HTMLImageElement, binding: { value: string }) {
    // 保存原始图片地址
    el.dataset.src = binding.value
    
    // 创建 IntersectionObserver
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // 图片进入视口,加载真实图片
          el.src = el.dataset.src!
          observer.unobserve(el)
          
          // 图片加载完成后添加淡入效果
          el.classList.add('loaded')
        }
      })
    }, {
      rootMargin: '50px' // 提前50px加载
    })
    
    observer.observe(el)
    
    // 保存 observer 以便清理
    el._lazyObserver = observer
  },
  
  unmounted(el: HTMLImageElement) {
    if (el._lazyObserver) {
      el._lazyObserver.unobserve(el)
      el._lazyObserver.disconnect()
    }
  }
}

增强版:支持加载中、加载失败占位

typescript 复制代码
interface LazyBinding {
  value: string          // 真实图片地址
  arg?: string           // 加载中占位图
  modifiers?: {
    error?: string       // 加载失败占位图
  }
}

export const vLazy = {
  mounted(el: HTMLImageElement, binding: LazyBinding) {
    // 设置加载中占位图
    if (binding.arg) {
      el.src = binding.arg
    }
    
    // 处理加载失败
    el.onerror = () => {
      if (binding.modifiers?.error) {
        el.src = binding.modifiers.error
      }
    }
    
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // 创建新图片对象预加载
          const img = new Image()
          img.src = binding.value
          
          img.onload = () => {
            el.src = binding.value
            el.classList.add('fade-in')
          }
          
          observer.unobserve(el)
        }
      })
    }, {
      rootMargin: '50px'
    })
    
    observer.observe(el)
    el._lazyObserver = observer
  },
  
  unmounted(el: HTMLImageElement) {
    if (el._lazyObserver) {
      el._lazyObserver.unobserve(el)
      el._lazyObserver.disconnect()
    }
  }
}

使用

html 复制代码
<template>
  <img 
    v-lazy:loading.gif.error="'error.png'" 
    :data-src="imageUrl"
    alt="懒加载图片"
  />
</template>

<style>
img.fade-in {
  animation: fadeIn 0.3s ease-in;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}
</style>

指令的参数修饰符和值

使用 binding.arg 传递参数

参数让指令更加灵活:

typescript 复制代码
// 示例:滚动指令
app.directive('scroll', {
  mounted(el, binding) {
    const handler = () => {
      // 根据参数执行不同逻辑
      switch (binding.arg) {
        case 'bottom':
          if (el.scrollTop + el.clientHeight >= el.scrollHeight) {
            binding.value()
          }
          break
        case 'top':
          if (el.scrollTop === 0) {
            binding.value()
          }
          break
        case 'direction':
          // 检测滚动方向
          break
      }
    }
    
    el.addEventListener('scroll', handler)
    el._scrollHandler = handler
  },
  
  unmounted(el) {
    el.removeEventListener('scroll', el._scrollHandler)
  }
})

使用:

html 复制代码
<template>
  <div 
    v-scroll:bottom="loadMore"
    v-scroll:top="refresh"
    class="scroll-container"
  >
    <!-- 内容 -->
  </div>
</template>

使用 binding.modifiers 处理修饰符

修饰符是布尔值,适合开关型配置:

typescript 复制代码
// 拖拽指令
app.directive('draggable', {
  mounted(el, binding) {
    el.style.position = 'absolute'
    el.style.cursor = 'move'
    
    const handlers = {
      mousedown: (e: MouseEvent) => {
        e.preventDefault()
        
        const startX = e.clientX - el.offsetLeft
        const startY = e.clientY - el.offsetTop
        
        const onMouseMove = (e: MouseEvent) => {
          // 根据修饰符限制移动方向
          if (!binding.modifiers?.horizontal) {
            el.style.top = (e.clientY - startY) + 'px'
          }
          if (!binding.modifiers?.vertical) {
            el.style.left = (e.clientX - startX) + 'px'
          }
          
          // 边界限制
          if (binding.modifiers?.boundary) {
            const parent = el.parentElement
            if (parent) {
              const left = parseInt(el.style.left)
              const top = parseInt(el.style.top)
              
              el.style.left = Math.max(0, Math.min(left, parent.clientWidth - el.clientWidth)) + 'px'
              el.style.top = Math.max(0, Math.min(top, parent.clientHeight - el.clientHeight)) + 'px'
            }
          }
        }
        
        const onMouseUp = () => {
          document.removeEventListener('mousemove', onMouseMove)
          document.removeEventListener('mouseup', onMouseUp)
        }
        
        document.addEventListener('mousemove', onMouseMove)
        document.addEventListener('mouseup', onMouseUp)
      }
    }
    
    el.addEventListener('mousedown', handlers.mousedown)
    el._dragHandlers = handlers
  },
  
  unmounted(el) {
    el.removeEventListener('mousedown', el._dragHandlers.mousedown)
  }
})

使用:

html 复制代码
<template>
  <!-- 只能水平移动 -->
  <div v-draggable.horizontal class="draggable">水平拖拽</div>
  
  <!-- 只能垂直移动 -->
  <div v-draggable.vertical class="draggable">垂直拖拽</div>
  
  <!-- 边界限制 -->
  <div v-draggable.boundary class="draggable">带边界</div>
  
  <!-- 自由拖拽 -->
  <div v-draggable class="draggable">自由拖拽</div>
</template>

动态更新指令的逻辑

当指令的值变化时,可以在 updated 钩子中响应:

typescript 复制代码
// 颜色指令
app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value
  },
  
  updated(el, binding) {
    // 当值变化时更新颜色
    el.style.color = binding.value
  }
})

使用动态值:

html 复制代码
<template>
  <div v-color="color">颜色会变化</div>
  <button @click="color = 'red'">红色</button>
  <button @click="color = 'blue'">蓝色</button>
</template>

<script setup>
const color = ref('black')
</script>

在 Composition API 中使用指令

使用 v-bind 动态绑定指令

可以通过 v-bind 动态决定是否应用指令:

html 复制代码
<template>
  <input 
    v-bind="directives"
    v-model="searchText"
  />
</template>

<script setup>
import { computed } from 'vue'

const isDisabled = ref(false)

const directives = computed(() => {
  const dirs = {}
  
  // 根据条件添加指令
  if (!isDisabled.value) {
    dirs.focus = {}  // 应用 v-focus 指令
  }
  
  dirs.debounce = {
    value: handleSearch,
    arg: 'input',
    modifiers: { 500: true }
  }
  
  return dirs
})
</script>

组合式函数中封装指令逻辑

有时我们需要在组合式函数中封装指令相关的逻辑:

typescript 复制代码
// composables/useDraggable.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useDraggable(options: {
  horizontal?: boolean
  vertical?: boolean
  boundary?: boolean
} = {}) {
  const element = ref<HTMLElement | null>(null)
  
  const startDrag = (e: MouseEvent) => {
    if (!element.value) return
    
    e.preventDefault()
    
    const el = element.value
    const startX = e.clientX - el.offsetLeft
    const startY = e.clientY - el.offsetTop
    
    const onMouseMove = (e: MouseEvent) => {
      if (!options.horizontal) {
        el.style.top = (e.clientY - startY) + 'px'
      }
      if (!options.vertical) {
        el.style.left = (e.clientX - startX) + 'px'
      }
      
      if (options.boundary && el.parentElement) {
        const parent = el.parentElement
        const left = parseInt(el.style.left)
        const top = parseInt(el.style.top)
        
        el.style.left = Math.max(0, Math.min(left, parent.clientWidth - el.clientWidth)) + 'px'
        el.style.top = Math.max(0, Math.min(top, parent.clientHeight - el.clientHeight)) + 'px'
      }
    }
    
    const onMouseUp = () => {
      document.removeEventListener('mousemove', onMouseMove)
      document.removeEventListener('mouseup', onMouseUp)
    }
    
    document.addEventListener('mousemove', onMouseMove)
    document.addEventListener('mouseup', onMouseUp)
  }
  
  onMounted(() => {
    if (element.value) {
      element.value.addEventListener('mousedown', startDrag)
    }
  })
  
  onUnmounted(() => {
    if (element.value) {
      element.value.removeEventListener('mousedown', startDrag)
    }
  })
  
  return {
    element,
    // 可以返回一个指令对象
    draggable: {
      mounted(el: HTMLElement) {
        element.value = el
        el.style.position = 'absolute'
        el.style.cursor = 'move'
      }
    }
  }
}

在组件中使用:

html 复制代码
<template>
  <div v-draggable class="box">拖拽我</div>
</template>

<script setup>
import { useDraggable } from './composables/useDraggable'

// 方式1:直接使用指令
const { draggable } = useDraggable({ horizontal: true })

// 方式2:使用组合式函数控制
const { element } = useDraggable({ boundary: true })
</script>

TypeScript 类型支持

为自定义指令定义类型

typescript 复制代码
// directives/types.ts
import { Directive } from 'vue'

// 权限指令的类型
export interface PermissionDirective {
  (el: HTMLElement, binding: {
    value: string | string[]      // 权限列表
    modifiers?: {
      and?: boolean                // 是否需要同时满足
    }
  }): void
}

// 防抖指令的类型
export interface DebounceDirective {
  (el: HTMLElement, binding: {
    value: (event: Event) => void  // 回调函数
    arg?: 'input' | 'change'       // 事件类型
    modifiers?: {                   // 延迟时间,如 v-debounce.500
      [key: string]: boolean
    }
  }): void
}

// 定义指令对象类型
export type AppDirectives = {
  'permission': Directive<HTMLElement, string | string[]>
  'debounce': Directive<HTMLElement, (event: Event) => void>
  'click-outside': Directive<HTMLElement, () => void>
  'focus': Directive<HTMLElement, number | undefined>
  'lazy': Directive<HTMLImageElement, string>
}

扩展 Vue 的类型声明

为了让 TypeScript 识别自定义指令,需要扩展 Vue 的类型:

typescript 复制代码
// directives/index.ts
import { App } from 'vue'
import { vFocus } from './focus'
import { vClickOutside } from './click-outside'
import { vDebounce } from './debounce'
import { vPermission } from './permission'
import { vLazy } from './lazy'

export function setupDirectives(app: App) {
  app.directive('focus', vFocus)
  app.directive('click-outside', vClickOutside)
  app.directive('debounce', vDebounce)
  app.directive('permission', vPermission)
  app.directive('lazy', vLazy)
}

// types/vue.d.ts
import { Directive } from 'vue'

declare module '@vue/runtime-core' {
  export interface ComponentCustomProperties {
    // 如果有全局属性可以添加
  }
  
  // 扩展全局指令类型
  export interface DirectiveBinding {
    // 可以添加自定义属性
  }
}

// 为导入的指令提供类型
declare module '@/directives' {
  export const vFocus: Directive<HTMLElement, number>
  export const vClickOutside: Directive<HTMLElement, () => void>
  export const vDebounce: Directive<HTMLElement, (event: Event) => void>
  export const vPermission: Directive<HTMLElement, string | string[]>
  export const vLazy: Directive<HTMLImageElement, string>
}

在组件中局部注册并获取类型

html 复制代码
<!-- 局部注册指令并获取类型支持 -->
<script setup lang="ts">
import { vFocus } from '@/directives/focus'
import { vDebounce } from '@/directives/debounce'

// 局部注册
defineProps<{
  modelValue: string
}>()

defineEmits<{
  'update:modelValue': [value: string]
}>()

// vDebounce 现在有类型提示了
function handleSearch(event: Event) {
  const value = (event.target as HTMLInputElement).value
  // ...
}
</script>

<template>
  <input 
    v-focus
    v-debounce:input.300="handleSearch"
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

自定义指令的设计模式

自定义指令的使用决策树

graph TD A[遇到 DOM 操作需求] --> B{逻辑需要复用?} B -->|否| C[写在组件内即可] B -->|是| D{需要参数配置?} D -->|否| E[简单指令: 只用 mounted] D -->|是| F{需要动态更新?} F -->|否| G[使用 mounted + 参数] F -->|是| H[使用 mounted + updated] H --> I{需要清理资源?} I -->|是| J[添加 unmounted 钩子]

指令设计的最佳实践清单

  • 单一职责:一个指令只做一件事
  • 命名清晰 :如 v-focusv-click-outside
  • 提供默认行为:在无参数时也能工作
  • 及时清理 :在 unmounted 中移除事件监听
  • 使用 TypeScript:提供完整的类型定义
  • 考虑边界情况:空值、异常情况处理
  • 性能优化:避免不必要的 DOM 操作

指令模板

typescript 复制代码
import { Directive } from 'vue'

// 定义参数类型
interface DirectiveBindingType {
  value: string      // 主要值
  arg?: 'option1' | 'option2'  // 参数
  modifiers?: {      // 修饰符
    [key: string]: boolean
  }
}

// 指令实现
const myDirective: Directive<HTMLElement, DirectiveBindingType> = {
  created(el, binding, vnode) {
    // 初始化
  },
  
  mounted(el, binding) {
    // DOM 已挂载,执行主要逻辑
    
    // 保存处理函数以便清理
    el._handler = (event: Event) => {
      // 处理逻辑
    }
    
    el.addEventListener('click', el._handler)
  },
  
  updated(el, binding) {
    // 当 binding.value 变化时更新
  },
  
  unmounted(el) {
    // 清理
    el.removeEventListener('click', el._handler)
    delete el._handler
  }
}

export default myDirective

最终建议

自定义指令是 Vue 提供的一个强大但容易被忽视的特性。它最适合:

  1. 原生 DOM 操作:需要直接访问 DOM 的场景
  2. 跨组件逻辑复用:多个组件共享的 DOM 相关逻辑
  3. 声明式 API:让模板更加声明式、可读性更好

结语

当我们在组件中频繁使用 ref 配合生命周期钩子操作 DOM 时,不妨考虑一下,是否应该将其封装成自定义指令。这不仅能让组件代码更加简洁,还能让这些逻辑在项目中被轻松复用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
C_心欲无痕2 小时前
前端 PDF 渲染与下载实现
前端·pdf
jiayong232 小时前
可视化流程设计器技术对比:钉钉风格 vs BPMN
java·前端·钉钉
前端不太难2 小时前
Flutter Web / Desktop 为什么“能跑但不好用”?
前端·flutter·状态模式
甘露s2 小时前
新手入门:传统 Web 开发与前后端分离开发的区别
开发语言·前端·后端·web
双河子思2 小时前
自动化控制逻辑建模方法
前端·数据库·自动化
wsad05322 小时前
Vue.js 整合传统 HTML 项目:注册页面实战教程
前端·vue.js·html
XXYBMOOO2 小时前
Flarum 主题定制:从零打造你的赛博朋克/JOJO 风格社区(含全套 CSS 源码)
前端·css
升鲜宝供应链及收银系统源代码服务2 小时前
升鲜宝生鲜配送供应链管理系统生产加工子模块的详细表设计说明
java·大数据·前端·数据库·bootstrap·供应链系统·生鲜配送
行者-全栈开发2 小时前
43 篇系统实战:uni-app 从入门到架构师成长之路
前端·typescript·uni-app·vue3·最佳实践·企业级架构