Skip to content

前言

项目开发的时候遇到一个问题,发现一个下拉选择的弹框在点击弹框外面区域的时候,没有收拢上去,因此想要采用v-clickoutside指令,使得弹框收拢,但是在添加了指令之后,发现并没有按照预期执行,于是研究了一下v-clickoutside

在网上看了实现原理后发现,我的项目中使用的v-clickoutside和element-ui的v-clickoutside代码基本一致。

vue指令和判断是否满足clickoutside

为了避免每绑定一个元素,就在window上添加一个监听事件,v-clickoutside的解决方案是:将所有绑定的元素添加到一个数组(nodeList )中,然后在document上只需要添加两个事件mousedownmouseup,然后在事件回调中遍历nodeList判断每一个元素,是否满足了clickoutside的条件。

整体上理解就是,在注册指令的时候,将被绑定指令的元素添加到一个全局变量

要彻底理解v-clickoutside,首先要理解vue指令的用法,然后再理解v-clickoutside如何收集元素和遍历判断是否满足clickoutside

v-clickoutside源码

直接能看懂源码就不需要看我后面啰嗦的原理解释了,首先贴出源码

js
const nodeList = [];
const ctx = "@@clickoutsideContext";

let seed = 0;
let startClick;

// 鼠标按下时 记录按下元素的事件对象
!Vue.prototype.$isServer && on(document, "mousedown", (e) => (startClick = e));

// 鼠标松开时 遍历 nodeList 中的元素,执行 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 ||
            !mouseup.target ||
            !mousedown.target ||
            el.contains(mouseup.target) ||
            el.contains(mousedown.target) ||
            el === mouseup.target ||
            (vnode.context.popperElm &&
                (vnode.context.popperElm.contains(mouseup.target) ||
                    vnode.context.popperElm.contains(mousedown.target)))
        )
            return;
        // 执行我们绑定指令时的函数
        if (
            binding.expression &&
            el[ctx].methodName &&
            vnode.context[el[ctx].methodName]
        ) {
            // vnode.context 是组件实例上下文
            // 就像开头的例子,methodName 是 "handler",通过索引上下文的属性找到 methods 中定义的 handler 函数
            vnode.context[el[ctx].methodName]();
        } else {
            el[ctx].bindingFn && el[ctx].bindingFn();
        }
    };
}
export default {
    // 指令绑定时触发
    bind(el, binding, vnode) {
        // 每次绑定时会把dom元素存放到 nodeList 中
        nodeList.push(el);
        // 创建递增id标识
        const id = seed++;
        // 在dom元素上设置一些属性和方法
        // ctx的作用是一个标识,为了不和原生的属性冲突
        el[ctx] = {
            id,
            // 这个是点击元素区域外时会执行的函数,后面会提到
            documentHandler: createDocumentHandler(el, binding, vnode),
            // 绑定的值表达式,值相当于上面例子中的 "handler" 字符串
            methodName: binding.expression,
            // 绑定的值,值相当于上面例子中的 handler 函数
            bindingFn: binding.value,
        };
    },
    // 组件更新时触发
    update(el, binding, vnode) {
        el[ctx].documentHandler = createDocumentHandler(el, binding, vnode);
        el[ctx].methodName = binding.expression;
        el[ctx].bindingFn = binding.value;
    },
    // 指令解绑时触发
    unbind(el) {
        let len = nodeList.length;
        // 找到对应的dom元素,从 nodeList 移除它
        for (let i = 0; i < len; i++) {
            if (nodeList[i][ctx].id === el[ctx].id) {
                nodeList.splice(i, 1);
                break;
            }
        }
        // 移除之前添加的自定义属性
        delete el[ctx];
    },
};
const nodeList = [];
const ctx = "@@clickoutsideContext";

let seed = 0;
let startClick;

// 鼠标按下时 记录按下元素的事件对象
!Vue.prototype.$isServer && on(document, "mousedown", (e) => (startClick = e));

// 鼠标松开时 遍历 nodeList 中的元素,执行 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 ||
            !mouseup.target ||
            !mousedown.target ||
            el.contains(mouseup.target) ||
            el.contains(mousedown.target) ||
            el === mouseup.target ||
            (vnode.context.popperElm &&
                (vnode.context.popperElm.contains(mouseup.target) ||
                    vnode.context.popperElm.contains(mousedown.target)))
        )
            return;
        // 执行我们绑定指令时的函数
        if (
            binding.expression &&
            el[ctx].methodName &&
            vnode.context[el[ctx].methodName]
        ) {
            // vnode.context 是组件实例上下文
            // 就像开头的例子,methodName 是 "handler",通过索引上下文的属性找到 methods 中定义的 handler 函数
            vnode.context[el[ctx].methodName]();
        } else {
            el[ctx].bindingFn && el[ctx].bindingFn();
        }
    };
}
export default {
    // 指令绑定时触发
    bind(el, binding, vnode) {
        // 每次绑定时会把dom元素存放到 nodeList 中
        nodeList.push(el);
        // 创建递增id标识
        const id = seed++;
        // 在dom元素上设置一些属性和方法
        // ctx的作用是一个标识,为了不和原生的属性冲突
        el[ctx] = {
            id,
            // 这个是点击元素区域外时会执行的函数,后面会提到
            documentHandler: createDocumentHandler(el, binding, vnode),
            // 绑定的值表达式,值相当于上面例子中的 "handler" 字符串
            methodName: binding.expression,
            // 绑定的值,值相当于上面例子中的 handler 函数
            bindingFn: binding.value,
        };
    },
    // 组件更新时触发
    update(el, binding, vnode) {
        el[ctx].documentHandler = createDocumentHandler(el, binding, vnode);
        el[ctx].methodName = binding.expression;
        el[ctx].bindingFn = binding.value;
    },
    // 指令解绑时触发
    unbind(el) {
        let len = nodeList.length;
        // 找到对应的dom元素,从 nodeList 移除它
        for (let i = 0; i < len; i++) {
            if (nodeList[i][ctx].id === el[ctx].id) {
                nodeList.splice(i, 1);
                break;
            }
        }
        // 移除之前添加的自定义属性
        delete el[ctx];
    },
};

