一、指令介绍
见名思意,click-outside指令是点击元素节点之外时进行相应的处理。
常见的用途是点击弹框之外的部分时关闭弹出框,在element-plus源码中被用于el-select/el-color-picker等组件中。
我们先看一下在el-select中的使用代码:
// packages\components\select\src\select.vue
<div
ref="selectWrapper"
v-click-outside:[popperPaneRef]="handleClose"
class="el-select"
:class="[selectSize ? 'el-select--' + selectSize : '']"
@click.stop="toggleMenu"
>
<el-popper .....>
</el-popper>
</div>
上述代码在使用指令时:
- 传入了一个arg(即参数)
[popperPaneRef],该参数是数组类型,数组的元素是节点,表示点击这些节点时,不触发click-outside的回调函数; - 绑定了一个值:
handleClose,这是一个函数,作为click-outside的回调函数,从函数名称上可以看出,该回调函数是用于关闭弹出框的;
二、指令源码分析
import { on } from "@element-plus/utils/dom";
import isServer from "@element-plus/utils/isServer";
import type { ComponentPublicInstance, DirectiveBinding, ObjectDirective } from "vue";
import type { Nullable } from "@element-plus/utils/types";
// 类型定义
type DocumentHandler = <T extends MouseEvent>(mouseup: T, mousedown: T) => void;
type FlushList = Map<
HTMLElement,
{
documentHandler: DocumentHandler;
bindingFn: (...args: unknown[]) => unknown;
}[]
>;
// map类型,存储节点及其handlers
const nodeList: FlushList = new Map();
// 存储mousedown事件
let startClick: MouseEvent;
// 非服务端渲染时
if (!isServer) {
// 绑定mousedown事件,将事件存储在startClick中
on(document, "mousedown", (e: MouseEvent) => (startClick = e));
// 绑定mouseup事件
on(document, "mouseup", (e: MouseEvent) => {
// 遍历nodeList中所有的节点
for (const handlers of nodeList.values()) {
// 遍历节点上注册的handlers
for (const { documentHandler } of handlers) {
// 执行每个handler,传入的参数是:mouseup事件、mousedown事件
documentHandler(e, startClick);
}
}
});
}
// 创建一个handler
function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
// 用来存储使指令不生效的节点
let excludes: HTMLElement[] = [];
// 如果在使用指令时传入了参数,参数的作用是指定click-outside不生效的节点
if (Array.isArray(binding.arg)) {
excludes = binding.arg;
} else if ((binding.arg as unknown) instanceof HTMLElement) {
// due to current implementation on binding type is wrong the type casting is necessary here
excludes.push((binding.arg as unknown) as HTMLElement);
}
// 可以理解为函数curry
// 在点击时,真正执行的handler
// 入参是点击时触发的mouseup mousedown事件
//(使用mouseup和mousedown事件而不是click事件是考虑拖动的场景吗)
return function (mouseup, mousedown) {
// popperRef是el-popper组件中popper部分的元素ref
// 这里click-outside与el-popper的组件一起使用的场景
const popperRef = (binding.instance as ComponentPublicInstance<{
popperRef: Nullable<HTMLElement>;
}>).popperRef;
const mouseUpTarget = mouseup.target as Node;
const mouseDownTarget = mousedown?.target as Node;
// 一些判断条件,是否触发clickoutside的回调
// 注册指令的组件实例是否存在
const isBound = !binding || !binding.instance;
// 触发mouseup/mousedown的目标元素是否存在
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
// 触发的元素是否包含在指令绑定的元素内部
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
// 触发的元素是否是指定绑定的元素本身
const isSelf = el === mouseUpTarget;
// 触发的元素是否是应该排除的元素
const isTargetExcluded =
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
(excludes.length && excludes.includes(mouseDownTarget as HTMLElement));
// 点击的元素是否是popper部分包含的元素
const isContainedByPopper = popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
// 若是上述判断条件中的一种,则返回,不执行回调函数
if (isBound || isTargetExists || isContainedByEl || isSelf || isTargetExcluded || isContainedByPopper) {
return;
}
// 执行回调函数
binding.value(mouseup, mousedown);
};
}
// 指令
const ClickOutside: ObjectDirective = {
// 指令钩子函数,参数可以参考官方文档https://vue3js.cn/docs/zh/api/application-api.html#directive
// el: 指令绑定的DOM元素节点
// binding: 包含了多个对象属性,
// - instance:使用指令的组件实例;
// - value: 传递给指令的值,在这里是值click-outside的回调函数;
// - oldValue: 先前的值,仅在 beforeUpdate 和 updated 中可用。
// - arg: 传递给指令的参数;
// ...
beforeMount(el, binding) {
// 一个节点上可能绑定多个事件处理函数
// 所以nodeList map的value是数组类型
if (!nodeList.has(el)) {
nodeList.set(el, []);
}
// 创建一个事件响应函数,push到当前节点的hanlders数组中
nodeList.get(el).push({
documentHandler: createDocumentHandler(el, binding),
bindingFn: binding.value,
});
},
// 节点更新时的钩子
updated(el, binding) {
if (!nodeList.has(el)) {
nodeList.set(el, []);
}
const handlers = nodeList.get(el);
// 根据之前绑定的回调函数,查找旧的hanlder
const oldHandlerIndex = handlers.findIndex((item) => item.bindingFn === binding.oldValue);
// 生成新的handler
const newHandler = {
documentHandler: createDocumentHandler(el, binding),
bindingFn: binding.value,
};
if (oldHandlerIndex >= 0) {
// 如果旧的handler存在,则用新的handler替换旧的hanlder
handlers.splice(oldHandlerIndex, 1, newHandler);
} else {
// 否则就直接push新的handler
handlers.push(newHandler);
}
},
unmounted(el) {
// 节点卸载时,在nodeList中删除掉本节点
nodeList.delete(el);
},
};
export default ClickOutside;
三、总结:
- 使用一个全局变量nodeList存储节点及其handlers,在节点beforeMount时通过
createDocumentHandler创建一个handler,并存入nodeList中; - 使用
mousedown mouseup事件,应该是为了兼容处理拖拽的情况,在mousedown的响应中使用startClick存储事件,在mouseup事件中遍历nodeList中的节点,再遍历节点的handlers,依次执行handler; createDocumentHandler函数内部使用函数curry的方式保留了创建handler时传入的节点el和binding,在handler调用时,只需要传入mousedown和mouseup的事件对象即可。- hanlder中,判断了一系列不需要触发回调函数的场景