概述
在日常的开发工作中,Vue指令的使用非常常见,而Vue指令对于我们的来说也非常的有用且高效。下面我将按照知识图谱的展示介绍与剖析Vue指令。
指令简介
什么是指令
指令 (Directives) 是带有
v-
前缀的特殊 attribute。指令 attribute 的值预期是单个 JavaScript 表达式 。指令的职责是,当表达式的值改变时,将其产生的连带影响,响应式地作用于 DOM。
v-
前缀作为一种视觉提示,用来识别模板中 Vue 特定的 attribute。当你在使用 Vue.js 为现有标签添加动态行为 (dynamic behavior) 时,v-
前缀很有帮助,然而,对于一些频繁用到的指令来说,就会感到使用繁琐。同时,在构建由 Vue 管理所有模板的单页面应用程序 (SPA - single page application) 时,v-
前缀也变得没那么重要了。
在 Vue 中存在一个全局 APIVue.directive
,下面我们来进行介绍与学习。
- 用法
bash
Vue.directive( id, [definition] )
-
参数
- {string} id
- {Function | Object} [definition]
-
作用:注册或获取全局指令
js
//注册
Vue.directive("my-directive", {
bind: function () {},
instered: function () {},
update: function () {},
componentUpdate: function () {},
unbind: function () {},
});
//注册指令函数
Vue.directive("my-directive", function () {
//这里将会被bind和update调用
});
//getter 方法,返回已注册的指令
let myDirective = Vue.directive("my-directive");
这里需要强调 Vue.directive 方法的作用是注册或获取全局指令,而不是让指令生效。其区别是注册指令需要做的事是将指令保存在某个位置,而让指令生效是指将指令从某个位置拿出来执行它。
注册指令的实现并不难,代码如下
js
Vue.options = Object.create(null)
Vue.options['directives'] = Object.create(null)
Vue.directive = function(id,definition) {
if(!definition) {
return this.options
} else {
if(typeof definition === 'function') {
definition = {bind:fubction,update:function}
}
this.options['directive'][id] = definition
return definition
}
}
我们在 Vue 构造函数上创建了 options 属性来存放选项,并在选项上新增了 directive 方法用于存放指令。
指令原理概述
Vue.directive全局API可以创建自定义指令并获取全局指令,但它并不能让指令生效,而指令的相关知识贯穿Vue.js内部的各个核心技术点,所以我们来介绍一下指令的原理。
在模板解析阶段,我们将指令解析到AST ,然后使用AST生成代码字符串的过程中实现某些内置指令的功能,最后在虚拟DOM渲染的过程中触发自定义指令的钩子函数使指令生效。
下图给出了指令生效的全过程。在模板解析阶段,会将节点上的指令解析出来并添加到AST的directives属性中。
随后directives数据会传递到VNode中,接着就可以通过vnode.data.directives获取一个节点所绑定的指令。
最后当虚拟DOM进行修补时,会根据节点的对比结果触发一些钩子函数。更新指令的程序会监听create、update和destory钩子函数,并在这三个钩子函数触发时对VNode和oldVNode进行对比,最终根据对比结果触发指令的钩子函数。(使用自定义指令时,可以监听5种钩子函数:bind,inserted,uodate,componentUpdate与unbind。)指令的钩子函数出发后,就说明指令生效了。
有一些内置指令是在模板编译阶段实现的。在代码生成时,通过生成一个特殊的代码字符串来实现指令的功能。例如,在模板中使用v-if指令
html
<li v-if="has">if</li>
<li v-else>else</li>
在模板编译的代码生成阶段会生成这样的代码字符串:
js
(has)?_c('li',[_v('if')]):_c('li',[_v('else')])
为了方便观察,我们将字符串格式化:
js
(has)
? _c('li',[_v('if')])
: _c('li',[_v('else')])
这样一段代码字符串在最终被执行时,会根据has变量的值来选择创建哪个节点。诸如此类的还有v-for
指令,而除此之外还有复杂的v-on
指令的原理在这里不做详细的介绍。
内置指令
内置指令指的就是Vue
自带指令,开箱即用。
Vue
一共有16
个自带指令,包括了:
v-text
、v-html
、v-show
、v-if
、v-else
、v-else-if
、v-for
、v-on
、v-bind
、v-model
、v-slot
、v-pre
、v-cloak
、v-once
、v-memo
、v-is
,其中v-memo
是3.2
新增的,v-is
在3.1.0
中废弃。
内置指令的使用想必大家都非常熟悉了,这里就不一一列举具体的使用了。
自定义指令
除了核心功能默认的内置指令外,Vue.js 也允许注册自定义指令。虽然代码复用和抽象的主要形式是组件,但是有些情况下,仍然要对普通 DOM 元素进行底层操作,这时就会用到自定义指令。
自定义指令的内部原理
我们知道,虚拟DOM通过算法对比两个VNode之间的差异并更新真实的DOM节点。在更新真实的DOM节点时,有可能是创建新的节点,或者更新一个已有的节点,还有可能是删除一个节点等。虚拟DOM在渲染时,除了更新DOM内容外,还会触发钩子函数。例如,在更新节点时,除了更新节点的内容外,还会触发update钩子函数。这是因为标签上通常会绑定一些指令、事件或属性,这些内容也需要在更新节点时同步被更新。因此,事件、指令、属性等相关处理逻辑只需要监听钩子函数,在钩子函数触发时执行相关处理逻辑即可实现功能。
指令的处理逻辑分别监听了create、update与destroy,其代码如下
js
export default {
create: updateDirectives,
update: updateDirectives,
destroy: function unbindDirectives(vnode){
updateDirectives(vnode, emptyNode)
}
}
虚拟DOM在触发钩子函数时,上面代码中对应的函数会被执行。但无论哪个钩子函数触发,最终都会执行一个叫作updateDirectives的函数。从代码中可以得知,指令相关的处理逻辑都在updateDirectives函数中实现,该函数的代码如下:
js
function updateDirectives (oldVnode, vnode){
if(oldVnode.data.directives || vnode.data.directives) {
_update(oldVnode, vnode)
}
}
可以看到,不论oldVnode还是vnode,只要其中一个有一个虚拟节点存在directives,那么就会执行_update函数处理指令。
说明:在模板解析时,directives会从模板的属性中解析出来并最终设置到VNode中。
自定义指令的使用
如何注册自定义指令
要想使用自定义指令,我们必须先提前把它注册好,就好比我们的组件一样,得先注册,才能使用。
注册指令也分为全局注册和局部注册,和全局注册组件和局部注册组件一个道理。全局注册的指令可以在任何组件中直接使用,局部注册的指令只能在注册的地方使用。
全局注册
全局注册顾名思义。自定义指令注册好后,在项目的所有组件内都可以直接使用。 vue提供了一个directive方法给我们注册自定义指令,我们在main.js中注册一个全局的自定义指令。 代码如下:
js
Vue.directive('name',{
bind(){},
inserted(){},
update(){},
componentUpdated(){},
unbind(){}
})
上段代码中我们就直接调用了Vue提供的directive方法来注册全局的自定义指令,该方法接收两个参数:指令名称.包含指令钩子函数的对象。
指令注册完毕后,我们就可以在项目中任意组件中的元素上使用"v-指令名称"的形式使用指令了。
需要注意的是,指令钩子函数不是必须的,大家可以把它与vue的生命周期钩子函数做类比,它们的作用就是用来让指令在不同的过程中做不同的事情。
局部注册
通常来说,如果自定义指令不是每个组件都会用到的话,我们一般局注册自定义指令就好了。
js
export default {
name: "App",
directives: {
resize: {
bind() {},
inserted() {},
update() {},
componentUpdated() {},
unbind() {},
},
},
};
如上所示,Vue提供了一个directives选项供我们注册自定义指令,它与data、methods同级别,上段代码中我们注册了一个名叫resize的自定义指令,该指令只允许在组件内部使用。
很多时候我们不需要用到自定义指令中的所有钩子函数,常用的就那么几个,所以官方给我们提供了一种简写的方式。
js
resize(el, binding)
console.log("我是简写的自定义指令",binding.value);
上面代码的写法让我们的指令变得很简洁,上段代码的意思就是把bind和update钩子函数合二为一了,并且只会执行这两个方法,通常我们想要这两个钩子函数做同样的事的时候使用。
自定义指令参数详解
上面简单介绍了局部注册自定义指令和全局注册自定义指令,可以看到指令里面有几个钩子函数,我们的操作逻辑主要在这几个钩子函数当中,所以我们有必要介绍下这几个钩子函数。
bind: 只调用一次。指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
inserted: 被绑定元素插入父节点时调用(仅保证父节点存在,但不一定已被播入文档中)。
update: 所在组件的VNode更新时调用,但是可能发生在其子VNode更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新
componentUpdated: 指令所在组件的VNode及其子VNode全部更新后调用。
unbind: 只调用一次,指令与元素解绑时调用。
上面5个就是自定义指令的全部钩子函数,每个钩子函数都是可选的,视情况而定。大家可以简单理解钩子函数顺序;指令绑定到元素时(bind)、元素插入时(inserted),组件更新时(update)、组件更新后(componentUpdated)、指令与元素解绑时(unbind)。这些和组件的生命周期函数有点类似。
钩子函数参数介绍
为了方便我们的逻辑操作,每个钩子函数都会接收参数,我们可以用这些参数做我们想做的事。
el: 指令所绑定的元素,可以用来直接操作DOM.
binding: 一个对象,包含以下属性:
- name:指令名,不包括v-前缀。
- value :指令的绑定值。例如: v-my-directivem-1 + 1"书,绑定值为2。
- oldvalue :指令绑定的前一个值,仅在update和componentupdated钩子中可用。无论值是否改变都可用。
- expression:字符串形式的指令表达式。例如v-my-directivem"1 + 1"中,表达式为"1+1".
- arg ∶传给指令的参数,可选。例如v-my-directive:foo中,参数为"foo".
- modifiers ∶一个包含修饰符的对象。例如: v-my-directive.foo.bar中,修饰符对象为(foo: true, bar: true }。
vnode:
Vue编译生成的虚拟节点。
oldVnode:
上一个虚拟节点,仅在update和componentUpdated钩子中可用。
在使用的时候,el和binding参数是我们使用得最平凡的,有了这些参数,我们的操作就变得简单起来。
几个常见使用的自定义指令
需求:实现长按,用户需要按下并按住按钮几秒钟,触发相应的事件
思路:
- 创建一个计时器, 2 秒后执行函数
- 当用户按下按钮时触发 mousedown 事件,启动计时器;用户松开按钮时调用 mouseout 事件。
- 如果 mouseup 事件 2 秒内被触发,就清除计时器,当作一个普通的点击事件
- 如果计时器没有在 2 秒内清除,则判定为一次长按,可以执行关联的函数。
- 在移动端要考虑 touchstart,touchend 事件
js
const longpress = {
bind: function (el, binding, vNode) {
if (typeof binding.value !== 'function') {
throw 'callback must be a function'
}
// 定义变量
let pressTimer = null
// 创建计时器( 2秒后执行函数 )
let start = (e) => {
if (e.type === 'click' && e.button !== 0) {
return
}
if (pressTimer === null) {
pressTimer = setTimeout(() => {
handler()
}, 2000)
}
}
// 取消计时器
let cancel = (e) => {
if (pressTimer !== null) {
clearTimeout(pressTimer)
pressTimer = null
}
}
// 运行函数
const handler = (e) => {
binding.value(e)
}
// 添加事件监听器
el.addEventListener('mousedown', start)
el.addEventListener('touchstart', start)
// 取消计时器
el.addEventListener('click', cancel)
el.addEventListener('mouseout', cancel)
el.addEventListener('touchend', cancel)
el.addEventListener('touchcancel', cancel)
},
// 当传进来的值更新的时候触发
componentUpdated(el, { value }) {
el.$value = value
},
// 指令与元素解绑的时候,移除事件绑定
unbind(el) {
el.removeEventListener('click', el.handler)
},
}
export default longpress
需求:实现点击元素外部时,触发指定事件,完成相关操作。
思路:
- 判断点击的元素是否是本身,是本身则返回。
- 判断指令中是否绑定了函数,如果绑定了函数则调用那个函数。
- 监听元素的点击事件,执行处理函数。
js
Vue.directive('clickOutside', {
// 初始化指令
bind(el, binding) {
function clickHandler(e) {
// 这里判断点击的元素是否是本身,是本身则返回。
if (el.contains(e.target)) return false
// 判断指令中是否绑定了函数
if (binding.expression) {
// 如果绑定了函数 则调用那个函数,此处的binding.value就是handleClose方法
binding && binding.value(e)
}
}
// 给当前元素绑定一个私有变量,方便在unbind中可以接触事件的监听。
el.__vueClickOutside__ = clickHandler
document.addEventListener('click', clickHandler)
},
update() {},
unbind(el) {
// 解除事件监听
document.removeEventListener('click', el.__vueClickOutside__)
delete el.__vueClickOutside__
}
})
需求:当我们需要快速复制一段文本到剪切板时,手动选中复制后粘贴稍显麻烦,通过自定义指令可以实现点击文本时自动将文本复制到剪贴板,直接粘贴完成需求。
思路:
- 获取与指令绑定的值。
- 创建一个input元素,将获取到的值赋于input元素的value属性。
- 利用input元素的select方法选中value,然后再使用浏览器提供的方法将选中的指添加到剪切板。
js
Vue.directive('copy', {
bind(el, binding) {
el.__vueCopy__ = binding.value
// 给目标元素定义点击事件
el.clickHandler = () => {
if (!el.__vueCopy__) {
console.log('没有可复制的数据!')
}
return false
}
/*
在获取到与指令绑定的值之后如何将内容添加到剪切板呢?
核心思想是:
首先创建一个不可见的input或者textarea元素将获取到的值添加给元素的value属性
然后利用input元素的select方法选中value
然后再使用浏览器提供的方法将选中的值添加到剪切板
*/
let inputElement = document.createElement('input')
inputElement.setAttribute('value', el.__vueCopy__)
inputElement.style.position = 'absolute'
inputElement.style.left = '-9999px'
document.body.appendChild(inputElement)
inputElement.select()
// 将选中内容添加至剪切板目前有两种方法
/*
1.使用 document.execCommand 但是此特性已不推荐使用,所以尽量不要使用该特性。
https://developer.mozilla.org/zh-CN/docs/Web/API/Document/execCommand
*/
/*
2.使用 Clipboard Clipboard API 可用于实现剪切、复制和粘贴功能,系统剪贴板暴露于全局属性 Navigator.clipboard 之中。
如果用户没有适时使用 Permissions API 授予相应权限和"clipboard-read"或 "clipboard-write"权限,调用Clipboard对象的方法不会成功。
所有剪贴板API 方法都是异步的;它们返回一个 Promise 对象,在剪贴板访问完成后被执行。如果剪贴板访问被拒绝,promise 也会被拒绝。
https://developer.mozilla.org/zh-CN/docs/Web/API/Clipboard
*/
if (navigator.clipboard) {
navigator.clipboard.writeText(el.__vueCopy__).then(
// 复制成功callback
function() {
console.log('复制成功!')
},
// 复制失败callback
function() {
console.log('复制失败!')
}
)
} else {
document.execCommand('copy')
console.log('复制成功!')
}
document.body.removeChild(inputElement)
// 给目标元素添加点击事件
el.addEventListener('click', el.clickHandler)
},
// 当传进来的值更新的时候触发
componentUpdated(el, { value }) {
el.__vueCopy__ = value
},
// 指令与元素解绑时,移除点击事件绑定。
unbind(el) {
el.removeEventListener('click', el.clickHandler)
}
})
需求:根据用户的权限,动态展示用户可以看到的模块。
思路:根据用户的权限,展示或移除对应的DOM元素。
js
let checkPermission = function(el, binding) {
const { value } = binding
const userIds = [1, 2, 3] // 这里用来获取用户的权限列表,可以动态获取或者存储在vuex。
try {
let flag
if (value && value instanceof Array) {
flag = value.some((item) => userIds.includes(item))
} else {
flag = userIds.includes(value)
}
// 隐藏无权限内容
if (!flag) el.parentNode && el.parentNode.removeChild(el)
} catch (error) {
throw new Error('check v-')
}
}
// 检查用户是否具备权限,绑定元素接收数组,用户权限涵盖数组内的任意一项就允许展示。
Vue.directive('permission', {
inserted(el, binding) {
checkPermission(el, binding)
},
update(el, binding) {
checkPermission(el, binding)
}
})
需求:配合animate.css,对于即将要出现在可视区的元素添加动画特效。
思路:通过判断元素位置,添加对应的类名,展示动画特效。
js
Vue.directive('animate', {
inserted(el, binding) {
// 聚焦元素
binding.addClass = () => {
// 支持IntersectionObserver
if (window.IntersectionObserver) {
const obj = new IntersectionObserver(function(els) {
els.forEach((item) => {
if (
item.isIntersecting &&
item.target.className.indexOf(binding.value) === -1
) {
item.target.className = binding.value + '' + item.target.className
obj.unobserve(item.target)
}
})
})
// 创建观察者对象
obj.observe(el)
} else {
const { top } = el.getBoundingClientRect()
const h =
document.documentElement.clientHeight || document.body.clientHeight
if (top < h) {
if (el.className.indexOf(binding.value) === -1) {
el.className = binding.value + ' ' + el.className
}
if (binding.addClass) {
window.removeEventListener('scroll', binding.addClass)
}
}
window.addEventListener('scroll', binding.addClass, true)
}
binding.addClass()
}
},
unbind(el, binding) {
if (binding.addClass) {
window.removeEventListener('mousewheel', binding.addClass)
}
}
})
自定义指令还能做什么
除了实现控制页面元素显示与隐藏、图片懒加载、输入内容过滤等功能,Vue的自定义指令还可以做很多其他的事情。例如,你可以使用自定义指令来创建复杂的模板渲染函数、封装常见的逻辑操作、实现高级的动画效果等。总之,自定义指令使得Vue更加灵活和强大,可以满足你在前端开发中的各种需求。
例如一下自定义指令,封装之后我们就可以在使用elment-ui
的dialog
组件时通过拖拽来改变dialog
的位置了。
js
Vue.directive('moveDialog', {
bind(el) {
const dialogHeaderEl = el.querySelector('.el-dialog__header')
const dragDom = el.querySelector('.el-dialog')
dialogHeaderEl.style.cssText += ';cursor:move;'
// 获取元素原有属性
const sty = dragDom.currentStyle || window.getComputedStyle(dragDom, null)
dialogHeaderEl.onmousedown = (e) => {
// 鼠标按下,计算当前元素距离可视区域的高度。
const disX = e.clientX - dialogHeaderEl.offsetLeft
const disY = e.clientY - dialogHeaderEl.offsetTop
// 获取到的值带 px 进行正则替换
let styL, styT
// 注意在ie中,第一次取到值为组件自带,50%移动之后复制为px;
if (sty.left.includes('%')) {
styL = +document.body.clientWidth * (+sty.left.replace(/%/g, '') / 100)
styT = +document.body.clientHeight * (+sty.top.replace(/%/g, '') / 100)
} else {
styL = +sty.left.replace(/\px/g, '')
styT = +sty.top.replace(/\px/g, '')
}
document.onmousemove = function(e) {
// 通过事件委托,计算移动的距离。
const l = e.clientX - disX
const t = e.clientY - disY
// 移动当前元素
dragDom.style.left = `${l + styL}px`
dragDom.style.top = `${t + styT}px`
}
document.onmouseup = function() {
document.onmousemove = null
document.onmouseup = null
}
}
}
})
总结
Vue自定义指令是Vue框架中的一个强大功能,它允许我们创建自定义的指令来实现页面上的各种效果。通过自定义指令,我们可以封装和复用代码,提高开发效率,同时使代码更加清晰易懂。
在未来,随着Vue的持续发展和更新,自定义指令将更加灵活和强大。我们可以期待更多的自定义指令选项,如更复杂的的行为、更精细的参数控制等。同时,随着前端技术的不断发展,自定义指令将在前端开发中扮演更重要的的角色,为我们的开发工作带来更多便利和效率。
总之,通过本文的介绍和实例演示,我希望帮助读者更好地理解和使用Vue的自定义指令,从而在开发中发挥更大的创造力,实现更多以前难以实现的效果和功能。