directive指令

指令的注册和使用

同组件一样,指令的注册方式有两种:全局注册和局部注册。

全局注册

全局注册指令可以使用全局API方法:Vue.directive()来注册,注册完成以后所有的指令都在Vue.options['directives']选项中。

Vue源码里,Vue.directive()全局API方法的处理如下:

// component, directive, filter
import { ASSET_TYPES } from 'shared/constants'
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
      }
    }
  })
}

假如我们有如下代码:

import Vue from 'vue'
Vue.directive('focus', {
  inserted: function (el) {
    el.focus()
  }
})

在以上代码中,我们全局注册了一个名为focus的指令,其作用是:当绑定该指令的元素被插入到DOM时,此元素自动聚焦。在注册完毕后,我们打印Vue.options['directives'],其结果如下:

{
  show: {...},
  model: {...},
  focus: {
    inserted: function (el) {
      el.focus()
    }
  }
}

我们可以看到,除了我们自己定义的focus指令以外,还有两个showmodel指令,这些指令是Vue默认提供的,我们可以直接使用。

在介绍完全局指令的注册方式后,我们来看一下我们自己定义的focus指令应该如何去使用:

<template>
  <input v-focus />
</template>

局部注册

与组件的局部注册类似,指令的局部注册需要撰写在组件的directives选项中:

<template>
  <div>
    <input v-focus />
  </div>
</template>
<script>
export default {
  name: 'App',
  directives: {
    focus: {
      inserted: function (el) {
        el.focus()
      }
    }
  }
}
</script>

当我们打印App组件实例的$options['directives']属性时,我们可以得到如下结果:

{
  focus: {
    inserted: function (el) {
      el.focus()
    }
  },
  __proto__: {
    model: {},
    show: {}
  }
}

我们可以看到对于子组件而言,自身局部注册的指令和全局注册的指令的处理方式是不相同的,全局注册的指令会被挂载到组件directives对象的原型上。

自定义指令

除了我们之前提到过的全局默认指令v-showv-model以外,我们还可以选择注册自定义指令。就像在指令的注册和使用小节中一样,我们注册的v-focus指令就是自定义指令。

钩子函数

介绍自定义指令之前,我们有必要来了解一下指令的钩子函数。

  • bind: 只调用一次,指令第一次绑定到元素时调用,在这里可以进行一次性的初始化设置。
  • inserted: 被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
  • update: 所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。
  • componentUpdated: 指令所在组件的 VNode 及其子 VNode 全部更新后调用。
  • unbind: 只调用一次,指令与元素解绑时调用。

以上钩子函数都是可选的,利用不同的钩子函数,我们可以在不同的状态下做更多的事情。

钩子函数参数

在日常开发过程中,你可能见到过很多种使用指令的方式,如下:

<input v-model="inputValue" />
<input v-model:value="inputValue" />
<div v-show="age > 18"></div>
<div v-focus:foo="bar"></div>
<div v-font.color.fontSize="{ color: '#f60', fontSize: '14px' }"></div>

以上所有形式的参数,都会在钩子函数的第二个参数binding对象中体现出来:

{
  inserted: function (el, binding) {
    console.log(binding.name)
    console.log(binding.value)
    console.log(binding.oldValue)
    console.log(binding.expression)
    console.log(binding.arg)
    console.log(binding.modifiers)
  }
}

其中binding对象包含以下属性:

  • name:指令的名字,不带v前缀,例如:modelshow以及focus
  • value:指令的绑定值,这个值是一个合法的JavaScript表达式,例如:age > 18根据age的大小,绑定值为truefalse。或者直接绑定一个对象{ color: '#f60', fontSize: '14px' }
  • oldValue:指令上一次绑定的值,这个值只在updatecomponentUpdated这两个钩子函数中可用。
  • expression:指令的字符串形式表达式,例如:v-show="age > 18",表达式就是age > 18
  • arg:指令的参数,例如:v-model:value="inputValue",参数为value
  • modifiers:修饰符对象,例如:v-font.color.fontSize,修饰符对象为:{color: true, fontSize: true}

指令的解析和指令的运行

我们以如下代码为例来分析指令的解析和运行。

new Vue({
  el: '#app',
  directives: {
    focus: {
      inserted: function (el) {
        el.focus()
      }
    }
  },
  template: '<input v-focus />'
})

指令的解析

