Vue 自定义指令完全指南:定义与应用场景详解

Vue 自定义指令完全指南:定义与应用场景详解

自定义指令是 Vue 中一个非常强大但常常被忽视的功能,它允许你直接操作 DOM 元素,扩展 Vue 的模板功能。

一、自定义指令基础

1. 什么是自定义指令?

javascript 复制代码
// 官方指令示例
<template>
  <input v-model="text" />      <!-- 内置指令 -->
  <div v-show="isVisible"></div> <!-- 内置指令 -->
  <p v-text="content"></p>       <!-- 内置指令 -->
</template>

// 自定义指令示例
<template>
  <div v-focus></div>           <!-- 自定义指令 -->
  <p v-highlight="color"></p>   <!-- 带参数的自定义指令 -->
  <button v-permission="'edit'"></button> <!-- 自定义权限指令 -->
</template>

二、自定义指令的定义与使用

1. 定义方式

全局自定义指令
javascript 复制代码
// main.js 或 directives.js
import Vue from 'vue'

// 1. 简单指令(聚焦)
Vue.directive('focus', {
  // 指令第一次绑定到元素时调用
  inserted(el) {
    el.focus()
  }
})

// 2. 带参数和修饰符的指令
Vue.directive('pin', {
  inserted(el, binding) {
    const { value, modifiers } = binding
    
    let pinnedPosition = value || { x: 0, y: 0 }
    
    if (modifiers.top) {
      pinnedPosition = { ...pinnedPosition, y: 0 }
    }
    if (modifiers.left) {
      pinnedPosition = { ...pinnedPosition, x: 0 }
    }
    
    el.style.position = 'fixed'
    el.style.left = `${pinnedPosition.x}px`
    el.style.top = `${pinnedPosition.y}px`
  },
  
  // 参数更新时调用
  update(el, binding) {
    if (binding.value !== binding.oldValue) {
      // 更新位置
      el.style.left = `${binding.value.x}px`
      el.style.top = `${binding.value.y}px`
    }
  }
})

// 3. 完整生命周期指令
Vue.directive('tooltip', {
  // 只调用一次,指令第一次绑定到元素时
  bind(el, binding, vnode) {
    console.log('bind 钩子调用')
    
    const { value, modifiers } = binding
    const tooltipText = typeof value === 'string' ? value : value?.text
    
    // 创建tooltip元素
    const tooltip = document.createElement('div')
    tooltip.className = 'custom-tooltip'
    tooltip.textContent = tooltipText
    
    // 添加样式
    Object.assign(tooltip.style, {
      position: 'absolute',
      background: '#333',
      color: 'white',
      padding: '8px 12px',
      borderRadius: '4px',
      fontSize: '14px',
      whiteSpace: 'nowrap',
      pointerEvents: 'none',
      opacity: '0',
      transition: 'opacity 0.2s',
      zIndex: '9999'
    })
    
    // 存储引用以便清理
    el._tooltip = tooltip
    el.appendChild(tooltip)
    
    // 事件监听
    el.addEventListener('mouseenter', showTooltip)
    el.addEventListener('mouseleave', hideTooltip)
    el.addEventListener('mousemove', updateTooltipPosition)
    
    function showTooltip() {
      tooltip.style.opacity = '1'
    }
    
    function hideTooltip() {
      tooltip.style.opacity = '0'
    }
    
    function updateTooltipPosition(e) {
      tooltip.style.left = `${e.offsetX + 10}px`
      tooltip.style.top = `${e.offsetY + 10}px`
    }
    
    // 保存事件处理器以便移除
    el._showTooltip = showTooltip
    el._hideTooltip = hideTooltip
    el._updateTooltipPosition = updateTooltipPosition
  },
  
  // 被绑定元素插入父节点时调用
  inserted(el, binding, vnode) {
    console.log('inserted 钩子调用')
  },
  
  // 所在组件的 VNode 更新时调用
  update(el, binding, vnode, oldVnode) {
    console.log('update 钩子调用')
    // 更新tooltip内容
    if (binding.value !== binding.oldValue) {
      const tooltip = el._tooltip
      if (tooltip) {
        tooltip.textContent = binding.value
      }
    }
  },
  
  // 指令所在组件的 VNode 及其子 VNode 全部更新后调用
  componentUpdated(el, binding, vnode, oldVnode) {
    console.log('componentUpdated 钩子调用')
  },
  
  // 只调用一次,指令与元素解绑时调用
  unbind(el, binding, vnode) {
    console.log('unbind 钩子调用')
    
    // 清理事件监听器
    el.removeEventListener('mouseenter', el._showTooltip)
    el.removeEventListener('mouseleave', el._hideTooltip)
    el.removeEventListener('mousemove', el._updateTooltipPosition)
    
    // 移除tooltip元素
    if (el._tooltip && el._tooltip.parentNode === el) {
      el.removeChild(el._tooltip)
    }
    
    // 清除引用
    delete el._tooltip
    delete el._showTooltip
    delete el._hideTooltip
    delete el._updateTooltipPosition
  }
})

// 4. 动态参数指令
Vue.directive('style', {
  update(el, binding) {
    const styles = binding.value
    
    if (typeof styles === 'object') {
      Object.assign(el.style, styles)
    } else if (typeof styles === 'string') {
      el.style.cssText = styles
    }
  }
})
局部自定义指令
vue 复制代码
<template>
  <div>
    <input v-local-focus />
    <div v-local-resize="size"></div>
  </div>
</template>

<script>
export default {
  name: 'MyComponent',
  
  // 局部指令定义
  directives: {
    // 1. 函数简写(bind 和 update 时调用)
    'local-focus': function(el, binding) {
      if (binding.value !== false) {
        el.focus()
      }
    },
    
    // 2. 完整对象形式
    'local-resize': {
      bind(el, binding) {
        console.log('本地resize指令绑定')
        el._resizeObserver = new ResizeObserver(entries => {
          for (let entry of entries) {
            binding.value?.callback?.(entry.contentRect)
          }
        })
        el._resizeObserver.observe(el)
      },
      
      unbind(el) {
        if (el._resizeObserver) {
          el._resizeObserver.disconnect()
          delete el._resizeObserver
        }
      }
    },
    
    // 3. 带参数和修饰符
    'local-position': {
      inserted(el, binding) {
        const { value, modifiers } = binding
        
        if (modifiers.absolute) {
          el.style.position = 'absolute'
        } else if (modifiers.fixed) {
          el.style.position = 'fixed'
        } else if (modifiers.sticky) {
          el.style.position = 'sticky'
        }
        
        if (value) {
          const { x, y } = value
          if (x !== undefined) el.style.left = `${x}px`
          if (y !== undefined) el.style.top = `${y}px`
        }
      },
      
      update(el, binding) {
        if (binding.value !== binding.oldValue) {
          const { x, y } = binding.value
          if (x !== undefined) el.style.left = `${x}px`
          if (y !== undefined) el.style.top = `${y}px`
        }
      }
    }
  },
  
  data() {
    return {
      size: {
        callback: (rect) => {
          console.log('元素尺寸变化:', rect)
        }
      }
    }
  }
}
</script>

2. 指令钩子函数参数详解

javascript 复制代码
Vue.directive('demo', {
  // 每个钩子函数都有以下参数:
  bind(el, binding, vnode, oldVnode) {
    // el: 指令所绑定的元素,可以直接操作 DOM
    console.log('元素:', el)
    
    // binding: 一个对象,包含以下属性:
    console.log('指令名称:', binding.name)        // "demo"
    console.log('指令值:', binding.value)         // 绑定值,如 v-demo="1 + 1" 的值为 2
    console.log('旧值:', binding.oldValue)       // 之前的值,仅在 update 和 componentUpdated 中可用
    console.log('表达式:', binding.expression)   // 字符串形式的表达式,如 v-demo="1 + 1" 的表达式为 "1 + 1"
    console.log('参数:', binding.arg)            // 指令参数,如 v-demo:foo 中,参数为 "foo"
    console.log('修饰符:', binding.modifiers)    // 修饰符对象,如 v-demo.foo.bar 中,修饰符为 { foo: true, bar: true }
    
    // vnode: Vue 编译生成的虚拟节点
    console.log('虚拟节点:', vnode)
    console.log('组件实例:', vnode.context)      // 指令所在的组件实例
    
    // oldVnode: 上一个虚拟节点,仅在 update 和 componentUpdated 钩子中可用
  }
})

三、自定义指令的应用场景

场景1:DOM 操作与交互

