自定义指令 clickoutside

7,442 阅读9分钟

Element源码中的clickoutside

在node_moudles的element-ui中的可以找到clickoutside.js的源码 如果我们在模板中想利用Element中的clickoutside,可以注册成局部指令。

<template>
    <div v-clickoutside="handleClose" v-show="flag"></div>
</template>
<script>
    import Clickoutside from "element-ui/src/utils/clickoutside"
    export default{
        data(){
            return {
                flag: true
            }
        },
        directives: { Clickoutside },
        methods: {
            handleClose(){
                this.flag = false;
            }
        }
    }    
</script>

自定义指令clickoutside

下面对Element中clickoutside进行了简化,在没有引入element的时候也可以用。

const clickoutsideContext = '@@clickoutsideContext';
export default {
    bind(el, binding, vnode) {
        console.log(el);
        const documentHandler = (e) => {
            if (vnode.context && el !== e.target && !el.contains(e.target)) {
                vnode.context[el[clickoutsideContext].methodName]();
            }
        };
        el[clickoutsideContext] = {
            documentHandler,
            methodName: binding.expression,
            arg: binding.arg || 'click',
        };
        document.addEventListener(el[clickoutsideContext].arg, documentHandler, false);
    },
    update(el, binding) {
        el[clickoutsideContext].methodName = binding.expression;
    },
    unbind(el) {
        document.removeEventListener(
            el[clickoutsideContext].arg,
            el[clickoutsideContext].documentHandler);
    },
    install(Vue) {
        Vue.directive('clickoutside', {
            bind: this.bind,
            unbind: this.unbind,
        });
    },
};

在组件中也是要通过注册指令来使用。

<template>
    <div v-clickoutside="outFn" v-show="flag"></div>
</template>
<script>
    import clickoutside from "./clickoutside"
    export default{
        data(){
            return {
                flag: true
            }
        },
        directives: { clickoutside },
        methods: {
            outFn(){
                this.flag = false;
            }
        }
    }    
</script>

v-click-outside-x

npm中也有关于clickoutside的插件,例如v-click-outside、v-click-outside-x,个人推荐使用v-click-outside-x

使用v-click-outside-x,

<template>
   <div v-click-outside="onClickOutside"></div>
   
   <!-- 阻止冒泡 -->
   <div v-click-outside:mouseup="doThis"></div>
   <div v-click-outside:mouseup.stop="doThis"></div>
   
   <!-- 去除默认行为  -->
   <div v-click-outside.prevent="doThat"></div>

   <!-- 阻止冒泡与默认行为 -->
   <div v-click-outside.stop.prevent="doThat"></div>

   <!-- 事件捕获 -->
   <div v-click-outside.capture="doThis"></div>
   
   <!-- 绑定多个事件类型 -->
   <div
    v-click-outside.mouseup="onClickOutside1"
    v-click-outside:click="onClickOutside2"
    v-click-outside:dblclick.capture="onClickOutside3"
  ></div>
</template>

<script>
import * as vClickOutside from 'v-click-outside-x';
  export default {
    directives: {
      clickOutside: vClickOutside.directive,
    },
    methods: {
      onClickOutside (event) {
        console.log('Clicked outside. Event: ', event)
      },
      onClickOutside1(){},
      onClickOutside2(){},
      onClickOutside3(){}
    }
  };
</script>

v-click-outside-x源码:

//version: 4.1.0
const CLICK = 'click';
const captureInstances = Object.create(null);           //冒泡事件类型对象{}
const nonCaptureInstances = Object.create(null);        //捕获事件类型对象{}
const captureEventHandlers = Object.create(null);       //冒泡事件处理程序对象{}
const nonCaptureEventHandlers = Object.create(null);    //捕获事件处理程序对象{}
const instancesList = [captureInstances, nonCaptureInstances];

const commonHandler = function onCommonEvent(context, instances, event, arg) {

    //获取事件触发的目标元素
    const { target } = event;

    const itemIteratee = function itemIteratee(item) {
        //获取指令绑定的元素,虚拟Dom,VNode
        const { el } = item;

        //如果事件触发的目标元素,不是当前指令绑定的元素,或子元素
        //那么就触发事件处理程序,并绑定上下文
        if (el !== target && !el.contains(target)) {
            const { binding } = item;
            if (binding.modifiers.stop) {
                event.stopPropagation();
            }
            if (binding.modifiers.prevent) {
                event.preventDefault();
            }

            //触发事件处理程序,绑定上下文
            binding.value.call(context, event);
        }
    };

    instances[arg].forEach(itemIteratee);
};

/**
 * @param {boolean} useCapture,默认值是false表示事件冒泡,设置为true表示事件捕获
 */

