Vue原理-自定义指令 directive

3,877 阅读6分钟

前言

最近在业务开发过程中,发现了之前使用不是很多的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",传递修饰符topbar

钩子函数

一个指令定义对象可以提供如下几个钩子函数:

  • 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:指令绑定的前一个值,仅在updatecomponentUpdated钩子中可用。无论值是否改变都可用。
    • expression:字符串形式的指令表达式。例如v-my-directive="1 + 1"中,表达式为"1 + 1"
    • arg:传给指令的参数,可选。例如v-my-directive:foo中,参数为"foo"
    • modifiers:一个包含修饰符的对象。例如v-my-directive:foo.bar中,修饰符对象为{ bar: true }
  • vnode: Vue编译生成的虚拟节点
  • oldVnode: 上一个虚拟节点,仅在updatecomponentUpdated钩子中可用。

vnode返回的是一个对象,常用的有以下属性(全部属性可以参考这里):

  • tag,当前节点标签名,这里需要注意的是文本也视为一个vnode,保存在children中,并且其tag值为undefined
  • data,当前节点数据(VNodeData类型),classid等HTML属性都放在了data
  • children,当前节点子节点
  • text,节点文本信息
  • elm,当前节点对应的真实DOM节点
  • context,当前节点上下文,指向了Vue实例
  • parent,当前节点父节点
  • componentOptions,组件配置项

这里需要注意的是,在钩子函数中使用this关键字无法找到Vue实例,需要使用vnode.context

函数简写

如果仅在bindupdate时触发相同行为,而不关心其它的钩子,可以进行函数简写:

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

节点在渲染过程中,会有许多钩子函数被调用,其中包含指令的createupdatedestroy3个钩子。这三个钩子都会调用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)
      }
    }
  }
}

这里的bindupdateunbind钩子函数的触发都好理解,我们简单说下insertedcomponentUpdated

因为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
}

参考