1.1 点击外部关闭
javascript 复制代码
// directives/click-outside.js
export default {
  bind(el, binding, vnode) {
    // 点击外部关闭功能
    el._clickOutsideHandler = (event) => {
      // 检查点击是否在元素外部
      if (!(el === event.target || el.contains(event.target))) {
        // 调用绑定的方法
        const handler = binding.value
        if (typeof handler === 'function') {
          handler(event)
        }
      }
    }
    
    // 添加事件监听
    document.addEventListener('click', el._clickOutsideHandler)
    document.addEventListener('touchstart', el._clickOutsideHandler)
  },
  
  unbind(el) {
    // 清理事件监听
    document.removeEventListener('click', el._clickOutsideHandler)
    document.removeEventListener('touchstart', el._clickOutsideHandler)
    delete el._clickOutsideHandler
  }
}

// 使用
Vue.directive('click-outside', clickOutsideDirective)
vue 复制代码
<!-- 使用示例 -->
<template>
  <div class="dropdown-container">
    <!-- 点击按钮显示下拉菜单 -->
    <button @click="showDropdown = !showDropdown">
      下拉菜单
    </button>
    
    <!-- 点击外部关闭下拉菜单 -->
    <div 
      v-if="showDropdown" 
      class="dropdown-menu"
      v-click-outside="closeDropdown"
    >
      <ul>
        <li @click="selectItem('option1')">选项1</li>
        <li @click="selectItem('option2')">选项2</li>
        <li @click="selectItem('option3')">选项3</li>
      </ul>
    </div>
    
    <!-- 模态框示例 -->
    <div 
      v-if="modalVisible" 
      class="modal-overlay"
      v-click-outside="closeModal"
    >
      <div class="modal-content" @click.stop>
        <h2>模态框标题</h2>
        <p>点击外部关闭此模态框</p>
        <button @click="closeModal">关闭</button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      showDropdown: false,
      modalVisible: false
    }
  },
  
  methods: {
    closeDropdown() {
      this.showDropdown = false
    },
    
    closeModal() {
      this.modalVisible = false
    },
    
    selectItem(item) {
      console.log('选择了:', item)
      this.showDropdown = false
    },
    
    openModal() {
      this.modalVisible = true
    }
  }
}
</script>

<style>
.dropdown-container {
  position: relative;
  display: inline-block;
}

.dropdown-menu {
  position: absolute;
  top: 100%;
  left: 0;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  min-width: 150px;
  z-index: 1000;
}

.dropdown-menu ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.dropdown-menu li {
  padding: 8px 12px;
  cursor: pointer;
}

.dropdown-menu li:hover {
  background: #f5f5f5;
}

.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.modal-content {
  background: white;
  padding: 30px;
  border-radius: 8px;
  max-width: 500px;
  width: 90%;
}
</style>
1.2 拖拽功能
javascript 复制代码
// directives/draggable.js
export default {
  bind(el, binding) {
    // 默认配置
    const defaults = {
      handle: null,
      axis: 'both', // 'x', 'y', or 'both'
      boundary: null,
      grid: [1, 1],
      onStart: null,
      onMove: null,
      onEnd: null
    }
    
    const options = { ...defaults, ...binding.value }
    
    // 初始化状态
    let isDragging = false
    let startX, startY
    let initialLeft, initialTop
    
    // 获取拖拽手柄
    const handle = options.handle 
      ? el.querySelector(options.handle)
      : el
    
    // 设置元素样式
    el.style.position = 'relative'
    el.style.userSelect = 'none'
    
    // 鼠标按下事件
    handle.addEventListener('mousedown', startDrag)
    handle.addEventListener('touchstart', startDrag)
    
    function startDrag(e) {
      // 阻止默认行为和事件冒泡
      e.preventDefault()
      e.stopPropagation()
      
      // 获取起始位置
      const clientX = e.type === 'touchstart' 
        ? e.touches[0].clientX 
        : e.clientX
      const clientY = e.type === 'touchstart' 
        ? e.touches[0].clientY 
        : e.clientY
      
      startX = clientX
      startY = clientY
      
      // 获取元素当前位置
      const rect = el.getBoundingClientRect()
      initialLeft = rect.left
      initialTop = rect.top
      
      // 开始拖拽
      isDragging = true
      
      // 添加事件监听
      document.addEventListener('mousemove', onDrag)
      document.addEventListener('touchmove', onDrag)
      document.addEventListener('mouseup', stopDrag)
      document.addEventListener('touchend', stopDrag)
      
      // 设置光标样式
      document.body.style.cursor = 'grabbing'
      document.body.style.userSelect = 'none'
      
      // 触发开始回调
      if (typeof options.onStart === 'function') {
        options.onStart({
          element: el,
          x: rect.left,
          y: rect.top
        })
      }
    }
    
    function onDrag(e) {
      if (!isDragging) return
      
      e.preventDefault()
      
      // 计算移动距离
      const clientX = e.type === 'touchmove' 
        ? e.touches[0].clientX 
        : e.clientX
      const clientY = e.type === 'touchmove' 
        ? e.touches[0].clientY 
        : e.clientY
      
      let deltaX = clientX - startX
      let deltaY = clientY - startY
      
      // 限制移动轴
      if (options.axis === 'x') {
        deltaY = 0
      } else if (options.axis === 'y') {
        deltaX = 0
      }
      
      // 网格对齐
      if (options.grid) {
        const [gridX, gridY] = options.grid
        deltaX = Math.round(deltaX / gridX) * gridX
        deltaY = Math.round(deltaY / gridY) * gridY
      }
      
      // 边界限制
      let newLeft = initialLeft + deltaX
      let newTop = initialTop + deltaY
      
      if (options.boundary) {
        const boundary = typeof options.boundary === 'string'
          ? document.querySelector(options.boundary)
          : options.boundary
        
        if (boundary) {
          const boundaryRect = boundary.getBoundingClientRect()
          const elRect = el.getBoundingClientRect()
          
          newLeft = Math.max(boundaryRect.left, 
            Math.min(newLeft, boundaryRect.right - elRect.width))
          newTop = Math.max(boundaryRect.top, 
            Math.min(newTop, boundaryRect.bottom - elRect.height))
        }
      }
      
      // 更新元素位置
      el.style.left = `${newLeft - initialLeft}px`
      el.style.top = `${newTop - initialTop}px`
      
      // 触发移动回调
      if (typeof options.onMove === 'function') {
        options.onMove({
          element: el,
          x: newLeft,
          y: newTop,
          deltaX,
          deltaY
        })
      }
    }
    
    function stopDrag(e) {
      if (!isDragging) return
      
      isDragging = false
      
      // 移除事件监听
      document.removeEventListener('mousemove', onDrag)
      document.removeEventListener('touchmove', onDrag)
      document.removeEventListener('mouseup', stopDrag)
      document.removeEventListener('touchend', stopDrag)
      
      // 恢复光标样式
      document.body.style.cursor = ''
      document.body.style.userSelect = ''
      
      // 获取最终位置
      const rect = el.getBoundingClientRect()
      
      // 触发结束回调
      if (typeof options.onEnd === 'function') {
        options.onEnd({
          element: el,
          x: rect.left,
          y: rect.top
        })
      }
    }
    
    // 存储清理函数
    el._cleanupDraggable = () => {
      handle.removeEventListener('mousedown', startDrag)
      handle.removeEventListener('touchstart', startDrag)
    }
  },
  
  unbind(el) {
    if (el._cleanupDraggable) {
      el._cleanupDraggable()
      delete el._cleanupDraggable
    }
  }
}
vue 复制代码
<!-- 使用示例 -->
<template>
  <div class="draggable-demo">
    <h2>拖拽功能演示</h2>
    
    <!-- 基本拖拽 -->
    <div 
      v-draggable 
      class="draggable-box"
      :style="{ backgroundColor: boxColor }"
    >
      可拖拽的盒子
    </div>
    
    <!-- 带手柄的拖拽 -->
    <div 
      v-draggable="{ handle: '.drag-handle' }"
      class="draggable-box-with-handle"
    >
      <div class="drag-handle">
        🎯 拖拽手柄
      </div>
      <div class="content">
        只能通过手柄拖拽
      </div>
    </div>
    
    <!-- 限制方向的拖拽 -->
    <div 
      v-draggable="{ axis: 'x' }"
      class="horizontal-draggable"
    >
      只能水平拖拽
    </div>
    
    <div 
      v-draggable="{ axis: 'y' }"
      class="vertical-draggable"
    >
      只能垂直拖拽
    </div>
    
    <!-- 网格对齐拖拽 -->
    <div 
      v-draggable="{ grid: [20, 20] }"
      class="grid-draggable"
    >
      20px网格对齐
    </div>
    
    <!-- 边界限制拖拽 -->
    <div class="boundary-container">
      <div 
        v-draggable="{ boundary: '.boundary-container' }"
        class="bounded-draggable"
      >
        在容器内拖拽
      </div>
    </div>
    
    <!-- 带回调的拖拽 -->
    <div 
      v-draggable="dragOptions"
      class="callback-draggable"
    >
      带回调的拖拽
      <div class="position-info">
        位置: ({{ position.x }}, {{ position.y }})
      </div>
    </div>
    
    <!-- 拖拽列表 -->
    <div class="draggable-list">
      <div 
        v-for="(item, index) in draggableItems" 
        :key="item.id"
        v-draggable="{
          onStart: () => handleDragStart(index),
          onMove: handleDragMove,
          onEnd: handleDragEnd
        }"
        class="list-item"
        :style="{
          backgroundColor: item.color,
          zIndex: activeIndex === index ? 100 : 1
        }"
      >
        {{ item.name }}
        <div class="item-index">#{{ index + 1 }}</div>
      </div>
    </div>
  </div>