//这个方法的返回值,是事件处理的核心程序
//每一种事件类型都对应一个方法,这也是事件触发时执行的方法,
//因此在这个方法中,可以拿到事件默认参数event,以及上下文this,事件触发的目标元素
const getEventHandler = function getEventHandler(useCapture, arg) {
    if (useCapture) {
        if (captureEventHandlers[arg]) {
            return captureEventHandlers[arg];
        }
        captureEventHandlers[arg] = function onCaptureEvent(event) {
            commonHandler(this, captureInstances, event, arg);
        };
        return captureEventHandlers[arg];
    }
    if (nonCaptureEventHandlers[arg]) {
        return nonCaptureEventHandlers[arg];
    }
    nonCaptureEventHandlers[arg] = function onNonCaptureEvent(event) {
        commonHandler(this, nonCaptureInstances, event, arg);
    };
    return nonCaptureEventHandlers[arg];
};

export const directive = Object.defineProperties(
    {},
    {
        $captureInstances: {
            value: captureInstances,
        },
        $nonCaptureInstances: {
            value: nonCaptureInstances,
        },
        $captureEventHandlers: {   //捕获事件处理程序{}
            value: captureEventHandlers,
        },
        $nonCaptureEventHandlers: {  //冒泡事件处理程序{}
            value: nonCaptureEventHandlers,
        },
        bind: {
            value: function bind(el, binding) {

                //自定义指令绑定的值必须是一个函数
                if (typeof binding.value !== 'function') {
                    throw new TypeError('Binding value must be a function.');
                }

                //如果自定义指令没有传参默认是click,也就是事件处理程序触发的方式,默认是点击操作
                const arg = binding.arg || CLICK;
                const normalisedBinding = {  //取到binding里面的值
                    ...binding,
                    ...{
                        arg,

                        //修饰符对象,共有三个属性,默认冒泡、不阻止默认事件,不阻止冒泡,
                        //当然了指定定义了修饰符时,会覆盖默认值。
                        modifiers: {
                            ...{
                                capture: false,
                                prevent: false,
                                stop: false,
                            },
                            ...binding.modifiers,
                        },
                    },
                };
                const useCapture = normalisedBinding.modifiers.capture;
                const instances = useCapture ? captureInstances : nonCaptureInstances;

                //在冒泡或捕获对象中,如果arg这个属性值不是一个数组,那就变成数组。
                //也就是说,每增加一种自定义指令触发事件处理程序的方式,
                //都会多一对键值对,例如默认触发的方式是"click",那么{ "click": []},
                //如果又增加一个通过双击触发的方式,就会变成{ "click": [], "dblclick": []}
                if (!Array.isArray(instances[arg])) {
                    instances[arg] = [];
                }

                //当每种事件类型第一次绑定时,我们会在document上监听相同的事件类型,
                //并且,每种事件类型,document只会添加一个事件处理程序,也就是getEventHandler(useCapture, arg)
                //这个方法的返回值就是事件处理的核心程序!
                if (instances[arg].push({ el, binding: normalisedBinding }) === 1) {
                    if (typeof document === 'object' && document) {
                        document.addEventListener(arg, getEventHandler(useCapture, arg), useCapture);
                    }
                }
            },
        },

        unbind: {
            value: function unbind(el) {
                const compareElements = function compareElements(item) {
                    return item.el !== el;
                };
                const instancesIteratee = function instancesIteratee(instances) {

                    //得到了事件类型的数组,eg: ["click", "dblclick"];
                    const instanceKeys = Object.keys(instances);

                    if (instanceKeys.length) {

                        //是否是捕获
                        const useCapture = instances === captureInstances;

                        const keysIteratee = function keysIteratee(eventName) {

                            //在当前解除的事件类型对应的事件处理数组中,找到那么些未解除绑定的事件处理程序
                            const newInstance = instances[eventName].filter(compareElements);

                            //如果存在未解除绑定的事件处理程序,那么就将成为最新的事件处理数组,
                            //意思就是把解除绑定的事件从事件处理数组中删除掉!
                            if (newInstance.length) {
                                instances[eventName] = newInstance;
                            } else {

                                //如果不存在未解除绑定的事件处理程序,那么就更简单了,直接让document取消对该事件类型的监听!
                                //并且在事件处理程序{}对象上,删除该属性
                                if (typeof document === 'object' && document) {
                                    document.removeEventListener(eventName, getEventHandler(useCapture, eventName), useCapture);
                                }
                                delete instances[eventName];

                            }
                        };

                        //循环事件类型的数组
                        instanceKeys.forEach(keysIteratee);
                    }
                };

                //指令与元素解除绑定时,循环冒泡对象与捕获对象,
                instancesList.forEach(instancesIteratee);
            },
        },
    },
);

export function install(Vue) {
    Vue.directive('click-outside', directive);
}

源码解析:

源码中的注释,是本人对这个插件的理解,directives的源码还在学习中!

总结:

clickoutside的实现的思路大致都是相同的,都是通过addEventListener给document添加事件监听器,然后在利用contains判断是不是当前元素或是其内部元素,来触发事件处理程序。

v-click-outside-x插件中,指令绑定的值,只允许是一个函数。在阅读源码的时候可以看到,这个插件设计的巧妙之处在于,无论在多少个元素上使用该自定义指令(clickOutside),每一种事件类型比如“click、mouseup、dblclick”,都只会给document上面绑定一个事件,在内部通过循环来处理多个事件处理程序,性能有很大提升(在题外篇会介绍性能提升的原因),而且还增加了一些类似修饰符的功能。

