element-ui的v-clickoutside的原理

148 阅读3分钟

(一)指令的作用

点击元素外部, 触发对应的钩子函数, 经常用来实现popper dom的关闭

(二)注释版的源代码

import Vue from "vue";
// import { on } from "element-ui/src/utils/dom";

/**
 * on的作用:
 * 在DOM元素上绑定监听事件
 *
 * 如果不是服务端渲染并且addEventListener方法存在
 * 使用addEventListener进行绑定
 * 否则使用attachEvent进行绑定
 */
const isServer = Vue.prototype.$isServer;
export const on = (function () {
    if (!isServer && document.addEventListener) {
        return function (element, event, handler) {
            if (element && event && handler) {
                element.addEventListener(event, handler, false);
            }
        };
    } else {
        return function (element, event, handler) {
            if (element && event && handler) {
                element.attachEvent("on" + event, handler);
            }
        };
    }
})();

/**
 * nodeList:
 * 收集所有使用了clickoutside指令的dom元素
 *
 * 原因:
 * 可能有多个地方都使用了clickoutside指令
 */
const nodeList = [];

/**
 * 真实DOM元素上新增的一个特殊的属性
 * 用来存储clickoutside指令的信息
 */
const ctx = "@@clickoutsideContext";

/**
 * 用来存储鼠标按下的事件
 */
let startClick;

/**
 * 使用了clickoutside指令的真实DOM的唯一标识
 */
let seed = 0;

/**
 * 点击外部区域触发对应回调函数的两种做法
 *
 * (1)给document添加点击事件
 * 判断点击的元素是不是被自定义指令所在的元素包含
 * 如果包含,表明单击的是内部元素,什么也不干
 * 如果不包含,表明点击的是外部元素,触发对应的回调函数
 *
 * (2)现在这种做法
 * mouseup和mousedown
 */

/**
 * 非服务端渲染
 * 鼠标按下
 * 存储mousedown事件对象到startClick
 */
!Vue.prototype.$isServer && on(document, "mousedown", (e) => (startClick = e));

/**
 * 非服务端渲染
 * 鼠标抬起
 * 遍历nodeList数组
 * 执行存储的所有真实DOM元素上的ctx属性中的documentHandler方法
 */
!Vue.prototype.$isServer &&
    on(document, "mouseup", (e) => {
        nodeList.forEach((node) => node[ctx].documentHandler(e, startClick));
    });

/**
 * 创建文档点击事件的回调函数
 */
function createDocumentHandler(el, binding, vnode) {
    return function (mouseup = {}, mousedown = {}) {
        if (
            !vnode ||
            !vnode.context ||
            /**
             * 鼠标按下的元素和鼠标抬起的元素可能不是同一个元素
             * 只要鼠标按下的元素和鼠标抬起的元素中的一个被包含在使用clickoutside指令的元素上
             * 都算没有点击在绑定元素外部
             * 即什么也不干
             */
            !mouseup.target ||
            !mousedown.target ||
            el.contains(mouseup.target) ||
            el.contains(mousedown.target) ||
            el === mouseup.target ||
            /**
             * 如果点击在popper类型的元素上
             * 例如下拉菜单的下拉选项
             * 也算没有点击在绑定元素外部
             * 这种情况下也是什么也不干
             *
             * 这种场景只针对
             * element中的popper类型的元素
             */
            (vnode.context.popperElm &&
                (vnode.context.popperElm.contains(mouseup.target) ||
                    vnode.context.popperElm.contains(mousedown.target)))
        )
            return;

        /**
         * 这里为什么要做这么多判断?
         * 原因:
         * 绑定给clickoutside指令的函数可能是method,也可能不是method
         * 策略:
         * 优先使用组件实例上的method
         * 如果绑定的回调函数不是method
         * 则直接执行这个回调函数
         */
        if (
            binding.expression &&
            el[ctx].methodName &&
            vnode.context[el[ctx].methodName]
        ) {
            vnode.context[el[ctx].methodName]();
        } else {
            el[ctx].bindingFn && el[ctx].bindingFn();
        }
    };
}

/**
 * v-clickoutside
 * <div v-clickoutside="handleClose">
 */
export default {
    /**
     * 指令第一次绑定到元素时调用
     */
    bind(el, binding, vnode) {
        /**
         * 绑定节点的时候
         * 存储真实DOM
         * 并且使自定义指令的id自增
         */
        nodeList.push(el);
        const id = seed++;

        /**
         * 使用了clickoutside指令的dom元素上
         * 额外新增了@@clickoutsideContext属性
         * 这个属性的属性值包括:
         * (1)dom元素的唯一id
         * 移除dom元素的时候借助的是这个id
         * (2)绑定给自定义指令的方法的名称
         * (3)绑定给自定义指令的方法本身
         * (4)点击操作发生时触发的方法documentHandler
         */
        el[ctx] = {
            id,
            documentHandler: createDocumentHandler(el, binding, vnode),
            /**
             * methodName:
             * 绑定给自定义指令的方法的名称
             * 可以通过组件实例调用
             */
            methodName: binding.expression,
            /**
             * bindingFn:
             * 绑定给自定义指令的方法
             * 可以直接调用
             */
            bindingFn: binding.value,
        };
    },

    /**
     * 所在组件的vnode更新时调用
     *
     * vnode更新时
     * 要及时更新dom元素上的ctx属性
     */
    update(el, binding, vnode) {
        el[ctx].documentHandler = createDocumentHandler(el, binding, vnode);
        el[ctx].methodName = binding.expression;
        el[ctx].bindingFn = binding.value;
    },

    /**
     * 指令与元素解绑时调用
     * 从nodeList数组中,通过id,删除对应的DOM元素
     * 并且移除真实DOM上的ctx属性
     */
    unbind(el) {
        let len = nodeList.length;
        for (let i = 0; i < len; i++) {
            if (nodeList[i][ctx].id === el[ctx].id) {
                nodeList.splice(i, 1);
                break;
            }
        }
        delete el[ctx];
    },
};

(二)源码的几个关键点

(1)可能有多个dom节点使用了clickoutside指令

  • 需要存储所有使用了clickoutside指令的dom元素(nodeList) 并给每个dom元素一个唯一id
  • id通过数字自增即可
  • 当某个元素和自定义指令解绑时,通过id从nodeList中删掉对应的真实DOM

(2)绑定给clickoutside指令的方法可能是methods中的方法,也可能不是

1. 绑定给clickoutside指令的方法不是method的两种情况

  • method方法的bind
  • 匿名函数,匿名函数内部调用method方法
<h1 v-clickoutside="fun.bind({name:'张三'})">第一个使用了clickoutside指令的元素</h1>
<h1 v-clickoutside="()=>fun()">第二个使用了clickoutside指令的元素</h1>

2. 对此的策略

优先通过组件实例调用绑定给clickoutside指令的方法 其次再直接调用绑定给clickoutside指令的方法本身

(3)点击事件用的是mousedown和mouseup事件,而不是click

鼠标按下的元素和鼠标离开的元素可能不是同一个元素