</template>

<script>
import draggableDirective from '@/directives/draggable'

export default {
  directives: {
    draggable: draggableDirective
  },
  
  data() {
    return {
      boxColor: '#4CAF50',
      position: { x: 0, y: 0 },
      activeIndex: -1,
      draggableItems: [
        { id: 1, name: '项目A', color: '#FF6B6B' },
        { id: 2, name: '项目B', color: '#4ECDC4' },
        { id: 3, name: '项目C', color: '#FFD166' },
        { id: 4, name: '项目D', color: '#06D6A0' },
        { id: 5, name: '项目E', color: '#118AB2' }
      ]
    }
  },
  
  computed: {
    dragOptions() {
      return {
        onStart: this.handleStart,
        onMove: this.handleMove,
        onEnd: this.handleEnd
      }
    }
  },
  
  methods: {
    handleStart(data) {
      console.log('开始拖拽:', data)
      this.boxColor = '#FF9800'
    },
    
    handleMove(data) {
      this.position = {
        x: Math.round(data.x),
        y: Math.round(data.y)
      }
    },
    
    handleEnd(data) {
      console.log('结束拖拽:', data)
      this.boxColor = '#4CAF50'
    },
    
    handleDragStart(index) {
      this.activeIndex = index
      console.log('开始拖拽列表项:', index)
    },
    
    handleDragMove(data) {
      console.log('拖拽移动:', data)
    },
    
    handleDragEnd(data) {
      this.activeIndex = -1
      console.log('结束拖拽列表项:', data)
    }
  }
}
</script>

<style>
.draggable-demo {
  padding: 20px;
  min-height: 100vh;
  background: #f5f5f5;
}

.draggable-box {
  width: 150px;
  height: 150px;
  background: #4CAF50;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: grab;
  margin: 20px;
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}

.draggable-box-with-handle {
  width: 200px;
  height: 200px;
  background: white;
  border-radius: 8px;
  border: 2px solid #ddd;
  margin: 20px;
  overflow: hidden;
}

.drag-handle {
  background: #2196F3;
  color: white;
  padding: 10px;
  cursor: grab;
  text-align: center;
  font-weight: bold;
}

.draggable-box-with-handle .content {
  padding: 20px;
  text-align: center;
}

.horizontal-draggable,
.vertical-draggable {
  width: 200px;
  height: 100px;
  background: #9C27B0;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  margin: 20px;
  cursor: grab;
}

.grid-draggable {
  width: 100px;
  height: 100px;
  background: #FF9800;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  margin: 20px;
  cursor: grab;
}

.boundary-container {
  width: 400px;
  height: 300px;
  background: #E0E0E0;
  border: 2px dashed #999;
  margin: 20px;
  position: relative;
}

.bounded-draggable {
  width: 100px;
  height: 100px;
  background: #3F51B5;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: grab;
}

.callback-draggable {
  width: 200px;
  height: 200px;
  background: #00BCD4;
  color: white;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  margin: 20px;
  cursor: grab;
}

.position-info {
  margin-top: 10px;
  font-size: 12px;
  background: rgba(0,0,0,0.2);
  padding: 4px 8px;
  border-radius: 4px;
}

.draggable-list {
  margin-top: 40px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-width: 300px;
}

.list-item {
  padding: 15px;
  color: white;
  border-radius: 6px;
  cursor: grab;
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-shadow: 0 2px 4px rgba(0,0,0,0.2);
  transition: transform 0.2s, box-shadow 0.2s;
}

.list-item:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}

.item-index {
  background: rgba(0,0,0,0.2);
  padding: 2px 8px;
  border-radius: 10px;
  font-size: 12px;
}
</style>

场景2:权限控制与条件渲染

javascript 复制代码
// directives/permission.js
import store from '@/store'

export default {
  inserted(el, binding, vnode) {
    const { value, modifiers } = binding
    
    // 获取用户权限
    const userPermissions = store.getters.permissions || []
    const userRoles = store.getters.roles || []
    
    let hasPermission = false
    
    // 支持多种权限格式
    if (Array.isArray(value)) {
      // 数组格式:['user:create', 'user:edit']
      hasPermission = value.some(permission => 
        userPermissions.includes(permission)
      )
    } else if (typeof value === 'string') {
      // 字符串格式:'user:create'
      hasPermission = userPermissions.includes(value)
    } else if (typeof value === 'object') {
      // 对象格式:{ roles: ['admin'], permissions: ['user:create'] }
      const { roles = [], permissions = [] } = value
      
      const hasRole = roles.length === 0 || roles.some(role => 
        userRoles.includes(role)
      )
      
      const hasPermissionCheck = permissions.length === 0 || 
        permissions.some(permission => 
          userPermissions.includes(permission)
        )
      
      hasPermission = hasRole && hasPermissionCheck
    }
    
    // 检查修饰符
    if (modifiers.not) {
      hasPermission = !hasPermission
    }
    
    if (modifiers.or) {
      // OR 逻辑:满足任一条件即可
      // 已经在数组处理中实现
    }
    
    if (modifiers.and) {
      // AND 逻辑:需要满足所有条件
      if (Array.isArray(value)) {
        hasPermission = value.every(permission => 
          userPermissions.includes(permission)
        )
      }
    }
    
    // 根据权限决定是否显示元素
    if (!hasPermission) {
      // 移除元素
      if (modifiers.hide) {
        el.style.display = 'none'
      } else {
        el.parentNode && el.parentNode.removeChild(el)
      }
    }
  },
  
  update(el, binding) {
    // 权限变化时重新检查
    const oldValue = binding.oldValue
    const newValue = binding.value
    
    if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
      // 重新插入指令以检查权限
      vnode.context.$nextTick(() => {
        this.inserted(el, binding, vnode)
      })
    }
  }
}

