Vue 自定义指令完全指南:定义与应用场景详解
自定义指令是 Vue 中一个非常强大但常常被忽视的功能,它允许你直接操作 DOM 元素,扩展 Vue 的模板功能。
一、自定义指令基础
1. 什么是自定义指令?
// 官方指令示例
<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. 定义方式
全局自定义指令
// 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
}
}
})
局部自定义指令
<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. 指令钩子函数参数详解
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 点击外部关闭
// 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)
<!-- 使用示例 -->
<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 拖拽功能
// 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
}
}
}
<!-- 使用示例 -->
<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:权限控制与条件渲染
// 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)
<!-- 权限控制示例 -->
<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:表单验证与输入限制
// 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
}
}
})
<!-- 表单验证示例 -->
<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:图片懒加载
// 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:复制到剪贴板
// 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. 指令命名规范
// 好的命名示例
Vue.directive('focus', {...}) // 动词开头
Vue.directive('lazy-load', {...}) // 使用连字符
Vue.directive('click-outside', {...}) // 描述性名称
Vue.directive('permission', {...}) // 名词表示功能
// 避免的命名
Vue.directive('doSomething', {...}) // 驼峰式
Vue.directive('myDirective', {...}) // 太通用
Vue.directive('util', {...}) // 不明确
2. 性能优化建议
// 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. 可重用性设计
// 创建可配置的指令工厂
function createDirectiveFactory(defaultOptions) {
return {
bind(el, binding) {
const options = { ...defaultOptions, ...binding.value }
// 指令逻辑
},
// 其他钩子...
}
}
// 使用工厂创建指令
Vue.directive('tooltip', createDirectiveFactory({
position: 'top',
delay: 100,
theme: 'light'
}))
4. 测试策略
// 指令单元测试示例
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 中的自定义指令
// 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 强大的扩展机制,适用于:
- DOM 操作和交互
- 权限控制和条件渲染
- 表单验证和输入限制
- 性能优化(懒加载、防抖)
- 集成第三方库
正确使用自定义指令可以大大提高代码的复用性和可维护性,但也要避免过度使用,优先考虑组件和组合式函数。