前言
Vue
自定义指令允许直接操作 DOM
元素,能够在模板中轻松应用特定的交互和效果,强大且灵活,使得我们能够更加高效地封装和应用指令。
接下来探索Vue3自定义指令的用法和技巧,如何创建、封装自定义指令,使用 Hooks
封装常用的自定义指令,以及最佳实践。
自定义指令的基本用法
创建自定义指令
- 全局自定义指令
在 main.js
入口调用应用 API app.directive
注册
// main.js
const app = createApp({})
// 使 v-focus 在所有组件中都可用
app.directive('focus', {
/* ... */
})
在项目中,如果一个一个注册全局指令比较麻烦,好的做法是统一将全局指令放在 directives
目录下进行批量注册,将全局指令注册作为插件安装
批量注册指令,新建 directives/index.js
文件
import permission from './permission'
import clickOutside from './click-outside'
// 自定义指令
const directives = {
permission,
clickOutside,
...
}
export default {
install(app) {
Object.keys(directives).forEach((key) => {
app.directive(key, directives[key])
})
},
}
在 main.js
引入并调用
import directives from './directives/index.js'
const app = createApp({})
app.use(directives)
- 局部注册:在
directives
选项注册
export default {
setup() {
/*...*/
},
directives: {
// 在模板中启用 v-focus
focus: {
/* ... */
}
}
}
- 局部注册:在 script setup 注册
在 <script setup>
定义组件内的指令,以 v
开头的驼峰式命名的变量都可以被用作一个自定义指令,然后在模板中使用,如鼠标聚焦指令
<script setup>
// 在模板中启用 v-focus
const vFocus = {
mounted: (el) => el.focus()
}
</script>
<template>
<input v-focus />
</template>
指令钩子函数和参数
指令钩子
自定义指令的生命周期钩子函数提供了丰富的功能和灵活性,使得我们可以在不同的时机对 DOM
元素进行操作和交互
const myDirective = {
// 在绑定元素的 attribute 前,或事件监听器应用前调用
created(el, binding, vnode, prevVnode) {},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
mounted(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件更新前调用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件,及他自己的所有子节点都更新后调用
updated(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载前调用
beforeUnmount(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载后调用
unmounted(el, binding, vnode, prevVnode) {}
}
-
created:指令进行初始化逻辑,还不能操作
DOM
-
beforeMount:绑定的元素挂载到
DOM
之前调用,此时元素尚未插入到页面中。比如获取元素的初始位置、样式等 -
mounted:绑定的元素挂载到
DOM
之后调用,此时元素已经插入到页面中。比如初始化第三方插件、注册事件监听器等。 -
beforeUpdate:组件的
VNode
更新之前调用,但是子组件的VNode
尚未更新。比如获取更新前的DOM
状态,用于后续比较和优化。 -
updated:
VNode
更新之后调用,子组件的VNode
已经更新。比如更新DOM
状态、重新计算样式等。 -
beforeUnmount:组件的
VNode
销毁之前调用。比如取消事件监听器、释放资源等。 -
unmounted:
VNode
销毁后调用。执行一些收尾操作,比如清除定时器、释放内存等。
钩子的参数
自定义指令的钩子函数在调用时会传入不同的参数,这些参数提供了与指令相关的信息,帮助我们更好地理解和操作指令
mounted(el, binding, vnode, preVnode) {
el.focus()
}
钩子函数接收 4 个参数:
-
el
:指令绑定到的DOM
元素,可以用于直接操作当前元素,默认传入钩子的就是el
参数,例如我们开始实现的focus
指令,就是直接操作的元素DOM
-
binding
:这是一个对象,包含以下属性:value
:传递给指令的值。例如在v-my-directive="1 + 1"
中,值是2
oldValue
:更新前的旧值,仅在beforeUpdate
和updated
中可用。无论值是否更改,它都可用arg
:传递给指令的参数 (如果有的话)。例如在v-my-directive:foo
中,参数是"foo"
modifiers
:一个包含修饰符的对象 (如果有的话)。例如在v-my-directive.foo.bar
中,修饰符对象是{ foo: true, bar: true }
instance
:使用该指令的组件实例,注意不是DOM
dir
:指令的定义对象
-
vnode
:代表绑定元素的底层VNode
-
preVnode
:代表之前的渲染中指令所绑定元素的VNode
。仅在beforeUpdate
和updated
钩子中可用
例如
<script setup>
import { ref } from 'vue'
const vFocus = {
mounted (el, bind, vnode, preVode) {
console.log(el, bind, vnode, preVode)
}
}
const msg = ref('Hello World!')
</script>
<template>
<input v-model="msg" v-focus:foo.bar="msg" >
</template>
binding
参数对象
vnode
参数对象
封装常用自定义指令
权限指令
封装一个权限指令,在模板中根据用户权限来控制元素的显示或隐藏
// permission.js
import { ref, watchEffect } from 'vue';
const hasPermission = (permission) => {
// 在实际项目中,根据后端返回的用户权限进行判断
const userPermissions = ['view', 'edit'];
return userPermissions.includes(permission);
};
export default {
beforeMount(el, binding) {
const { value } = binding;
const visible = ref(false);
// 监听权限变化,当权限发生改变时重新判断是否显示元素
watchEffect(() => {
visible.value = hasPermission(value);
});
// 根据 visible 的值来显示或隐藏元素
el.style.display = visible.value ? 'block' : 'none';
}
}
定义了一个 hasPermission
函数来模拟权限判断逻辑,根据用户权限来返回 true
或 false
。
在模板中使用 v-permission
指令来根据用户权限控制元素的显示或隐藏
<template>
<div>
<button v-permission="'view'">View Button</button>
<button v-permission="'edit'">Edit Button</button>
<button v-permission="'delete'">Delete Button</button>
</div>
</template>
v-click-outside指令
常见下拉框组件,点击下拉框外部其他地方,可以收起下拉框。实现这种效果可以在目标元素上使用 v-click-outside
指令,判断如果点击的元素和指令绑定在同一个元素,则不执行操作,否则执行回调函数隐藏下拉框
// v-click-outside
export default {
mounted(el, binding) {
el.handler = function(e) {
// 点击范围在绑定的元素范围内,不执行指令操作
if (el.contains(e.target)) return;
if (typeof binding.value === 'function') {
// 绑定给指令的如果是一个函数,那么将回调并指定this
binding.value.apply(this, arguments)
}
}
// 监听全局的点击事件
document.addEventListener('click', el.handler)
},
// 解除事件绑定
beforeUnmount(el) {
document.removeEventListener('click', el.handler)
}
}
关键逻辑判断在 el.contains(e.target)
防抖函数指令
在模板中使用防抖功能,可以用于减少频繁触发的事件的执行次数,比如在输入框中的实时搜索场景
- 封装防抖指令文件
// debounce.js
import { ref, watchEffect } from 'vue';
export default {
beforeMount(el, binding) {
const { value } = binding;
// 需要回调函数以及监听事件类型
if (typeof value.fn !== 'function' || !value.event) return;
el.timer = null
el.handler = function() {
if (el.timer) {
clearTimeout(el.timer);
el.timer = null;
};
el.timer = setTimeout(() => {
binding.value.fn.apply(this, arguments)
el.timer = null;
}, value.delay || 300);
}
el.addEventListener(value.event, el.handler)
},
beforeUnmount(el, binding) {
// 在组件卸载前清除定时器,防止内存泄漏
if (el.timer) {
clearTimeout(el.timer);
el.timer = null;
}
el.removeEventListener(binding.value.event, el.handler)
}
}
- 模版上使用指令
<template>
<div>
<input v-model="inputValue" v-debounce="handleInput" />
</div>
</template>
<script setup>
import { ref } from 'vue';
const inputValue = ref('');
const handleInput = {
event: 'input',
fn (event) {
console.log('Debounced Input:', event.target.value);
},
delay: 500
}
</script>
节流指令
防抖是限制执行次数,多次密集的触发只会执行最后一次,无规律,更关注结果;一般用于服务端校验,如防止多次点击、搜索输入、表单校验
节流是限制执行频率,有节奏的执行,有规律, 更关注过程。一般用于 DOM
操作频次限制,优化性能,如拖拽、滚动、resize 等操作
// throtte.js
export default {
mounted(el, binding) {
// 至少需要回调函数以及监听事件类型
if (typeof binding.value.fn !== 'function' || !binding.value.event) return;
let delay = 200;
el.timer = null;
el.handler = function() {
if (el.timer) return;
el.timer = setTimeout(() => {
binding.value.fn.apply(this, arguments)
el.timer = null;
}, binding.value.delay || delay);
}
el.addEventListener(binding.value.event, el.handler)
},
// 元素卸载前也记得清理定时器并且移除监听事件
beforeUnmount(el, binding) {
if (el.timer) {
clearTimeout(el.timer);
el.timer = null;
}
el.removeEventListener(binding.value.event, el.handler)
}
}
resize 指令
resize
在模板中使用该指令来监听元素大小的变化,执行相应的业务逻辑代码
// resize.js
import { ref, onMounted, onUnmounted } from 'vue';
export default {
mounted(el, binding) {
const { value: callback } = binding;
const width = ref(0);
const height = ref(0);
function handleResize() {
width.value = el.clientWidth;
height.value = el.clientHeight;
callback({ width: width.value, height: height.value });
}
// 监听窗口大小变化,调用 handleResize
window.addEventListener('resize', handleResize);
// 初始时调用一次 handleResize
handleResize();
// 在组件卸载前移除事件监听
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
}
}
滚动加载指令
封装一个滚动加载监听指令,在模板中使用该指令来实现滚动加载的功能
// scrollLoad.js
import { onMounted, onUnmounted } from 'vue';
export default {
mounted(el, binding) {
const { fn, distance = 100 } = binding.value;
function handleScroll() {
const scrollHeight = el.scrollHeight;
const offsetHeight = el.offsetHeight;
const scrollTop = el.scrollTop;
if (scrollHeight - offsetHeight - scrollTop <= distance) {
fn();
}
}
// 监听滚动事件,调用 handleScroll
el.addEventListener('scroll', handleScroll);
// 在组件卸载前移除事件监听
onUnmounted(() => {
el.removeEventListener('scroll', handleScroll);
});
}
}
在模板中使用 v-scroll-load
指令来监听滚动加载事件,并执行相应的回调函数:
<template>
<div v-scroll-load="loadMore" style="overflow: auto; height: 200px; border: 1px solid #ccc;">
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
const items = ref([]);
function loadMore() {
// 模拟加载更多数据
for (let i = 0; i < 10; i++) {
items.value.push('Item ' + (items.value.length + 1));
}
}
</script>
图片懒加载指令
在图片元素上使用指令,实现图片的懒加载
// LoadIMage
import { onMounted, onUnmounted } from 'vue';
export default {
mounted(el, binding) {
// 使用 Intersection Observer 实现图片懒加载
const io = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
el.src = binding.value;
observer.unobserve(el);
}
});
});
io.observe(el);
}
}
自定义图像占位指令
在图像加载时使用指令,显示占位图像,直到真实图像加载完成。
<template>
<img v-placeholder-src="imageUrl" placeholder="/placeholder.png" />
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
directives: {
'placeholder-src': {
mounted(el, binding) {
const placeholder = binding.arg;
el.setAttribute('src', placeholder);
const img = new Image();
img.src = binding.value;
img.onload = () => {
el.setAttribute('src', binding.value);
};
}
}
}
})
</script>
自定义提示框指令
在元素上使用指令,显示自定义的提示框
<template>
<button v-tooltip="tooltipText">Hover Me</button>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
directives: {
tooltip: {
mounted(el, binding) {
const tooltipText = binding.value;
el.addEventListener('mouseenter', () => {
showTooltip(el, tooltipText);
});
el.addEventListener('mouseleave', hideTooltip);
},
beforeUnmount(el) {
el.removeEventListener('mouseenter', showTooltip);
el.removeEventListener('mouseleave', hideTooltip);
}
}
}
})
function showTooltip(el, text) {
// 显示自定义提示框逻辑
}
function hideTooltip() {
// 隐藏自定义提示框逻辑
}
</script>
复制指令
实现一键复制文本内容,用于鼠标右键粘贴
// copy.js
export default {
mounted (el, { value }) {
el.$value = value
el.handler = () => {
if (!el.$value) {
// 值为空的时候,给出提示。可根据项目UI仔细设计
console.log('无复制内容')
return
}
// 动态创建 textarea 标签
const textarea = document.createElement('textarea')
// 将该 textarea 设为 readonly 防止 iOS 下自动唤起键盘,同时将 textarea 移出可视区域
textarea.readOnly = 'readonly'
textarea.style.position = 'absolute'
textarea.style.left = '-9999px'
// 将要 copy 的值赋给 textarea 标签的 value 属性
textarea.value = el.$value
// 将 textarea 插入到 body 中
document.body.appendChild(textarea)
// 选中值并复制
textarea.select()
const result = document.execCommand('Copy')
if (result) {
console.log('复制成功') // 可根据项目UI仔细设计
}
document.body.removeChild(textarea)
}
// 绑定点击事件,就是所谓的一键 copy 啦
el.addEventListener('click', el.handler)
},
// 当传进来的值更新的时候触发
updated(el, { value }) {
el.$value = value
},
// 指令与元素解绑的时候,移除事件绑定
beforeUnmounted(el) {
el.removeEventListener('click', el.handler)
},
}
-
动态创建
textarea
标签,并设置readOnly
属性及移出可视区域 -
将要复制的值赋给
textarea
标签的value
属性,并插入到body
-
选中值
textarea
并复制,将body
中插入的textarea
移除 -
在第一次调用时绑定事件,在解绑时移除事件
表情指令
开发中遇到的表单输入,往往会有对输入内容的限制,比如不能输入表情和特殊字符,只能输入数字或字母等。
根据正则表达式,设计自定义处理表单输入规则的指令,下面以禁止输入表情和特殊字符为例
let findEle = (parent, type) => {
return parent.tagName.toLowerCase() === type ? parent : parent.querySelector(type)
}
const trigger = (el, type) => {
const e = document.createEvent('HTMLEvents')
e.initEvent(type, true, true)
el.dispatchEvent(e)
}
export default {
mounted: function (el, binding, vnode) {
// 正则规则可根据需求自定义
const regRule = /[^u4E00-u9FA5|d|a-zA-Z|rns,.?!,。?!…—&$=()-+/*{}[]]|s/g
el.handle = function () {
const val = binding.value
el.target.value = val.replace(regRule, '')
}
el.addEventListener('input', el.handle)
},
unmounted: function (el) {
el.removeEventListener('input', el.handle)
},
}
拖拽指令
在元素上使用指令,实现拖拽功能。
-
鼠标按下(onmousedown)时记录目标元素当前的
left
和top
值。 -
鼠标移动(onmousemove)时计算每次移动的横向距离和纵向距离的变化值,并改变元素的
left
和top
值 -
鼠标松开(onmouseup)时完成一次拖拽
<template>
<div v-draggable>
Drag Me
</div>
</template>
<script>
export default {
directives: {
draggable: {
mounted(el) {
let offsetX = 0;
let offsetY = 0;
el.addEventListener('mousedown', (e) => {
offsetX = e.clientX - el.getBoundingClientRect().left;
offsetY = e.clientY - el.getBoundingClientRect().top;
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', stop);
});
function move(e) {
el.style.left = `${e.clientX - offsetX}px`;
el.style.top = `${e.clientY - offsetY}px`;
}
function stop() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', stop);
}
}
}
}
}
</script>
水印指令
给页面添加背景水印。
- 使用 canvas 特性生成 base64 格式的图片文件,设置其字体大小,颜色等。
- 将其设置为背景图片,从而实现页面或组件水印效果
const addWaterMarker = (str, parentNode, font, textColor) => {
// 水印文字,父元素,字体,文字颜色
let can: HTMLCanvasElement = document.createElement("canvas");
parentNode.appendChild(can);
can.width = 200;
can.height = 150;
can.style.display = "none";
let cans = can.getContext("2d");
cans.rotate((-20 * Math.PI) / 180);
cans.font = font || "16px Microsoft JhengHei";
cans.fillStyle = textColor || "rgba(180, 180, 180, 0.3)";
cans.textAlign = "left";
cans.textBaseline = "Middle";
cans.fillText(str, can.width / 10, can.height / 2);
parentNode.style.backgroundImage = "url(" + can.toDataURL("image/png") + ")";
};
const waterMarker = {
mounted(el, binding) {
addWaterMarker(binding.value.text, el, binding.value.font, binding.value.textColor);
}
};
export default waterMarker;
使用:设置水印文案,颜色,字体大小
<template>
<div
class="content-box"
v-waterMarker="{
text: 'Watermark Direct',
textColor: 'rgba(180, 180, 180, 0.6)'
}"
>
<span class="text">水印指令</span>
</div>
</template>
<style scoped lang="scss">
.content-box {
width: 100%;
height: 600px;
}
</style>
自定义指令的性能优化
避免在指令中执行昂贵的操作
-
避免频繁的
DOM
操作,可以考虑使用防抖或节流等技术来限制操作频率,以减轻性能负担 -
避免计算密集型的操作,比如大量的数据处理或复杂的计算,可以使用异步操作或缓存计算结果
-
避免在指令中进行网络请求或其他异步操作,尽量将异步操作移到组件或异步组件中处理
使用合适的钩子函数进行性能优化
Vue3提供了多个钩子函数来处理指令的生命周期,合理使用这些钩子函数可以优化指令的性能。
-
如果指令只需要在元素挂载和卸载时执行一次操作,可以将逻辑放在
beforeMount
和unmounted
钩子函数中,避免重复执行。 -
如果指令的逻辑依赖于响应式数据的变化,可以使用
beforeUpdate
和updated
钩子函数来控制逻辑的更新时机,避免不必要的计算。
异步更新和懒加载指令的策略
异步更新:如果指令的逻辑涉及到异步操作,可以使用 nextTick
函数或 Promise
来延迟执行,以避免阻塞主线程。
懒加载:对于不是在页面初始加载时必须使用的指令,可以考虑使用懒加载的策略。即在需要使用指令的组件中动态导入指令,而不是在全局注册指令。这样可以减少页面初始加载时的代码量,提升页面加载性能。