// 注册全局指令
Vue.directive('permission', permissionDirective)
vue 复制代码
<!-- 权限控制示例 -->
<template>
  <div class="permission-demo">
    <h2>权限控制演示</h2>
    
    <div class="user-info">
      <h3>当前用户信息</h3>
      <p>角色: {{ currentUser.roles.join(', ') }}</p>
      <p>权限: {{ currentUser.permissions.join(', ') }}</p>
    </div>
    
    <div class="permission-controls">
      <!-- 切换用户角色 -->
      <div class="role-selector">
        <label>切换角色:</label>
        <button 
          v-for="role in availableRoles" 
          :key="role"
          @click="switchRole(role)"
          :class="{ active: currentUser.roles.includes(role) }"
        >
          {{ role }}
        </button>
      </div>
      
      <!-- 添加/移除权限 -->
      <div class="permission-manager">
        <label>权限管理:</label>
        <div class="permission-tags">
          <span 
            v-for="permission in allPermissions" 
            :key="permission"
            class="permission-tag"
            :class="{ active: currentUser.permissions.includes(permission) }"
            @click="togglePermission(permission)"
          >
            {{ permission }}
          </span>
        </div>
      </div>
    </div>
    
    <div class="permission-examples">
      <h3>权限控制示例</h3>
      
      <!-- 1. 基础权限控制 -->
      <div class="example-section">
        <h4>基础权限控制</h4>
        <button v-permission="'user:create'">
          创建用户 (需要 user:create 权限)
        </button>
        <button v-permission="'user:edit'">
          编辑用户 (需要 user:edit 权限)
        </button>
        <button v-permission="'user:delete'">
          删除用户 (需要 user:delete 权限)
        </button>
      </div>
      
      <!-- 2. 多权限控制(OR 逻辑) -->
      <div class="example-section">
        <h4>多权限控制(任一权限即可)</h4>
        <button v-permission="['user:create', 'user:edit']">
          创建或编辑用户
        </button>
        <button v-permission="['post:create', 'post:edit']">
          创建或编辑文章
        </button>
      </div>
      
      <!-- 3. 多权限控制(AND 逻辑) -->
      <div class="example-section">
        <h4>多权限控制(需要所有权限)</h4>
        <button v-permission.and="['user:read', 'user:edit']">
          读取并编辑用户 (需要两个权限)
        </button>
      </div>
      
      <!-- 4. 角色控制 -->
      <div class="example-section">
        <h4>角色控制</h4>
        <button v-permission="{ roles: ['admin'] }">
          管理员功能
        </button>
        <button v-permission="{ roles: ['editor'] }">
          编辑功能
        </button>
        <button v-permission="{ roles: ['admin', 'super-admin'] }">
          管理员或超级管理员
        </button>
      </div>
      
      <!-- 5. 角色和权限组合 -->
      <div class="example-section">
        <h4>角色和权限组合</h4>
        <button v-permission="{ 
          roles: ['editor'], 
          permissions: ['post:publish'] 
        }">
          编辑并发布文章
        </button>
      </div>
      
      <!-- 6. 反向控制(没有权限时显示) -->
      <div class="example-section">
        <h4>反向控制</h4>
        <button v-permission.not="'admin'">
          非管理员功能
        </button>
        <div v-permission.not="['user:delete', 'user:edit']" class="info-box">
          您没有删除或编辑用户的权限
        </div>
      </div>
      
      <!-- 7. 隐藏而不是移除 -->
      <div class="example-section">
        <h4>隐藏元素(而不是移除)</h4>
        <button v-permission.hide="'admin'">
          管理员按钮(隐藏)
        </button>
        <p>上面的按钮对非管理员会隐藏,但DOM元素仍然存在</p>
      </div>
      
      <!-- 8. 动态权限 -->
      <div class="example-section">
        <h4>动态权限控制</h4>
        <button v-permission="dynamicPermission">
          动态权限按钮
        </button>
        <div class="permission-control">
          <label>设置动态权限:</label>
          <input v-model="dynamicPermission" placeholder="输入权限,如 user:create">
        </div>
      </div>
      
      <!-- 9. 条件渲染结合 -->
      <div class="example-section">
        <h4>结合 v-if 使用</h4>
        <template v-if="hasUserReadPermission">
          <div class="user-data">
            <h5>用户数据(只有有权限时显示)</h5>
            <!-- 用户数据内容 -->
          </div>
        </template>
        <div v-else class="no-permission">
          没有查看用户数据的权限
        </div>
      </div>
      
      <!-- 10. 复杂权限组件 -->
      <div class="example-section">
        <h4>复杂权限组件</h4>
        <permission-guard 
          :required-permissions="['user:read', 'user:edit']"
          :required-roles="['editor']"
          fallback-message="您没有足够的权限访问此内容"
        >
          <template #default>
            <div class="privileged-content">
              <h5>特权内容</h5>
              <p>只有有足够权限的用户才能看到这个内容</p>
              <button @click="handlePrivilegedAction">特权操作</button>
            </div>
          </template>
        </permission-guard>
      </div>
      
      <!-- 11. 权限边界 -->
      <div class="example-section">
        <h4>权限边界组件</h4>
        <permission-boundary 
          :permissions="['admin', 'super-admin']"
          :fallback="fallbackComponent"
        >
          <admin-panel />
        </permission-boundary>
      </div>
    </div>
  </div>
</template>

<script>
import permissionDirective from '@/directives/permission'
import PermissionGuard from '@/components/PermissionGuard.vue'
import PermissionBoundary from '@/components/PermissionBoundary.vue'
import AdminPanel from '@/components/AdminPanel.vue'

export default {
  name: 'PermissionDemo',
  
  components: {
    PermissionGuard,
    PermissionBoundary,
    AdminPanel
  },
  
  directives: {
    permission: permissionDirective
  },
  
  data() {
    return {
      currentUser: {
        roles: ['user'],
        permissions: ['user:read', 'post:read']
      },
      availableRoles: ['user', 'editor', 'admin', 'super-admin'],
      allPermissions: [
        'user:read',
        'user:create', 
        'user:edit',
        'user:delete',
        'post:read',
        'post:create',
        'post:edit',
        'post:delete',
        'post:publish',
        'settings:read',
        'settings:edit'
      ],
      dynamicPermission: 'user:create',
      fallbackComponent: {
        template: '<div class="no-permission">权限不足</div>'
      }
    }
  },
  
  computed: {
    hasUserReadPermission() {
      return this.currentUser.permissions.includes('user:read')
    }
  },
  
  methods: {
    switchRole(role) {
      if (this.currentUser.roles.includes(role)) {
        // 如果已经拥有该角色,移除它
        this.currentUser.roles = this.currentUser.roles.filter(r => r !== role)
      } else {
        // 添加新角色
        this.currentUser.roles.push(role)
        
        // 根据角色自动添加默认权限
        this.addDefaultPermissions(role)
      }
    },
    
    addDefaultPermissions(role) {
      const rolePermissions = {
        'user': ['user:read', 'post:read'],
        'editor': ['post:create', 'post:edit', 'post:publish'],
        'admin': ['user:read', 'user:create', 'user:edit', 'settings:read'],
        'super-admin': ['user:delete', 'post:delete', 'settings:edit']
      }
      
      if (rolePermissions[role]) {
        rolePermissions[role].forEach(permission => {
          if (!this.currentUser.permissions.includes(permission)) {
            this.currentUser.permissions.push(permission)
          }
        })
      }
    },
    
    togglePermission(permission) {
      const index = this.currentUser.permissions.indexOf(permission)
      if (index > -1) {
        this.currentUser.permissions.splice(index, 1)
      } else {
        this.currentUser.permissions.push(permission)
      }
    },
    
    handlePrivilegedAction() {
      alert('执行特权操作')
    }
  },
  
  // 模拟从服务器获取用户权限
  created() {
    // 在实际应用中,这里会从服务器获取用户权限
    this.simulateFetchPermissions()
  },
  
  methods: {
    simulateFetchPermissions() {
      // 模拟API请求延迟
      setTimeout(() => {
        // 假设从服务器获取到的权限
        const serverPermissions = ['user:read', 'post:read', 'settings:read']
        this.currentUser.permissions = serverPermissions
        
        // 更新Vuex store(如果使用)
        this.$store.commit('SET_PERMISSIONS', serverPermissions)
      }, 500)
    }
  }
}
</script>

