Skip to content

Vue 自定义指令与 Composables

大白话解释:

  • 自定义指令:直接操作 DOM 的"钩子"。比如 v-focus 让输入框自动聚焦,v-permission 没权限就隐藏元素
  • Composables:封装可复用的响应式逻辑。比如 useFetch 封装请求逻辑,useStorage 封装本地存储

自定义指令 vs Composable 怎么选?

  • 自定义指令:需要直接操作 DOM 时用(聚焦、拖拽、权限控制、点击外部关闭)
  • Composable:需要封装响应式逻辑时用(请求、状态管理、事件监听)

什么时候用自定义指令?

  • 自动聚焦(v-focus)
  • 权限控制(v-permission)
  • 点击外部关闭下拉框(v-click-outside)
  • 拖拽功能(v-draggable)
  • 懒加载图片(v-lazy)

自定义指令用于直接操作 DOM,Composables 用于封装可复用的响应式逻辑。两者职责不同,配合使用覆盖大部分业务场景。


自定义指令

注册方式

ts
// 全局注册 —— app 来自 createApp(),即 Vue 应用实例
// 通常在 main.ts 中:const app = createApp(App)
app.directive('focus', {
  mounted(el) {
    el.focus()
  },
})

// 局部注册(Vue 3)
const vFocus = {
  mounted: (el: HTMLElement) => el.focus(),
}
vue
<template>
  <input v-focus />
</template>

指令的完整生命周期

ts
const vMyDirective = {
  // 元素的 attribute 应用前(很少用)
  created(el, binding, vnode, prevVNode) {},

  // 元素插入 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 对象详解

ts
mounted(el, binding) {
  console.log(binding.value)      // 指令的值 v-my="value"
  console.log(binding.oldValue)   // 之前的值(updated 中可用)
  console.log(binding.arg)        // 参数 v-my:foo → "foo"
  console.log(binding.modifiers)  // 修饰符 v-my.stop.prevent → { stop: true, prevent: true }
  console.log(binding.instance)   // 使用该指令的组件实例
  console.log(binding.dir)        // 指令定义对象
}

常用指令示例

v-permission —— 权限控制

ts
// directives/permission.ts
import type { Directive } from 'vue'

export const vPermission: Directive = {
  mounted(el, binding) {
    const requiredRoles: string[] = Array.isArray(binding.value)
      ? binding.value
      : [binding.value]

    // 从 store 或 localStorage 获取用户角色
    const userRoles: string[] = JSON.parse(localStorage.getItem('roles') || '[]')
    const hasPermission = requiredRoles.some((role) => userRoles.includes(role))

    if (!hasPermission) {
      // 方案一:移除元素
      el.parentNode?.removeChild(el)
    }
  },
}
vue
<template>
  <!-- 只有 admin 可见 -->
  <button v-permission="'admin'">删除用户</button>

  <!-- admin 或 editor 可见 -->
  <button v-permission="['admin', 'editor']">编辑文章</button>
</template>

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

ts
// directives/click-outside.ts
import type { Directive } from 'vue'

interface ClickOutsideElement extends HTMLElement {
  _clickOutsideHandler?: (event: Event) => void
}

export const vClickOutside: Directive = {
  mounted(el: ClickOutsideElement, binding) {
    const handler = (event: Event) => {
      if (!el.contains(event.target as Node) && el !== event.target) {
        binding.value(event)
      }
    }

    el._clickOutsideHandler = handler
    // 延迟绑定,避免当前点击立即触发
    setTimeout(() => document.addEventListener('click', handler), 0)
  },

  unmounted(el: ClickOutsideElement) {
    if (el._clickOutsideHandler) {
      document.removeEventListener('click', el._clickOutsideHandler)
    }
  },
}
vue
<template>
  <div v-click-outside="() => isOpen = false" class="dropdown">
    <button @click="isOpen = !isOpen">下拉菜单</button>
    <div v-show="isOpen" class="dropdown-menu">
      <a href="#">选项 1</a>
      <a href="#">选项 2</a>
    </div>
  </div>
</template>

v-lazy —— 图片懒加载

ts
// directives/lazy.ts
import type { Directive } from 'vue'

// WeakMap 的 key 必须是对象,且允许垃圾回收器在元素被移除时自动清理对应条目,
// 避免内存泄漏。比普通 Map 更适合存储 DOM 元素关联的数据。
const observerMap = new WeakMap<HTMLElement, IntersectionObserver>()

export const vLazy: Directive = {
  mounted(el: HTMLImageElement, binding) {
    // 设置占位图
    el.src = binding.value.placeholder || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          el.src = binding.value.src || binding.value
          el.classList.add('loaded')
          observer.unobserve(el)
          observerMap.delete(el)
        }
      },
      { rootMargin: '200px' } // 提前 200px 开始加载
    )

    observer.observe(el)
    observerMap.set(el, observer)
  },

  unmounted(el) {
    const observer = observerMap.get(el)
    if (observer) {
      observer.disconnect()
      observerMap.delete(el)
    }
  },
}
vue
<template>
  <img v-lazy="{ src: '/images/hero.webp', placeholder: '/images/loading.svg' }" />
  <!-- 或简写 -->
  <img v-lazy="'/images/hero.webp'" />
