哈喽,大家好,我是 SuperYing。今天我们聊一个 Vue3 自定义指令 --- ClickOutside,顾名思义,就是处理点击当前区域之外的位置的场景。
Vue 指令
首先,我们先来回顾一下 Vue 指令
相关的知识点。
简介
Vue 指令是带有 v-
前缀的特殊 attribute。指令 attribute 的值预期是 单个 JavaScript 表达式。指令的职责是当表达式的值改变时,将其产生的连带影响,响应式的作用于 DOM。
注册方式
1.全局注册
通过 Vue 实例对象的 directive
方法全局注册:
import { createApp } from 'vue'
const app = Vue.createApp({})
// 注册一个全局自定义指令 `v-focus`
app.directive('focus', {
// 当被绑定的元素挂载到 DOM 中时……
mounted(el) {
// 聚焦元素
el.focus()
}
})
2.局部注册
Vue 组件提供 directives
选项,实现局部注册指令:
directives: {
focus: {
// 指令的定义
mounted(el) {
el.focus()
}
}
}
钩子函数
一个指令定义对象可以提供如下几个钩子函数 (均为可选):
- created: 在绑定的 attribute 或事件监听器被应用之前调用。在指令需要附加在普通的
v-on
事件监听器调用前的事件监听器中时,这很有用。 - beforeMount: 当指令第一次绑定到元素并且在挂载父组件之前调用。
- mounted: 在绑定元素的父组件被挂在前调用。
- beforeUpdate: 在更新包含组件的 VNode 之前调用。
- updated: 在包含组件的 VNode 及其子组件的 VNode 更新后调用。
- beforeUnmount: 在卸载绑定元素的父组件之前调用。
- unmounted: 当指令与元素接触绑定且父组件已卸载时,只调用一次。
示例:
import { createApp } from 'vue'
const app = createApp({})
// 注册
app.directive('my-directive', {
// 指令具有一组生命周期钩子:
// 在绑定元素的 attribute 或事件监听器被应用之前调用
created() {},
// 在绑定元素的父组件挂载之前调用
beforeMount() {},
// 在绑定元素的父组件挂载之后调用
mounted() {},
// 在包含组件的 VNode 更新之前调用
beforeUpdate() {},
// 在包含组件的 VNode 及其子组件的 VNode 更新之后调用
updated() {},
// 在绑定元素的父组件卸载之前调用
beforeUnmount() {},
// 在绑定元素的父组件卸载之后调用
unmounted() {}
})
// 注册 (函数指令)
app.directive('my-directive', () => {
// 这将被作为 `mounted` 和 `updated` 调用
})
// getter, 如果已注册,则返回指令定义
const myDirective = app.directive('my-directive')
钩子函数参数:
- el: 指令绑定到的元素。可用于直接操作 DOM。
- binding: 包含以下属性的对象。
- instance:使用指令的组件实例。
- value: 传递给指令的值。例如在
v-my-directive="1 + 1"
中,该值为 2. - oldValue: 先前的值,仅在 beforeUpdate 和 updated 钩子中可用。无论是否有更改都可用。
- arg: 传递给指令的参数(如果有的话)。例如在
v-my-directive:foo
中,arg 为foo
。 - modifiers: 包含修饰符(如果有的话)的对象。例如在
v-my-directive.foo.bar
中,modifiers 为{foo: true,bar: true}
。 - dir: 一个对象,在注册指令时作为参数传递。例如,在以下指令中
dir 将会是app.directive('focus', { mounted(el) { el.focus() } })
{ mounted(el) { el.focus() } }
- vnode: 一个真实 DOM 元素的蓝图,对应上面收到的 el 参数。
- prevode: 上一个虚拟节点,仅在 beforeUpdate 和 updated 钩子中可用。
好啦,Vue 指令相关就回顾到这里,除了上面的内容之外,还有动态参数使用,简写及组件应用等。更多详情可以移步 Vue3 官网。
ClickOutside 实现
应用场景
典型场景:假设我们有一个下拉框组件,当下拉框展开的时候,点击下拉框之外的元素可以自动关闭下拉框。
分析
首我们先来分析一下 ClickOutside
的需求。
- 若想要明确点击位置是否为当前 DOM 之外,就要确认当前 DOM 元素的范围,恰巧对应自定义指令的 el 参数客户。
- 事件监听。监听元素的相应事件,如
click
,mousedown
+mouseup
等,那么监听哪部分 DOM 呢,这部分 DOM 应该同时包括当前 DOM 和当前 DOM 之外的部分,那么 document 就很合适了。 - 现在元素和监听事件已经确认,在事件处理函数中对比
Event target
,若target
在当前 DOM 元素之外,触发指令绑定的处理函数即可。
代码
结合上面的过程分析,我们来实现一个丐版 ClickOutside
:
<template>
<div v-click-outside="onClickOutside"></div>
</template>
<script setup>
// 定义局部自定义指令,这里是在 setup 标签下编写,指令名称以 v 开头,无需额外注册逻辑
const vClickOutside = {
mounted(el, binding) {
function eventHandler(e) {
if (el.contains(e.target)) {
return false
}
// 如果绑定的参数是函数,正常情况也应该是函数,执行
if (binding.value && typeof binding.value === 'function') {
binding.value(e)
}
}
// 用于销毁前注销事件监听
el.__click_outside__ = eventHandler
// 添加事件监听
document.addEventListener('click', eventHandler)
},
beforeUnmount(el) {
// 移除事件监听
document.removeEventListener('click', el.__click_outside__)
// 删除无用属性
delete el.__click_outside__
}
}
// 自定义指令参数,点击外部区域的处理函数,如关闭弹窗
const onClickOutside = () => {
console.log('点击了外部 DOM')
}
</script>
经典案例(Element-Plus)
// DOM 元素与其事件监听函数组成的 Map,key 是 DOM 元素,value 是对应的处理函数数组
const nodeList: FlushList = new Map()
// 鼠标操作分为两个阶段
// 1. mousedown 按下鼠标
// 2. mouseup 松开鼠标
// 完成以上两步,才算完成一次完整的 click 事件触发
// startClick 是 mousedown 事件处理函数的 Event 参数
let startClick: MouseEvent
if (isClient) {
// 监听 mousedown 事件,设置 startClick 对象
on(document, 'mousedown', (e: MouseEvent) => (startClick = e))
// 监听 mouseup 事件,循环执行事件处理函数,但是至于是否真正执行,要满足 createDocumentHandler 中的执行条件才行。
on(document, 'mouseup', (e: MouseEvent) => {
for (const handlers of nodeList.values()) {
for (const { documentHandler } of handlers) {
documentHandler(e as MouseEvent, startClick)
}
}
})
}
// 生成事件处理函数,供 mouseup 事件调用
function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
// ClickOutside 允许通过参数设置例外的 target,点击该参数对应的 DOM 不会触发处理函数
let excludes: HTMLElement[] = []
// 正常情况,参数是数组形式,直接将该参数数组设置为例外 DOM
if (Array.isArray(binding.arg)) {
excludes = binding.arg
} else if ((binding.arg as unknown) instanceof HTMLElement) {
// 容错处理,如果参数是 HTMLElement,将其 push 到 excludes 中
excludes.push(binding.arg as unknown as HTMLElement)
}
// 返回一个函数,参数是 mouseup 和 mousedown 事件处理函数的 Event 参数
return function (mouseup, mousedown) {
// popper(若存在的话,如下拉框等)
const popperRef = (binding.instance as ComponentPublicInstance<{popperRef: Nullable<HTMLElement>}>).popperRef
// mouseup 事件触发的 target
const mouseUpTarget = mouseup.target as Node
// mousedown 事件触发的 target
const mouseDownTarget = mousedown?.target as Node
// 校验情况1:ClickOutside 是否绑定了处理函数
const isBound = !binding || !binding.instance
// 校验情况2:是否存在事件触发的 target
const isTargetExists = !mouseUpTarget || !mouseDownTarget
// 校验情况3:事件触发的 target 是否在 el 内
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget)
// 校验情况4:事件触发的 target 是否为 el
const isSelf = el === mouseUpTarget
// 校验情况5:事件触发的 target 是否在例外 DOM 数组内
const isTargetExcluded = (excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) || (excludes.length && excludes.includes(mouseDownTarget as HTMLElement))
// 校验情况6:若存在 popper,事件触发的 target 是否在 popper 内
const isContainedByPopper = popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget))
// 若满足以上 6 中校验情况的任意一种,直接跳出,否则执行 ClickOutside 指令绑定的处理函数
if (
isBound ||
isTargetExists ||
isContainedByEl ||
isSelf ||
isTargetExcluded ||
isContainedByPopper
) {
return
}
binding.value(mouseup, mousedown)
}
}
// clickOutside 实现代码
const ClickOutside: ObjectDirective = {
beforeMount(el: HTMLElement, binding: DirectiveBinding) {
// 当前的 DOM 元素可能存在多个处理好函数
// 1.确定监听的 DOM 元素中是否存在当前 el,若不存在,初始化为空数组[]
if (!nodeList.has(el)) {
nodeList.set(el, [])
}
// 将事件处理函数添加到 el 对应的处理函数列表中
nodeList.get(el).push({
// 调用 createDocumentHandler 方法返回处理函数,内部会对是否校验各种边界情况,判断是否需要执行 binding.value 绑定的处理函数
documentHandler: createDocumentHandler(el, binding),
bindingFn: binding.value,
})
},
updated(el: HTMLElement, binding: DirectiveBinding) {
if (!nodeList.has(el)) {
nodeList.set(el, [])
}
// 获取当前 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) {
// 移除 el 绑定的所有监听事件处理函数
nodeList.delete(el)
},
}
好啦!以上便是「 Vue3 自定义指令:ClickOutside 」的全部内容,感谢阅读。
欢迎各路大佬讨论、批评、指正,共同进步才是硬道理!