Skip to content

Clickoutside指令 ​

1. 实现效果 ​

在popper 节点展示后点击蓝色区域后popper 节点才会消失

2. 具体实现 ​

demo.vue

vue
<template>
  <div class="view-demo-component">
    <div class="wrapper">
      <button v-click-outside="handleOutsideClick" class="trigger" @click="handleClick">click me</button>
      <div ref="popoverRef" v-show="show" class="popper">this is popper area</div>
    </div>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import ClickOutside from './clickoutside.ts'

const vClickOutside = ClickOutside
const show = ref(false)
const popoverRef = ref()

const handleClick = () => {
  show.value = true
}

const handleOutsideClick = () => {
  show.value = false
}
</script>
<style lang="scss" scoped>
</style>

在上面的代码中, 我们将当前实例设置了一个popoverRef 属性, 该属性就是配合ClickOutside 指令来使用的, 即如果我们点击trigger 节点展示popper 节点, 那么只要后续鼠标点击区域在popoverRef 节点和trigger 节点上, 那么popper 节点就永远不会消失; 而如果点击区域是在这两个节点外, 那么就会触发指令对应的函数.

具体指令代码: clickoutside.ts

typescript
import {DirectiveBinding} from "@/types/common";

// 定义相关类型
interface ClickOutsideInterface {
  id: number
  documentHandler: (e1: Event, e2: Event) => void
  documentFn: () => void
}

interface CustomHTML extends HTMLElement {
  '@@clickOutsideZK'?: ClickOutsideInterface
}

// 设置监听函数
const on = (function () {
  return function<K extends keyof HTMLElementEventMap> (element: HTMLElement | Document, event: K, handler: (ev: Event) => void) {
    element.addEventListener(event, handler, false)
  }
})()

const ctx = '@@clickOutsideZK'

// 用来卸载相应的监听节点
let seed = 0
// 用来记录触发元素
let startClick: Event
// 保存监听节点
let nodeLists: CustomHTML[] = []

// 全局监听鼠标行为
// 记录触发元素
on(document, 'mousedown', (e: Event) => (startClick = e))
// 初始化当前节点的触发函数(如popoverElm这种, 将其绑定在指令节点上)
on(document, 'mouseup', (e: Event) => {
  nodeLists.forEach(node => node[ctx]!.documentHandler(e, startClick))
})

// 指令处理函数, 即判断什么情况去触发函数执行
const createDocumentHandler = (el: CustomHTML, binding: DirectiveBinding) => {
  // 返回闭包 在mouseup的时候传入相应事件
  return function (mouseup: Event, mousedown: Event) {
    // 这里可以对popoverRef进行操作, 来处理特殊元素的clickOutside事件
    const mouseupElm = mouseup.target as Node
    const mousedownElm = mousedown.target as Node
    // 是否绑定相应函数
    const isBound = !binding || !binding.instance
    // 是否点击到元素
    const isTargetExists = !mouseupElm || !mousedownElm
    // el是当前DOM元素
    const isClickSelf = el === mouseupElm
    // 如果想自定义不触发outside的逻辑  可以自己在上下文里面添加popoverRef属性进行相应控制
    const popoverElm = binding.instance?.$refs.popoverRef as HTMLElement
    const isContainedByPopper =
      popoverElm &&
      (popoverElm.contains(mouseupElm) || popoverElm.contains(mousedownElm))
    // 如果满足下面条件之一, 那么就不触发指令绑定的函数
    if (isBound ||
      isTargetExists ||
      el.contains(mouseupElm) ||
      el.contains(mousedownElm) ||
      el === mouseupElm ||
      isContainedByPopper
    ) return

    el[ctx]?.documentFn && el[ctx]?.documentFn()
  }
}

