背景
记得以前面试官问过我一个问题:我现在有一个弹窗,怎样才能实现点击弹窗以外的区域,实现关闭弹窗呢?
当时确实比较菜,没想出应该怎么做才行,因为当时我的脑子里只有 click事件,我在想点击事件不是只能绑定在本元素身上吗?怎么才能点击其他地方来影响本元素呢?
实现思路
过了一两年后,回头发现,其实实现并不困难,很多人其实也都会,换一种说法,面试官想问的是:在 Vue 中,有一个元素X,怎么做到点击元素X以外的东西,触发绑定在元素X上的事件。
我把实现思路分为几步:
- 定义一个 Map,来收集弹窗元素
- 监听 document 的鼠标按下、松开事件
- document 鼠标按下时记录触发的元素A
- document 鼠标松开时遍历 Map 中所有弹窗元素,让这些弹窗元素跟元素A一一比较,不等于则说明是点了弹窗元素外部,等于则说明点击了弹窗元素内部
- 点击了外部则触发绑定事件,点击了内部则不触发
ClickOutside
其实上面的思路,就是 v-clickoutside的实现思路,这个自定义指令,是 Vue 中用的非常广泛的指令~具体用法是这样的:
cs () {
console.log('点击外部')
}
<div v-clickoutside="cs"></div>
<button>点我</button>
<button>哈哈哈</button>
当你点击了 div 元素,也就是本元素,并不会触发事件 cs,而当你点击它以外的元素,则会触发 cs 事件。
代码实现
1.TypeScript类型准备
// vue自带的一些类型
import type { ComponentPublicInstance, DirectiveBinding, ObjectDirective } from 'vue';
// 下面会用到,是记录绑定事件的函数
type DocumentHandler = <T extends MouseEvent>(mouseup: T, mousedown: T) => void;
// Map 的类型
// key 是元素本地
// value 是绑定的事件
type FlushList = Map<
HTMLElement,
DocumentHandler
>;
2.绑定事件函数
首先封装一个绑定事件的函数,大家在平时封装函数的时候一定要注意判空,兜底~
export function on(
element: Element | HTMLElement | Document | Window,
event: string,
handler: EventListenerOrEventListenerObject,
): void {
if (element && event && handler) {
element.addEventListener(event, handler, false);
}
}
3.判断点击元素是否是本元素
想一想我们的目的是啥,有一元素A,我需要点击元素A以外的地方才触发绑定的事件,点击元素A或者元素A以内的区域则不触发
所以这个函数主要做几件事:
- 判断点击的元素是否是本元素(不触发)
- 判断点击的元素是否在本元素内(不触发)
- 兜底,判断元素是否存在(触发)
function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
return function (mouseup, mousedown) {
const mouseUpTarget = mouseup.target as Node;
const mouseDownTarget = mousedown.target as Node;
const isBound = !binding || !binding.instance;
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
const isSelf = el === mouseUpTarget;
if (
isBound ||
isTargetExists ||
isContainedByEl ||
isSelf
) {
return;
}
binding.value();
};
}
4.自定义指令
自定义指令的几个生命周期里,需要做这些事:
- 绑定时,记录绑定元素与绑定事件到 nodeList 中
- 更新时,记录绑定元素与绑定事件到 nodeList 中
- 销毁时,将此元素从 nodeList 中抹除
// 记录绑定元素的 Map
const nodeList: FlushList = new Map();
const ClickOutside: ObjectDirective = {
beforeMount(el, binding) {
nodeList.set(el,
createDocumentHandler(el, binding));
},
updated(el, binding) {
nodeList.set(el,
createDocumentHandler(el, binding));
},
unmounted(el) {
nodeList.delete(el);
},
};
export default ClickOutside;
5.监听 document 鼠标按下、松开
万事俱备只欠东风,现在只需要监听 document 的鼠标按下、松开事件 即可,大概分为几步:;
- 鼠标按下时,记录这个触发的元素
- 鼠标松开时,遍历 nodeList 中的元素,跟这个触发元素做对比
- 符合条件则执行绑定事件,不符合则不执行
let startClick: MouseEvent;
on(document, 'mousedown', (e: MouseEvent) => (startClick = e));
on(document, 'mouseup', (e: MouseEvent) => {
for (const { documentHandler } of nodeList.values()) {
documentHandler(e, startClick);
}
});
这就实现了点击外部触发内部事件的效果了!