<style>
.permission-demo {
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.user-info {
  background: #f8f9fa;
  padding: 15px;
  border-radius: 8px;
  margin-bottom: 20px;
}

.permission-controls {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  margin-bottom: 30px;
}

.role-selector,
.permission-manager {
  margin-bottom: 20px;
}

.role-selector button,
.permission-tag {
  margin: 5px;
  padding: 8px 12px;
  border: 1px solid #ddd;
  background: white;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.role-selector button:hover,
.permission-tag:hover {
  background: #f0f0f0;
}

.role-selector button.active,
.permission-tag.active {
  background: #007bff;
  color: white;
  border-color: #007bff;
}

.permission-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 10px;
}

.permission-examples {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.example-section {
  margin-bottom: 30px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 6px;
}

.example-section h4 {
  margin-top: 0;
  color: #333;
  border-bottom: 2px solid #007bff;
  padding-bottom: 10px;
}

.example-section button {
  margin: 5px;
  padding: 10px 15px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.3s;
}

.example-section button:hover {
  background: #0056b3;
}

.info-box {
  background: #e3f2fd;
  border: 1px solid #bbdefb;
  padding: 15px;
  border-radius: 4px;
  margin: 10px 0;
}

.no-permission {
  background: #ffebee;
  border: 1px solid #ffcdd2;
  padding: 15px;
  border-radius: 4px;
  color: #c62828;
}

.privileged-content {
  background: #e8f5e9;
  border: 1px solid #c8e6c9;
  padding: 20px;
  border-radius: 6px;
}

.permission-control {
  margin-top: 10px;
}

.permission-control input {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-left: 10px;
  width: 200px;
}

.user-data {
  background: #f3e5f5;
  padding: 15px;
  border-radius: 4px;
  border: 1px solid #e1bee7;
}
</style>

场景3:表单验证与输入限制

javascript 复制代码
// directives/form-validator.js
export default {
  bind(el, binding, vnode) {
    const { value, modifiers } = binding
    const vm = vnode.context
    
    // 支持的验证规则
    const defaultRules = {
      required: {
        test: (val) => val !== null && val !== undefined && val !== '',
        message: '此字段为必填项'
      },
      email: {
        test: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val),
        message: '请输入有效的邮箱地址'
      },
      phone: {
        test: (val) => /^1[3-9]\d{9}$/.test(val),
        message: '请输入有效的手机号码'
      },
      number: {
        test: (val) => !isNaN(Number(val)) && isFinite(val),
        message: '请输入有效的数字'
      },
      minLength: {
        test: (val, length) => val.length >= length,
        message: (length) => `长度不能少于 ${length} 个字符`
      },
      maxLength: {
        test: (val, length) => val.length <= length,
        message: (length) => `长度不能超过 ${length} 个字符`
      },
      pattern: {
        test: (val, pattern) => new RegExp(pattern).test(val),
        message: '格式不正确'
      }
    }
    
    // 获取验证规则
    let rules = []
    
    if (typeof value === 'string') {
      // 字符串格式:"required|email"
      rules = value.split('|').map(rule => {
        const [name, ...params] = rule.split(':')
        return { name, params }
      })
    } else if (Array.isArray(value)) {
      // 数组格式:['required', { name: 'minLength', params: [6] }]
      rules = value.map(rule => {
        if (typeof rule === 'string') {
          const [name, ...params] = rule.split(':')
          return { name, params }
        } else {
          return rule
        }
      })
    } else if (typeof value === 'object') {
      // 对象格式:{ required: true, minLength: 6 }
      rules = Object.entries(value).map(([name, params]) => ({
        name,
        params: Array.isArray(params) ? params : [params]
      }))
    }
    
    // 添加修饰符作为规则
    Object.keys(modifiers).forEach(modifier => {
      if (defaultRules[modifier]) {
        rules.push({ name: modifier, params: [] })
      }
    })
    
    // 创建错误显示元素
    const errorEl = document.createElement('div')
    errorEl.className = 'validation-error'
    Object.assign(errorEl.style, {
      color: '#dc3545',
      fontSize: '12px',
      marginTop: '4px',
      display: 'none'
    })
    
    el.parentNode.insertBefore(errorEl, el.nextSibling)
    
    // 验证函数
    function validate(inputValue) {
      for (const rule of rules) {
        const ruleDef = defaultRules[rule.name]
        
        if (!ruleDef) {
          console.warn(`未知的验证规则: ${rule.name}`)
          continue
        }
        
        const isValid = ruleDef.test(inputValue, ...rule.params)
        
        if (!isValid) {
          const message = typeof ruleDef.message === 'function'
            ? ruleDef.message(...rule.params)
            : ruleDef.message
          
          return {
            valid: false,
            rule: rule.name,
            message
          }
        }
      }
      
      return { valid: true }
    }
    
    // 实时验证
    function handleInput(e) {
      const result = validate(e.target.value)
      
      if (result.valid) {
        // 验证通过
        el.style.borderColor = '#28a745'
        errorEl.style.display = 'none'
        
        // 移除错误类
        el.classList.remove('has-error')
        errorEl.textContent = ''
      } else {
        // 验证失败
        el.style.borderColor = '#dc3545'
        errorEl.textContent = result.message
        errorEl.style.display = 'block'
        
        // 添加错误类
        el.classList.add('has-error')
      }
      
      // 触发自定义事件
      el.dispatchEvent(new CustomEvent('validate', {
        detail: { valid: result.valid, message: result.message }
      }))
    }
    
    // 初始化验证
    function initValidation() {
      const initialValue = el.value
      if (initialValue) {
        handleInput({ target: el })
      }
    }
    
    // 事件监听
    el.addEventListener('input', handleInput)
    el.addEventListener('blur', handleInput)
    
    // 表单提交时验证
    if (el.form) {
      el.form.addEventListener('submit', (e) => {
        const result = validate(el.value)
        if (!result.valid) {
          e.preventDefault()
          errorEl.textContent = result.message
          errorEl.style.display = 'block'
          el.focus()
        }
      })
    }
    
    // 暴露验证方法
    el.validate = () => {
      const result = validate(el.value)
      handleInput({ target: el })
      return result
    }
    
    // 清除验证
    el.clearValidation = () => {
      el.style.borderColor = ''
      errorEl.style.display = 'none'
      el.classList.remove('has-error')
    }
    
    // 存储引用
    el._validator = {
      validate: el.validate,
      clearValidation: el.clearValidation,
      handleInput,
      rules
    }
    
    // 初始化
    initValidation()
  },
  
  update(el, binding) {
    // 规则更新时重新绑定
    if (binding.value !== binding.oldValue && el._validator) {
      // 清理旧的事件监听
      el.removeEventListener('input', el._validator.handleInput)
      el.removeEventListener('blur', el._validator.handleInput)
      
      // 重新绑定
      this.bind(el, binding)
    }
  },
  
  unbind(el) {
    // 清理
    if (el._validator) {
      el.removeEventListener('input', el._validator.handleInput)
      el.removeEventListener('blur', el._validator.handleInput)
      
      // 移除错误元素
      const errorEl = el.nextElementSibling
      if (errorEl && errorEl.className === 'validation-error') {
        errorEl.parentNode.removeChild(errorEl)
      }
      
      delete el._validator
      delete el.validate
      delete el.clearValidation
    }
  }
}

// 输入限制指令
Vue.directive('input-limit', {
  bind(el, binding) {
    const { value, modifiers } = binding
    
    const defaultOptions = {
      type: 'text',          // text, number, decimal, integer
      maxLength: null,
      min: null,
      max: null,
      decimalPlaces: 2,
      allowNegative: false,
      allowSpace: true,
      allowSpecialChars: false,
      pattern: null
    }
    
    const options = { ...defaultOptions, ...value }
    
    // 创建提示元素
    const hintEl = document.createElement('div')
    hintEl.className = 'input-hint'
    Object.assign(hintEl.style, {
      fontSize: '12px',
      color: '#6c757d',
      marginTop: '4px',
      display: 'none'
    })
    
    el.parentNode.insertBefore(hintEl, el.nextSibling)
    
    // 输入处理函数
    function handleInput(e) {
      let inputValue = e.target.value
      
      // 应用限制
      inputValue = applyLimits(inputValue, options)
      
      // 更新值
      if (inputValue !== e.target.value) {
        e.target.value = inputValue
        // 触发input事件,确保v-model更新
        e.target.dispatchEvent(new Event('input'))
      }
      
      // 显示提示
      updateHint(inputValue, options)
    }
    
    // 粘贴处理
    function handlePaste(e) {
      e.preventDefault()
      
      const pastedText = e.clipboardData.getData('text')
      let processedText = applyLimits(pastedText, options)
      
      // 插入文本
      const start = el.selectionStart
      const end = el.selectionEnd
      const currentValue = el.value
      
      const newValue = currentValue.substring(0, start) + 
                      processedText + 
                      currentValue.substring(end)
      
      el.value = applyLimits(newValue, options)
      el.dispatchEvent(new Event('input'))
      
      // 设置光标位置
      setTimeout(() => {
        el.selectionStart = el.selectionEnd = start + processedText.length
      }, 0)
    }
    
    // 应用限制
    function applyLimits(value, options) {
      if (options.type === 'number' || options.type === 'integer' || options.type === 'decimal') {
        // 数字类型限制
        let filtered = value.replace(/[^\d.-]/g, '')
        
        // 处理负号
        if (!options.allowNegative) {
          filtered = filtered.replace(/-/g, '')
        } else {
          // 只允许开头有一个负号
          filtered = filtered.replace(/(.)-/g, '$1')
          if (filtered.startsWith('-')) {
            filtered = '-' + filtered.substring(1).replace(/-/g, '')
          }
        }
        
        // 处理小数点
        if (options.type === 'integer') {
          filtered = filtered.replace(/\./g, '')
        } else if (options.type === 'decimal') {
          // 限制小数位数
          const parts = filtered.split('.')
          if (parts.length > 1) {
            parts[1] = parts[1].substring(0, options.decimalPlaces)
            filtered = parts[0] + '.' + parts[1]
          }
          
          // 只允许一个小数点
          const dotCount = (filtered.match(/\./g) || []).length
          if (dotCount > 1) {
            const firstDotIndex = filtered.indexOf('.')
            filtered = filtered.substring(0, firstDotIndex + 1) + 
                      filtered.substring(firstDotIndex + 1).replace(/\./g, '')
          }
        }
        
        value = filtered
        
        // 范围限制
        if (options.min !== null) {
          const num = parseFloat(value)
          if (!isNaN(num) && num < options.min) {
            value = options.min.toString()
          }
        }
        
        if (options.max !== null) {
          const num = parseFloat(value)
          if (!isNaN(num) && num > options.max) {
            value = options.max.toString()
          }
        }
      } else if (options.type === 'text') {
        // 文本类型限制
        if (!options.allowSpace) {
          value = value.replace(/\s/g, '')
        }
        
        if (!options.allowSpecialChars) {
          value = value.replace(/[^\w\s]/g, '')
        }
        
        if (options.pattern) {
          const regex = new RegExp(options.pattern)
          value = value.split('').filter(char => regex.test(char)).join('')
        }
      }
      
      // 长度限制
      if (options.maxLength && value.length > options.maxLength) {
        value = value.substring(0, options.maxLength)
      }
      
      return value
    }
    
    // 更新提示
    function updateHint(value, options) {
      let hintText = ''
      
      if (options.maxLength) {
        const remaining = options.maxLength - value.length
        hintText = `还可以输入 ${remaining} 个字符`
        
        if (remaining < 0) {
          hintEl.style.color = '#dc3545'
        } else if (remaining < 10) {
          hintEl.style.color = '#ffc107'
        } else {
          hintEl.style.color = '#28a745'
        }
      }
      
      if (options.min !== null || options.max !== null) {
        const num = parseFloat(value)
        if (!isNaN(num)) {
          if (options.min !== null && num < options.min) {
            hintText = `最小值: ${options.min}`
            hintEl.style.color = '#dc3545'
          } else if (options.max !== null && num > options.max) {
            hintText = `最大值: ${options.max}`
            hintEl.style.color = '#dc3545'
          }
        }
      }
      
      if (hintText) {
        hintEl.textContent = hintText
        hintEl.style.display = 'block'
      } else {
        hintEl.style.display = 'none'
      }
    }
    
    // 事件监听
    el.addEventListener('input', handleInput)
    el.addEventListener('paste', handlePaste)
    
    // 初始化提示
    updateHint(el.value, options)
    
    // 存储引用
    el._inputLimiter = {
      handleInput,
      handlePaste,
      options
    }
  },
  
  unbind(el) {
    if (el._inputLimiter) {
      el.removeEventListener('input', el._inputLimiter.handleInput)
      el.removeEventListener('paste', el._inputLimiter.handlePaste)
      
      // 移除提示元素
      const hintEl = el.nextElementSibling
      if (hintEl && hintEl.className === 'input-hint') {
        hintEl.parentNode.removeChild(hintEl)
      }
      
      delete el._inputLimiter
    }
  }
})
vue 复制代码
<!-- 表单验证示例 -->
<template>
  <div class="form-validation-demo">
    <h2>表单验证与输入限制演示</h2>
    
    <form @submit.prevent="handleSubmit" class="validation-form">
      <!-- 1. 基本验证 -->
      <div class="form-section">
        <h3>基本验证</h3>
        
        <div class="form-group">
          <label>必填字段:</label>
          <input 
            v-model="form.requiredField"
            v-validate="'required'"
            placeholder="请输入内容"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>邮箱验证:</label>
          <input 
            v-model="form.email"
            v-validate="'required|email'"
            type="email"
            placeholder="请输入邮箱"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>手机号验证:</label>
          <input 
            v-model="form.phone"
            v-validate="'required|phone'"
            placeholder="请输入手机号"
            class="form-input"
          />
        </div>
      </div>
      
      <!-- 2. 长度验证 -->
      <div class="form-section">
        <h3>长度验证</h3>
        
        <div class="form-group">
          <label>用户名(6-20位):</label>
          <input 
            v-model="form.username"
            v-validate="['required', { name: 'minLength', params: [6] }, { name: 'maxLength', params: [20] }]"
            placeholder="6-20个字符"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>密码(至少8位):</label>
          <input 
            v-model="form.password"
            v-validate="'required|minLength:8'"
            type="password"
            placeholder="至少8个字符"
            class="form-input"
          />
        </div>
      </div>
      
      <!-- 3. 自定义验证规则 -->
      <div class="form-section">
        <h3>自定义验证</h3>
        
        <div class="form-group">
          <label>自定义正则(只能数字字母):</label>
          <input 
            v-model="form.customField"
            v-validate="{ pattern: '^[a-zA-Z0-9]+$' }"
            placeholder="只能输入数字和字母"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>同时使用多个规则:</label>
          <input 
            v-model="form.multiRule"
            v-validate="['required', 'email', { name: 'minLength', params: [10] }]"
            placeholder="邮箱且长度≥10"
            class="form-input"
          />
        </div>
      </div>
      
      <!-- 4. 输入限制 -->
      <div class="form-section">
        <h3>输入限制</h3>
        
        <div class="form-group">
          <label>只能输入数字:</label>
          <input 
            v-model="form.numberOnly"
            v-input-limit="{ type: 'number' }"
            placeholder="只能输入数字"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>限制最大长度(10字符):</label>
          <input 
            v-model="form.maxLength"
            v-input-limit="{ type: 'text', maxLength: 10 }"
            placeholder="最多10个字符"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>小数限制(2位小数):</label>
          <input 
            v-model="form.decimal"
            v-input-limit="{ type: 'decimal', decimalPlaces: 2 }"
            placeholder="最多2位小数"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>范围限制(0-100):</label>
          <input 
            v-model="form.range"
            v-input-limit="{ type: 'number', min: 0, max: 100 }"
            placeholder="0-100之间的数字"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>不允许空格:</label>
          <input 
            v-model="form.noSpaces"
            v-input-limit="{ type: 'text', allowSpace: false }"
            placeholder="不能有空格"
            class="form-input"
          />
        </div>
        
        <div class="form-group">
          <label>不允许特殊字符:</label>
          <input 
            v-model="form.noSpecial"
            v-input-limit="{ type: 'text', allowSpecialChars: false }"
            placeholder="不能有特殊字符"
            class="form-input"
          />
        </div>
      </div>
      
      <!-- 5. 实时验证反馈 -->
      <div class="form-section">
        <h3>实时验证反馈</h3>
        
        <div class="form-group">
          <label>密码强度验证:</label>
          <input 
            v-model="form.passwordStrength"
            v-validate="'required|minLength:8'"
            @validate="handlePasswordValidate"
            type="password"
            placeholder="输入密码"
            class="form-input"
          />
          <div class="password-strength">
            <div class="strength-bar" :style="{ width: passwordStrengthPercentage + '%' }"></div>
            <span class="strength-text">{{ passwordStrengthText }}</span>
          </div>
        </div>
      </div>
      
      <!-- 6. 表单级验证 -->
      <div class="form-section">
        <h3>表单级验证</h3>
        
        <div class="form-group">
          <label>确认密码:</label>
          <input 
            v-model="form.confirmPassword"
            v-validate="'required'"
            @input="validatePasswordMatch"
            type="password"
            placeholder="确认密码"
            class="form-input"
            :class="{ 'has-error': !passwordMatch }"
          />
          <div v-if="!passwordMatch" class="validation-error">
            两次输入的密码不一致
          </div>
        </div>
      </div>
      
      <!-- 提交按钮 -->
      <div class="form-actions">
        <button 
          type="submit" 
          :disabled="!isFormValid"
          class="submit-btn"
        >
          {{ isSubmitting ? '提交中...' : '提交表单' }}
        </button>
        
        <button 
          type="button" 
          @click="resetForm"
          class="reset-btn"
        >
          重置表单
        </button>
        
        <button 
          type="button" 
          @click="validateAll"
          class="validate-btn"
        >
          手动验证
        </button>
      </div>
      
      <!-- 验证结果 -->
      <div v-if="validationResults.length" class="validation-results">
        <h4>验证结果:</h4>
        <ul>
          <li 
            v-for="(result, index) in validationResults" 
            :key="index"
            :class="{ 'valid': result.valid, 'invalid': !result.valid }"
          >
            {{ result.field }}: {{ result.message }}
          </li>
        </ul>
      </div>
    </form>
    
    <!-- 表单数据预览 -->
    <div class="form-preview">
      <h3>表单数据预览</h3>
      <pre>{{ form }}</pre>
    </div>
  </div>
</template>

<script>
import validateDirective from '@/directives/validate'
import inputLimitDirective from '@/directives/input-limit'

export default {
  name: 'FormValidationDemo',
  
  directives: {
    validate: validateDirective,
    'input-limit': inputLimitDirective
  },
  
  data() {
    return {
      form: {
        requiredField: '',
        email: '',
        phone: '',
        username: '',
        password: '',
        customField: '',
        multiRule: '',
        numberOnly: '',
        maxLength: '',
        decimal: '',
        range: '',
        noSpaces: '',
        noSpecial: '',
        passwordStrength: '',
        confirmPassword: ''
      },
      passwordMatch: true,
      passwordStrengthPercentage: 0,
      passwordStrengthText: '无',
      isSubmitting: false,
      validationResults: []
    }
  },
  
  computed: {
    isFormValid() {
      // 在实际应用中,这里会有更复杂的验证逻辑
      return this.form.requiredField && 
             this.form.email && 
             this.form.password &&
             this.passwordMatch
    }
  },
  
  methods: {
    handleSubmit() {
      if (!this.isFormValid) {
        this.validateAll()
        return
      }
      
      this.isSubmitting = true
      
      // 模拟API请求
      setTimeout(() => {
        console.log('表单提交:', this.form)
        alert('表单提交成功!')
        this.isSubmitting = false
      }, 1000)
    },
    
    resetForm() {
      Object.keys(this.form).forEach(key => {
        this.form[key] = ''
      })
      this.passwordMatch = true
      this.passwordStrengthPercentage = 0
      this.passwordStrengthText = '无'
      this.validationResults = []
      
      // 清除所有验证状态
      document.querySelectorAll('.has-error').forEach(el => {
        el.classList.remove('has-error')
      })
      document.querySelectorAll('.validation-error').forEach(el => {
        el.style.display = 'none'
      })
    },
    
    validateAll() {
      this.validationResults = []
      
      // 手动触发所有输入框的验证
      const inputs = document.querySelectorAll('[v-validate]')
      inputs.forEach(input => {
        if (input.validate) {
          const result = input.validate()
          this.validationResults.push({
            field: input.placeholder || input.name,
            valid: result.valid,
            message: result.valid ? '验证通过' : result.message
          })
        }
      })
      
      // 检查密码匹配
      this.validatePasswordMatch()
    },
    
    handlePasswordValidate(event) {
      const password = event.target.value
      let strength = 0
      let text = '无'
      
      if (password.length >= 8) strength += 25
      if (/[A-Z]/.test(password)) strength += 25
      if (/[0-9]/.test(password)) strength += 25
      if (/[^A-Za-z0-9]/.test(password)) strength += 25
      
      this.passwordStrengthPercentage = strength
      
      if (strength >= 75) text = '强'
      else if (strength >= 50) text = '中'
      else if (strength >= 25) text = '弱'
      
      this.passwordStrengthText = text
    },
    
    validatePasswordMatch() {
      this.passwordMatch = this.form.password === this.form.confirmPassword
    }
  }
}
</script>

<style>
.form-validation-demo {
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.validation-form {
  background: white;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.form-section {
  margin-bottom: 30px;
  padding-bottom: 20px;
  border-bottom: 1px solid #eee;
}

.form-section h3 {
  margin-top: 0;
  color: #333;
  margin-bottom: 20px;
  padding-bottom: 10px;
  border-bottom: 2px solid #007bff;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
  color: #555;
}

.form-input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  transition: border-color 0.3s;
}

.form-input:focus {
  outline: none;
  border-color: #007bff;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.form-input.has-error {
  border-color: #dc3545;
}

.password-strength {
  margin-top: 8px;
  height: 4px;
  background: #e9ecef;
  border-radius: 2px;
  overflow: hidden;
  position: relative;
}

.strength-bar {
  height: 100%;
  background: #28a745;
  transition: width 0.3s;
}

.strength-text {
  position: absolute;
  right: 0;
  top: -20px;
  font-size: 12px;
  color: #6c757d;
}

.validation-error {
  color: #dc3545;
  font-size: 12px;
  margin-top: 4px;
}

.form-actions {
  display: flex;
  gap: 10px;
  margin-top: 30px;
  padding-top: 20px;
  border-top: 1px solid #eee;
}

.submit-btn,
.reset-btn,
.validate-btn {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.3s;
}

.submit-btn {
  background: #007bff;
  color: white;
  flex: 1;
}

.submit-btn:hover:not(:disabled) {
  background: #0056b3;
}

.submit-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.reset-btn {
  background: #6c757d;
  color: white;
}

.reset-btn:hover {
  background: #545b62;
}

.validate-btn {
  background: #ffc107;
  color: #212529;
}

.validate-btn:hover {
  background: #e0a800;
}

.validation-results {
  margin-top: 20px;
  padding: 15px;
  background: #f8f9fa;
  border-radius: 4px;
}

.validation-results ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.validation-results li {
  padding: 8px 12px;
  margin-bottom: 5px;
  border-radius: 4px;
}

.validation-results li.valid {
  background: #d4edda;
  color: #155724;
}

.validation-results li.invalid {
  background: #f8d7da;
  color: #721c24;
}

.form-preview {
  margin-top: 30px;
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
}

.form-preview pre {
  background: white;
  padding: 15px;
  border-radius: 4px;
  overflow-x: auto;
  font-family: 'Courier New', monospace;
  font-size: 14px;
}
</style>

四、高级应用场景

场景4:图片懒加载

javascript 复制代码
// directives/lazy-load.js
export default {
  inserted(el, binding) {
    const options = {
      root: null,
      rootMargin: '0px',
      threshold: 0.1,
      placeholder: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIGZpbGw9IiNGRkZGRkUiLz48cGF0aCBkPSJNMzAgNTBMMzAgMzBINzBWNzBIMzBWNTBaIiBmaWxsPSIjRkZGRkZGIi8+PC9zdmc+',
      error: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIGZpbGw9IiNGRkZGRkUiLz48cGF0aCBkPSJNMzAgMzBINzBWNzBIMzBWMzBaIiBmaWxsPSIjRkZGRkZGIi8+PHBhdGggZD0iTTMwIDMwTzcwIDcwTTcwIDMwTDMwIDcwIiBzdHJva2U9IiNEQzM1NDUiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+'
    }
    
    // 合并配置
    const config = typeof binding.value === 'string' 
      ? { src: binding.value }
      : { ...options, ...binding.value }
    
    // 设置占位符
    if (el.tagName === 'IMG') {
      el.src = config.placeholder
      el.setAttribute('data-src', config.src)
      el.classList.add('lazy-image')
    } else {
      el.style.backgroundImage = `url(${config.placeholder})`
      el.setAttribute('data-bg', config.src)
      el.classList.add('lazy-bg')
    }
    
    // 添加加载类
    el.classList.add('lazy-loading')
    
    // 创建Intersection Observer
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loadImage(el, config)
          observer.unobserve(el)
        }
      })
    }, {
      root: config.root,
      rootMargin: config.rootMargin,
      threshold: config.threshold
    })
    
    // 开始观察
    observer.observe(el)
    
    // 存储observer引用
    el._lazyLoadObserver = observer
  },
  
  unbind(el) {
    if (el._lazyLoadObserver) {
      el._lazyLoadObserver.unobserve(el)
      delete el._lazyLoadObserver
    }
  }
}

