自定义指令 clickoutside

8,183 阅读5分钟

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上面绑定一个事件,在内部通过循环来处理多个事件处理程序,性能有很大提升,而且还增加了一些类似修饰符的功能。