(一)指令的作用
点击元素外部, 触发对应的钩子函数, 经常用来实现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
鼠标按下的元素和鼠标离开的元素可能不是同一个元素