click outside component

在 vue 中以 script setup 的形式实现一个性能还不错的 <ClickOutside /> 组件, 理论上适用于任何语言框架, 只需要适配一下 <template><script> 的部分即可, 同时也是可以适用于原生 JS 的.

<template>
  <div ref="targetRef">
    <slot />
  </div>
</template>

<script setup>
import { intersection } from 'lodash'
import { ref, unref, defineEmits, defineProps, onBeforeUnmount } from 'vue'

const targetRef = ref(null)
const emits = defineEmits(['trigger'])

// 这两个属性通常不会发生变化, 都是初始就预设好的, 所以就直接解构使用了, 在开发中不推荐这么使用
// eslint-disable-next-line vue/no-setup-props-destructure
const { ignore, capture } = defineProps({
  // 需要忽略的元素类名,多个类名以 , 分割
  ignore: {
    type: String,
    default: 'el-popper'
  },
  // 是否以捕获的形式触发事件
  capture: {
    type: Boolean,
    default: true
  }
})

// 过滤不合法的元素选择器
const IGNORES = ignore.split(',').filter(Boolean)

const clickEventHandler = async(event) => {
  const el = event.target

  // 实现的关键, 这个方法返回事件触发时, 在事件冒泡的完整路径
  const composed = event.composedPath() // ^[1] Event.composedPath()

  if (
    !el ||
    el === unref(targetRef) || // 如果点击的元素是当前组件本身
    composed.includes(unref(targetRef)) || // 如果点击的是组件的子元素时
    // 如果有设置忽略的元素类名并且被包含在内的话
    ignore && composed.some(element =>
      !!intersection(IGNORES, element.classList).length)
      // ↑ 这里也可以自己手写判断数据交集的实现, 主要是判断元素的类名是否处于忽略的列表中
  ) { return }

  emits('trigger', event)
}

// 因为我并不需要操作或获取当前组件的 dom 元素
// 又因为 document 是全局对象, 不管组件是否挂载都可以访问到
// 所以在添加事件的时候可以不用放在 onMounted() 里
document.addEventListener(
  'click',
  clickEventHandler,
  {
    capture,
    passive: true
  }
)

onBeforeUnmount(() => {
  // 组件卸载时一定记得清理副作用
  document.removeEventListener(
    'click',
    clickEventHandler,
    {
      capture,
      passive: true
    }
  )
})
</script>

<script>
// 给组件添加名字, 以及不在 DOM 元素上显式外部给组件设置的属性
export default {
  name: 'TrClickOutside',
  inheritAttrs: false
}
</script>

  1. MDN Event.composedPath()