vue指令

js
// 注册一个全局自定义指令
Vue.directive('directiveName', {
  bind: function(el, binding, vnode){
    // 当指令第一次绑定到元素时调用,常用来进行一些初始化设置
  	...
  },
  inserted: function(el, binding, vnode){
    // 当被绑定的元素插入到 DOM 中时……
  	...
  },
  update: function(el, binding, vnode, oldVnode){
    // 所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前
  	...
  },
  componentUpdated: function(el, binding, vnode, oldVnode){
    // 指令所在组件的 VNode 及其子 VNode 全部更新后调用
  	...
  },
  unbind: function(el, binding, vnode){
    // 只调用一次,指令与元素解绑时调用,类似于beforeDestroy的功能
  	...
  }
});
// 注册一个全局自定义指令
Vue.directive('directiveName', {
  bind: function(el, binding, vnode){
    // 当指令第一次绑定到元素时调用,常用来进行一些初始化设置
  	...
  },
  inserted: function(el, binding, vnode){
    // 当被绑定的元素插入到 DOM 中时……
  	...
  },
  update: function(el, binding, vnode, oldVnode){
    // 所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前
  	...
  },
  componentUpdated: function(el, binding, vnode, oldVnode){
    // 指令所在组件的 VNode 及其子 VNode 全部更新后调用
  	...
  },
  unbind: function(el, binding, vnode){
    // 只调用一次,指令与元素解绑时调用,类似于beforeDestroy的功能
  	...
  }
});

解释一下每个钩子里面的参数

  1. el:指令绑定的DOM元素 所有的DOM API都从Node接口继承,而Node.contains()Node 接口的 contains() 方法返回一个布尔值,表示一个节点是否是给定节点的后代,即该节点本身、其直接子节点(childNodes)、子节点的直接子节点等。利用contains方法判断是否满足clickoutside
  2. binding:传给指令的参数 binding.expression可以拿到绑定的表达式,比如1+1 binding.value可以拿到绑定的值,可以是方法,可以是表达式的计算结果
  3. vnode:vue的虚拟节点,在这个虚拟节点可以拿到当前的组件实例,当前组件实例绑定的DOM元素 vnode.context就是组件实例的this

判断是否满足clickoutside

核心的思想是判断Node.contains()是否满足,满足后,就调用注册指令时传入的回调,不满足,直接中断执行。

documentHandler方法在指令绑定的时候就添加到了节点上,它是实现判断Node.contains()的主要方法

探索指令失效

为了探索为什么添加v-clickoutside指令失效,就得研究documentHandler内部中断执行的逻辑

js
if (
    !vnode ||
    !vnode.context ||
    !mouseup.target ||
    !mousedown.target ||
    el.contains(mouseup.target) ||
    el.contains(mousedown.target) ||
    el === mouseup.target ||
    (vnode.context.popperElm &&
        (vnode.context.popperElm.contains(mouseup.target) ||
            vnode.context.popperElm.contains(mousedown.target)))
)
    return;
if (
    !vnode ||
    !vnode.context ||
    !mouseup.target ||
    !mousedown.target ||
    el.contains(mouseup.target) ||
    el.contains(mousedown.target) ||
    el === mouseup.target ||
    (vnode.context.popperElm &&
        (vnode.context.popperElm.contains(mouseup.target) ||
            vnode.context.popperElm.contains(mousedown.target)))
)
    return;
  1. vnode是vue虚拟节点,在绑定v-clickoutside指令的时候,对应的节点一定存在,!vnode和!vnode.context都为false
  2. mouseup.target是mouseup之间的目标对象,也存在,mousedown.target同理,!mouseup.target和!mousedown.target都为false
  3. el.contains(mouseup.target)在点击el外部的元素时,结果为false, el.contains(mousedown.target)同理
  4. el === mouseup.target在点击el外部时,这两个不相等,结果也是false
  5. vnode.context.popperElm 这个是绑定v-clickoutside的元素对应所在的组件实例上的popperElm属性,这里的判读主要是解决下拉框场景,绑定将v-clickoutside指令到输入框时,在弹出下拉框后,点击下拉框的时候,实际上是点击到了绑定指令元素的外部,但是需要处理忽略下拉框元素,即需要在组件中设置popperElm元素。经过排查,并没有在设置我用到指令的地方设置popperElm,因此这里为false

既然都没有命中条件,那就不应该中断啊

最后我找到原因了,绑定指令的元素的祖先元素上,阻止了mouseup事件冒泡,我去除了这个阻止,问题就一下子解决了。原来mouseup事件会影响到v-clickoutside呀。又学到了一个小知识点。

参考文章 Element 指令clickoutside源码分析翻了翻element-ui源码,发现一个很实用的指令clickoutside Vue|VNode、elm、context、el是个啥?使用el-popover遇到的点击事件冒泡问题神奇的点击事件