前言
祝各位码农新年快乐 头发越来越多(狗头)
兔年的第一篇博客是以业务为主,主要介绍Vue自定义指令的各种特性
以及博主如何使用这个特性在业务中一分钟实现一个需求的(节省时间用来划水)
以下内容是博主半年前实现,可能有些地方会遗漏,所以最后会放出源码,欢迎同志们指正
一、博主用Vue自定义指令在业务中实现了什么需求?
1. 首屏Loading切换指令(用来占位,支持调节Loading样式)
2. 复制指令
3. 文件流形式下载后端数据(转blob下载)
4. 防抖(支持设置延迟时间)
5. 按钮或菜单权限控制(支持参数)
6. 界面添加水印指令(支持文字以及部分样式调节)
二、Vue指令详解(了解代码,可以直接看第三步)
自带指令
截止到Vue3.2
一共有16
个自带指令,用起来让人直呼过瘾,在封装自定义指令之前,让我们来概览一下所有指令并了解其特性
v-text
(用于更新dom元素的 textContent)v-html
(用于更新dom元素的 innerHTML)v-show
(切换元素的display值,用于控制元素的展示和隐藏,值得注意的是这个在dom元素里仍是存在的,不支持<template>
)v-if
(根据条件来渲染dom元素,销毁/重建,支持<template>
)v-else
(v-if的对立条件,不满足if则走else语句)v-else-if
(如果存在此条件,则判断条件先走if和elseif,最后走else)v-for
(可以根据数据源渲染多个dom元素,业务中比较常见)v-on
(用于给元素绑定事件,语法糖为@
,可以使用语法修饰符,这里不再列举)v-bind
(绑定数据和元素属性,语法糖为为: 或.(在使用 .prop 修饰符时),同样有三个语法修饰符)v-model
(Vue对指定标签类型进行双向数据绑定(input/select/textarea))v-slot
(使用具名插槽或需要接收prop的插槽,默认为default)v-pre
(直接跳过这个元素以及子元素的编译阶段,常常用于博客论坛类网址)v-cloak
(用于解决页面上插值问题,例如{{}}刚开始会在页面上显示出来)v-once
(只进行一次渲染,后续刷新不会重新渲染)v-memo
(3.2+新出语法,一般用于树结构,用来缓存dom以及数据,如果数据没变,dom及子节点将不会重新渲染)v-is
(3.1.0被废弃,改为:is,常用于动态组件,符合条件就进行渲染)
其中v-memo是3.2新增的,v-is在3.1.0中废弃
本文着重介绍自定义指令,以上可以给同志们温故知新
自定义指令
局部自定义指令注册
directives: {
focus: {
// 指令的定义
inserted: function (el) {
el.focus()
}
}
}
全局自定义指令注册
// 注册一个全局自定义指令 `v-focus`
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus()
}
})
两种不同注册方式使用方法是一样的
<input v-focus>
指令和组件是一样的,有它自己的生命周期
同时在Vue
里面会在生命周期里给我们回调一些指定的参数用于我们快捷实现需求,如下:
指令的5个生命周期:
bind 只调用一次,指令第一次绑定到元素时候调用,用这个钩子可以定义一个绑定时执行一次的初始化动作。
inserted:被绑定的元素插入父节点的时候调用(父节点存在即可调用,不必存在document中)
update: 被绑定于元素所在模板更新时调用,而且无论绑定值是否有变化,通过比较更新前后的绑定值,忽略不必要的模板更新
componentUpdate :被绑定的元素所在模板完成一次更新更新周期的时候调用
unbind: 只调用一次,指令与元素解绑的时候调用
指令的回调参数:
el:指令所绑定的元素,可以用来直接操作 DOM。
binding:一个对象,包含以下 property:
name:指令名,不包括 v- 前缀。
value:指令的绑定值,例如:v-my-directive="1 + 1" 中,绑定值为 2。
oldValue:指令绑定的前一个值,仅在 update 和 componentUpdated 钩子中可用。无论值是否改变都可用。
expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式为 "1 + 1"。
arg:传给指令的参数,可选。例如 v-my-directive:foo 中,参数为 "foo"。
modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }。
vnode:Vue 编译生成的虚拟节点。移步 VNode API 来了解更多详情。
oldVnode:上一个虚拟节点,仅在 update 和 componentUpdated 钩子中可用。
三、如何实现标题中六个自定义指令(代码层面)
1.Loading指令
分析需求:在不考虑样式的情况下,这个功能在我看来其实就是
在恰当的时机进行dom
元素插入(Loading图),而后在合适的时机销毁对应的dom
元素
我们首先来实现这个需求
// 插入到目标元素
const insertDom = (parent, el) => {
parent.appendChild(el.mask)
}
//控制元素的创建和销毁
const toggleLoading = (el, binding) => {
//这个binding.value其实就是自定义指令的传参
if (binding.value) {
Vue.nextTick(() => {
// 插入到目标元素
insertDom(el, el)
})
} else {
el.mask && el.mask.parentNode && el.mask.parentNode.removeChild(el.mask)
}
el.style.position = 'relative'
}
OK 一个动态创建和销毁元素的逻辑已经完成
值得注意的是博主在里面加入了一句el.style.position = 'relative'
这个代码是给父级dom
设置相对定位,便于我们之后的Loading图使用定位的方式进行居中
随后我们需要一个真实的元素在页面中间进行跳动,这个地方其实用div
标签也能实现需求,
但是显然不尽善尽美,于是博主动起了小脑袋瓜
有没有办法可以把一个组件的所有属性以及dom
元素进行继承呢?
答案显然是可以的,它就是Vue
的extend
函数
这个语法会创建一个Vue
默认的构造器,拥有默认模板同时,会把里面的数据进行替换
首先我们需要自己创建一个组件(即是界面中的Loading
组件)
样式直接贴代码,纯css就能实现,有兴趣的同学可以自己去拓展
<!--
* @Descripttion:
* @version:
* @Author: 崔战神
* @Date: 2022-06-22 16:20:25
* @LastEditors: 崔战神
* @LastEditTime: 2022-06-28 14:38:02
-->
<template>
<div class="loading" >
<div></div>
<div></div>
</div>
</template>
<style>
.loading,
.loading > div {
position: relative;
box-sizing: border-box;
}
.loading {
display: block;
font-size: 0;
color: #accbee;
position: absolute;
background-image: -webkit-gradient(linear, 0 0, 0 bottom, from(#accbee), to(#e7f0fd));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
top:50%;
left:50%;
transform: translate(-50%,-50%);
}
.loading.la-dark {
color: #1479ff;
}
.loading > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.loading {
width: 2rem;
height: 2rem;
}
.loading > div:nth-child(1),
.loading > div:nth-child(2) {
position: absolute;
left: 0;
width: 100%;
}
.loading > div:nth-child(1) {
top: -25%;
z-index: 1;
height: 100%;
border-radius: 10%;
animation: square-jelly-box-animate 0.6s -0.1s linear infinite;
}
.loading > div:nth-child(2) {
bottom: -9%;
height: 10%;
background: #1479ff;
border-radius: 50%;
opacity: 0.2;
animation: square-jelly-box-shadow 0.6s -0.1s linear infinite;
}
.loading.la-sm {
width: 1rem;
height: 1rem;
}
.loading.la-2x {
width: 4rem;
height: 4rem;
}
.loading.la-3x {
width: 6rem;
height: 6rem;
}
@keyframes square-jelly-box-animate {
17% {
border-bottom-right-radius: 10%;
}
25% {
transform: translateY(25%) rotate(22.5deg);
}
50% {
border-bottom-right-radius: 100%;
transform: translateY(50%) scale(1, 0.9) rotate(45deg);
}
75% {
transform: translateY(25%) rotate(67.5deg);
}
100% {
transform: translateY(0) rotate(90deg);
}
}
@keyframes square-jelly-box-shadow {
50% {
transform: scale(1.25, 1);
}
}
</style>
组件目录结构如图:
万事俱备,只欠东风
我们接下来在指令代码中,引入这个Loading
的组件
const Mask = Vue.extend(Loading) //创建一个vue默认的构造器,会拥有默认模板的同时,对函数指定数据进行替换
这时我们已经成功通过Vue.extend()
来拿到了目录中Loading
组件的所有内容
接下来我们需要用一个变量承载Mask
这个实例的同时,替换掉指定的dom
内容
bind: function(el, binding, vNode) {
//给Loading组件一个载体
const mask = new Mask({
el: document.createElement('div'),
data(){
return {}
}
})
//如果自定义指令传的有参数,就用参数的位置,否则进行居中
let top=binding.arg==undefined?'50%':binding.arg.top;
let left=binding.arg==undefined?'50%':binding.arg.left;
let position=binding.position==undefined?'absolute':binding.arg.position;
[mask.$el.style.top,mask.$el.style.position,mask.$el.style.left]=[top,position,left]
// 用一个变量接住mask实例的同时替换指定的dom内容
el.instance = mask
el.mask = mask.$el
el.maskStyle = {}
binding.value && toggleLoading(el, binding)
},
这时候其实一个简单的Loading效果已经完成了
但是我们发现在实际使用中v-customLoading
绑定的值变化时
这个组件并不会进行切换,这个时候需要用到我们的另外一个生命周期update
// 所在组件的 VNode 更新时调用--比较更新前后的值
update: function(el, binding) {
if (binding.oldValue !== binding.value) {
toggleLoading(el, binding)
}
},
这个生命周期类似于watch
方法,用于监听
在这里我们只要观察到新旧值不一样,就进行dom
操作(销毁或创建)
同时我们也需要在指令解绑
之后进行对应dom
的销毁
// 指令与元素解绑时调用
unbind: function(el, binding) {
el.instance && el.instance.$destroy()
}
使用方法:
<div v-customLoading="loading" ></div>
loading: true,
//带自定义参数方法
<div v-customLoading:[customStyle]=loading ></div>
loading: true,
customStyle: {
top: '50%', left: '50%', position: 'absolute',
},
一定要注意,带参数和不带参数的指令绑定方式是不一样的,这个参数生命周期里回调参数为binding.arg
效果预览:
2.复制指令
有做过复制内容功能
的同志应该都会知道
目前前端复制功能基本上大同小异(创建一个只读的textarea
标签),使其不显示在可视区域内
而后以select()
方法选中textarea
用document.execCommand
语法进行复制到粘贴板,博主这里也不例外
// 复制指令
Vue.directive('copy', {
// 第一次绑定到元素时调用
bind(el,binding) {
// console.warn(binding.arg,'是否有对应参数')
el.$value = binding.value
let success=binding.arg==undefined?'复制成功':binding.arg.success
let error=binding.arg==undefined?'复制失败':binding.arg.error
let empty=binding.arg==undefined?'复制失败':binding.arg.empty
el.handler = () => {
// 值为空的对应提示
if (!el.$value) {
Message.error(empty)
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()
textarea.setSelectionRange(0,textarea.value.length)
const result = document.execCommand('Copy')
if (result) {
Message.success(success)
}else{
Message.error(error)
}
document.body.removeChild(textarea)
}
// 绑定点击事件
el.addEventListener('click', el.handler)
},
// 当传进来的值更新的时候触发
componentUpdated(el, { value }) {
el.$value = value
},
// 指令与元素解绑的时候,移除事件绑定
unbind(el) {
el.removeEventListener('click', el.handler)
},
});
支持三种不同状态的提示文字自定义
效果预览:
3.文件流下载指令
依旧是老生常谈的url转blob地址方式进行下载
// 以文件流的形式下载数据
Vue.directive('downloadUrl', {
// 第一次绑定到元素时调用
bind(el, binding) {
// console.warn(binding)
if (binding.value.url) {
el.addEventListener('click', () => {
const a = document.createElement('a')
const url = binding.value.url // 完整的url则直接使用
// 这里是将url转成blob地址,
fetch(url).then(res => res.blob()).then(blob => { // 将链接地址字符内容转变成blob地址
a.href = URL.createObjectURL(blob)
a.download = `${binding.value.fileName}` || '' // 下载文件的名字
document.body.appendChild(a)
a.click()
//在资源下载完成后 清除 占用的缓存资源
window.URL.revokeObjectURL(a.href);
document.body.removeChild(a);
})
})
}
}
});
我这里是遍历出来的,这里暴露了三个自定义属性(文件名,文件地址,文件类型)
<el-link v-downloadUrl="{fileName:item.fileName,url:item.signedPath,type:item.fileType}" target="_blank" >下载</el-link>
效果预览:
4.防抖指令
// 防抖指令,默认延迟是1s
Vue.directive('debounce', {
inserted: function (el, binding) {
console.warn(binding)
let timer
el.addEventListener('mouseup', () => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
binding.value()
},binding.arg==undefined?1000:binding.arg)
})
},
});
使用方法:
<div v-debounce:[1000]="test"></div>
test(){
console.warn('防抖测试)
}
效果预览:
5.权限指令(通过给的指定字段是否在权限数组中控制dom展示)
// 权限指令
Vue.directive('permission', {
// 首次挂载时候调用
bind: function(el, binding, vNode) {
console.warn(vNode,'dom结构')
let permission = binding.value; //所具有的权限,要跟所有的权限进行对比,没有在其中则不展示
if (permission && binding.arg!==undefined ) {
let hasPermission = binding.arg.includes(binding.value);
if (!hasPermission) {
// 没有权限 移除Dom元素
el.parentNode && el.parentNode.removeChild(el)
}
}
},
});
使用方式:
如果对应字符串在权限数组里面,则按钮展示,否则不展示
效果预览:
6.水印指令(生成指定水印)
// 水印指令
Vue.directive('waterMarker', {
// 首次挂载时候调用
bind: function (el, binding) {
var tempCanvas = document.createElement('canvas')
el.appendChild(tempCanvas)
tempCanvas.width = 200
tempCanvas.height = 150
tempCanvas.style.display = 'none'
var cans = tempCanvas.getContext('2d')
cans.rotate((-20 * Math.PI) / 180)
cans.font = binding.value.font || '16px Microsoft JhengHei'
cans.fillStyle = binding.value.textColor || 'rgba(180, 180, 180, 0.3)'
cans.textAlign = 'left'
cans.textBaseline = 'Middle'
cans.fillText(binding.value.text, tempCanvas.width / 10, tempCanvas.height / 2)
el.style.backgroundImage = 'url(' + tempCanvas.toDataURL('image/png') + ')'
},
});
这里是创建canvas
标签再通过fillText
进行文字填充,而后设为指定dom背景图
的方式实现
暴露了两个属性字族和字体颜色
使用方法如下:
<div v-waterMarker="{text:'崔战神',textColor:'rgba(180, 180, 180, 0.4)'}"></div>
预览效果:
四、相关源码
customInstructions.js
/* eslint-disable */
/*
* @Descripttion:
* @version:
* @Author: 崔战神
* @Date: 2022-06-28 09:22:28
* @LastEditors: 崔战神
* @LastEditTime: 2022-07-05 15:52:28
*/
import Vue from 'vue';
import Loading from '../../components/Loading.vue'
import { Message } from 'element-ui';
const Mask = Vue.extend(Loading) //创建一个vue默认的构造器,会拥有默认模板的同时,对函数指定数据进行替换
// 这里是用绑定数据来进行展示的,所以用的时候要v-xxx='true'
const toggleLoading = (el, binding) => {
if (binding.value) {
Vue.nextTick(() => {
// 插入到目标元素
insertDom(el, el)
})
} else {
el.mask && el.mask.parentNode && el.mask.parentNode.removeChild(el.mask)
}
el.style.position = 'relative'
}
// 插入到目标元素
const insertDom = (parent, el) => {
parent.appendChild(el.mask)
}
// 自定义loading指令
Vue.directive('customLoading', {
// 第一次绑定到元素时调用
bind: function(el, binding, vNode) {
const mask = new Mask({
el: document.createElement('div'),
data(){
return {}
}
})
let top=binding.arg==undefined?'50%':binding.arg.top;
let left=binding.arg==undefined?'50%':binding.arg.left;
let position=binding.position==undefined?'absolute':binding.arg.position;
[mask.$el.style.top,mask.$el.style.position,mask.$el.style.left]=[top,position,left]
// 用一个变量接住mask实例的同时替换指定的dom内容
el.instance = mask
el.mask = mask.$el
el.maskStyle = {}
binding.value && toggleLoading(el, binding)
},
// 所在组件的 VNode 更新时调用--比较更新前后的值
update: function(el, binding) {
if (binding.oldValue !== binding.value) {
toggleLoading(el, binding)
}
},
// 指令与元素解绑时调用
unbind: function(el, binding) {
el.instance && el.instance.$destroy()
}
});
// 复制指令
Vue.directive('copy', {
// 第一次绑定到元素时调用
bind(el,binding) {
// console.warn(binding.arg,'是否有对应参数')
el.$value = binding.value
let success=binding.arg==undefined?'复制成功':binding.arg.success
let error=binding.arg==undefined?'复制失败':binding.arg.error
let empty=binding.arg==undefined?'复制失败':binding.arg.empty
el.handler = () => {
// 值为空的对应提示
if (!el.$value) {
Message.error(empty)
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()
textarea.setSelectionRange(0,textarea.value.length)
const result = document.execCommand('Copy')
if (result) {
Message.success(success)
}else{
Message.error(error)
}
document.body.removeChild(textarea)
}
// 绑定点击事件
el.addEventListener('click', el.handler)
},
// 当传进来的值更新的时候触发
componentUpdated(el, { value }) {
el.$value = value
},
// 指令与元素解绑的时候,移除事件绑定
unbind(el) {
el.removeEventListener('click', el.handler)
},
});
// 以文件流的形式下载数据
Vue.directive('downloadUrl', {
// 第一次绑定到元素时调用
bind(el, binding) {
// console.warn(binding)
if (binding.value.url) {
el.addEventListener('click', () => {
const a = document.createElement('a')
const url = binding.value.url // 完整的url则直接使用
// 这里是将url转成blob地址,
fetch(url).then(res => res.blob()).then(blob => { // 将链接地址字符内容转变成blob地址
a.href = URL.createObjectURL(blob)
a.download = `${binding.value.fileName}` || '' // 下载文件的名字
document.body.appendChild(a)
a.click()
//在资源下载完成后 清除 占用的缓存资源
window.URL.revokeObjectURL(a.href);
document.body.removeChild(a);
})
})
}
}
});
// 防抖指令,默认延迟是1s
Vue.directive('debounce', {
inserted: function (el, binding) {
console.warn(binding)
let timer
el.addEventListener('mouseup', () => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
binding.value()
},binding.arg==undefined?1000:binding.arg)
})
},
});
// 权限指令
Vue.directive('permission', {
// 首次挂载时候调用
bind: function(el, binding, vNode) {
console.warn(vNode,'dom结构')
let permission = binding.value; //所具有的权限,要跟所有的权限进行对比,没有在其中则不展示
if (permission && binding.arg!==undefined ) {
let hasPermission = binding.arg.includes(binding.value);
if (!hasPermission) {
// 没有权限 移除Dom元素
el.parentNode && el.parentNode.removeChild(el)
}
}
},
});
// 水印指令
Vue.directive('waterMarker', {
// 首次挂载时候调用
bind: function (el, binding) {
var tempCanvas = document.createElement('canvas')
el.appendChild(tempCanvas)
tempCanvas.width = 200
tempCanvas.height = 150
tempCanvas.style.display = 'none'
var cans = tempCanvas.getContext('2d')
cans.rotate((-20 * Math.PI) / 180)
cans.font = binding.value.font || '16px Microsoft JhengHei'
cans.fillStyle = binding.value.textColor || 'rgba(180, 180, 180, 0.3)'
cans.textAlign = 'left'
cans.textBaseline = 'Middle'
cans.fillText(binding.value.text, tempCanvas.width / 10, tempCanvas.height / 2)
el.style.backgroundImage = 'url(' + tempCanvas.toDataURL('image/png') + ')'
},
});
在main.js
中
import './assets/js/customInstructions';
Loading.vue
组件
<!--
* @Descripttion:
* @version:
* @Author: 崔战神
* @Date: 2022-06-22 16:20:25
* @LastEditors: 崔战神
* @LastEditTime: 2022-06-28 14:38:02
-->
<template>
<div class="loading" >
<div></div>
<div></div>
</div>
</template>
<style>
.loading,
.loading > div {
position: relative;
box-sizing: border-box;
}
.loading {
display: block;
font-size: 0;
color: #accbee;
position: absolute;
background-image: -webkit-gradient(linear, 0 0, 0 bottom, from(#accbee), to(#e7f0fd));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
top:50%;
left:50%;
transform: translate(-50%,-50%);
}
.loading.la-dark {
color: #1479ff;
}
.loading > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.loading {
width: 2rem;
height: 2rem;
}
.loading > div:nth-child(1),
.loading > div:nth-child(2) {
position: absolute;
left: 0;
width: 100%;
}
.loading > div:nth-child(1) {
top: -25%;
z-index: 1;
height: 100%;
border-radius: 10%;
animation: square-jelly-box-animate 0.6s -0.1s linear infinite;
}
.loading > div:nth-child(2) {
bottom: -9%;
height: 10%;
background: #1479ff;
border-radius: 50%;
opacity: 0.2;
animation: square-jelly-box-shadow 0.6s -0.1s linear infinite;
}
.loading.la-sm {
width: 1rem;
height: 1rem;
}
.loading.la-2x {
width: 4rem;
height: 4rem;
}
.loading.la-3x {
width: 6rem;
height: 6rem;
}
@keyframes square-jelly-box-animate {
17% {
border-bottom-right-radius: 10%;
}
25% {
transform: translateY(25%) rotate(22.5deg);
}
50% {
border-bottom-right-radius: 100%;
transform: translateY(50%) scale(1, 0.9) rotate(45deg);
}
75% {
transform: translateY(25%) rotate(67.5deg);
}
100% {
transform: translateY(0) rotate(90deg);
}
}
@keyframes square-jelly-box-shadow {
50% {
transform: scale(1.25, 1);
}
}
</style>
项目目录结构:
祝各位码农新年快乐,明天,又是充满希望的一天!
最后放上一张镇楼图