在编译原理章节中,我们了解了一些与编译相关的流程。以上面代码为例,template模板里的内容会调用parse编译成ast

const ast = parse(template.trim(), options)

parse编译的时候,v-focus首先会被解析到ast的属性数组中:

const ast = {
  tag: 'input',
  attrsList: [
    { name: 'v-focus' }
  ],
  attrMap: {
    'v-focus': ''
  }
}

然后在input标签触发编译end钩子函数的时候,在processElement函数中调用了processAttrs来处理属性:

export function processElement (
  element: ASTElement,
  options: CompilerOptions
) {
  // ...省略其它
  processAttrs(element)
  return element
}

processAttrs方法的省略代码如下:

export const dirRE = process.env.VBIND_PROP_SHORTHAND ? /^v-|^@|^:|^\.|^#/ : /^v-|^@|^:|^#/
export const bindRE = /^:|^\.|^v-bind:/
export const onRE = /^@|^v-on:/
function processAttrs (el) {
  const list = el.attrsList
  let i, l, name, rawName, value, modifiers, syncGen, isDynamic
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name
    value = list[i].value
    if (dirRE.test(name)) {
      // ...省略代码
      if (bindRE.test(name)) {
        // v-bind逻辑
      } else if (onRE.test(name)) {
        // v-on逻辑
      } else {
        name = name.replace(dirRE, '')
        // parse arg
        const argMatch = name.match(argRE)
        let arg = argMatch && argMatch[1]
        isDynamic = false
        if (arg) {
          name = name.slice(0, -(arg.length + 1))
          if (dynamicArgRE.test(arg)) {
            arg = arg.slice(1, -1)
            isDynamic = true
          }
        }
        addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i])
        if (process.env.NODE_ENV !== 'production' && name === 'model') {
          checkForAliasModel(el, value)
        }
      }
    }
  }

processAttrs代码分析:

  • 首先通过dirRE正则表达式匹配v-focus是否满足指令的形式。
  • 然后再通过bindRE正则表达式判断是否为v-bind
  • 如果不是则继续使用onRE正则表达式判断是否为v-on
  • 如果都不是则表示其为normal directives

else分支中,我们通过调用addDirective方法,来把attrsList中的v-focus指令添加到ast对象的directives数组中,addDirective代码如下:

export function addDirective (
  el: ASTElement,
  name: string,
  rawName: string,
  value: string,
  arg: ?string,
  isDynamicArg: boolean,
  modifiers: ?ASTModifiers,
  range?: Range
) {
  (el.directives || (el.directives = [])).push(rangeSetItem({
    name,
    rawName,
    value,
    arg,
    isDynamicArg,
    modifiers
  }, range))
  el.plain = false
}

在处理完毕后,我们的ast值如下:

const ast = {
  tag: 'input',
  attrsList: [
    { name: 'v-focus' }
  ],
  attrMap: {
    'v-focus': ''
  },
  directives: [
    { name: 'v-focus', value: '' }
  ]
}

parse完毕后,会调用generate来生成render函数,由于这个过程在编译原理已经提到过了,所以我们直接看最后生成的render函数:

const code = generate(ast, options)

// code打印结果
{
  render: "with(this){ return _c('input', {directives: [{ name: 'focus', rawName: 'v-focus' }]})}",
  staticRenderFns: []
}

指令的运行

render函数生成后,当组件patch的时候会调用render函数来生成虚拟DOM

接下来,让我们回顾一下patch方法以及虚拟DOM的钩子函数:

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })

在调用createPatchFunction方法的时候,我们传递了一个modules数组。在这个modules中,我们关注baseModules

// src/core/vdom/modules/index.js
import directives from './directives'
export default [ directives ]

// src/core/vdom/modules/directives.js
export default {
  create: updateDirectives,
  update: updateDirectives,
  destroy: function unbindDirectives (vnode: VNodeWithData) {
    updateDirectives(vnode, emptyNode)
  }
}
function updateDirectives () {
  // ...省略代码
}

directives.js文件中导出了一个对象,这个对象的键有三个:createupdate以及destroy。这三个对应组件的hooks钩子函数,也就是说当组件触发createupdate以及destroy的时候会自动调用updateDirectives或者unbindDirectives方法。

然后,让我们回顾一下createPatchFunction方法是如何处理这些钩子函数的:

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
export function createPatchFunction (backend) {
  let i, j
  const cbs = {}
  const { modules, nodeOps } = backend
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  // ...省略代码
}

