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
封装原则
- 命名以
use开头:useCounter、useFetch、useLocalStorage - 参数接受 ref 或 getter:保持响应性
- 返回 ref:保持响应性
- 副作用自动清理:在
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 自定义指令
| 特性 | Composables | Mixins | 自定义指令 |
|---|---|---|---|
| 命名冲突 | 无,解构可重命名 | 有,同名覆盖 | 无 |
| 类型推导 | 完整支持 | 困难 | 部分支持 |
| 来源清晰 | 明确 | 不确定 | 明确 |
| 复用性 | 纯函数,高 | 依赖组件上下文 | DOM 级别 |
| 适用场景 | 响应式逻辑 | Vue 2 时代 | DOM 操作 |
| 推荐度 | ⭐⭐⭐ | ❌ | ⭐⭐ |
参考
- Vue 3 自定义指令
- Vue 3 组合式函数
- VueUse — 常用 Composables 集合