前言
最近在业务开发过程中,发现了之前使用不是很多的Vue功能-自定义指令,实现了部分元素逻辑的抽象复用。这里对其进行了简单的分析整理。
注册
自定义指令分为两种注册方式:
- 全局注册
Vue.directive('focus', {
// 当被绑定的元素插入到 DOM 中时……
inserted: function (el) {
// 聚焦元素
el.focus()
}
})
注意,若Vue.directive
第二个参数未传入数据,则根据指令名称返回已注册的指令。
- 针对某个组件的局部注册
directives: {
focus: {
// 指令的定义
inserted: function (el) {
el.focus()
}
}
}
注册完毕后,可以直接在元素上添加v-focus
来使用。
<input v-focus>
当然,除了上面的格式,也可以在该指令上添加一些额外信息:
v-name="data"
,传递数值给指令,这里的data可以是组件中的data数据,也可以是methods方法。v-myon:click="clickHandle"
,传递参数click
,这里可以通过[xx]
的格式动态传递参数。v-myon:click.top.bar="clickHandle"
,传递修饰符top
和bar
。
钩子函数
一个指令定义对象可以提供如下几个钩子函数:
- bind
只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置,比如样式设置等。
// html
<div v-red></div>
// js
Vue.directive('red', {
bind: (el, binding) => {
el.style.background = 'red';
}
});
- inserted
被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
这里一般执行和JS行为有关的操作,比如用来给元素添加一些监听事件:
// html
<span v-down={ url: 'xx', name: 'xx'} />
// js
Vue.directive('down', {
inserted: (el, binding) => {
el.addEventListener('click', () => {
// 执行下载事件
});
}
});
- update
所在组件的VNode
更新时调用,但是可能发生在其子VNode
更新之前,可多次触发。
指令的值可能发生了变化,也可能未变化,我们可以通过比较新旧VNode
来做具体判断。
- componentUpdated
指令所在组件的VNode
及其子VNode
全部更新后调用。
- unbind
只调用一次,指令与元素解绑时调用。
执行顺序
钩子函数的执行顺序如下:
bind ==> inserted ==> updated ==> componentUpdated ==> bind
函数参数
下面是钩子函数调用时传入的参数,详细信息可点击这里:
- el: 指令所绑定的元素,可以用来直接操作DOM。
- binding: 一个对象,包含指令的很多信息。
- 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
中,修饰符对象为{ bar: true }
。
- name:指令名,不包括
- vnode: Vue编译生成的虚拟节点
- oldVnode: 上一个虚拟节点,仅在
update
和componentUpdated
钩子中可用。
vnode
返回的是一个对象,常用的有以下属性(全部属性可以参考这里):
- tag,当前节点标签名,这里需要注意的是文本也视为一个
vnode
,保存在children
中,并且其tag
值为undefined
- data,当前节点数据(VNodeData类型),
class
、id
等HTML属性都放在了data
中 - children,当前节点子节点
- text,节点文本信息
- elm,当前节点对应的真实DOM节点
- context,当前节点上下文,指向了Vue实例
- parent,当前节点父节点
- componentOptions,组件配置项
这里需要注意的是,在钩子函数中使用this
关键字无法找到Vue实例,需要使用vnode.context
。
函数简写
如果仅在bind
和update
时触发相同行为,而不关心其它的钩子,可以进行函数简写:
Vue.directive('color-swatch', function (el, binding) {
el.style.backgroundColor = binding.value
})
源码学习
注:以下源码解析都是基于版本2.6.12
。
初始对象
Vue源码从源头上看,来自core目录下的instance/index.js
文件,并且会在core里的index.js
,再次调用initGlobalAPI(Vue)
来初始化全局的api方法,我们先来看一下initGlobalAPI
。
export function initGlobalAPI (Vue: GlobalAPI) {
...
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})
...
}
在目录shared/constants
下的ASSET_TYPES
为数组['component','directive','filter']
,这里会在options
中生成初始的directives
对象,用于保存Vue的自定义指令。
全局方法
initGlobalAPI
方法继续往下,会执行initAssetRegisters(Vue)
方法,会声明Vue的directive
方法。在调用directive
方法时,会添加指令到前面生成的Vue.options.directives
对象中。
export function initAssetRegisters (Vue: GlobalAPI) {
ASSET_TYPES.forEach(type => {
Vue[type] = function (
id: string,
definition: Function | Object
): Function | Object | void {
if (!definition) {
return this.options[type + 's'][id]
} else {
...
if (type === 'directive' && typeof definition === 'function') {
definition = { bind: definition, update: definition }
}
this.options[type + 's'][id] = definition
return definition
}
}
})
}
局部方法
查看完了initGlobalAPI
,我们再回到instance/index.js
文件,在initMixin
中声明Vue.prototype._init
方法,其中会调用mergeOptions
方法生成$options
信息:
Vue.prototype._init = function (options?: Object) {
...
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
而在mergeOptions
方法中,会处理组件内部的directives
信息,并进行合并。
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object {
...
normalizeDirectives(child)
...
const options = {}
...
for (key in child) {
if (!hasOwn(parent, key)) {
mergeField(key)
}
}
function mergeField (key) {
const strat = strats[key] || defaultStrat
options[key] = strat(parent[key], child[key], vm, key)
}
return options
}
function normalizeDirectives (options: Object) {
const dirs = options.directives
if (dirs) {
for (const key in dirs) {
const def = dirs[key]
if (typeof def === 'function') {
dirs[key] = { bind: def, update: def }
}
}
}
}
// 设置directives的合并逻辑
function mergeAssets (
parentVal: ?Object,
childVal: ?Object,
vm?: Component,
key: string
): Object {
const res = Object.create(parentVal || null)
if (childVal) {
process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
return extend(res, childVal)
} else {
return res
}
}
ASSET_TYPES.forEach(function (type) {
strats[type + 's'] = mergeAssets
})
这里可以看出,合并的是组件内部directives
对象和全局directives
对象通过Object.create
生成的对象,所以组件内的自定义指令会优先全局自定义指令。
模板解析
模板上指令会被解析成数组,类似下面的格式:
with(this) {
return _c('div', {
directives: [{
name: "down",
rawName: "v-down",
value: 'value',
...
}]
})
}
directives
中的信息为指令钩子函数中的binding
参数的数据。
钩子函数触发
Vue中有专门的方法来处理指令,即updateDirectives
。
节点在渲染过程中,会有许多钩子函数被调用,其中包含指令的create
、update
、destroy
3个钩子。这三个钩子都会调用updateDirectives
方法。
// 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)
}
}
可以看见,三个钩子其实都调用了_update
方法,下面我们来看下该方法。
Vue实例中的指令钩子函数获取
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)
...
}
function normalizeDirectives (
dirs: ?Array<VNodeDirective>,
vm: Component
): { [key: string]: VNodeDirective } {
const res = Object.create(null)
...
let i, dir
for (i = 0; i < dirs.length; i++) {
dir = dirs[i]
...
res[getRawDirName(dir)] = dir
dir.def = resolveAsset(vm.$options, 'directives', dir.name, true)
}
return res
}
这里可以看见,_update
中会先调用normalizeDirectives
,在其中,传入节点的指令解析后的数据,并根据指令名称,去$options.directives
获取对应的指令钩子函数,并添加到当前指令模板数据上,即如下格式:
directives: [{
name: "down",
rawName: "v-down",
...
def:{
bind(){...},
... 其他钩子
}
}]
Vue实例中的指令钩子函数触发
当我们拿到Vue实例中定义的指令钩子函数后就要开始分别调用它们。
function _update (oldVnode, vnode) {
...
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)
}
}
}
}
这里的bind
、update
、unbind
钩子函数的触发都好理解,我们简单说下inserted
和componentUpdated
。
因为inserted
需要在被绑定元素插入父节点时调用,componentUpdated
需要在指令所在组件的VNode
及其子VNode
全部更新后调用,所以通过mergeVNodeHook
将其添加到节点的insert
钩子和postpatch
钩子中(注:后面研究下页面渲染导致的节点钩子触发逻辑)。
// src/core/vdom/helpers/merge-hook.js
export function mergeVNodeHook (def: Object, hookKey: string, hook: Function) {
if (def instanceof VNode) {
def = def.data.hook || (def.data.hook = {})
}
let invoker
const oldHook = def[hookKey]
function wrappedHook () {
hook.apply(this, arguments)
remove(invoker.fns, wrappedHook)
}
if (isUndef(oldHook)) {
// no existing hook
invoker = createFnInvoker([wrappedHook])
} else {
/* istanbul ignore if */
if (isDef(oldHook.fns) && isTrue(oldHook.merged)) {
// already a merged invoker
invoker = oldHook
invoker.fns.push(wrappedHook)
} else {
// existing plain hook
invoker = createFnInvoker([oldHook, wrappedHook])
}
}
invoker.merged = true
def[hookKey] = invoker
}
// src/core/vdom/helpers/update-listeners.js
export function createFnInvoker (fns: Function | Array<Function>, vm: ?Component): Function {
function invoker () {
const fns = invoker.fns
if (Array.isArray(fns)) {
const cloned = fns.slice()
for (let i = 0; i < cloned.length; i++) {
invokeWithErrorHandling(cloned[i], null, arguments, vm, `v-on handler`)
}
} else {
// return handler return value for single handlers
return invokeWithErrorHandling(fns, null, arguments, vm, `v-on handler`)
}
}
invoker.fns = fns
return invoker
}