// 加载图片
function loadImage(el, config) {
  const img = new Image()
  
  img.onload = () => {
    if (el.tagName === 'IMG') {
      el.src = config.src
    } else {
      el.style.backgroundImage = `url(${config.src})`
    }
    
    el.classList.remove('lazy-loading')
    el.classList.add('lazy-loaded')
    
    // 触发自定义事件
    el.dispatchEvent(new CustomEvent('lazyload:loaded', {
      detail: { src: config.src }
    }))
  }
  
  img.onerror = () => {
    if (el.tagName === 'IMG') {
      el.src = config.error
    } else {
      el.style.backgroundImage = `url(${config.error})`
    }
    
    el.classList.remove('lazy-loading')
    el.classList.add('lazy-error')
    
    // 触发自定义事件
    el.dispatchEvent(new CustomEvent('lazyload:error', {
      detail: { src: config.src }
    }))
  }
  
  img.src = config.src
}

// 预加载指令
Vue.directive('preload', {
  inserted(el, binding) {
    const urls = Array.isArray(binding.value) ? binding.value : [binding.value]
    
    urls.forEach(url => {
      const link = document.createElement('link')
      link.rel = 'preload'
      link.as = getResourceType(url)
      link.href = url
      document.head.appendChild(link)
    })
  }
})

