vue2源码解析之自定义指令
基本使用
自定义指令,其实就是在vue提供的钩子中写代码,而这个钩子的执行是在dom渲染的不同阶段执行不同的钩子;
自定义指令的两种方式
// 全局
// 注册一个全局自定义指令 `v-focus`
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus()
}
})
// 组件内
directives: {
focus: {
// 指令的定义
inserted: function (el) {
el.focus()
}
}
}
<input v-focus>
自定义指令中可以使用的钩子函数:
一个指令定义对象可以提供如下几个钩子函数 (均为可选):
`bind`:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
`inserted`:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
`update`:所在组件的 VNode 更新时调用,**但是可能发生在其子 VNode 更新之前**。
指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板
更新 (详细的钩子函数参数见下)。
`componentUpdated`:指令所在组件的 VNode **及其子 VNode** 全部更新后调用。
`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 编译生成的虚拟节点。
`oldVnode`:上一个虚拟节点,仅在 `update` 和 `componentUpdated` 钩子中可用。
eg:
<div id="hook-arguments-example" v-demo:foo.a.b="message"></div>
Vue.directive('demo', {
bind: function (el, binding, vnode) {
var s = JSON.stringify
el.innerHTML =
'name: ' + s(binding.name) + '<br>' +
'value: ' + s(binding.value) + '<br>' +
'expression: ' + s(binding.expression) + '<br>' +
'argument: ' + s(binding.arg) + '<br>' +
'modifiers: ' + s(binding.modifiers) + '<br>' +
'vnode keys: ' + Object.keys(vnode).join(', ')
}
})
new Vue({
el: '#hook-arguments-example',
data: {
message: 'hello!'
}
})
动态指令:
指令的参数可以是动态的。例如,在 v-mydirective:[argument]="value"
中,argument
参数可以根据组件实例数据进行更新!这使得自定义指令可以在应用中被灵活使用;
源码解析
模板解析阶段:
编译模板解析每个标签,会把标签上的属性解析到一个对象的attrs属性上,解析到闭合标签之后,会调用闭合标签的回调方法,方法中会针对属性进行处理,先处理v-bind,v-on,再处理自定义指令,会把自定义指令处理之后放在抽象语法树的directives属性上;
抽象语法树生成render函数阶段:
解析抽象语法树生成render函数,directives指令会被作为render函数的属性参数;
render函数生成虚拟dom阶段:
执行render函数,把自定义指令属性挂载到虚拟节点的data下的directives上
生成真实dom阶段
creatElem创建真实dom的函数内部在创建完真实dom之后,会去调用invokeCreateHooks方法执行create相关的钩子函数,create函数内部就会执行_update方法;下面进行解析_update方法;
// 源码位置: src/core/vdom/modules/directives.js
export default {
create: updateDirectives,
update: updateDirectives,
destroy: function unbindDirectives (vnode: VNodeWithData) {
updateDirectives(vnode, emptyNode)
}
}
function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) {
if (oldVnode.data.directives || vnode.data.directives) {
_update(oldVnode, vnode)
}
}
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)
// 保存需要执行inserted钩子的指令
const dirsWithInsert = []
// 保存需要执行update钩子的指令
const dirsWithPostpatch = []
let key, oldDir, dir
// 遍历新节点的指令
for (key in newDirs) {
// 获取到旧节点中对应的指令
oldDir = oldDirs[key]
dir = newDirs[key]
// 如果旧节点中的指令不存在 表示是新添加的,通过bind进行绑定
if (!oldDir) {
// new directive, bind
callHook(dir, 'bind', vnode, oldVnode)
// 如有inserted就进行添加
if (dir.def && dir.def.inserted) {
dirsWithInsert.push(dir)
}
} else { // 如果旧节点中的指令存在 表示是需要更新的,执行update
// 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)
}
}
}
// 如果dirsWithInsert有值
if (dirsWithInsert.length) {
// 创建一个函数,函数内部遍历dirsWithInsert并且执行inserted钩子
const callInsert = () => {
for (let i = 0; i < dirsWithInsert.length; i++) {
callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
}
}
// 如果是新建的,就和insert钩子进行合并执行
if (isCreate) {
mergeVNodeHook(vnode, 'insert', callInsert)
} else {
// 否则执行指令中的inserted钩子
callInsert()
}
}
// dirsWithPostpatch有值 就合并componentUpdated和postpatch钩子在dom更新的时候执行
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) {
// 如果新节点中不存在此指令,表示是要解绑的,调用unbind钩子
if (!newDirs[key]) {
// no longer present, unbind
callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
}
}
}
}
指令的创建,更新和销毁都是同一个函数updateDirectives,而updateDirectives函数内部,判断就节点上的指令或新节点上的指令存在就执行_update方法;
export default {
create: updateDirectives,
update: updateDirectives,
destroy: function unbindDirectives (vnode: VNodeWithData) {
updateDirectives(vnode, emptyNode)
}
}
function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) {
// 旧节点的指令或新节点的指令存在
if (oldVnode.data.directives || vnode.data.directives) {
_update(oldVnode, vnode)
}
}
_update函数内部首先声明了6个变量;
// 旧节点不存在就表示是新创建的
const isCreate = oldVnode === emptyNode
// 新节点不存在表示是需要删除的
const isDestroy = vnode === emptyNode
// 旧节点中的指令集合
const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
// 新节点中的指令集合
const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)
// 保存需要执行inserted钩子的指令
const dirsWithInsert = []
// 保存需要执行update钩子的指令
const dirsWithPostpatch = []
接着遍历新节点中的指令,并且保存新旧节点中对应的指令的值;
let key, oldDir, dir
// 遍历新节点的指令
for (key in newDirs) {
// 获取到旧节点中对应的指令
oldDir = oldDirs[key]
dir = newDirs[key]
如果旧节点中的此指令不存在,表示是新添加的,执行bind钩子进行绑定,并且判断是否传递了inserted钩子,如果传递了就把当前指令存到dirsWithInsert中;
// 如果旧节点中的指令不存在 表示是新添加的,通过bind进行绑定
if (!oldDir) {
// new directive, bind
callHook(dir, 'bind', vnode, oldVnode)
// 如有inserted就进行添加
if (dir.def && dir.def.inserted) {
dirsWithInsert.push(dir)
}
}
如果旧节点中的此指令存在,表示是需要更新,把旧指令的参数和值保存到新指令对象上,并且执行update钩子,判断是否传递了componentUpdated钩子,传递了就保存到dirsWithPostpatch中;
else { // 如果旧节点中的指令存在 表示是需要更新的,执行update
// 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)
}
}
如果dirsWithInsert有值,就创建一个函数,函数内部遍历这个dirsWithInsert,通过callback调用每一个指令的inserted钩子;判断当前节点是否是新建的,是新创建的就和insert钩子进行合并执行,如果不是新创建的直接执行inserted钩子;(为什么需要合并钩子?是因为inserted钩子的执行时机是dom已经插入到页面中,而新创建的节点被插入到页面中就会执行insert钩子,因此把它们进行合并,再插入的时候再去执行)
// 如果dirsWithInsert有值
if (dirsWithInsert.length) {
// 创建一个函数,函数内部遍历dirsWithInsert并且执行inserted钩子
const callInsert = () => {
for (let i = 0; i < dirsWithInsert.length; i++) {
callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
}
}
// 如果是新建的,就和insert钩子进行合并执行
if (isCreate) {
mergeVNodeHook(vnode, 'insert', callInsert)
} else {
// 否则执行指令中的inserted钩子
callInsert()
}
}
如果dirsWithPostpatch有值,就进行遍历执行componentUpdated钩子;
// dirsWithPostpatch有值 就合并componentUpdated和postpatch钩子在dom更新的时候执行
if (dirsWithPostpatch.length) {
mergeVNodeHook(vnode, 'postpatch', () => {
for (let i = 0; i < dirsWithPostpatch.length; i++) {
callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
}
})
}
如果不是新创建的,就遍历旧节点的指令,判断新节点上是否有此指令,没有就表示需要解绑,那么就执行unbind钩子;
// 如果不是新建的
if (!isCreate) {
// 遍历旧节点中的指令
for (key in oldDirs) {
// 如果新节点中不存在此指令,表示是要解绑的,调用unbind钩子
if (!newDirs[key]) {
// no longer present, unbind
callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
}
}
}
normalizeDirectives函数,把指令的值进行格式化处理;
const emptyModifiers = Object.create(null)
/**
* 获取到钩子的值,并且进行统一的格式化操作
* @param {*} dirs
* @param {*} vm
* @returns
格式化之后的结果
{
* v-focus: {
* name: 'focus', // 指令名称
* value: '', // 指令值
* arg: '', // 参数
* modifiers: {}, // 修饰符
* def: { inserted: fn } // 回调
* }
* }
*/
function normalizeDirectives (
dirs: ?Array<VNodeDirective>,
vm: Component
): { [key: string]: VNodeDirective } {
// 创建一个对象
const res = Object.create(null)
// 指令不存在直接返回
if (!dirs) {
// $flow-disable-line
return res
}
let i, dir
// 遍历指令
for (i = 0; i < dirs.length; i++) {
dir = dirs[i]
// 如果修饰符属性不存在就创建一个空的对象
if (!dir.modifiers) {
// $flow-disable-line
dir.modifiers = emptyModifiers
}
// 添加到对象上
res[getRawDirName(dir)] = dir
// 找出指令的值
dir.def = resolveAsset(vm.$options, 'directives', dir.name, true)
}
// $flow-disable-line
return res
}
function getRawDirName (dir: VNodeDirective): string {
return dir.rawName || `${dir.name}.${Object.keys(dir.modifiers || {}).join('.')}`
}
callHook函数,内部直接执行对应的hook函数;
function callHook (dir, hook, vnode, oldVnode, isDestroy) {
const fn = dir.def && dir.def[hook]
if (fn) {
try {
fn(vnode.elm, dir, vnode, oldVnode, isDestroy)
} catch (e) {
handleError(e, vnode.context, `directive ${dir.name} ${hook} hook`)
}
}
}
小结:
- _update函数内部,主要通过新旧的判断是新建还是删除节点,并且通过一个方法把指令进行格式化处理得到新旧节点上的新旧指令;
- 遍历新指令集合如果旧指令集合中不存在此指令,表示新建,就执行bind钩子,并且把指令存储到插入指令的数组中;如果旧指令集合中存在,表示更新,执行update钩子,并且把此指令存入到更新的数组中;
- 接着判断插入指令数组如果有值,如果当前节点是新建的,那么就合并执行insert和inserted钩子;否则不是新建,直接执行inserted钩子
- 判断更新的数组有值,合并执行postpatch和componentUpdated钩子
- 最后判断如果不是新建的节点,并且遍历旧指令集合,如果在新指令中没有找到此指令,表示是删除的,执行unbind钩子;
- 以上就完成了自定义指令中的各个钩子的执行,并且_update函数的执行是在dom插入到文档中之前执行的,并且在子组件创建完成之后;
总结:
自定义指令是在模板编译节点进行解析收集自定义指令属性的,自定义指令在此阶段都是作为属性被添加到ast的directives上;在render函数生成虚拟节点的时候被作为虚拟节点的data下的directives属性;在创建真实dom之后,就会执行自定义指令的各个钩子方法,通过新旧节点来判断是新建的还是删除的还是更新的(旧虚拟dom不存在表示新建,新虚拟dom不存在表示删除),如果是新建的并且新的指令集合中存在,旧的指令集合中不存在的指令就执行bind方法,表示新建并且把当前指令添加到插入数组中;如果旧指令集合中存在此指令,表示更新,执行update方法并且插入到更新数组中;如果插入数组有值,并且是新增的节点就合并执行insert和inserted方法;如果不是新增就执行inserted方法;如果更新数组有值,直接合并执行postpatch和componentUpdated方法;最后遍历旧指令集合如果旧的存在新的不存在那么表示删除,直接调用unbind方法;(自定义指令其实就是在对应的dom创建之后还未插入到文档中之前,通过新旧虚拟节点和新旧指令判断是新建的还是更新的还是删除的,从而执行不同的钩子函数)
转载自:https://juejin.cn/post/7236954203554496573