我们之前说过,vue除了其内置的一些指令外,还允许自定义一些指令,自定义指令调用Vue.directive方法即可。并且被保存在vue.options中或者是组件的vm.$options中。无论是那种定义方式,都会被保存在特定的地方。那么这些自定义指令是如何生效的呢?这将是我们的重点。
何时生效
我们知道指令是被作为标签的属性写在模版HTML中的。所以,指令的生效必然是会在模版编译的阶段。其实,在虚拟dom渲染更新的时候,他在执行相关操作的同时,还会在每个阶段触发相应的钩子函数。下面就是虚拟dom在渲染的不通阶段触发的不通钩子函数以及触发的时机:
钩子函数名称 | 触发时机 | 回调参数 |
---|---|---|
init | 已创建VNode,在patch期间发现新的虚拟节点时被触发 | VNode |
create | 已基于VNode创建了DOM元素 | emptyNode和VNode |
activate | keep-alive组件被创建 | emptyNode和innerNode |
insert | VNode对应的DOM元素被插入到父节点中时被触发 | VNode |
prepatch | 一个VNode即将被patch之前触发 | oldVNode和VNode |
update | 一个VNode更新时触发 | oldVNode和VNode |
postpatch | 一个VNode被patch完毕时触发 | oldVNode和VNode |
destory | 一个VNode对应的DOM元素从DOM中移除时或者它的父元素从DOM中移除时触发 | VNode |
remove | 一个VNode对应的DOM元素从DOM中移除时触发。与destory不同的是,如果是直接将该VNode的父元素从DOM中移除导致该元素被移除,那么不会触发 | VNode和removeCallback |
所以我们只需在恰当的阶段监听对应的钩子函数来处理指令的相关逻辑,从而就可以使指令生效了。
当一个节点被创建成DOM
元素时,如果这个节点上有指令,那此时得处理指令逻辑,让指令生效;当一个节点被更新时,如果节点更新之前没有指令,而更新之后有了指令,或者是更新前后节点上的指令发生了变化,那此时得处理指令逻辑,让指令生效;另外,当节点被移除时,那节点上的指令自然也就没有用了,此时还得处理指令逻辑。 所以,在虚拟DOM
渲染更新的create
、update
、destory
阶段都得处理指令逻辑,所以我们需要监听这三个钩子函数来处理指令逻辑。
export default {
create: updateDirectives,
update: updateDirectives,
destroy: function unbindDirectives (vnode: VNodeWithData) {
updateDirectives(vnode, emptyNode)
}
}
指令钩子函数
Vue
对于自定义指令定义对象提供了几个钩子函数,这几个钩子函数分别对应着指令的几种状态,一个指令从第一次被绑定到元素上到最终与被绑定的元素解绑,它会经过以下几种状态:
- bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
- inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
- update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。
- componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。
- unbind:只调用一次,指令与元素解绑时调用。
有了每个状态的钩子函数,这样我们就可以让指令在不同状态下做不同的事情。
// 全局注册一个指令
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus()
}
})
// 模版中使用
<input v-focus>
如何生效
当虚拟DOM
渲染更新的时候会触发create
、update
、destory
这三个钩子函数,从而就会执行updateDirectives
函数来处理指令的相关逻辑,执行指令函数,让指令生效。所以我们来详细看下updateDirectives函数是什么样子的?
src/core/vdom/modules/directives.js
function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (oldVnode.data.directives || vnode.data.directives) {
_update(oldVnode, vnode)
}
}
和easy。主要还是要看_update函数,接收了俩个参数,旧的虚拟dom和新的虚拟dom.
function _update (oldVnode, vnode) {
const isCreate = oldVnode === emptyNode
const isDestroy = vnode === emptyNode
const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)
const dirsWithInsert = []
const dirsWithPostpatch = []
let key, oldDir, dir
for (key in newDirs) {
oldDir = oldDirs[key]
dir = newDirs[key]
if (!oldDir) {
// new directive, bind
callHook(dir, 'bind', vnode, oldVnode)
if (dir.def && dir.def.inserted) {
dirsWithInsert.push(dir)
}
} else {
// existing directive, update
dir.oldValue = oldDir.value
dir.oldArg = oldDir.arg
callHook(dir, 'update', vnode, oldVnode)
if (dir.def && dir.def.componentUpdated) {
dirsWithPostpatch.push(dir)
}
}
}
if (dirsWithInsert.length) {
const callInsert = () => {
for (let i = 0; i < dirsWithInsert.length; i++) {
callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
}
}
if (isCreate) {
mergeVNodeHook(vnode, 'insert', callInsert)
} else {
callInsert()
}
}
if (dirsWithPostpatch.length) {
mergeVNodeHook(vnode, 'postpatch', () => {
for (let i = 0; i < dirsWithPostpatch.length; i++) {
callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
}
})
}
if (!isCreate) {
for (key in oldDirs) {
if (!newDirs[key]) {
// no longer present, unbind
callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
}
}
}
}
函数内部先定义了几个变量:
- isCreate:判断当前节点
vnode
对应的旧节点oldVnode
是不是一个空节点,如果是的话,表明当前节点是一个新创建的节点。 - isDestroy:判断当前节点
vnode
是不是一个空节点,如果是的话,表明当前节点对应的旧节点将要被销毁。 - oldDirs:旧的指令集合,即
oldVnode
中保存的指令。 - newDirs:新的指令集合,即
vnode
中保存的指令。 - dirsWithInsert:保存需要触发
inserted
指令钩子函数的指令列表。 - dirsWithPostpatch:保存需要触发
componentUpdated
指令钩子函数的指令列表。
在定义新旧指令集合的变量中调用了normalizeDirectives
函数,其实该函数是用来模板中使用到的指令从存放指令的地方取出来,并将其格式进行统一化:
function normalizeDirectives (dirs,vm): {
const res = Object.create(null)
if (!dirs) {
return res
}
let i, dir
for (i = 0; i < dirs.length; i++) {
dir = dirs[i]
if (!dir.modifiers) {
dir.modifiers = emptyModifiers
}
res[getRawDirName(dir)] = dir
dir.def = resolveAsset(vm.$options, 'directives', dir.name, true)
}
return res
}
假如我们要实现一个上面的v-focus指令,通过normalizeDirectives
函数取出的指令会变成:
{
'v-focus':{
name : 'focus' , // 指令的名称
value : '', // 指令的值
arg:'', // 指令的参数
modifiers:{}, // 指令的修饰符
def:{
inserted:fn
}
}
}
获取到oldDirs
和newDirs
之后,接下来要做的事情就是对比这两个指令集合并触发对应的指令钩子函数。
首先,循环newDirs
,并分别从oldDirs
和newDirs
取出当前循环到的指令分别保存在变量oldDir
和dir
中。
然后判断当前循环到的指令名key
在旧的指令列表oldDirs
中是否存在,如果不存在,说明该指令是首次绑定到元素上的一个新指令,此时调用callHook
触发指令中的bind
钩子函数,接着判断如果该新指令在定义时设置了inserted
钩子函数,那么将该指令添加到dirsWithInsert
中,以保证执行完所有指令的bind
钩子函数后再执行指令的inserted
钩子函数。
如果当前循环到的指令名key
在旧的指令列表oldDirs
中存在时,说明该指令在之前已经绑定过了,那么这一次的操作应该是更新指令。
首先,在dir
上添加oldValue
属性和oldArg
属性,用来保存上一次指令的value
属性值和arg
属性值,然后调用callHook
触发指令中的update
钩子函数,接着判断如果该指令在定义时设置了componentUpdated
钩子函数,那么将该指令添加到dirsWithPostpatch
中,以保证让指令所在的组件的VNode
及其子VNode
全部更新完后再执行指令的componentUpdated
钩子函数。
最后,判断dirsWithInsert
数组中是否有元素,如果有,则循环dirsWithInsert
数组,依次执行每一个指令的inserted
钩子函数。可以看到,并没有直接去循环执行每一个指令的inserted
钩子函数,而是新创建了一个callInsert
函数,当执行该函数的时候才会去循环执行每一个指令的inserted
钩子函数。
这是因为指令的inserted
钩子函数必须在被绑定元素插入到父节点时调用,那么如果是一个新增的节点,如何保证它已经被插入到父节点了呢?我们之前说过,虚拟DOM
在渲染更新的不同阶段会触发不同的钩子函数,比如当DOM
节点在被插入到父节点时会触发insert
函数,那么我们就知道了,当虚拟DOM
渲染更新的insert
钩子函数被调用的时候就标志着当前节点已经被插入到父节点了,所以我们要在虚拟DOM
渲染更新的insert
钩子函数内执行指令的inserted
钩子函数。也就是说,当一个新创建的元素被插入到父节点中时虚拟DOM
渲染更新的insert
钩子函数和指令的inserted
钩子函数都要被触发。既然如此,那就可以把这两个钩子函数通过调用mergeVNodeHook
方法进行合并,然后统一在虚拟DOM
渲染更新的insert
钩子函数中触发,这样就保证了元素确实被插入到父节点中才执行的指令的inserted
钩子函数。
同理,我们也需要保证指令所在的组件的VNode
及其子VNode
全部更新完后再执行指令的componentUpdated
钩子函数,所以我们将虚拟DOM
渲染更新的postpatch
钩子函数和指令的componentUpdated
钩子函数进行合并触发。
最后,当newDirs
循环完毕后,再循环oldDirs
,如果某个指令存在于旧的指令列表oldDirs
而在新的指令列表newDirs
中不存在,那说明该指令是被废弃的,所以则触发指令的unbind
钩子函数对指令进行解绑。
这就是vue自定义指令生效的过程。