function getResourceType(url) {
  if (/\.(jpe?g|png|gif|webp|svg)$/i.test(url)) return 'image'
  if (/\.(woff2?|ttf|eot)$/i.test(url)) return 'font'
  if (/\.(css)$/i.test(url)) return 'style'
  if (/\.(js)$/i.test(url)) return 'script'
  return 'fetch'
}

场景5:复制到剪贴板

javascript 复制代码
// directives/copy.js
export default {
  bind(el, binding) {
    const { value, modifiers } = binding
    
    // 默认配置
    const config = {
      text: typeof value === 'string' ? value : value?.text,
      successMessage: value?.success || '复制成功!',
      errorMessage: value?.error || '复制失败',
      showToast: modifiers.toast !== false,
      autoClear: modifiers.autoClear !== false,
      timeout: value?.timeout || 2000
    }
    
    // 创建提示元素
    let toast = null
    if (config.showToast) {
      toast = document.createElement('div')
      Object.assign(toast.style, {
        position: 'fixed',
        top: '20px',
        right: '20px',
        background: '#333',
        color: 'white',
        padding: '10px 20px',
        borderRadius: '4px',
        zIndex: '9999',
        opacity: '0',
        transition: 'opacity 0.3s',
        pointerEvents: 'none'
      })
      document.body.appendChild(toast)
    }
    
    // 显示提示
    function showToast(message, isSuccess = true) {
      if (!toast) return
      
      toast.textContent = message
      toast.style.background = isSuccess ? '#28a745' : '#dc3545'
      toast.style.opacity = '1'
      
      setTimeout(() => {
        toast.style.opacity = '0'
      }, config.timeout)
    }
    
    // 复制函数
    async function copyToClipboard(text) {
      try {
        // 使用现代 Clipboard API
        if (navigator.clipboard && window.isSecureContext) {
          await navigator.clipboard.writeText(text)
          return true
        } else {
          // 降级方案
          const textarea = document.createElement('textarea')
          textarea.value = text
          textarea.style.position = 'fixed'
          textarea.style.opacity = '0'
          document.body.appendChild(textarea)
          
          textarea.select()
          textarea.setSelectionRange(0, textarea.value.length)
          
          const success = document.execCommand('copy')
          document.body.removeChild(textarea)
          
          return success
        }
      } catch (error) {
        console.error('复制失败:', error)
        return false
      }
    }
    
    // 处理点击
    async function handleClick() {
      let textToCopy = config.text
      
      // 动态获取文本
      if (typeof config.text === 'function') {
        textToCopy = config.text()
      } else if (modifiers.input) {
        // 从输入框复制
        const input = el.querySelector('input, textarea') || el
        textToCopy = input.value || input.textContent
      } else if (modifiers.selector) {
        // 从选择器指定的元素复制
        const target = document.querySelector(value.selector)
        textToCopy = target?.value || target?.textContent || ''
      }
      
      if (!textToCopy) {
        showToast('没有内容可复制', false)
        return
      }
      
      const success = await copyToClipboard(textToCopy)
      
      if (success) {
        showToast(config.successMessage, true)
        
        // 触发成功事件
        el.dispatchEvent(new CustomEvent('copy:success', {
          detail: { text: textToCopy }
        }))
        
        // 自动清除
        if (config.autoClear && modifiers.input) {
          const input = el.querySelector('input, textarea') || el
          input.value = ''
          input.dispatchEvent(new Event('input'))
        }
      } else {
        showToast(config.errorMessage, false)
        
        // 触发失败事件
        el.dispatchEvent(new CustomEvent('copy:error', {
          detail: { text: textToCopy }
        }))
      }
    }
    
    // 添加点击事件
    el.addEventListener('click', handleClick)
    
    // 设置光标样式
    el.style.cursor = 'pointer'
    
    // 添加提示
    if (modifiers.tooltip) {
      el.title = '点击复制'
    }
    
    // 存储引用
    el._copyHandler = handleClick
    el._copyToast = toast
  },
  
  update(el, binding) {
    // 更新绑定的值
    if (binding.value !== binding.oldValue && el._copyHandler) {
      // 可以在这里更新配置
    }
  },
  
  unbind(el) {
    // 清理
    if (el._copyHandler) {
      el.removeEventListener('click', el._copyHandler)
      delete el._copyHandler
    }
    
    if (el._copyToast && el._copyToast.parentNode) {
      el._copyToast.parentNode.removeChild(el._copyToast)
      delete el._copyToast
    }
  }
}

