前言

最近,vue3 发布了 beta 版本,大家对其新特性的研究也越来越多,我也在思考着怎么将一些 vue2 的工具迁移至 vue3 中。刚好前几天思考到如何在 vue3 中给元素使用 clickoutside 事件,正好借这个机会简单记录一下。

开始搞起

我希望能够用 vue@event 写法来进行事件的触发,组件的样子大概是这样的

<template>
  <div @clickoutside="handleClickOutside"></div>
</template>

<script>
export default {
  name: 'MyComponent',
  setup () {
    function handleClickOutside () {
      // some action
    }

    return {
      handleClickOutside
    }
  }
}
<script>

基本的思路就是要让事件直接从元素上派发, 那样 vue 就可以捕捉到事件, 并进行回调

所以首先要实现的是在原生 js 下的 clickoutside 的逻辑实现,相信这里的实现大家都懂,就不细说了,直接上代码~

先是事件的派发部分

// 用来存放需要 clickoutside 的元素
const elementSet = new Set()

document.addEventListener('click', event => {
  const target = event.target
  const path = event.path || (event.composedPath && event.composedPath())

  elementSet.forEach(el => {
    if (target !== el && (path ? !path.includes(el) : !el.contains(target))) {
      dispatchEvent(el, { type: 'clickoutside' })
    }
  })
})

function dispatchEvent (el, payload = {}) {
  const { type, bubbles = false, cancelable = false, ...data } = payload
  const event = new Event(type, { bubbles, cancelable })

  Object.assign(event, data)

  return el.dispatchEvent(event)
}

然后就是如何把组件用的根元素放入 set 中,在 vue3 中获取根元素的方法与原来的有些不同

// vue2
export default {
  mounted () {
    console.log(this.$el)
  }
}

// vue3
import { onMounted, getCurrentInstance } from 'vue'

export default {
  setup () {
    onMounted(() => {
      const instance = getCurrentInstance()

      console.log(instance.ctx.$el)
    })
  }
}

于是乎,只需要将根元素节点在适当的时候加入或移出 set 便大功告成

import { onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'

function useClickOutside () {
  const instance = getCurrentInstance()

  onMounted(() => {
    elementSet.add(instance.ctx.$el)
  })

  onBeforeUnmount(() => {
    elementSet.delete(instance.ctx.$el)
  })
}

再稍微改进一下,可以支持任意元素的绑定

import { ref, onMounted, onBeforeUnmount } from 'vue'

function useClickOutside () {
  const wrapper = ref(null)

  onMounted(() => {
    elementSet.add(wrapper)
  })

  onBeforeUnmount(() => {
    elementSet.delete(wrapper)
  })

  return wrapper
}
<template>
  <div ref="wrapper" @clickoutside="handleClickOutside"></div>
</template>

<script>
import { useClickOutside } from 'clickoutside'

export default {
  name: 'MyComponent',
  setup () {
    const wrapper = useClickOutside()

    function handleClickOutside () {
      // some action
    }

    return {
      wrapper,
      handleClickOutside
    }
  }
}
</script>

最后

其实只要把自定义事件派发逻辑的部分写好,就可以实现任意的自定义事件在 vue 中以 @event 的形式调用

完整代码直接贴出来

import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'

const isNull = any => typeof any === 'undefined' || any === null

const USE_TOUCH = isNull(window) && ('ontouchstart' in window || (isNull(navigator) && navigator.msMaxTouchPoints > 0))
const CLICK_TYPE = USE_TOUCH ? 'touchstart' : 'click'

const CLICK_OUTSIDE = 'clickoutside'

const events = {
  [CLICK_OUTSIDE]: new Set()
}

document.addEventListener(CLICK_TYPE, event => {
  const target = event.target
  const type = CLICK_OUTSIDE
  const path = event.path || (event.composedPath && event.composedPath())

  events[type].forEach(el => {
    if (target !== el && (path ? !path.includes(el) : !el.contains(target))) {
      dispatchEvent(el, { type })
    }
  })
})

function observe (el, types) {
  if (typeof types === 'string') {
    types = [types]
  }

  Array.isArray(types) && types.forEach(type => {
    events[type].add(el)
  })
}

function disconnect (el, types) {
  if (typeof types === 'string') {
    types = [types]
  }

  Array.isArray(types) && types.forEach(type => {
    events[type].delete(el)
  })
}

function dispatchEvent (el, payload = {}, Event = window.Event) {
  const { type, bubbles = false, cancelable = false, ...data } = payload

  let event

  if (!isNull(Event)) {
    event = new Event(type, { bubbles, cancelable })
  } else {
    event = document.createEvent('HTMLEvents')
    event.initEvent(type, bubbles, cancelable)
  }

  Object.assign(event, data)

  return el.dispatchEvent(event)
}

export function useClickOutside () {
  const wrapper = ref(null)

  onMounted(() => {
    nextTick(() => {
      observe(wrapper.value, CLICK_OUTSIDE)
    })
  })

  onBeforeUnmount(() => {
    disconnect(wrapper.value, CLICK_OUTSIDE)
  })
  
  return wrapper
}

未觉雨声
1.5k 声望69 粉丝

Vue3 组件库 VexipUI 作者,擅长 js 和 vue 系列技术,主攻前端(交互),稍微会一点点 Java(Spring Boot)。