题外篇:

要点一:

这里要说的第一点就是,在使用事件时例如click,需要注意事件处理程序的this指向的是事件触发的元素,也就是本身,但是内部的event.target指向的是事件触发的目标元素,不一定就是本身,因此我们在做clickoutside的时候要用event.target去判断,而不能用this,举个例子:

<div id="box" style="padding: 20px;background-color:#ccc;">
    <button id="button">按钮</button>
</div>

<script>
var box = document.getElementById("box");
var button = document.getElementById("button");

button.addEventListener( "click", function(event){
    console.log("我是button事件一!");
    Promise.resolve().then(function(){
		console.log( "我是buttton中的Promise实例1!");
	});
});

button.addEventListener( "click", function(event){
    console.log("我是button事件二!");
    var that = this;
    Promise.resolve().then(function(){
        console.log( "我是buttton中的Promise实例2!");
		console.log( "button: " + (that === event.target));
	});
});

box.addEventListener( "click", function(event){
    console.log("我是box事件!");
    var that = this;
    Promise.resolve().then(function(){
        console.log( "我是box中的Promise实例!");
        console.log( "box: " + (that === event.target));
	});
});
// 我是button事件一!
// 我是buttton中的Promise实例1!

// 我是button事件二!
// 我是buttton中的Promise实例2!
// button: true

// 我是box事件!
// 我是box中的Promise实例!
// box: false

</script>

当我们点击button时,button是click事件触发的目标元素,因此this === event.target为true。我们也知道这个click事件会默认冒泡到父元素box上面,box上的click事件也会触发,内部this指向box,但是event.target指向的目标元素,事件触发的元素,因此的event.target永远指向button,所以box里面的this === event.target为false。

同时我们还要注意一个细节,就是我们在button监听了两个事件,当button上的两个事件都触发之后,才会冒泡到父元素box上面,触发它的click事件。我们也知道用户操作触发的事件是一个宏任务,而Promise.then是一个微任务。当前click产生的微任务是在本轮事件循环中执行的,那么我们就可以看出,button上的两个事件产生了两个宏任务,box上面产生了一个宏任务!两个事件产生了两个宏任务,如果事件里面有对应的dom操作,还会在事件循环的末尾增加一次UI渲染的操作,其实是对性能极大的浪费。

此时,我们想到了click-outsde-x插件上面的处理,它定义了一个事件处理程序的数组,一种事件类型只会document上面绑定一个处理事件,循环的处理事件的核心程序,这样无论在多少个元素上面添加自定义指令,一种事件类型触发时也只会产生一个宏任务,这对性能来说是一个极大的提升,可能会减少很多次的事件循环!这也是个人推荐使用click-outside-x这个插件的主要原因!

要点二:

第二点,v-click-outside-x这个插件可通过Vue.use,变成全局使用

import Vue from 'vue';
import * as vClickOutside from 'v-click-outside-x';

Vue.use(vClickOutside);

<script>
  export default {
    methods: {
      onClickOutside(event) {
        console.log('Clicked outside. Event: ', event);
      },
    },
  };
</script>

<template>
  <div v-click-outside="onClickOutside"></div>
</template>

接下来了解一下Vue.use,二话不说,上源码

Vue.use = function (plugin) {
    var installedPlugins = (this._installedPlugins || (this._installedPlugins = []));
    if (installedPlugins.indexOf(plugin) > -1) {
        return this;
    }
    
    // additional parameters
    var args = toArray(arguments, 1);
    args.unshift(this);
    if (typeof plugin.install === 'function') {
        plugin.install.apply(plugin, args);
    } else if (typeof plugin === 'function') {
        plugin.apply(null, args);
    }
    
    installedPlugins.push(plugin);
    return this;
};

use是Vue构造函数上的一个静态方法,作用就是注册全局插件。
这段代码好像也没啥好解释的,如果插件的plugin.install是方法就执行,否则如果plugin是方法也去执行。

export function install(Vue) {
    Vue.directive('click-outside', directive);
}

Vue.use(vClickOutside);

这样就可以看出,调用use这个方法就是全局添加了click-outside这个自定义指令。 再看一个例子:

Vue.use(VueRouter);

Vue.mixin({
    beforeCreate: function beforeCreate () {
      if (isDef(this.$options.router)) {
        this._routerRoot = this;
        this._router = this.$options.router;
        this._router.init(this);
        Vue.util.defineReactive(this, '_route', this._router.history.current);
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
      }
      registerInstance(this, this);
    },
    destroyed: function destroyed () {
      registerInstance(this);
    }
  });

  Object.defineProperty(Vue.prototype, '$router', {
    get: function get () { return this._routerRoot._router }
  });

  Object.defineProperty(Vue.prototype, '$route', {
    get: function get () { return this._routerRoot._route }
  });

  Vue.component('router-view', View);
  Vue.component('router-link', Link);

  var strats = Vue.config.optionMergeStrategies;
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created;
}

Vue实例上的$router、$route,就是从这而来!