五、最佳实践总结

1. 指令命名规范

javascript 复制代码
// 好的命名示例
Vue.directive('focus', {...})           // 动词开头
Vue.directive('lazy-load', {...})       // 使用连字符
Vue.directive('click-outside', {...})   // 描述性名称
Vue.directive('permission', {...})      // 名词表示功能

// 避免的命名
Vue.directive('doSomething', {...})     // 驼峰式
Vue.directive('myDirective', {...})     // 太通用
Vue.directive('util', {...})            // 不明确

2. 性能优化建议

javascript 复制代码
// 1. 使用防抖/节流
Vue.directive('scroll', {
  bind(el, binding) {
    const handler = _.throttle(binding.value, 100)
    window.addEventListener('scroll', handler)
    el._scrollHandler = handler
  },
  unbind(el) {
    window.removeEventListener('scroll', el._scrollHandler)
  }
})

// 2. 合理使用 Intersection Observer
Vue.directive('lazy', {
  inserted(el, binding) {
    const observer = new IntersectionObserver((entries) => {
      // 只处理进入视口的元素
    }, { threshold: 0.1 })
    observer.observe(el)
    el._observer = observer
  }
})

// 3. 事件委托
Vue.directive('click-delegate', {
  bind(el, binding) {
    // 使用事件委托减少事件监听器数量
    el.addEventListener('click', (e) => {
      if (e.target.matches(binding.arg)) {
        binding.value(e)
      }
    })
  }
})

3. 可重用性设计

javascript 复制代码
// 创建可配置的指令工厂
function createDirectiveFactory(defaultOptions) {
  return {
    bind(el, binding) {
      const options = { ...defaultOptions, ...binding.value }
      // 指令逻辑
    },
    // 其他钩子...
  }
}

// 使用工厂创建指令
Vue.directive('tooltip', createDirectiveFactory({
  position: 'top',
  delay: 100,
  theme: 'light'
}))

4. 测试策略

javascript 复制代码
// 指令单元测试示例
import { shallowMount } from '@vue/test-utils'
import { directive } from './directive'

describe('v-focus directive', () => {
  it('should focus the element when inserted', () => {
    const focusMock = jest.fn()
    const el = { focus: focusMock }
    
    directive.bind(el)
    
    expect(focusMock).toHaveBeenCalled()
  })
})

六、Vue 3 中的自定义指令

javascript 复制代码
// Vue 3 自定义指令
const app = createApp(App)

// 全局指令
app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

// 带生命周期的指令
app.directive('tooltip', {
  beforeMount(el, binding) {
    // 相当于 Vue 2 的 bind
  },
  mounted(el, binding) {
    // 相当于 Vue 2 的 inserted
  },
  beforeUpdate(el, binding) {
    // 新钩子:组件更新前
  },
  updated(el, binding) {
    // 相当于 Vue 2 的 componentUpdated
  },
  beforeUnmount(el, binding) {
    // 相当于 Vue 2 的 unbind
  },
  unmounted(el, binding) {
    // 新钩子:组件卸载后
  }
})

// 组合式 API 中使用
import { directive } from 'vue'

const vMyDirective = directive({
  mounted(el, binding) {
    // 指令逻辑
  }
})

总结:自定义指令是 Vue 强大的扩展机制,适用于:

  1. DOM 操作和交互
  2. 权限控制和条件渲染
  3. 表单验证和输入限制
  4. 性能优化(懒加载、防抖)
  5. 集成第三方库

正确使用自定义指令可以大大提高代码的复用性和可维护性,但也要避免过度使用,优先考虑组件和组合式函数。

相关推荐
北辰alk6 小时前
Vue 动态路由完全指南:定义与参数获取详解
vue.js
北辰alk6 小时前
Vue Router 完全指南:作用与组件详解
vue.js
北辰alk6 小时前
Vue 中使用 this 的完整指南与注意事项
vue.js
xkxnq6 小时前
第二阶段:Vue 组件化开发(第 16天)
前端·javascript·vue.js
北辰alk6 小时前
Vue 插槽(Slot)完全指南:组件内容分发的艺术
vue.js
北辰alk7 小时前
Vue 组件中访问根实例的完整指南
vue.js
北辰alk7 小时前
Vue Router 中获取路由参数的全面指南
vue.js
北辰alk7 小时前
Vue 的 v-cloak 和 v-pre 指令详解
vue.js
期待のcode7 小时前
前后端分离项目 Springboot+vue 在云服务器上的部署
服务器·vue.js·spring boot