Appearance
前言
项目开发的时候遇到一个问题,发现一个下拉选择的弹框在点击弹框外面区域的时候,没有收拢上去,因此想要采用v-clickoutside指令,使得弹框收拢,但是在添加了指令之后,发现并没有按照预期执行,于是研究了一下v-clickoutside
在网上看了实现原理后发现,我的项目中使用的v-clickoutside和element-ui的v-clickoutside代码基本一致。
vue指令和判断是否满足clickoutside
为了避免每绑定一个元素,就在window上添加一个监听事件,v-clickoutside的解决方案是:将所有绑定的元素添加到一个数组(nodeList )中,然后在document上只需要添加两个事件mousedown、mouseup,然后在事件回调中遍历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的功能
...
}
});解释一下每个钩子里面的参数
- el:指令绑定的DOM元素 所有的DOM API都从Node接口继承,而Node.contains()Node 接口的 contains() 方法返回一个布尔值,表示一个节点是否是给定节点的后代,即该节点本身、其直接子节点(childNodes)、子节点的直接子节点等。利用contains方法判断是否满足clickoutside
- binding:传给指令的参数 binding.expression可以拿到绑定的表达式,比如
1+1binding.value可以拿到绑定的值,可以是方法,可以是表达式的计算结果- 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;- vnode是vue虚拟节点,在绑定v-clickoutside指令的时候,对应的节点一定存在,!vnode和!vnode.context都为false
- mouseup.target是mouseup之间的目标对象,也存在,mousedown.target同理,!mouseup.target和!mousedown.target都为false
- el.contains(mouseup.target)在点击el外部的元素时,结果为false, el.contains(mousedown.target)同理
- el === mouseup.target在点击el外部时,这两个不相等,结果也是false
- 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遇到的点击事件冒泡问题神奇的点击事件