指令参数:el、binding、vnode、oldVnode 四个参数详解

自定义指令的钩子函数会收到四个参数,分别是 elbindingvnodeoldVnode。很多初学者看到这四个参数就头大,觉得太抽象。其实你想想看,它们各自扮演的角色非常清晰。

我个人习惯把这四个参数分成两类:DOM 相关虚拟 DOM 相关。el 是实实在在的 DOM 元素,binding 是绑定的配置信息,vnode 和 oldVnode 则是 Vue 内部的虚拟节点。

el:真实的 DOM 元素

el 就是指令所绑定的那个 DOM 元素。它是原生的,不是 Vue 包装过的。你可以直接对它进行各种 DOM 操作。

核心要点:el 就是 document.querySelector 拿到的那个东西,完全一样。

// 举个例子
Vue.directive('demo', {
  bind(el) {
    // el 就是 <div v-demo> 这个 div
    el.style.color = 'red'
    el.addEventListener('click', () => {
      console.log('我被点击了')
    })
  }
})

我在项目中遇到过一个问题:有人试图在 el 上使用 Vue 的响应式数据,比如 el.someData = this.someValue。这其实是不对的,el 不是响应式的,你赋值了也不会触发视图更新。要操作数据,得通过 binding 或者组件实例。

注意:el 是真实的 DOM,不是虚拟 DOM。不要试图在 el 上挂载响应式数据,它不会自动更新。

binding:指令的配置信息

这个参数最常用,也最复杂。它是一个对象,包含了指令的所有配置信息。说白了,你写在指令后面的所有东西,都会解析到这个对象里。

binding 对象包含以下属性:

属性名 类型 说明
name String 指令名,不包括 v- 前缀
value Any 指令的绑定值,比如 v-demo="1 + 1" 的值是 2
oldValue Any 上一次的绑定值,仅在 update 和 componentUpdated 中可用
expression String 绑定值的字符串形式,比如 "1 + 1"
arg String 传给指令的参数,比如 v-demo:foo 的参数是 "foo"
modifiers Object 修饰符对象,比如 v-demo.prevent 的 modifiers 是 { prevent: true }

小技巧:我个人习惯在开发时用 console.log(binding) 打印出来看看,一目了然。比看文档快多了。

// 假设模板中这样使用
// <div v-demo:foo.prevent="count + 1"></div>

Vue.directive('demo', {
  bind(el, binding) {
    console.log(binding.name)        // 'demo'
    console.log(binding.value)       // count + 1 的计算结果
    console.log(binding.expression)  // 'count + 1'
    console.log(binding.arg)         // 'foo'
    console.log(binding.modifiers)   // { prevent: true }
  }
})

我曾经踩过一个坑:在 bind 钩子里访问 binding.oldValue,结果发现它是 undefined。后来才意识到,bind 是第一次绑定,哪来的旧值?oldValue 只在 updatecomponentUpdated 里才有意义。

vnode:虚拟节点

vnode 是 Vue 的虚拟节点,它包含了组件或元素的所有信息。比如标签名、属性、子节点、组件实例等等。

说实话,日常开发中用到 vnode 的场景不多。但如果你要做一些高级操作,比如访问组件实例,那就离不开它了。

Vue.directive('get-instance', {
  bind(el, binding, vnode) {
    // 获取组件实例
    const vm = vnode.context
    console.log(vm.$options.name)  // 组件名称
    
    // 如果是组件节点,还可以拿到组件实例
    if (vnode.componentInstance) {
      console.log(vnode.componentInstance.someData)
    }
  }
})

重点:vnode.context 就是当前组件的实例,通过它可以访问组件的数据和方法。但要注意,这破坏了组件的封装性,能不用尽量别用。

oldVnode:上一次的虚拟节点

oldVnode 只在 updatecomponentUpdated 钩子里有值。它代表指令更新前的虚拟节点状态。

你想想看,为什么需要这个参数?因为有时候你需要对比新旧状态,来决定要不要做某些操作。比如一个拖拽指令,你需要知道元素之前的位置和现在的位置,才能计算偏移量。

Vue.directive('track-change', {
  update(el, binding, vnode, oldVnode) {
    // 对比新旧值
    if (binding.value !== binding.oldValue) {
      console.log('值发生了变化')
      console.log('旧值:', binding.oldValue)
      console.log('新值:', binding.value)
    }
    
    // 对比虚拟节点
    if (oldVnode && oldVnode.tag !== vnode.tag) {
      console.log('元素类型变了')
    }
  }
})

注意:在 bind 钩子里 oldVnode 是 undefined,别指望它。只有在 update 和 componentUpdated 里才能用。

实际应用:一个完整的例子

说了这么多理论,不如看个实际例子。这是我之前封装的一个权限指令,用到了上面提到的所有参数:

Vue.directive('permission', {
  bind(el, binding, vnode) {
    const { value, arg } = binding
    const permissions = vnode.context.$store.state.user.permissions
    
    // arg 是权限类型,比如 'read', 'write'
    // value 是权限标识,比如 'article'
    if (!permissions[arg] || !permissions[arg].includes(value)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  },
  
  update(el, binding, vnode, oldVnode) {
    // 如果权限列表变了,重新检查
    const { value, arg } = binding
    const permissions = vnode.context.$store.state.user.permissions
    
    if (!permissions[arg] || !permissions[arg].includes(value)) {
      el.parentNode && el.parentNode.removeChild(el)
    }
  }
})

// 使用方式
// <button v-permission:write="'article'">编辑文章</button>

这个例子中,arg 用来区分权限类型,value 是具体的权限标识,vnode.context 用来获取 Vuex 中的权限数据。嗯,这样设计是不是很清晰?

我的建议:刚开始学自定义指令时,别急着把所有参数都用上。先从 el 和 binding 入手,等用熟了再考虑 vnode 和 oldVnode。循序渐进,反而学得更扎实。

最后说一句,这四个参数虽然看起来多,但每个都有明确的职责。el 管 DOM,binding 管配置,vnode 管虚拟节点,oldVnode 管对比。记住这个分工,你就不会搞混了。