</template>

v-debounce —— 防抖输入

ts
// directives/debounce.ts
import type { Directive } from 'vue'

interface DebounceElement extends HTMLElement {
  _debounceTimer?: ReturnType<typeof setTimeout>
  _debounceHandler?: (event: Event) => void
}

export const vDebounce: Directive = {
  mounted(el: DebounceElement, binding) {
    const delay = binding.arg ? parseInt(binding.arg) : 300

    const inputHandler = (event: Event) => {
      clearTimeout(el._debounceTimer)
      el._debounceTimer = setTimeout(() => {
        binding.value(event)
      }, delay)
    }

    el.addEventListener('input', inputHandler)
    el._debounceHandler = inputHandler
  },

  unmounted(el: DebounceElement) {
    clearTimeout(el._debounceTimer)
    if (el._debounceHandler) {
      el.removeEventListener('input', el._debounceHandler)
    }
  },
}
vue
<template>
  <!-- 300ms 防抖 -->
  <input v-debounce:300="onSearch" placeholder="搜索..." />

  <!-- 500ms 防抖 -->
  <input v-debounce:500="onInput" placeholder="输入..." />
</template>

v-copy —— 点击复制

ts
// directives/copy.ts
import type { Directive } from 'vue'

interface CopyElement extends HTMLElement {
  _copyHandler?: () => Promise<void>
}

export const vCopy: Directive = {
  mounted(el: CopyElement, binding) {
    el._copyHandler = async () => {
      try {
        await navigator.clipboard.writeText(binding.value)
        // 可以触发一个 toast 提示
        console.log('复制成功')
      } catch (err) {
        // 降级方案
        const textarea = document.createElement('textarea')
        textarea.value = binding.value
        document.body.appendChild(textarea)
        textarea.select()
        document.execCommand('copy')
        document.body.removeChild(textarea)
      }
    }
    el.addEventListener('click', el._copyHandler)
  },

  unmounted(el: CopyElement) {
    if (el._copyHandler) {
      el.removeEventListener('click', el._copyHandler)
    }
  },
}
vue
<template>
  <button v-copy="'要复制的文本'">复制</button>
  <span v-copy="dynamicText">点击复制</span>
</template>

指令注意事项

⚠️ 自定义指令中操作 DOM 后,必须在 unmounted 中清理副作用(事件监听、定时器、Observer 等),否则内存泄漏。

⚠️ 自定义指令是 DOM 级别的操作,不参与 Vue 的响应式系统。如果需要响应式逻辑,用 Composables。


Composables

封装原则

  1. 命名以 use 开头useCounteruseFetchuseLocalStorage
  2. 参数接受 ref 或 getter:保持响应性
  3. 返回 ref:保持响应性
  4. 副作用自动清理:在 onUnmounted 中清理

useCounter

ts
// composables/useCounter.ts
import { ref, computed } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const doubled = computed(() => count.value * 2)

  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initial }

  return { count, doubled, increment, decrement, reset }
}

useFetch

ts
// composables/useFetch.ts
import { ref, watchEffect, onUnmounted, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
  execute: () => Promise<void>
}

