4.1 Sensors – onClickOutside
https://vueuse.org/onClickOutside
作用
监听当前的点击是否是在目标元素之外。在弹窗和下拉框中非常有用。
官方示例
<template>
<div ref="target">
Hello world
</div>
<div>
Outside element
</div>
</template>
<script>
import { ref } from 'vue'
import { onClickOutside } from '@vueuse/core'
export default {
setup() {
const target = ref(null)
onClickOutside(target, (event) => console.log(event))
return { target }
}
}
</script>
- 无渲染组件的代码如下,通过
@trigger
触发回调
<OnClickOutside @trigger="count++" :options="{ ignore: [/* ... */] }">
<div>
Click Outside of Me
</div>
</OnClickOutside>
- 指令用法,指令放在被监控元素上
<script setup lang="ts">
import { ref } from 'vue'
import { vOnClickOutside } from '@vueuse/components'
const modal = ref(false)
function closeModal() {
modal.value = false
}
</script>
<template>
<button @click="modal = true">
Open Modal
</button>
<div v-if="modal" v-on-click-outside="closeModal">
Hello World
</div>
</template>
源码分析
我们自己实现的时候,一般都是判断鼠标点击的元素以及它的所有父元素,和目标元素是否有交集,通过递归el.parentNode
实现。
看一下源码的实现。
cleanup=[]
,其中的useEventListener
返回的都是取消监听的函数。- 监听了
click、pointerdown、pointer
事件,我们忽略iframe
的情况。 click
事件始终触发handler
回调,除非点击了忽略的元素。pointerdown
判断鼠标按下,修改shouldListen
,判断起点是否在target
之外。pointerup
回调listener
事件,判断终点是否在target
之外,同时又shouldListen
,所以是否触发回调是二者共同控制的,只有起点和终点都在元素外才可以触发。
⚠️:思考一些问题:
1、为啥注册click、pointerdown、pointerup
这么多事件?
如果是鼠标左键点击: pointerdown --> pointerup --> click
,如果是鼠标右键点击: pointerdown --> pointerup
,如果是拖动,也不会触发click
,所以只监听click
是不够的。
2、回调会重复触发吗?
不会,因为pointerup
的回调是fallback = window.setTimeout(() => listener(e), 50)
,所以如果触发了click
,会执行window.clearTimeout(fallback)
。所以只有click
真正触发了回调。
3、右键点击是不是也会触发回调?
从以上的结论来看,右键不会触发click
,同时pointerup
又限制了e.button === 0
,只有左键起作用,所以右键不会触发回调。
export function onClickOutside<T extends OnClickOutsideOptions>(
target: MaybeElementRef,
handler: OnClickOutsideHandler<{ detectIframe: T['detectIframe'] }>,
options: T = {} as T,
) {
const { window = defaultWindow, ignore = [], capture = true, detectIframe = false } = options
if (!window)
return
let shouldListen = true
let fallback: number
/**
* 判断当前这次事件是否应该被忽略
*/
const shouldIgnore = (event: PointerEvent) => {
// 如果用户传递ingore数组中有一项满足条件,这个就返回true
return ignore.some((target) => {
// 如果传的是字符串,就找到所有符合条件的dom元素。然后一项项进行比较。
// 假如其中有一个dom满足:这个dom就是点击的dom,或者这个dom在被点击dom的冒泡路径上(也就是这个dom被鼠标冒泡路径上某一个dom包含)
if (typeof target === 'string') {
return Array.from(window.document.querySelectorAll(target))
.some(el => el === event.target || event.composedPath().includes(el))
}
else {
// 如果传递的是dom代理,直接比较
const el = unrefElement(target)
return el && (event.target === el || event.composedPath().includes(el))
}
})
}
const listener = (event: PointerEvent) => {
window.clearTimeout(fallback)
const el = unrefElement(target)
// 如果点击了target元素或者target的子元素
if (!el || el === event.target || event.composedPath().includes(el))
return
// 对于click事件,detail就是1;对于mousedown和mouseup,detail每次都+1;其他事件都是0
if (event.detail === 0)
shouldListen = !shouldIgnore(event)
if (!shouldListen) {
shouldListen = true
return
}
// 点击了外部元素,且本次事件不被忽略,则触发用户传递的回掉
handler(event)
}
const cleanup = [
useEventListener(window, 'click', listener, { passive: true, capture }),
useEventListener(window, 'pointerdown', (e) => {
const el = unrefElement(target)
if (el)
// 按下时,鼠标不在target之内,则shouldListen=true
shouldListen = !e.composedPath().includes(el) && !shouldIgnore(e)
}, { passive: true }),
useEventListener(window, 'pointerup', (e) => {
// e.button === 0 表示鼠标左键
if (e.button === 0) {
const path = e.composedPath()
e.composedPath = () => path
fallback = window.setTimeout(() => listener(e), 50)
}
}, { passive: true }),
detectIframe && useEventListener(window, 'blur', (event) => {
const el = unrefElement(target)
if (
window.document.activeElement?.tagName === 'IFRAME'
&& !el?.contains(window.document.activeElement)
)
handler(event as any)
}),
].filter(Boolean) as Fn[]
const stop = () => cleanup.forEach(fn => fn())
return stop
}