// 用来挂载和卸载节点的时候初始化节点
const ClickOutside = {
  mounted(el: CustomHTML, binding: DirectiveBinding) {
    // 加入对应的数组中, handle多个outside情况
    nodeLists.push(el)
    const id = seed++
    el[ctx] = {
      id,
      documentHandler: createDocumentHandler(el, binding),
      documentFn: binding.value
    }
  },
  updated(el: CustomHTML, binding: DirectiveBinding) {
    el[ctx]!.documentHandler = createDocumentHandler(el, binding)
    el[ctx]!.documentFn = binding.value
  },
  unmounted(el: CustomHTML) {
    const len = nodeLists.length
    // 移除当前组件的监听
    for (let i = 0; i < len; i++) {
      if (nodeLists[i][ctx]!.id === el[ctx]!.id) {
        nodeLists.splice(i, 1)
        break
      }
    }
  }
}
export default ClickOutside

该部分代码可以分为三部分, 大致为下, 详细的可以看上面代码中的注释

  • 全局监听和节点处理

在此部分使用nodeList 数组进行监听节点的保存; 使用全局id 来控制监听节点的唯一性, 同时也便于后期卸载该节点的监听; 使用startClick 来保存初始的触发节点

typescript
// 用来卸载相应的监听节点
let seed = 0
// 用来记录触发元素
let startClick: Event
// 保存监听节点
let nodeLists: CustomHTML[] = []
  • 初始化指令绑定的节点

在此部分初始化指令节点, 添加唯一id, 添加该节点触发函数的函数(trigger这里使用了闭包),添加触发函数(fn).

同时在节点更新和卸载的时候执行相应的节点属性更新和卸载操作.

  • 指令触发条件函数的编写

首先是全局监听鼠标事件, 当mouseup 和 mousedown 被触发后就去初始化指令绑定节点上**触发函数的函数trigger,

在mousedown 被触发后就会执行该触发函数, 如果满足触发函数的条件, 那么就运行fn , 否则就不执行.

3. element-plus 的实现 ​

element-plus 支持了arg的输入, 即可以通过在指令后添加arg 来增加函数的触发逻辑, 这样可以添加多个元素去扩大outside 的范围; 同时另一个差异是其采用元素本身作为对象的key 来维护nodeList 数组.

typescript
import { isClient } from '@vueuse/core'
import { isElement } from '@element-plus/utils'

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 nodeList: FlushList = new Map()

let startClick: MouseEvent

if (isClient) {
  document.addEventListener('mousedown', (e: MouseEvent) => (startClick = e))
  document.addEventListener('mouseup', (e: MouseEvent) => {
    for (const handlers of nodeList.values()) {
      for (const { documentHandler } of handlers) {
        documentHandler(e as MouseEvent, startClick)
      }
    }
  })
}

function createDocumentHandler(
  el: HTMLElement,
  binding: DirectiveBinding
): DocumentHandler {
  let excludes: HTMLElement[] = []
  if (Array.isArray(binding.arg)) {
    excludes = binding.arg
  } else if (isElement(binding.arg)) {
    // 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(mouseup, mousedown)
  }
}

const ClickOutside: ObjectDirective = {
  beforeMount(el: HTMLElement, binding: DirectiveBinding) {
    // there could be multiple handlers on the element
    if (!nodeList.has(el)) {
      nodeList.set(el, [])
    }

    nodeList.get(el)!.push({
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    })
  },
  updated(el: HTMLElement, binding: DirectiveBinding) {
    if (!nodeList.has(el)) {
      nodeList.set(el, [])
    }

    const handlers = nodeList.get(el)!
    const oldHandlerIndex = handlers.findIndex(
      (item) => item.bindingFn === binding.oldValue
    )
    const newHandler = {
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    }

    if (oldHandlerIndex >= 0) {
      // replace the old handler to the new handler
      handlers.splice(oldHandlerIndex, 1, newHandler)
    } else {
      handlers.push(newHandler)
    }
  },
  unmounted(el: HTMLElement) {
    // remove all listeners when a component unmounted
    nodeList.delete(el)
  },
}

export default ClickOutside