export function useFetch<T>(url: Ref<string> | string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(false)
  let abortController: AbortController | null = null

  async function execute() {
    // 取消上一次未完成的请求,避免竞态条件(旧请求的响应覆盖新数据)
    abortController?.abort()
    abortController = new AbortController()

    loading.value = true
    error.value = null
    try {
      const urlValue = typeof url === 'string' ? url : url.value
      const res = await fetch(urlValue, { signal: abortController.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
      data.value = await res.json()
    } catch (e) {
      // 被取消的请求不更新 error,避免误导用户
      if ((e as Error).name !== 'AbortError') {
        error.value = e as Error
      }
    } finally {
      loading.value = false
    }
  }

  // 组件卸载时取消未完成的请求
  onUnmounted(() => abortController?.abort())

  // 自动追踪 url 变化
  if (typeof url !== 'string') {
    watchEffect(() => { execute() })
  } else {
    execute()
  }

  return { data, error, loading, execute }
}

⚠️ 竞态条件:当 URL 快速变化时,多次 execute() 可能并发执行,最后一个响应会覆盖之前的数据(即使它对应的是旧 URL)。生产环境建议用 AbortController 取消过期请求,或用请求计数器丢弃过期响应。

vue
<script setup lang="ts">
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'

const url = ref('/api/user')
const { data, error, loading } = useFetch<User[]>(url)
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">错误: {{ error.message }}</div>
  <ul v-else>
    <li v-for="user in data" :key="user.id">{{ user.name }}</li>
  </ul>
</template>

useLocalStorage

ts
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T) {
  // 读取
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)

  // 监听变化并保存
  watch(
    data,
    (val) => {
      localStorage.setItem(key, JSON.stringify(val))
    },
    { deep: true }
  )

  return data
}
vue
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'

const theme = useLocalStorage('theme', 'light')
const settings = useLocalStorage('settings', { fontSize: 14, lang: 'zh-CN' })
</script>

useEventListener

ts
// composables/useEventListener.ts
import { onMounted, onUnmounted, watch, type Ref } from 'vue'

export function useEventListener(
  target: Ref<EventTarget | null> | EventTarget | Window,
  event: string,
  handler: (e: Event) => void,
  options?: AddEventListenerOptions
) {
  let el: EventTarget | null = null

  function add() {
    el?.addEventListener(event, handler, options)
  }

  function remove() {
    el?.removeEventListener(event, handler, options)
  }

  if (target instanceof EventTarget || target === window) {
    el = target
    onMounted(add)
    onUnmounted(remove)
  } else {
    // Ref
    watch(
      target,
      (newEl, oldEl) => {
        remove()
        el = newEl
        add()
      },
      { immediate: true }
    )
    onUnmounted(remove)
  }
}
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useEventListener } from '@/composables/useEventListener'

// 监听 window
useEventListener(window, 'resize', () => {
  console.log('窗口大小变化')
})

// 监听元素
const elRef = ref<HTMLElement>()
useEventListener(elRef, 'scroll', () => {
  console.log('元素滚动')
})
</script>

useToggle

ts
// composables/useToggle.ts
import { ref } from 'vue'

export function useToggle(initial = false) {
  const value = ref(initial)
  const toggle = () => { value.value = !value.value }
  const setTrue = () => { value.value = true }
  const setFalse = () => { value.value = false }
  return { value, toggle, setTrue, setFalse }
}

useDebounce / useThrottle

ts
// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'

export function useDebounce<T>(value: Ref<T>, delay = 300) {
  const debounced = ref(value.value) as Ref<T>
  let timer: ReturnType<typeof setTimeout>

  watch(value, (newVal) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debounced.value = newVal
    }, delay)
  })

  return debounced
}
ts
// composables/useThrottle.ts
import { ref, watch, type Ref } from 'vue'

export function useThrottle<T>(value: Ref<T>, delay = 300) {
  const throttled = ref(value.value) as Ref<T>
  let timer: ReturnType<typeof setTimeout> | null = null
  let lastTime = 0

  watch(value, (newVal) => {
    const now = Date.now()
    const remaining = delay - (now - lastTime)

    if (remaining <= 0) {
      if (timer) {
        clearTimeout(timer)
        timer = null
      }
      lastTime = now
      throttled.value = newVal
    } else if (!timer) {
      timer = setTimeout(() => {
        lastTime = Date.now()
        timer = null
        throttled.value = newVal
      }, remaining)
    }
  })

  return throttled
}

Composables vs Mixins vs 自定义指令

特性ComposablesMixins自定义指令
命名冲突无,解构可重命名有,同名覆盖
类型推导完整支持困难部分支持
来源清晰明确不确定明确
复用性纯函数,高依赖组件上下文DOM 级别
适用场景响应式逻辑Vue 2 时代DOM 操作
推荐度⭐⭐⭐⭐⭐

参考

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准