[Element Plus 源码解析 - 指令篇] click-outside 指令

3,267 阅读4分钟

一、指令介绍

见名思意,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;

三、总结:

  1. 使用一个全局变量nodeList存储节点及其handlers,在节点beforeMount时通过createDocumentHandler创建一个handler,并存入nodeList中;
  2. 使用mousedown mouseup事件,应该是为了兼容处理拖拽的情况,在mousedown的响应中使用startClick存储事件,在mouseup事件中遍历nodeList中的节点,再遍历节点的handlers,依次执行handler;
  3. createDocumentHandler函数内部使用函数curry的方式保留了创建handler时传入的节点el和binding,在handler调用时,只需要传入mousedown和mouseup的事件对象即可。
  4. hanlder中,判断了一系列不需要触发回调函数的场景