检测元素点击在指定范围之外指令实现,vue3+ts,文末有非ts实现,看elementPlus源码所得

1,468 阅读2分钟

闲言碎语

自己在写组件库,目前已经写了popper组件,popup组件,然后现在在写颜色选择器。全部都是用的vue3和ts。

缘起于popper组件的细节优化。我的颜色选择器组件,基于popper为底层组件。然后除了一般的参数控制关闭之外。我需要做到点击在非组件绑定的元素区域要关闭弹窗。

引入使用

要结果的直接最后复制源码

import ClickOutside from '../ClickOutside'
export default defineComponent({
  directives: {
    'dht-click-outside': ClickOutside.directive,
  },
 }
)
<span v-dht-click-outside={clickOutside}></span>

基础原理实现

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="utf-8">
    <title>js实现的点击div区域外隐藏div区域</title>
    <style type="text/css">
        #myDiv{
            border:1px solid #000000;
            width:200px;
            height:100px;
            background:#FF0000;
        }
    </style>
    <script type="text/javascript">
        window.onload=function(){
            var myDiv = document.getElementById("myDiv");
            document.addEventListener("click",function(){
                console.log(11111)
                myDiv.style.display="none";
            });
            myDiv.addEventListener("click",function(event){
                console.log(2222)
                event=event||window.event;
                event.stopPropagation();
            });
        };
    </script>
</head>
<body>
<div id="myDiv"></div>
</body>

这部分代码就是核心原理了。或者我们可以根据元素的xy轴判断等等。

翻看elemenPlus源码,addEventListener细节

我的组件也是有些模仿elementUI的,所以就翻看他怎么实现的。然后我发现elementUI自己实现了一个自定义指令。ClickOutside

其中有一个关键的细节。指令可以绑定到不同的元素上面,但是elementUI做了一个操作,他把绑定之后的数据存到了Map下面,我想本身已经绑定到元素下面了,为什么还需要存放到一个数组一样的结构中呢。

实验之后,发现

下面这种代码方式,addEventListener只会触发一次

 <span v-dht-click-outside={clickOutside}</span>
<span v-dht-click-outside={clickOutside}></span>

源码

我自己也懒,把elementUI的源码小改改直接放项目里面使用了。

下面是完整源码,使用参考上面的引入使用

// 该代码抄自elementUI
import type { ComponentPublicInstance, DirectiveBinding, ObjectDirective } from 'vue'

type DocumentHandler = <T extends MouseEvent>(mouseup: T, mousedown: T) => void

type FlushList = Map<
  HTMLElement,
  {
    documentHandler: DocumentHandler
    bindingFn: (...args: unknown[]) => unknown
  }
>

const on = function (
  element: HTMLElement | Document | Window,
  event: string,
  handler: EventListenerOrEventListenerObject,
  useCapture = false,
): void {
  if (element && event && handler) {
    element.addEventListener(event, handler, useCapture)
  }
}

const nodeList: FlushList = new Map()

let startClick: MouseEvent

on(document, 'mousedown', (e) => (startClick = e as MouseEvent))
on(document, 'mouseup', (e) => {
  for (const { documentHandler } of nodeList.values()) {
    documentHandler(e as MouseEvent, startClick)
  }
})

function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
  let excludes: HTMLElement[] = []
  if (Array.isArray(binding.arg)) {
    excludes = binding.arg
  } else {
    // due to current implementation on binding type is wrong the type casting is necessary here
    excludes.push((binding.arg as unknown) as HTMLElement)
  }
  return function (mouseup, mousedown) {
    const popperRef = (binding.instance as ComponentPublicInstance<{
      popperRef: HTMLElement
    }>).popperRef
    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

    const isTargetExcluded =
      (excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
      (excludes.length && excludes.includes(mouseDownTarget as HTMLElement))
    const isContainedByPopper =
      popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget))
    if (
      isBound ||
      isTargetExists ||
      isContainedByEl ||
      isSelf ||
      isTargetExcluded ||
      isContainedByPopper
    ) {
      return
    }
    binding.value()
  }
}

const ClickOutside: ObjectDirective = {
  beforeMount(el, binding) {
    nodeList.set(el, {
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    })
  },
  updated(el, binding) {
    nodeList.set(el, {
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    })
  },
  unmounted(el) {
    nodeList.delete(el)
  },
}

export default ClickOutside

非ts代码实现

const on = function (element, event, handler, useCapture = false) {
    if (element && event && handler) {
        element.addEventListener(event, handler, useCapture);
    }
};
const nodeList = new Map();
let startClick;
on(document, 'mousedown', (e) => (startClick = e));
on(document, 'mouseup', (e) => {
    for (const { documentHandler } of nodeList.values()) {
        documentHandler(e, startClick);
    }
});
function createDocumentHandler(el, binding) {
    let excludes = [];
    if (Array.isArray(binding.arg)) {
        excludes = binding.arg;
    }
    else {
        // due to current implementation on binding type is wrong the type casting is necessary here
        excludes.push(binding.arg);
    }
    return function (mouseup, mousedown) {
        const popperRef = binding.instance.popperRef;
        const mouseUpTarget = mouseup.target;
        const mouseDownTarget = mousedown.target;
        const isBound = !binding || !binding.instance;
        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));
        const isContainedByPopper = popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
        if (isBound ||
            isTargetExists ||
            isContainedByEl ||
            isSelf ||
            isTargetExcluded ||
            isContainedByPopper) {
            return;
        }
        binding.value();
    };
}
const ClickOutside = {
    beforeMount(el, binding) {
        nodeList.set(el, {
            documentHandler: createDocumentHandler(el, binding),
            bindingFn: binding.value,
        });
    },
    updated(el, binding) {
        nodeList.set(el, {
            documentHandler: createDocumentHandler(el, binding),
            bindingFn: binding.value,
        });
    },
    unmounted(el) {
        nodeList.delete(el);
    },
};
export default ClickOutside;
//# sourceMappingURL=directive.js.map