全局注册示例
1、创建指令 + 统一导出
src\\directives\\totalInputDirective.ts
import type { Directive } from "vue";
// 使用 WeakMap 存储事件处理器,避免直接在 DOM 元素上添加自定义属性
const handlerMap = new WeakMap<HTMLInputElement, (e: Event) => void>();
// 格式化输入的金额,需定义在指令外部
const formatTotalInput = (value: string): string => {
let v = value;
// 如果值为空,设置为空字符串
if (!v) {
return "";
}
// 1. 只允许数字、小数点、负号
v = v.replace(/[^\\d.-]/g, "");
// 2. 处理负号:只能出现在开头,且只出现一次
let hasNegative = false;
if (v.includes("-")) {
// 检查负号是否在开头
if (v[0] === "-") {
hasNegative = true;
// 保留开头的负号,移除其他位置的负号
v = "-" + v.slice(1).replace(/-/g, "");
} else {
// 负号不在开头,移除所有负号
v = v.replace(/-/g, "");
}
}
// 3. 处理单独的小数点
if (v === ".") v = "0.";
if (v === "-.") v = "-0.";
// 4. 处理前导零:去除整数部分的前导零,但保留小数点和负数的情况
if (hasNegative) {
v = v.replace(/^(-?)0+(\\d)/, "$1$2");
} else {
v = v.replace(/^0+(\\d)/, "$1");
}
// 5. 去除多余的小数点(只保留第一个)
const firstDot = v.indexOf(".");
if (firstDot !== -1) {
const beforeDot = v.substring(0, firstDot + 1);
const afterDot = v.substring(firstDot + 1).replace(/\\./g, "");
v = beforeDot + afterDot;
}
// 6. 限制小数点后最多两位
const dotIndex = v.indexOf(".");
if (dotIndex !== -1) {
const integerPart = v.substring(0, dotIndex);
const decimalPart = v.substring(dotIndex + 1, dotIndex + 3);
v = integerPart + "." + decimalPart;
}
// 7. 特殊情况处理:如果只有负号或负号加0,保留它们
// 8. 处理以0开头的非小数情况
if (v.length > 1 && v[0] === "0" && v[1] !== ".") {
v = v.substring(1);
}
// 9. 返回格式化后的输入内容
return v;
};
/**
* 自定义指令:金额输入指令,只能输入数字、负号和小数点,最多两位小数,不能0开头,不能小数点开头,只能开头输入负号,只能输入一次负号和小数点
*/
export const totalInputDirective: Directive = {
/**
* 指令挂载到元素上时的钩子函数
* @param el – 指令绑定的DOM元素
*/
mounted(el: HTMLElement | HTMLInputElement) {
// 找到 input 元素
let input: HTMLInputElement | null = null;
if (el.tagName === "INPUT") {
input = el as HTMLInputElement;
} else {
input = el.querySelector && el.querySelector("input");
}
if (!input) return;
// 处理输入事件的函数
const handler = (e: Event) => {
const target = e.target as HTMLInputElement;
// 获取输入的值
const inputValue = target.value;
const value = formatTotalInput(inputValue);
if (value !== target.value) {
const oldValue = target.value;
target.value = value;
// 如果值发生了变化,触发input事件以确保Vue的数据绑定更新
if (oldValue !== value) {
target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
};
// 监听输入事件
input.addEventListener("input", handler);
// 使用 WeakMap 存储处理器引用,以便后续移除
handlerMap.set(input, handler);
},
/**
* 指令从元素解绑时的钩子函数
* 清理事件监听器以避免内存泄漏
* @param el – 指令绑定的DOM元素
*/
unmounted(el: HTMLElement | HTMLInputElement) {
let input: HTMLInputElement | null = null;
if (el.tagName === "INPUT") {
input = el as HTMLInputElement;
} else {
input = el.querySelector && el.querySelector("input");
}
if (input) {
const storedHandler = handlerMap.get(input);
if (storedHandler) {
// 移除事件监听器以防止内存泄漏
input.removeEventListener("input", storedHandler);
handlerMap.delete(input);
}
}
}
};
src\\directives\\index.ts
export * from "./totalInputDirective";
2、注册指令
2.1、全局注册指令(导入指令 + 安装指令)
src\\main.ts
// 使用命名导入,全局引入自定义指令:金额输入指令
import { totalInputDirective } from "@/directives";
// 创建 Vue 应用实例
const app = createApp(App);
// 安装自定义指令,金额输入指令 totalInput,在模板中使用 v-total-input 指令
app.directive("totalInput", totalInputDirective);
// 将 Vue 应用实例挂载到 DOM 中的一个指定容器上,从而启动 Vue 应用
app.mount("#app");
2.2、局部注册指令(导入指令 + 定义指令)
src\\views\\capital\\CapitalInfo.vue
import { totalInputDirective } from "@/directives";
// 定义指令:金额输入指令,规范以v开头,在模板中使用 v-total-input 指令
const vTotalInput = totalInputDirective ;
3、使用指令
src\\views\\capital\\CapitalInfo.vue
<!– 方法4:自定义指令 + @blur –>
<el-input v-model="localCapitalInfo.capitalTotal" v-total-input @blur="handleTotalBlur" clearable />
局局注册示例
代码:
1、创建指令 + 统一导出
src\\directives\\yearInputDirective.ts
import type { Directive } from "vue";
// 使用 WeakMap 存储事件处理器,避免直接在 DOM 元素上添加自定义属性
const handlerMap = new WeakMap<HTMLInputElement, (e: Event) => void>();
/**
* 自定义指令:年份输入指令,最多只能输入四位数字,不能输入其他符号
* 该指令限制输入框只能输入最多4位数字字符,通常用于年份输入场景
*/
export const yearInputDirective: Directive = {
/**
* 指令挂载到元素上时的钩子函数
* @param el – 指令绑定的DOM元素
*/
mounted(el: HTMLElement | HTMLInputElement) {
// 找到 input 元素
let input: HTMLInputElement | null = null;
if (el.tagName === "INPUT") {
input = el as HTMLInputElement;
} else {
input = el.querySelector && el.querySelector("input");
}
if (!input) return;
// 设置最大长度为4位数字
const maxLength = 4;
// 处理输入事件的函数,过滤掉非数字字符并限制长度
const handler = (e: Event) => {
const target = e.target as HTMLInputElement;
// 移除非数字字符(\\D表示非数字),然后截取前maxLength个字符
const value = target.value.replace(/\\D/g, "").slice(0, maxLength);
if (value !== target.value) {
const oldValue = target.value;
target.value = value;
// 如果值发生了变化,触发input事件以确保Vue的数据绑定更新
if (oldValue !== value) {
target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
};
// 监听输入事件
input.addEventListener("input", handler);
// 使用 WeakMap 存储处理器引用,以便后续移除
handlerMap.set(input, handler);
},
/**
* 指令从元素解绑时的钩子函数
* 清理事件监听器以避免内存泄漏
* @param el – 指令绑定的DOM元素
*/
unmounted(el: HTMLElement | HTMLInputElement) {
let input: HTMLInputElement | null = null;
if (el.tagName === "INPUT") {
input = el as HTMLInputElement;
} else {
input = el.querySelector && el.querySelector("input");
}
if (input) {
const storedHandler = handlerMap.get(input);
if (storedHandler) {
// 移除事件监听器以防止内存泄漏
input.removeEventListener("input", storedHandler);
handlerMap.delete(input);
}
}
}
};
src\\directives\\index.ts
export * from "./yearInputDirective";
2、注册指令
2.1、全局注册指令(导入指令 + 安装指令)
src\\main.ts
// 使用命名导入,全局引入自定义指令:年份输入指令
import { yearInputDirective } from "@/directives";
// 创建 Vue 应用实例
const app = createApp(App);
// 安装自定义指令,年份输入指令 yearInput,在模板中使用 v-year-input 指令
app.directive("yearInput", yearInputDirective);
// 将 Vue 应用实例挂载到 DOM 中的一个指定容器上,从而启动 Vue 应用
app.mount("#app");
2.2、局部注册指令(导入指令 + 定义指令)
src\\views\\capital\\CapitalInfo.vue
import { yearInputDirective } from "@/directives";
// 定义指令:年份输入指令,规范以v开头,在模板中使用 v-year-input 指令
const vYearInput = yearInputDirective;
3、使用指令
src\\views\\capital\\CapitalInfo.vue
<el-row :gutter="10">
<el-col :span="24">
<el-form-item label="资金年份" label-position="right">
<el-input v-model="capitalInfoStore.queryDTO.capitalYear" clearable v-year-input />
</el-form-item>
</el-col>
</el-row>
Vue3 + TypeScript 自定义指令完整教程
一、基础环境设置
1. 确保 TypeScript 配置正确
json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vue", "vite/client"],
"strict": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
二、创建自定义指令的完整流程
1. 创建基础指令(带完整类型)
typescript
// src/directives/NumberOnlyDirective.ts
import type { Directive, DirectiveBinding } from 'vue'
// 1. 定义指令参数类型
interface NumberOnlyOptions {
maxLength?: number
min?: number
max?: number
allowNegative?: boolean
allowDecimal?: boolean
}
// 2. 定义指令存储的扩展类型
declare global {
interface HTMLElement {
_numberOnlyHandler?: (e: Event) => void
_numberOnlyOptions?: NumberOnlyOptions
}
}
// 3. 创建指令
export const NumberOnlyDirective: Directive<HTMLElement, NumberOnlyOptions | number | undefined> = {
/**
* 元素挂载时调用
* @param el – 绑定的元素
* @param binding – 指令绑定信息
*/
mounted(el: HTMLElement, binding: DirectiveBinding<NumberOnlyOptions | number | undefined>) {
// 解析配置
const options = parseOptions(binding.value)
// 找到 input 元素
const input = findInputElement(el)
if (!input) {
console.warn('NumberOnlyDirective: 未找到 input 元素')
return
}
// 创建处理函数
const handler = createInputHandler(input, options)
// 存储引用
el._numberOnlyHandler = handler
el._numberOnlyOptions = options
// 绑定事件
input.addEventListener('input', handler)
input.addEventListener('blur', () => validateValue(input, options))
// 初始格式化
formatInputValue(input, options)
},
/**
* 参数更新时调用
*/
updated(el: HTMLElement, binding: DirectiveBinding<NumberOnlyOptions | number | undefined>) {
const newOptions = parseOptions(binding.value)
const input = findInputElement(el)
if (input && el._numberOnlyHandler) {
// 更新选项
el._numberOnlyOptions = newOptions
// 重新格式化当前值
formatInputValue(input, newOptions)
}
},
/**
* 元素卸载时调用
*/
unmounted(el: HTMLElement) {
const input = findInputElement(el)
if (input && el._numberOnlyHandler) {
// 移除事件监听
input.removeEventListener('input', el._numberOnlyHandler)
// 清理存储
delete el._numberOnlyHandler
delete el._numberOnlyOptions
}
}
}
// 辅助函数
function parseOptions(value: NumberOnlyOptions | number | undefined): NumberOnlyOptions {
const defaultOptions: NumberOnlyOptions = {
maxLength: 4,
min: 0,
max: 9999,
allowNegative: false,
allowDecimal: false
}
if (typeof value === 'number') {
return { …defaultOptions, maxLength: value }
}
if (typeof value === 'object' && value !== null) {
return { …defaultOptions, …value }
}
return defaultOptions
}
function findInputElement(el: HTMLElement): HTMLInputElement | null {
// 处理原生 input
if (el.tagName.toLowerCase() === 'input') {
return el as HTMLInputElement
}
// 处理 Element Plus 的 el-input
const input = el.querySelector('input')
if (input) return input
// 处理 Element Plus 的特殊类名
const elInput = el.querySelector('.el-input__inner')
return elInput as HTMLInputElement
}
function createInputHandler(input: HTMLInputElement, options: NumberOnlyOptions): (e: Event) => void {
return (e: Event) => {
const target = e.target as HTMLInputElement
const cursorPos = target.selectionStart || 0
const oldValue = target.value
// 根据配置过滤值
let newValue = filterValue(target.value, options)
// 限制长度
if (options.maxLength) {
newValue = newValue.slice(0, options.maxLength)
}
// 如果值发生变化
if (newValue !== oldValue) {
// 更新值
target.value = newValue
// 调整光标位置
const newCursorPos = calculateNewCursorPosition(oldValue, newValue, cursorPos)
target.setSelectionRange(newCursorPos, newCursorPos)
// 触发 Vue 更新
triggerVueUpdate(target)
}
}
}
function filterValue(value: string, options: NumberOnlyOptions): string {
// 构建正则表达式
let pattern = '0-9'
if (options.allowNegative) {
pattern = '-0-9'
}
if (options.allowDecimal) {
pattern += '.'
}
const regex = new RegExp(`[^${pattern}]`, 'g')
return value.replace(regex, '')
}
function calculateNewCursorPosition(oldValue: string, newValue: string, cursorPos: number): number {
const lengthDiff = newValue.length – oldValue.length
return Math.max(0, Math.min(cursorPos + lengthDiff, newValue.length))
}
function formatInputValue(input: HTMLInputElement, options: NumberOnlyOptions) {
const currentValue = input.value
const filteredValue = filterValue(currentValue, options)
if (filteredValue !== currentValue) {
input.value = filteredValue
triggerVueUpdate(input)
}
}
function validateValue(input: HTMLInputElement, options: NumberOnlyOptions) {
const value = input.value.trim()
if (!value) return
const numValue = parseFloat(value)
if (isNaN(numValue)) {
input.value = ''
triggerVueUpdate(input)
return
}
if (options.min !== undefined && numValue < options.min) {
input.value = options.min.toString()
triggerVueUpdate(input)
}
if (options.max !== undefined && numValue > options.max) {
input.value = options.max.toString()
triggerVueUpdate(input)
}
}
function triggerVueUpdate(input: HTMLInputElement) {
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
}
// 导出类型,方便在其他地方使用
export type { NumberOnlyOptions }
2. 创建指令管理器(可选)
typescript
// src/directives/index.ts
import type { App } from 'vue'
import { NumberOnlyDirective, type NumberOnlyOptions } from './NumberOnlyDirective'
import { AutoFocusDirective } from './AutoFocusDirective'
import { ClickOutsideDirective } from './ClickOutsideDirective'
// 指令集合
const directives = {
'number-only': NumberOnlyDirective,
'auto-focus': AutoFocusDirective,
'click-outside': ClickOutsideDirective
} as const
// 导出类型
export type DirectiveName = keyof typeof directives
// 全局安装函数
export function installDirectives(app: App) {
Object.entries(directives).forEach(([name, directive]) => {
app.directive(name, directive)
})
}
// 导出单个指令(用于局部注册)
export {
NumberOnlyDirective,
AutoFocusDirective,
ClickOutsideDirective
}
// 导出类型
export type { NumberOnlyOptions }
3. 全局注册指令
typescript
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { installDirectives } from './directives'
const app = createApp(App)
// 安装所有指令
installDirectives(app)
app.mount('#app')
三、局部注册和使用
1. 组件内局部注册
vue
<!– src/components/YearInput.vue –>
<template>
<div>
<!– 使用自定义指令 –>
<el-input
v-number-only="numberOnlyOptions"
v-model="yearValue"
placeholder="请输入年份"
clearable
/>
<!– 使用修饰符 –>
<el-input
v-number-only.debounce="numberOnlyOptions"
v-model="codeValue"
placeholder="请输入验证码"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NumberOnlyDirective, type NumberOnlyOptions } from '@/directives'
// 局部注册指令
const vNumberOnly = NumberOnlyDirective
// 指令配置
const numberOnlyOptions = computed<NumberOnlyOptions>(() => ({
maxLength: 4,
min: 1900,
max: 2100,
allowNegative: false,
allowDecimal: false
}))
// 数据
const yearValue = ref('')
const codeValue = ref('')
</script>
<style scoped>
/* 组件样式 */
</style>
2. 使用指令参数和修饰符
typescript
// src/directives/AdvancedNumberOnlyDirective.ts
import type { Directive, DirectiveBinding } from 'vue'
// 定义修饰符类型
interface NumberOnlyModifiers {
debounce?: boolean
trim?: boolean
lazy?: boolean
immediate?: boolean
}
// 创建支持修饰符的指令
export const AdvancedNumberOnlyDirective: Directive<
HTMLElement,
number | undefined,
NumberOnlyModifiers
> = {
mounted(el, binding) {
const { value, modifiers } = binding
console.log('指令参数:', value)
console.log('修饰符:', modifiers)
const input = el.querySelector('input')
if (!input) return
// 根据修饰符应用不同逻辑
let handler = createBaseHandler(input, value || 4)
if (modifiers.debounce) {
handler = createDebouncedHandler(handler, 300)
}
if (modifiers.trim) {
handler = createTrimmedHandler(handler)
}
if (modifiers.lazy) {
// 使用 change 事件而不是 input 事件
input.addEventListener('change', handler)
el._handlerType = 'change'
} else {
input.addEventListener('input', handler)
el._handlerType = 'input'
}
el._handler = handler
// 如果设置了 immediate,立即格式化
if (modifiers.immediate) {
formatInput(input, value || 4)
}
},
unmounted(el) {
const input = el.querySelector('input')
if (input && el._handler && el._handlerType) {
input.removeEventListener(el._handlerType, el._handler)
}
}
}
// 辅助函数
function createBaseHandler(input: HTMLInputElement, maxLength: number): (e: Event) => void {
return (e: Event) => {
const target = e.target as HTMLInputElement
target.value = target.value.replace(/\\D/g, '').slice(0, maxLength)
triggerUpdate(target)
}
}
function createDebouncedHandler(handler: Function, delay: number): Function {
let timer: NodeJS.Timeout | null = null
return (…args: any[]) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
handler(…args)
timer = null
}, delay)
}
}
function createTrimmedHandler(handler: Function): Function {
return (e: Event) => {
const target = e.target as HTMLInputElement
target.value = target.value.trim()
handler(e)
}
}
function formatInput(input: HTMLInputElement, maxLength: number) {
input.value = input.value.replace(/\\D/g, '').slice(0, maxLength)
triggerUpdate(input)
}
function triggerUpdate(input: HTMLInputElement) {
input.dispatchEvent(new Event('input', { bubbles: true }))
}
// 扩展类型
declare global {
interface HTMLElement {
_handler?: Function
_handlerType?: 'input' | 'change'
}
}
四、创建更多实用指令示例
1. 自动聚焦指令
typescript
// src/directives/AutoFocusDirective.ts
import type { Directive, DirectiveBinding } from 'vue'
interface AutoFocusOptions {
delay?: number
selectAll?: boolean
}
export const AutoFocusDirective: Directive<HTMLElement, AutoFocusOptions | boolean | undefined> = {
mounted(el, binding) {
const options = parseAutoFocusOptions(binding.value)
const focusElement = el.tagName.toLowerCase() === 'input'
? el as HTMLInputElement
: el.querySelector('input, textarea, [tabindex]')
if (!focusElement) return
const focusFn = () => {
focusElement.focus()
if (options.selectAll && 'select' in focusElement) {
;(focusElement as HTMLInputElement).select()
}
}
if (options.delay && options.delay > 0) {
setTimeout(focusFn, options.delay)
} else {
// 使用 nextTick 确保 DOM 已更新
setTimeout(focusFn, 0)
}
}
}
function parseAutoFocusOptions(value: AutoFocusOptions | boolean | undefined): AutoFocusOptions {
if (typeof value === 'boolean') {
return { delay: 0, selectAll: false }
}
if (typeof value === 'object' && value !== null) {
return {
delay: value.delay || 0,
selectAll: value.selectAll || false
}
}
return { delay: 0, selectAll: false }
}
2. 点击外部关闭指令
typescript
// src/directives/ClickOutsideDirective.ts
import type { Directive, DirectiveBinding } from 'vue'
export const ClickOutsideDirective: Directive<HTMLElement, (e: MouseEvent) => void> = {
mounted(el, binding) {
const handler = (e: MouseEvent) => {
if (!el.contains(e.target as Node) && el !== e.target) {
binding.value(e)
}
}
// 使用捕获阶段确保先执行
document.addEventListener('click', handler, true)
// 存储引用以便清理
el._clickOutsideHandler = handler
},
unmounted(el) {
if (el._clickOutsideHandler) {
document.removeEventListener('click', el._clickOutsideHandler, true)
delete el._clickOutsideHandler
}
}
}
declare global {
interface HTMLElement {
_clickOutsideHandler?: (e: MouseEvent) => void
}
}
3. 防抖指令
typescript
// src/directives/DebounceDirective.ts
import type { Directive, DirectiveBinding } from 'vue'
type DebounceFunction = (…args: any[]) => void
interface DebounceOptions {
delay?: number
events?: string
immediate?: boolean
}
export const DebounceDirective: Directive<HTMLElement, DebounceFunction, DebounceOptions> = {
mounted(el, binding) {
const { value: handler, modifiers, arg } = binding
if (typeof handler !== 'function') {
console.warn('DebounceDirective: 绑定值必须是函数')
return
}
const delay = parseInt(arg || '300', 10) || 300
const events = modifiers.events ? modifiers.events.split(',') : ['input']
const immediate = modifiers.immediate || false
// 创建防抖函数
let timeout: NodeJS.Timeout | null = null
let isImmediateCalled = false
const debouncedHandler = (…args: any[]) => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (immediate && !isImmediateCalled) {
handler(…args)
isImmediateCalled = true
} else {
timeout = setTimeout(() => {
handler(…args)
isImmediateCalled = false
}, delay)
}
}
// 绑定事件
events.forEach(eventName => {
el.addEventListener(eventName.trim(), debouncedHandler)
})
// 存储引用
el._debounceHandler = debouncedHandler
el._debounceEvents = events
},
unmounted(el) {
if (el._debounceHandler && el._debounceEvents) {
el._debounceEvents.forEach(eventName => {
el.removeEventListener(eventName, el._debounceHandler!)
})
delete el._debounceHandler
delete el._debounceEvents
}
}
}
declare global {
interface HTMLElement {
_debounceHandler?: Function
_debounceEvents?: string[]
}
}
五、在组件中使用指令
1. 使用多个指令
vue
<!– src/components/AdvancedForm.vue –>
<template>
<div class="form-container">
<!– 使用多个指令 –>
<el-input
v-number-only="{ maxLength: 4, min: 1900, max: 2100 }"
v-auto-focus="{ delay: 100, selectAll: true }"
v-model="form.year"
placeholder="请输入年份"
/>
<!– 使用防抖指令 –>
<el-input
v-model="form.search"
v-debounce:500="handleSearch"
placeholder="搜索(防抖500ms)"
/>
<!– 点击外部关闭 –>
<div v-click-outside="closeDropdown" class="dropdown">
<button @click="toggleDropdown">打开菜单</button>
<div v-if="isDropdownOpen" class="dropdown-content">
<!– 菜单内容 –>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// 局部注册指令
import { NumberOnlyDirective } from '@/directives/NumberOnlyDirective'
import { AutoFocusDirective } from '@/directives/AutoFocusDirective'
import { ClickOutsideDirective } from '@/directives/ClickOutsideDirective'
import { DebounceDirective } from '@/directives/DebounceDirective'
const vNumberOnly = NumberOnlyDirective
const vAutoFocus = AutoFocusDirective
const vClickOutside = ClickOutsideDirective
const vDebounce = DebounceDirective
// 数据
const form = ref({
year: '',
search: ''
})
const isDropdownOpen = ref(false)
// 方法
const handleSearch = () => {
console.log('搜索:', form.value.search)
// 执行搜索逻辑
}
const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value
}
const closeDropdown = () => {
isDropdownOpen.value = false
}
</script>
<style scoped>
.form-container {
max-width: 400px;
margin: 0 auto;
padding: 20px;
}
.dropdown {
position: relative;
display: inline-block;
margin-top: 20px;
}
.dropdown-content {
position: absolute;
background: white;
border: 1px solid #ccc;
padding: 10px;
z-index: 1000;
}
</style>
六、测试自定义指令
1. 创建单元测试
typescript
// tests/directives/NumberOnlyDirective.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, ref, nextTick } from 'vue'
import { NumberOnlyDirective } from '@/directives/NumberOnlyDirective'
// 创建测试组件
const createTestComponent = (template: string) => {
return defineComponent({
template,
directives: {
'number-only': NumberOnlyDirective
},
setup() {
return {
value: ref('')
}
}
})
}
describe('NumberOnlyDirective', () => {
it('应该过滤非数字字符', async () => {
const wrapper = mount(createTestComponent(
'<input v-number-only v-model="value" />'
))
const input = wrapper.find('input')
await input.setValue('abc123def')
expect(input.element.value).toBe('123')
expect(wrapper.vm.value).toBe('123')
})
it('应该限制最大长度', async () => {
const wrapper = mount(createTestComponent(
'<input v-number-only="{ maxLength: 4 }" v-model="value" />'
))
const input = wrapper.find('input')
await input.setValue('123456')
expect(input.element.value).toBe('1234')
expect(wrapper.vm.value).toBe('1234')
})
it('应该允许负数和浮点数', async () => {
const wrapper = mount(createTestComponent(
'<input v-number-only="{ allowNegative: true, allowDecimal: true }" v-model="value" />'
))
const input = wrapper.find('input')
await input.setValue('-123.45')
expect(input.element.value).toBe('-123.45')
expect(wrapper.vm.value).toBe('-123.45')
})
it('应该在元素卸载时清理事件监听', async () => {
const wrapper = mount(createTestComponent(
'<input v-number-only v-model="value" v-if="show" />'
))
const input = wrapper.find('input')
const removeEventListenerSpy = vi.spyOn(input.element, 'removeEventListener')
wrapper.vm.show = false
await nextTick()
expect(removeEventListenerSpy).toHaveBeenCalled()
})
})
七、最佳实践和注意事项
1. 命名规范
typescript
// 好的命名
vNumberOnly // 小写字母,短横线连接
vAutoFocus // 描述清晰
vClickOutside // 表达意图
// 不好的命名
vNumber // 太泛泛
vOnly // 不清楚功能
vOut // 缩写不明确
2. 性能优化
typescript
// 使用防抖/节流
import { throttle, debounce } from 'lodash-es'
const vOptimizedDirective = {
mounted(el, binding) {
const handler = binding.value
// 使用防抖
const debouncedHandler = debounce(handler, 300)
// 或者使用节流
const throttledHandler = throttle(handler, 300)
el.addEventListener('input', debouncedHandler)
el._handler = debouncedHandler
}
}
// 懒加载指令
const lazyDirective = {
mounted(el, binding) {
// 使用 IntersectionObserver 实现懒加载
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 执行指令逻辑
binding.value()
observer.unobserve(el)
}
})
})
observer.observe(el)
el._observer = observer
},
unmounted(el) {
if (el._observer) {
el._observer.disconnect()
}
}
}
3. 错误处理
typescript
const vSafeDirective = {
mounted(el, binding) {
try {
// 指令逻辑
if (typeof binding.value !== 'function') {
throw new Error('指令参数必须是函数')
}
// 执行指令
binding.value()
} catch (error) {
console.error('指令执行出错:', error)
// 可以根据环境决定是否显示错误
if (process.env.NODE_ENV === 'development') {
el.style.border = '2px solid red'
el.title = `指令错误: ${error.message}`
}
}
}
}
八、总结
学习要点:
理解指令生命周期:
-
mounted: 元素挂载时调用
-
updated: 元素更新时调用
-
unmounted: 元素卸载时调用
掌握核心概念:
-
指令参数:v-directive:arg="value"
-
修饰符:v-directive.modifier
-
绑定值:binding.value
TypeScript 类型支持:
-
使用 Directive 泛型类型
-
定义接口约束参数类型
-
扩展 HTMLElement 类型
最佳实践:
-
一个指令只做一件事
-
提供适当的配置选项
-
清理事件监听防止内存泄漏
-
添加错误处理
实际应用建议:
从简单开始:先实现基础功能,再逐步添加高级特性
复用现有方案:查看是否已有成熟的指令库
编写测试:确保指令的稳定性和可靠性
文档化:记录指令的使用方法和参数说明
通过这个完整的教程,你应该能够掌握 Vue3 + TypeScript 中自定义指令的创建、注册和使用。记住实践是最好的学习方式,尝试创建自己的指令来解决实际问题。