// cbs打印结果
{
  ...,
  create: [
    ...,
    updateDirectives(oldVnode, vnode) {}
  ],
  update: [
    ...,
    updateDirectives(oldVnode, vnode) {}
  ],
  destroy: [
    ...,
    unbindDirectives(vnode) {}
  ]
}

patch方法执行的过程中,会在合适的时机调用invokeCreateHooks来触发create钩子函数,会在合适的时机调用invokeDestroyHook来触发destroy钩子函数以及会在patchVnode方法中遍历cbs.update数组并执行update数组中的方法。

// invokeCreateHooks代码
function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) i.create(emptyNode, vnode)
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

// patchVnode
function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  // ... 省略代码
  if (isDef(data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }
  // ...省略代码
}

既然我们已经搞清楚了updateDirectivesunbindDirectives方法调用的时机,那么我们接下来就看一下directive.js文件中关于这两个方法的定义。

unbindDirectives方法

// directives.js
export default {
  create: updateDirectives,
  update: updateDirectives,
  destroy: function unbindDirectives (vnode: VNodeWithData) {
    updateDirectives(vnode, emptyNode)
  }
}

我们可以在unbindDirectives方法的定义中看到,其方法内部调用了updateDirectives方法,并且给该方法的第二个参数传递了一个emptyNode空节点来实现对指令的解绑。

updateDirectives方法

updateDirectives方法的定义很简单,如下:

function updateDirectives (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (oldVnode.data.directives || vnode.data.directives) {
    _update(oldVnode, vnode)
  }
}

updateDirectives中,它仅仅只是调用了_update,接下来让我们看一看这个核心方法的代码实现:

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)
      }
    }
  }
}

代码分析:

  • 变量说明:
  1. isCreate如果oldVnode为空节点,则表示当前vnode为新创建的节点。
  2. isDestroy如果当前vnode为空节点,则表示oldVnode应该被销毁。
  3. oldDirs存储旧指令集合。
  4. newDirs存储新指令集合。
  5. dirsWithInsert需要触发inserted钩子函数的指令集合。
  6. dirsWithPostpatch需要触发componentUpdated钩子函数的指令集合。
  • 格式化指令:格式化指令调用了normalizeDirectives方法,其代码如下:
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
}

我们以v-focus指令为例,格式化前、格式化后结果如下:

// 格式化前
const directives = [
  { name: 'focus', rawName: 'v-focus' }
]

// 格式化后
const directives = {
  'v-focus': {
    name: 'focus',
    rawName: 'v-focus',
    modifiers: {},
    def: {
      inserted: function (el) {
        el.focus()
      }
    }
  }
}
  • 遍历新指令集合:使用for循环遍历新指令集合,并且拿每次遍历的key在新、旧指令集合中取值,如果当前指令在旧指令集合中没有,则表示其为新指令。对于新指令而言,我们首先使用callhook触发指令的bind钩子函数,然后根据它是否定义了inserted钩子函数,来决定是否需要添加到dirsWithInsert数组中。如果当前指令在旧指令集合中有,则应该触发指令的update钩子函数,并且根据指令是否定义了update钩子函数,来决定是否需要添加到dirsWithPostpatch数组中。

  • 判断dirsWithInsert:如果dirsWithInsert数组有值,则根据isCreate的值来决定是直接调用callInsert,还是在虚拟DOMinsert钩子函数被触发的时候执行callInsert。对于新节点而言,这样做的目的是为了保证inserted钩子函数是在被绑定元素插入到父节点的时候调用

  • 判断dirsWithPostpatch:如果dirsWithPostpatch数组有值,则将其遍历并触发指令componentUpdated钩子函数封装起来并且合并到虚拟DOMpostpatch钩子函数上。这样做的目的是为了保证:组件的VNode以及子VNode全部更新完毕后才调用componentUpdated钩子函数

  • 触发unbind钩子函数:如果当前节点不是新增节点,则遍历旧指令集合。在遍历的过程中,所有不在新指令集合中的指令,都需要触发指令的unbind钩子函数。

小结

在这一章节,我们回顾了指令的注册方式、使用方式;了解了指令的钩子函数以及各种钩子函数参数的作用;学习了指令是如何被解析以及在什么时候运行;最后还学习了指令内部是如何根据不同的情况,触发对应的钩子函数的。

最后更新时间:
贡献者: wangtunan