• home > webfront > ECMAS > vue >

    vue.directive:vue自定义指令钩子函数——从源码解读

    Author:zhoulujun Date:

    如v-model、v-show、v-html等,但是有时候这些指令并不能满足我们,或者我们想为元素附加一些特别的功能,自定义指令解决的问题或者说使用场景是对普通 DOM 元素进行底层操作,所以我们不能盲目的胡乱的使用自定义指令。

    在angularJS里面,写directive还是蛮多了。转到react后,几乎忘记了这么回事。回到vue2.0,用自定义组件外,也基本不太用自定义组件,相比angularJS,写起来总感觉各种不适应。

    vue自定义指令

    看下vue指令官方文档:https://cn.vuejs.org/v2/guide/custom-directive.html

    注意,在 Vue2.0 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令

    用起来也很简单:

    // 注册一个全局自定义指令 `v-focus`
    Vue.directive('focus', {
      //TODO 指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置
      bind(el,binding,vnode){}
      // 当被绑定的元素插入到 DOM 中时……
      inserted: function (el,binding,vnode) {
        // 聚焦元素
        el.focus()
      },
      update:function(el,binding,vnode){ },
      componentUpdated:function(el,binding,vnode){ },
      unbind:function(el,binding,vnode){ },
    })
    // 局部注册
    new Vue({
      directives: {
        myDir: function (el) {
          console.log('myDir bind or update')
        }
      }
    })

    这里需要注意的是钩子函数

    钩子函数

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

    • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。

    • inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。

    • update:所在组件的 VNode 更新时调用但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 (详细的钩子函数参数见下)。

      • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。

      • inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。

      • update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 (详细的钩子函数参数见下)。

    • componentUpdated:指令所在组件的 VNode 及其子 VNode 全部更新后调用。

    • unbind:只调用一次,指令与元素解绑时调用。

    接下来我们来看一下钩子函数的参数 (即 el、binding、vnode 和 oldVnode)。

    钩子函数参数

    指令钩子函数会被传入以下参数:

    • 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 编译生成的虚拟节点。移步 VNode API 来了解更多详情。

    • oldVnode:上一个虚拟节点,仅在 update 和 componentUpdated 钩子中可用。

    除了 el 之外,其它参数都应该是只读的,切勿进行修改。如果需要在钩子之间共享数据,建议通过元素的 dataset 来进行。

    这是一个使用了这些 property 的自定义钩子样例:

    <div id="hook-arguments-example" v-demo:foo.a.b="message" v-pin:[direction]="200"></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(', ')
          el.style.position = 'fixed'
          var s = (binding.arg == 'left' ? 'left' : 'top')
          el.style[s] = binding.value + 'px'
      }
    })
    
    new Vue({
      el: '#hook-arguments-example',
      data: {
        message: 'hello!'
      }
    })

    指令的参数可以是动态的。例如,在 v-mydirective:[argument]="value" 中,argument 参数可以根据组件实例数据进行更新!这使得自定义指令可以在应用中被灵活使用。

    <p v-pin="200">Stick me 200px from the top of the page</p>

    当然,也可以通过html5 dataset 属性设置,data-demo=200

    函数简写

    在很多时候,你可能想在 bind 和 update 时触发相同行为,而不关心其它的钩子。比如这样写:

    Vue.directive('my-click', function(el, binding, vnode, oldVnode){
        //点击事件的回调挂在在元素myClick属性上
        el.myClick && el.removeEventListener('click', el.myClick);
        el.addEventListener('click', el.myClick = function(){
            console.log(el, binding.value)
        })
    })

    初始化运行bind钩子的时候为元素绑定事件,事件内获取的数据是初始化的时候传递过来的数据,因为形成了闭包。

    因为bind和update中的内容差不多,所以我们可以把bind和update合并为同一个函数

    我们在bind钩子中绑定了事件,当数据更新后,会运行update钩子,所以我们可以在update中先解绑再重新进行绑定。

    对象字面量

    如果指令需要多个值,可以传入一个 JavaScript 对象字面量。记住,指令函数能够接受所有合法的 JavaScript 表达式。

    <div v-demo="{ color: 'white', text: 'hello!' }"></div>
    Vue.directive('demo', function (el, binding) {
      console.log(binding.value.color) // => "white"
      console.log(binding.value.text)  // => "hello!"
    })

    这些主要还是为了传递多个参数

    从源码看指令注册流程

    全局注册

    全局有一个directive的方法,这个方法如何实现的呢?

    首先得找到initAssetRegisters方法,该函数注册全局的一些方法,包括:

    // shared/constants
    export const ASSET_TYPES = [
      'component', // 组件
      'directive', // 指令
      'filter' // 过滤器
    ]

    若注册指令,传入的是一个函数,则该函数默认为bindupdate的钩子函数。之后将指令挂载在Vue.options.directives上。同理全局组件与过滤器相应都挂载在Vue.options.componentsVue.options.filters上。

    // core/global-api/assets.js
    import { ASSET_TYPES } from 'shared/constants'
    import { isPlainObject, validateComponentName } from '../util/index'
    
    export function initAssetRegisters (Vue: GlobalAPI) {
      /**
       * Create asset registration methods.
       */
      ASSET_TYPES.forEach(type => {
        Vue[type] = function (
          id: string,
          definition: Function | Object
        ): Function | Object | void {
          if (!definition) {
            return this.options[type + 's'][id]
          } else {
            /* istanbul ignore if */
            if (process.env.NODE_ENV !== 'production' && type === 'component') {
              validateComponentName(id)
            }
            if (type === 'component' && isPlainObject(definition)) {
              definition.name = definition.name || id
              definition = this.options._base.extend(definition)
            }
            /* --------- 这里的directive ---------- */
            if (type === 'directive' && typeof definition === 'function') {
              definition = { bind: definition, update: definition }
            }
            this.options[type + 's'][id] = definition
            return definition
          }
        }
      })
    }


    局部注册以及合并全局选项

    在组件实例生成时,将会对组件进行选项操作。我们调用new Vue(options),其实调用的是Vue.prototype._init(options)

    //core/instance/init.js
    // ... 部分代码已省略
    import { extend, mergeOptions, formatComponentName } from '../util/index'
    ...
    
    export function initMixin (Vue: Class<Component>) {
      Vue.prototype._init = function (options?: Object) {
        const vm: Component = this
        // ...
        // merge options
        if (options && options._isComponent) {
          // optimize internal component instantiation
          // since dynamic options merging is pretty slow, and none of the
          // internal component options needs special treatment.
          initInternalComponent(vm, options)
        } else {
          // 该方法将合并选项的同时,规则化组件选项,包括normalizeDirectives
          vm.$options = mergeOptions(
            // 此处vm为Vue的实例,其constructor指向Vue
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
          )
        }
        // ...
      }
    }
    
    // ...
    
    export function resolveConstructorOptions (Ctor: Class<Component>) {
      // 该options即全局注册时注入的全局对象,将合并入组件中
      // 即包括我们之前提到的全局注册的directives
      let options = Ctor.options
      if (Ctor.super) {
        const superOptions = resolveConstructorOptions(Ctor.super)
        const cachedSuperOptions = Ctor.superOptions
        if (superOptions !== cachedSuperOptions) {
          // super option changed,
          // need to resolve new options.
          Ctor.superOptions = superOptions
          // check if there are any late-modified/attached options (#4976)
          const modifiedOptions = resolveModifiedOptions(Ctor)
          // update base extend options
          if (modifiedOptions) {
            extend(Ctor.extendOptions, modifiedOptions)
          }
          options = Ctor.options = mergeOptions(superOptions, Ctor.extendOptions)
          if (options.name) {
            options.components[options.name] = Ctor
          }
        }
      }
      return options
    }
    // ...

    从源码的角度看指令的执行周期

    在开始介绍钩子函数时,我们其实已经可以发现钩子函数的执行过程和组件的生命周期其实是密不可分的。

    // core/vdom/modules/directives
    
    export default {
      create: updateDirectives,
      update: updateDirectives,
      destroy: function unbindDirectives (vnode: VNodeWithData) {
        updateDirectives(vnode, emptyNode)
      }
    }

    该文件导出三个生命周期对应的回调监听函数,注入组件的生命周期中,在组件执行相应周期时,将会执行对应函数。

    // core/vdom/modules/directives
    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)
    
      const dirsWithInsert = [] // 此次收集的inserted的钩子函数
      const dirsWithPostpatch = [] // 此次收集的componentUpdated的钩子函数
    
      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) {
            // bind之后,收集inserted
            dirsWithInsert.push(dir)
          }
        } else {
          // existing directive, update
          dir.oldValue = oldDir.value
          dir.oldArg = oldDir.arg
          callHook(dir, 'update', vnode, oldVnode)
          // update后收集componentUpdated
          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)
          }
        }
        // inserted注入生命周期内待执行
        if (isCreate) {
          mergeVNodeHook(vnode, 'insert', callInsert)
        } else {
          callInsert()
        }
      }
    
      // componentUpdated注入生命周期内待执行
      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) {
          if (!newDirs[key]) {
            // no longer present, unbind
            callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
          }
        }
      }
    }

    vue directive示例

    父组件控制子组件加载卸载

    const Parent = new Vue({
      components: { Counter },
      data: {
        show: false
      },
      methods: {
        handleControl () {
          this.show = !this.show
        }
      },
      template: `
        <div>
          <counter v-if="show"></counter>
          <button @click="handleControl">{{ show ? '卸载' : '加载' }}</button>
        </div>
      `
    })

    子组件控制更新

    const Counter = new Vue({
      data: {
        count: 1
      },
      created() {
        console.log("组件 created");
      },
      beforeMount() {
        console.log("组件 beforeMount");
      },
      mounted() {
        console.log("组件 mounted");
      },
      beforeUpdate() {
        console.log("组件 beforeUpdate");
      },
      updated() {
        console.log("组件 updated");
      },
      methods: {
        handleUpdate() {
          this.count++;
        }
      },
      directives: {
        demo: {
          bind(el, binding) {
            console.log("demo bind", binding);
          },
          inserted(el) {
            console.log("demo inserted");
          },
          update(el, binding) {
            console.log("demo update", binding.value);
          },
          componentUpdated(el, binding) {
            console.log("demo componentUpdated", binding.value);
          },
          unbind(el) {
            console.log("demo unbind");
          }
        }
      },
      template: `
        <div>
          <span v-demo:a.modifer="count" data-name="demo">{{count}}</span>
          <button @click="handleUpdate">更新</button>
        </div>
      `
    })


    示例



    我们根据输出看binding的值:

    // binding{  name: "demo", // 指令名
      rawName: "v-demo:a.modifer", // 使用时的原始字符串
      value: 1, // 值
      expression: "count", // 字符串
      arg: "a",  modifiers: {    modifer: true
      },  modifer: true}复制代码

    我们具体看子组件加载->更新->卸载时的过程:

    2.jpg



    vue directive 使用注意事项

    组件进行初始化的时候,也就是第一次运行指令的时候,会执行bind钩子函数,我们所传入的参数(binding)都进入到了这里,并形成了一个闭包

    当我们进行数据更新的时候,vue虚拟dom不会销毁这个组件(如果说删除某个数据,会从后往前销毁组件,前面的总是最后销毁),而是进行更新(根据数据改变),如果指令有update钩子会运行这个钩子函数,但是对于元素在bind中绑定的事件,在update中没有处理的话,他不会消失(依然引用初始化时形成的闭包中的数据),所以当我们更改数据再次点击元素后,看到的数据还是原数据。

    Vue.directive('my-click',{
        bind:function(el, binding, vnode, oldVnode){
            el.addEventListener('click',function(){
                console.log(el, binding.value)
            })
        }
    })
    <ul>
        <li v-for="(item,index) in arr" :key="index" v-my-click="item">{{item}}</li>
    </ul>
    <div @click=changeArr()><div>
    {
        data(){
            arr: [1,2,3,4,5,6]
        },
        method:{
            changeArr(){
                arr[5] = 8
            }
    }

    当我们把最后一个元素动态的改为8之后(6 --> 8),点击元素,元素是对的,可是打印的数据却仍然是6.

    源码分析

    函数执行顺序:createElm/initComponent/patchVnode --> invokeCreateHooks (cbs.create) --> updateDirectives --> _update

    在createElm方法和initComponent方法和更新节点patchVnode时会调用invokeCreateHooks方法,它会去遍历cbs.create中钩子函数进行执行,cbs.create中的钩子函数如下图所示共8个。我们所需要看的就是updateDirectives这个函数,这个函数会继续调用_update函数,vue中的指令操作就都在这个_update函数中了。

    下面我们就来详细看下这个_update函数。

    function _update(oldVnode, vnode) {
        //判断旧节点是不是空节点,是的话表示新建/初始化组件
        var isCreate = oldVnode === emptyNode;
        //判断新节点是不是空节点,是的话表示销毁组件
        var isDestroy = vnode === emptyNode;
        //获取旧节点上的所有自定义指令
        var oldDirs = normalizeDirectives$1(oldVnode.data.directives, oldVnode.context);
        //获取新节点上的所有自定义指令
        var newDirs = normalizeDirectives$1(vnode.data.directives, vnode.context);
    
        //保存inserted钩子函数
        var dirsWithInsert = [];
        //保存componentUpdated钩子函数
        var dirsWithPostpatch = [];
    
        var key, oldDir, dir;
        
        //这里先说下callHook$1函数的作用
        //callHook$1有五个参数,第一个参数是指令对象,第二个参数是钩子函数名称,第三个参数新节点,
        //第四个参数是旧节点,第五个参数是是否为注销组件,默认为undefined,只在组件注销时使用
        //在这个函数里,会根据我们传递的钩子函数名称,运行我们自定义组件时,所声明的钩子函数,
        
        //遍历所有新节点上的自定义指令
        for(key in newDirs) {
            oldDir = oldDirs[key];
            dir = newDirs[key];
            //如果旧节点中没有对应的指令,一般都是初始化的时候运行
            if(!oldDir) {
                //对该节点执行指令的bind钩子函数
                callHook$1(dir, 'bind', vnode, oldVnode);
                //dir.def是我们所定义的指令的五个钩子函数的集合
                //如果我们的指令中存在inserted钩子函数
                if(dir.def && dir.def.inserted) {
                    //把该指令存入dirsWithInsert中
                    dirsWithInsert.push(dir);
                }
            } else { 
                //如果旧节点中有对应的指令,一般都是组件更新的时候运行
                //那么这里进行更新操作,运行update钩子(如果有的话)
                //将旧值保存下来,供其他地方使用(仅在 update 和 componentUpdated 钩子中可用)
                dir.oldValue = oldDir.value;
                //对该节点执行指令的update钩子函数
                callHook$1(dir, 'update', vnode, oldVnode);
                //dir.def是我们所定义的指令的五个钩子函数的集合
                //如果我们的指令中存在componentUpdated钩子函数
                if(dir.def && dir.def.componentUpdated) {
                    //把该指令存入dirsWithPostpatch中
                    dirsWithPostpatch.push(dir);
                }
            }
        }
        
        //我们先来简单讲下mergeVNodeHook的作用
        //mergeVNodeHook有三个参数,第一个参数是vnode节点,第二个参数是key值,第三个参数是回函数
        //mergeVNodeHook会先用一个函数wrappedHook重新封装回调,在这个函数里运行回调函数
        //如果该节点没有这个key属性,会新增一个key属性,值为一个数组,数组中包含上面说的函数wrappedHook
        //如果该节点有这个key属性,会把函数wrappedHook追加到数组中
        
        //如果dirsWithInsert的长度不为0,也就是在初始化的时候,且至少有一个指令中有inserted钩子函数
        if(dirsWithInsert.length) {
            //封装回调函数
            var callInsert = function() {
                //遍历所有指令的inserted钩子
                for(var i = 0; i < dirsWithInsert.length; i++) {
                    //对节点执行指令的inserted钩子函数
                    callHook$1(dirsWithInsert[i], 'inserted', vnode, oldVnode);
                }
            };
            if(isCreate) {
                //如果是新建/初始化组件,使用mergeVNodeHook绑定insert属性,等待后面调用。
                mergeVNodeHook(vnode, 'insert', callInsert);
            } else {
                //如果是更新组件,直接调用函数,遍历inserted钩子
                callInsert();
            }
        }
        
        //如果dirsWithPostpatch的长度不为0,也就是在组件更新的时候,且至少有一个指令中有componentUpdated钩子函数
        if(dirsWithPostpatch.length) {
            //使用mergeVNodeHook绑定postpatch属性,等待后面子组建全部更新完成调用。
            mergeVNodeHook(vnode, 'postpatch', function() {
                for(var i = 0; i < dirsWithPostpatch.length; i++) {
                    //对节点执行指令的componentUpdated钩子函数
                    callHook$1(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode);
                }
            });
        }
        
        //如果不是新建/初始化组件,也就是说是更新组件
        if(!isCreate) {
            //遍历旧节点中的指令
            for(key in oldDirs) {
                //如果新节点中没有这个指令(旧节点中有,新节点没有)
                if(!newDirs[key]) {
                    //从旧节点中解绑,isDestroy表示组件是不是注销了
                    //对旧节点执行指令的unbind钩子函数
                    callHook$1(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy);
                }
            }
        }
    }

    callHook$1函数

    function callHook$1(dir, hook, vnode, oldVnode, isDestroy) {
        var 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"));
            }
        }
    }

    参考文章:

    vue 自定义指令 - directive https://juejin.im/post/6844904191777849352

    vue自定义指令--directive https://segmentfault.com/a/1190000018767046





    转载本站文章《vue.directive:vue自定义指令钩子函数——从源码解读》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/vue/8573.html