Vue3+TypeScript 自定义指令

 全局注册示例

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}`
      }
    }
  }
}

八、总结

学习要点:

  1. 理解指令生命周期

    • mounted: 元素挂载时调用

    • updated: 元素更新时调用

    • unmounted: 元素卸载时调用

  2. 掌握核心概念

    • 指令参数:v-directive:arg="value"

    • 修饰符:v-directive.modifier

    • 绑定值:binding.value

  3. TypeScript 类型支持

    • 使用 Directive 泛型类型

    • 定义接口约束参数类型

    • 扩展 HTMLElement 类型

  4. 最佳实践

    • 一个指令只做一件事

    • 提供适当的配置选项

    • 清理事件监听防止内存泄漏

    • 添加错误处理

实际应用建议:

  1. 从简单开始:先实现基础功能,再逐步添加高级特性

  2. 复用现有方案:查看是否已有成熟的指令库

  3. 编写测试:确保指令的稳定性和可靠性

  4. 文档化:记录指令的使用方法和参数说明

通过这个完整的教程,你应该能够掌握 Vue3 + TypeScript 中自定义指令的创建、注册和使用。记住实践是最好的学习方式,尝试创建自己的指令来解决实际问题。


在 Vue3 中,可以通过以下几种方式全局注册自定义指令:

方式一:在 main.ts/main.js 中直接注册

typescript

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

// 导入自定义指令
import { totalInputDirective } from './directives/totalInputDirective'

const app = createApp(App)

// 注册全局指令
app.directive('total-input', totalInputDirective)

app.mount('#app')

方式二:创建指令插件批量注册

1. 创建指令插件文件

typescript

// plugins/directives.ts
import type { App } from 'vue'
import { totalInputDirective } from '../directives/totalInputDirective'
// 可以导入其他指令
// import { anotherDirective } from './anotherDirective'

export const directivesPlugin = {
  install(app: App) {
    app.directive('total-input', totalInputDirective)
    // app.directive('another', anotherDirective)
    // 可以批量注册更多指令
  }
}

2. 在 main.ts 中使用插件

typescript

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { directivesPlugin } from './plugins/directives'

const app = createApp(App)

// 使用插件注册所有指令
app.use(directivesPlugin)

app.mount('#app')

方式三:集中管理并自动注册所有指令

1. 创建一个指令集合文件

typescript

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

// 导入所有指令
import { totalInputDirective } from './totalInputDirective'
// import { focusDirective } from './focusDirective'
// import { lazyLoadDirective } from './lazyLoadDirective'

// 定义指令类型
interface DirectiveModule {
  name: string
  directive: Directive
}

// 指令集合
export const directives: DirectiveModule[] = [
  { name: 'total-input', directive: totalInputDirective },
  // { name: 'focus', directive: focusDirective },
  // { name: 'lazy-load', directive: lazyLoadDirective },
]

// 批量注册指令的插件
export const directivesPlugin = {
  install(app: App) {
    directives.forEach(directive => {
      app.directive(directive.name, directive.directive)
    })
  }
}

2. 在 main.ts 中使用

typescript

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { directivesPlugin } from './directives'

const app = createApp(App)

app.use(directivesPlugin)

app.mount('#app')

方式四:Vite 环境下自动导入(推荐)

如果你使用的是 Vite + Vue3,可以配置自动导入:

1. 安装依赖

bash

npm install unplugin-vue-components unplugin-auto-import -D

2. 配置 vite.config.ts

typescript

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({
      imports: ['vue'],
      dts: 'src/auto-imports.d.ts',
    }),
    Components({
      dts: 'src/components.d.ts',
      // 可以在这里配置自动导入指令
    }),
  ],
})

3. 创建指令自动导入配置

typescript

// auto-imports.d.ts 或单独的指令声明文件
declare module 'vue' {
  export interface GlobalComponents {
    // 组件类型声明
  }
  
  export interface ComponentCustomProperties {
    // 全局属性声明
  }
}

// 指令类型声明
declare module '@vue/runtime-core' {
  export interface ComponentCustomProperties {
    vTotalInput: typeof import('./directives/totalInputDirective')['totalInputDirective']
  }
}

使用全局指令

注册后,在组件模板中可以直接使用:

vue

<template>
  <div>
    <!-- 在 input 元素上使用 -->
    <input v-total-input v-model="amount" />
    
    <!-- 在组件上使用 -->
    <MyInputComponent v-total-input v-model="amount" />
    
    <!-- 使用指令参数 -->
    <input v-total-input:arg.modifier="value" />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const amount = ref('')
</script>

注意事项

  1. 命名规范:指令名称使用 kebab-case(短横线分隔),如 v-total-input

  2. TypeScript 支持:如果需要类型提示,可以扩展 Vue 的类型定义

  3. 性能考虑:全局指令会在所有组件中可用,但会增加包体积

  4. 作用域:全局指令在整个应用中都可用,包括第三方组件

扩展 Vue 类型定义(TypeScript)

typescript

// src/types/vue.d.ts 或 auto-imports.d.ts
import type { totalInputDirective } from '../directives/totalInputDirective'

declare module 'vue' {
  export interface GlobalDirectives {
    'total-input': typeof totalInputDirective
  }
}

选择哪种方式取决于你的项目规模和需求:

  • 小型项目:直接在 main.ts 中注册

  • 中型项目:使用插件方式批量注册

  • 大型项目:使用自动导入或模块化注册

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值