Vue3 自定义指令
Vue3 自定义指令
什么是自定义指令
在 Vue 应用开发中,我们大部分时间都在使用模板语法、组件和 Composable 来构建界面。然而,有些场景需要对 DOM 元素进行底层的直接操作——例如让输入框自动获得焦点、在元素上添加水印、实现拖拽功能、控制按钮的权限等。这些操作本质上是对 DOM 的封装,Vue 提供了自定义指令(Custom Directive)机制来优雅地处理这类需求。
自定义指令是 Vue 模板系统的一个补充。它允许我们注册一种特殊的"指令",在模板中以 v-xxx 的形式使用,Vue 会在合适的时机调用我们定义的钩子函数,并传入绑定的元素和参数。相比直接在 mounted 中操作 DOM,自定义指令具有更好的复用性、声明式的使用方式和统一的生命周期管理。
Vue3 对指令系统进行了重大重构,将 Vue2 的 bind/inserted/update/componentUpdated/unbind 钩子重命名为与组件生命周期对齐的 created/beforeMount/mounted/beforeUpdate/updated/unmounted,使得指令和组件的 API 更加一致。
指令生命周期钩子
Vue3 自定义指令提供了一组完整的生命周期钩子,每个钩子在不同阶段被调用:
| 钩子名称 | 触发时机 | 常用场景 |
|---|---|---|
created | 指令绑定到元素后,DOM 插入前 | 初始化配置(无法访问父元素) |
beforeMount | 元素插入 DOM 前 | 很少使用 |
mounted | 元素插入 DOM 后 | 最常用:操作 DOM、添加事件监听 |
beforeUpdate | 元素更新前 | 保存旧值快照 |
updated | 元素更新后 | 根据新值更新 DOM |
unmounted | 元素卸载后 | 关键:清理事件监听、Observer 等 |
每个钩子函数接收以下参数:
// 指令钩子的参数签名
type DirectiveHook<T = any, V = any> = (
el: T, // 指令绑定的 DOM 元素
binding: DirectiveBinding<V>, // 绑定信息对象
vnode: VNode, // Vue 编译生成的虚拟节点
prevVNode: VNode // 上一个虚拟节点(仅在 updated 中有值)
) => void;
// binding 对象的结构
interface DirectiveBinding<V = any> {
instance: ComponentInstance; // 使用指令的组件实例
value: V; // 传递给指令的值(如 v-perm="['admin']" 中的数组)
oldValue: V; // 上一次的值(仅在 updated 中可用)
arg?: string; // 传给指令的参数(如 v-perm:role 中的 "role")
modifiers: Record<string, boolean>; // 修饰符(如 v-focus.immediate 中的 { immediate: true })
dir: Directive; // 指令定义对象
}注册方式
全局注册
全局注册的指令在应用的任何组件模板中都可直接使用:
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { focusDirective, permDirective } from './directives';
const app = createApp(App);
// 全局注册
app.directive('focus', focusDirective);
app.directive('perm', permDirective);
// 也支持内联定义
app.directive('debounce', {
mounted(el, binding) {
let timer: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (timer) return;
timer = setTimeout(() => { timer = null; }, binding.value || 300);
};
el.addEventListener('click', handler);
},
});
app.mount('#app');局部注册
局部指令只在当前组件中可用,适合特定组件专用的 DOM 操作:
<script setup>
// 局部指令 — 使用 v 前缀驼峰命名
const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value || '#fffde7';
},
updated(el, binding) {
el.style.backgroundColor = binding.value || '#fffde7';
},
};
</script>
<template>
<!-- 直接使用,不需要 v- 前缀 -->
<p v-highlight="'#e3f2fd'">这段文字有高亮背景</p>
</template>指令简写形式
如果指令只需要在 mounted 和 updated 时执行相同逻辑,可以使用函数简写:
// 函数简写 — 等同于 mounted + updated 使用相同逻辑
app.directive('color', (el, binding) => {
el.style.color = binding.value;
});
// 等价于完整写法
app.directive('color', {
mounted(el, binding) { el.style.color = binding.value; },
updated(el, binding) { el.style.color = binding.value; },
});实战:常用自定义指令
1. 自动聚焦指令
// directives/focus.ts
import type { Directive } from 'vue';
export const vFocus: Directive<HTMLInputElement> = {
mounted(el) {
// 延迟聚焦,确保 DOM 已渲染
requestAnimationFrame(() => {
el.focus();
});
},
};
// 支持修饰符的增强版本
export const vFocus2: Directive<HTMLInputElement> = {
mounted(el, binding) {
const { modifiers } = binding;
// v-focus.select — 聚焦并选中内容
if (modifiers.select) {
el.addEventListener('focus', () => el.select());
}
// v-focus.delay — 延迟聚焦
const delay = binding.arg ? parseInt(binding.arg) : 0;
setTimeout(() => el.focus(), delay);
},
};
// 使用
// <input v-focus /> // 立即聚焦
// <input v-focus.select /> // 聚焦并选中
// <input v-focus:[500] /> // 延迟 500ms 聚焦2. 权限控制指令
权限控制是企业级应用中最常见的指令之一,它根据用户角色决定是否显示元素:
// directives/permission.ts
import type { Directive, DirectiveBinding } from 'vue';
// 权限模式
type PermissionMode = 'any' | 'all'; // any=满足任一,all=满足全部
interface PermissionBinding {
value: string[]; // 需要的权限列表
arg?: PermissionMode; // 权限匹配模式(默认 'any')
modifiers?: {
disabled?: boolean; // 改为禁用而非移除
loading?: boolean; // 显示加载态
};
}
// 模拟权限 Store(实际项目中从 Pinia 获取)
function getUserPermissions(): string[] {
// return useAuthStore().permissions;
return ['user:read', 'user:write', 'device:read'];
}
export const vPerm: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<string[]>) {
const required = binding.value;
const mode = (binding.arg as PermissionMode) || 'any';
const userPermissions = getUserPermissions();
// 检查权限
const hasPermission =
mode === 'all'
? required.every(p => userPermissions.includes(p))
: required.some(p => userPermissions.includes(p));
if (!hasPermission) {
if (binding.modifiers.disabled) {
// 禁用模式 — 设置 disabled 属性而非移除
el.setAttribute('disabled', 'true');
el.style.opacity = '0.5';
el.style.cursor = 'not-allowed';
// 阻止点击事件
el.addEventListener('click', stopPropagation, { capture: true });
} else {
// 默认模式 — 移除元素
el.parentNode?.removeChild(el);
}
}
},
unmounted(el: HTMLElement) {
// 清理事件监听
el.removeEventListener('click', stopPropagation, { capture: true });
},
};
function stopPropagation(e: Event) {
e.stopPropagation();
e.preventDefault();
}
// 使用
// <button v-perm="['admin']">仅管理员可见</button>
// <button v-perm="['user:read', 'user:write']">需要读写权限</button>
// <button v-perm:all="['admin', 'super']">需要同时拥有两个权限</button>
// <button v-perm.disabled="['admin']">无权限时禁用而非隐藏</button>3. 图片懒加载指令
利用 IntersectionObserver API 实现高性能的图片懒加载:
// directives/lazy.ts
import type { Directive, DirectiveBinding } from 'vue';
interface LazyImageOptions {
src: string;
loading?: string; // 加载中占位图
error?: string; // 加载失败占位图
threshold?: number; // 触发阈值(0-1)
rootMargin?: string; // 提前加载距离
}
// 保存 Observer 实例,支持多元素共享
const observerMap = new WeakMap<HTMLElement, IntersectionObserver>();
export const vLazy: Directive<HTMLImageElement, LazyImageOptions | string> = {
mounted(el, binding) {
const options = typeof binding.value === 'string'
? { src: binding.value }
: binding.value;
const { src, loading, error, threshold = 0.01, rootMargin = '200px' } = options;
// 设置占位图
if (loading) el.src = loading;
else el.style.opacity = '0';
// 创建 Observer
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 开始加载图片
const img = new Image();
img.src = src;
img.onload = () => {
el.src = src;
el.style.opacity = '1';
el.style.transition = 'opacity 0.3s ease';
el.classList.add('loaded');
};
img.onerror = () => {
if (error) el.src = error;
el.classList.add('error');
};
// 加载后停止观察
observer.unobserve(el);
}
});
},
{ threshold, rootMargin }
);
observer.observe(el);
observerMap.set(el, observer);
},
updated(el, binding) {
// 值变化时重新加载
if (binding.oldValue !== binding.newValue) {
const options = typeof binding.value === 'string'
? { src: binding.value }
: binding.value;
el.src = options.src;
}
},
unmounted(el) {
const observer = observerMap.get(el);
if (observer) {
observer.disconnect();
observerMap.delete(el);
}
},
};
// 使用
// <img v-lazy="'/assets/photo.jpg'" alt="照片" />
// <img v-lazy="{ src: '/assets/photo.jpg', loading: '/placeholder.svg', error: '/error.svg' }" />4. 拖拽指令
实现元素的拖拽移动功能,支持指定拖拽手柄:
// directives/draggable.ts
import type { Directive, DirectiveBinding } from 'vue';
interface DraggableOptions {
handle?: string; // 拖拽手柄选择器
bounds?: 'parent' | 'window'; // 拖拽边界限制
axis?: 'x' | 'y' | 'both'; // 拖拽轴向限制
onDragStart?: (x: number, y: number) => void;
onDrag?: (x: number, y: number) => void;
onDragEnd?: (x: number, y: number) => void;
}
export const vDraggable: Directive<HTMLElement, DraggableOptions | undefined> = {
mounted(el, binding) {
const options = binding.value || {};
const handle = binding.value?.handle
? el.querySelector(binding.value.handle)
: el;
if (!handle) return;
let startX = 0, startY = 0;
let initialLeft = 0, initialTop = 0;
let isDragging = false;
const onMouseDown = (e: MouseEvent) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = el.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
el.style.position = 'fixed';
el.style.zIndex = '9999';
el.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
options.onDragStart?.(initialLeft, initialTop);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
let newLeft = initialLeft + (e.clientX - startX);
let newTop = initialTop + (e.clientY - startY);
// 轴向限制
if (options.axis === 'x') newTop = initialTop;
if (options.axis === 'y') newLeft = initialLeft;
// 边界限制
if (options.bounds === 'parent') {
const parent = el.parentElement!.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
newLeft = Math.max(parent.left, Math.min(newLeft, parent.right - elRect.width));
newTop = Math.max(parent.top, Math.min(newTop, parent.bottom - elRect.height));
}
el.style.left = `${newLeft}px`;
el.style.top = `${newTop}px`;
options.onDrag?.(newLeft, newTop);
};
const onMouseUp = () => {
isDragging = false;
el.style.zIndex = '';
el.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const rect = el.getBoundingClientRect();
options.onDragEnd?.(rect.left, rect.top);
};
handle.addEventListener('mousedown', onMouseDown);
// 保存清理函数
(el as any).__draggableCleanup = () => {
handle.removeEventListener('mousedown', onMouseDown);
};
},
unmounted(el) {
(el as any).__draggableCleanup?.();
},
};
// 使用
// <div v-draggable>自由拖拽</div>
// <div v-draggable="{ handle: '.drag-handle', axis: 'x', bounds: 'parent' }">
// <div class="drag-handle">拖拽手柄</div>
// <div>内容区域</div>
// </div>5. 防抖与节流指令
// directives/debounce.ts
import type { Directive } from 'vue';
function throttle(fn: Function, delay: number) {
let last = 0;
return function (this: any, ...args: any[]) {
const now = Date.now();
if (now - last >= delay) {
last = now;
fn.apply(this, args);
}
};
}
function debounce(fn: Function, delay: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: any[]) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
// 防抖点击指令
export const vDebounce: Directive = {
mounted(el, binding) {
const delay = binding.value || 300;
const handler = debounce(() => {
el.dispatchEvent(new Event('debounced-click', { bubbles: true }));
}, delay);
el.addEventListener('click', handler);
(el as any).__debounceHandler = handler;
},
unmounted(el) {
el.removeEventListener('click', (el as any).__debounceHandler);
},
};
// 节流滚动指令
export const vThrottle: Directive = {
mounted(el, binding) {
const delay = binding.value || 100;
const handler = throttle(() => {
el.dispatchEvent(new Event('throttled-scroll', { bubbles: true }));
}, delay);
el.addEventListener('scroll', handler);
(el as any).__throttleHandler = handler;
},
unmounted(el) {
el.removeEventListener('scroll', (el as any).__throttleHandler);
},
};
// 使用
// <button v-debounce="500" @debounced-click="handleSave">保存</button>
// <div v-throttle="100" @throttled-scroll="handleScroll">滚动区域</div>6. 水印指令
// directives/watermark.ts
import type { Directive, DirectiveBinding } from 'vue';
interface WatermarkOptions {
text?: string;
color?: string;
fontSize?: number;
rotate?: number;
opacity?: number;
gap?: [number, number];
}
function createWatermarkCanvas(options: WatermarkOptions): HTMLCanvasElement {
const {
text = '内部文件',
color = 'rgba(0, 0, 0, 0.1)',
fontSize = 14,
rotate = -20,
opacity = 0.1,
gap = [100, 80],
} = options;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const [gapX, gapY] = gap;
canvas.width = 300;
canvas.height = 200;
ctx.rotate((rotate * Math.PI) / 180);
ctx.font = `${fontSize}px sans-serif`;
ctx.fillStyle = color;
ctx.globalAlpha = opacity;
ctx.fillText(text, 0, canvas.height / 2);
return canvas;
}
export const vWatermark: Directive<HTMLElement, WatermarkOptions> = {
mounted(el, binding) {
const options = binding.value || {};
const canvas = createWatermarkCanvas(options);
const dataUrl = canvas.toDataURL();
const watermarkDiv = document.createElement('div');
watermarkDiv.style.cssText = `
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 9999;
background-image: url(${dataUrl});
background-repeat: repeat;
`;
el.style.position = 'relative';
el.appendChild(watermarkDiv);
(el as any).__watermarkDiv = watermarkDiv;
},
unmounted(el) {
(el as any).__watermarkDiv?.remove();
},
};指令的 TypeScript 类型定义
为自定义指令提供完整的类型支持:
// types/directives.d.ts
import type { App, Directive } from 'vue';
// 声明全局指令类型
declare module 'vue' {
interface ComponentCustomProperties {
// 通过 globalProperties 挂载的指令相关方法
}
interface ObjectDirective<T = any, V = any> {
created?: DirectiveHook<T, V>;
beforeMount?: DirectiveHook<T, V>;
mounted?: DirectiveHook<T, V>;
beforeUpdate?: DirectiveHook<T, V>;
updated?: DirectiveHook<T, V>;
unmounted?: DirectiveHook<T, V>;
}
}
// 全局指令类型声明 — 让模板中的 v-xxx 有类型提示
declare module '@vue/runtime-core' {
export interface GlobalDirectives {
vFocus: Directive<HTMLInputElement>;
vPerm: Directive<HTMLElement, string[]>;
vLazy: Directive<HTMLImageElement, string | object>;
vDraggable: Directive<HTMLElement, object>;
vWatermark: Directive<HTMLElement, object>;
}
}指令插件化封装
将所有指令封装为一个 Vue 插件,方便统一注册和管理:
// directives/index.ts
import type { App, Plugin } from 'vue';
import { vFocus } from './focus';
import { vPerm } from './permission';
import { vLazy } from './lazy';
import { vDraggable } from './draggable';
import { vWatermark } from './watermark';
import { vDebounce, vThrottle } from './debounce';
export const DirectivesPlugin: Plugin = {
install(app: App) {
// 注册所有指令
app.directive('focus', vFocus);
app.directive('perm', vPerm);
app.directive('lazy', vLazy);
app.directive('draggable', vDraggable);
app.directive('watermark', vWatermark);
app.directive('debounce', vDebounce);
app.directive('throttle', vThrottle);
},
};
// main.ts 中使用
// import { DirectivesPlugin } from './directives';
// app.use(DirectivesPlugin);性能考虑
避免在指令中进行大量计算:指令的
updated钩子会在每次组件更新时调用,复杂逻辑应放在computed中。及时清理资源:
unmounted钩子中必须清理所有事件监听器、Observer 和定时器,否则会导致内存泄漏。使用 WeakMap 管理映射:当需要在指令和 DOM 元素之间建立映射关系时,使用
WeakMap而非普通对象,避免阻止垃圾回收。减少 DOM 操作:批量操作 DOM 时使用
DocumentFragment,避免频繁触发重排。指令 vs Composable 的选择:如果逻辑涉及响应式状态管理或复杂计算,优先使用 Composable;指令专注于 DOM 操作。
常见问题与排错
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 指令不生效 | 使用了 Vue2 的钩子名 bind/inserted | 改为 Vue3 的 mounted |
binding.instance 为 null | 在 created 钩子中组件实例未完成 | 在 mounted 中访问 |
| 指令值不更新 | 只实现了 mounted 未实现 updated | 补充 updated 钩子或使用函数简写 |
| 内存泄漏 | 未在 unmounted 中清理事件监听 | 添加清理逻辑 |
| TypeScript 报错 | 缺少全局指令类型声明 | 在 *.d.ts 中声明 GlobalDirectives |
最佳实践总结
- 命名规范:指令名使用小写 kebab-case(
v-my-directive),注册时使用 camelCase。 - 优先使用 Composable:能用 Composable 解决的优先不用指令,指令只用于纯 DOM 操作。
- 资源清理:始终在
unmounted中清理副作用。 - TypeScript 支持:为指令提供完整的类型定义。
- 插件化封装:将项目指令封装为插件,统一注册和管理。
- 文档完善:为每个指令编写使用文档和示例。
