深入理解Vue3自定义指令ClickOutside的实现

  当我们在开发一些组件的时候,比如下拉框或者一些模态框等组件,我们希望在点击元素之外的时候就能够把相应的元素收起来或者隐藏;这看似十分简单的需求,其实隐藏着很多的判断逻辑和代码技巧在里面,笔者就结合这几天阅读element-plus和naive-ui-admin源码的经验,总结分享自己的一些经验和想法。

  在学习源码之前,我们先进行铺垫一下,了解一下简单几个工具函数的使用,方便后续理解。

本文应该是全网最深入的对自定义指令ClickOutside的解读,文章内容较长,觉得有所收获的记得点赞关注收藏,一键三连。

工具函数

  首先是on和off函数,在naive-ui-admin中用来给函数注册绑定和解除绑定事件

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function on(
element: Element | HTMLElement | Document | Window,
event: string,
handler: EventListenerOrEventListenerObject
): void {
if (element && event && handler) {
element.addEventListener(event, handler, false);
}
}

export function off(
element: Element | HTMLElement | Document | Window,
event: string,
handler: Fn
): void {
if (element && event && handler) {
element.removeEventListener(event, handler, false);
}
}

  比如在给元素绑定事件时,也可以很方便的使用,看起来也比较简洁:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
const domClick = (ev) => {
// ...
}
on(el, 'click', domClick)
off(el, 'click', domClick)

  这里扩展一下,利用on和off函数组合,我们还能扩展出once函数,用来注册一次性的事件:

1
2
3
4
5
6
7
8
9
export function once(el: HTMLElement, event: string, fn: EventListener): void {
const listener = function (this: any, ...args: unknown[]) {
if (fn) {
fn.apply(this, args);
}
off(el, event, listener);
};
on(el, event, listener);
}

  在这里,我们并不是直接将fn函数绑定到元素上,而是巧妙的在函数内部定义了listener函数绑定在el元素上,再在listener函数触发后,在其内部执行一次fn函数后再进行解绑操作。

自定义指令

  在vue中有很多v-if、v-show、v-model等常用内置的指令可以使用,同时我们可以很方便灵活的封装自己的指令,来满足特定的业务需求和场景;我们在setup一文中介绍了如何定义和引入定义好的指令,指令对象中我们可以使用如下七个声明周期的钩子函数:

  • created:在绑定元素的 attribute 前
  • beforeMount:在元素被插入到 DOM 前调用
  • mounted:绑定元素的父组件及子节点都挂载完成后调用
  • beforeUpdate:绑定元素的父组件更新前调用
  • updated:绑定元素的父组件所有及子节点更新完成后
  • 谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  • beforeUnmount:绑定元素的父组件卸载前调用
  • unmounted:绑定元素的父组件卸载后调用

注意没有beforeCreate函数。

  钩子函数看似比较多,其实常见常用的也就是mounted、updated和beforeUnmount,在生命周期开始、中间和结束时做一些处理,钩子函数常见的写法如下:

1
2
3
4
5
const myDirective = {
mounted(el, binding, vnode, prevVnode) {
// ...
},
}

  这里我们重点看下钩子函数传入的两个参数:el和binding;el很明显就是绑定指令的dom元素,而binding就比较有趣了,它里面含有各种绑定的数据,它本身是一个对象,把它打印出来,我们看到它有以下属性:

  比如我们写了一个自定义指令:

1
<div v-click-outside:foo.stop.front="'hello'"></div>

  那么我们打印出来的binding对象就会像是这样的:

1
2
3
4
5
6
7
8
{
arg: "foo",
dir: { mounted:f, beforeUnmount:f },
instance: Proxy(Object),
modifiers: { stop: true, front: true },
oldValue: undefined,
value: 'hello'
}

  通过这个案例我们就能很清楚每个参数的作用,oldValue一般在update的时候会用到;而我们最常用的就是value值了,这里的value值不仅仅局限于普通的数值,还可以传入对象或者函数进行执行,我们在下面会看到。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

动态参数指令

  这里还要额外介绍一下指令的arg属性,利用这个指令属性,我们还可以玩出很多花样来;在上面的例子中,v-click-outside:foo这样的写法,指令参数arg的值就是确定的foo。

  在vue3的官方文档上就给出了这样一个场景,我们将一个div,通过固定布局fix的方式固定再页面一侧,但是需要改变它的位置,虽然我们可以通过value传入对象的方式解决,却不是很友好,通过动态arg的方式我们就可以把这个需求给轻松实现了;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div>
<div class="fixed" v-fixed:[direction]="200"></div>
<div @click="changeDirection">改变方向</div>
</div>
</template>
<script setup>
import vFixed from '@/directives/fixed'
const direction = ref('left')

const changeDirection = () => {
direction.value = 'right'
}
</script>

  通过v-fixed:[direction]的方式,我们给arg参数传入了left的值;点击按钮我们希望切换值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const fixed = {
mounted(el, binding) {
const s = binding.arg || 'left'
el.style[s] = (binding.value || 200) + 'px'
},
updated(el, binding) {
const s = binding.arg || 'left'
el.style = ''
el.style[s] = (binding.value || 200) + 'px'
},
beforeUnmount() {},
}

export default fixed

  这样我们就实现的动态指令参数的切换;除此之外,我们还可以给arg传入数组等复杂的数据。

简易版实现

  好了,上面的工具函数和钩子函数的介绍铺垫完了,我们对自定义指令也有了一定的了解;我们从最简单的功能开始,来看下如何实现一个简易版本的ClickOutside。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { on, off } from '@/utils/domUtils'

const clickOutside = {
mounted(el, binding) {
function eventHandler(e) {
// 对el和binding进行处理,判断是否触发value函数
}
el.__click_outside__ = eventHandler
on(document, 'click', eventHandler)
},
beforeUnmount(el) {
if(typeof el.__click_outside__ === 'function'){
off(document, 'click', el.__click_outside__)
}
},
}

export default clickOutside

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们在指令挂载的时候,给document定义并且绑定了一个eventHandler处理函数,并且挂载到元素的__click_outside__属性,方便在卸载的时候进行事件取消绑定。

eventHandler函数只能放到指令中定义,否则获取不到el和binding。

  在使用clickOutside的时候,我们给value传入绑定函数,因此binding.value的值接收到的其实是一个函数:

1
2
3
4
5
6
7
8
9
<template>
<div v-click-outside="onClickOutside">
</div>
</template>
<script setup>
const onClickOutside = () => {
// ..
}
</script>

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们在上面定义的eventHandler函数也是点击事件的触发函数,判断事件的target是否包含在el节点中,如果不在的话就执行binding.value函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
mounted(el, binding) {
function eventHandler(e) {
if (el.contains(e.target) || el === e.target) {
return false
}
// 触发binding.value
if (binding.value && typeof binding.value === 'function') {
binding.value(e)
}
}
}
}

  这里用到了一个contains函数,它返回一个布尔值,用来判断某一节点是否是另一个节点的子节点,我们看下MDN文档上的解释:

contains()方法返回一个布尔值,表示一个节点是否是给定节点的后代,即该节点本身、其直接子节点(childNodes)、子节点的直接子节点等。

  需要注意的是,由于contains会将节点本身判断返回true,这不是我们想要的结果,因此我们还要显示加一下el === e.target的判断过滤条件。

  因此最终的指令就是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { on , off } from '@/utils/domUtils'
const clickOutside = {
mounted(el, binding) {
function eventHandler(e) {
if (el.contains(e.target) || el === e.target) {
return false
}
if (binding.value && typeof binding.value === 'function') {
binding.value(e)
}
}
el.__click_outside__ = eventHandler
on(document, 'click', eventHandler)
},
beforeUnmount(el) {
if(typeof el.__click_outside__ === 'function'){
off(document, 'click', el.__click_outside__)
}
},
}
export default clickOutside

简易版优化

  我们继续对这个简易版的函数进行优化,我们发现,每次指令初始化和移除时给document绑定事件很麻烦;如果把document的绑定事件放到外面来,只绑定一次,不就减少了每次绑定和解绑的繁琐了么。

1
2
3
4
5
6
7
8
9
on(document, 'click', (e) => {
// ...
})

const clickOutside = {
mounted(el, binding) {
// ...
}
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  那么,接下来的问题就来到了,怎么能够在click事件中能够执行每个指令中定义的eventHandler函数,从而判断出binding.value函数是否需要触发执行呢?

  没错,我们可以定义一个数组,来收集所有指令的eventHandler函数,点击时统一执行;不过数组带来的问题是最后解绑时不容易去找到每个el对应的eventHandler函数。

  不过这里我们更加巧妙的定义了一个Map对象,由于我们的eventHandler函数和el是一一对用关系,利用Map对象的键值可以存储任何数据的特性加持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const nodeList = new Map()

on(document, 'click', (e) => {
for (const fn of nodeList.values()) {
fn(e)
}
})

const clickOutside = {
mounted(el, binding) {
function eventHandler(e) {
// ...
}
nodeList.set(el, eventHandler)
},
beforeUnmount(el) {
nodeList.delete(el)
},
}

  我们将eventHandler收集到nodeList中,document点击时触发每个eventHandler,再在eventHandler内部去判断bind.value是否需要触发。

简易版升级优化

  虽然是简易版,不过我们还可以对它再再进行优化;我们发现naive-ui-admin的源码clickOutside.ts中,并没有注册click事件,而是注册了mouseup/mousedown事件,这是为什么呢?我们在MDN中关于click/mouseup/mousedown事件找到了原话,是这样说的:

当定点设备的按钮(通常是鼠标的主键)在一个元素上被按下和放开时,click 事件就会被触发。

mouseup/mousedown事件在定点设备(如鼠标或触摸板)按钮在元素内按下时,会在该元素上触发。

  因此,总结下来就是,click事件只是由鼠标的左键触发,而mouseup/mousedown事件是由任意定点设备触发的,比如鼠标的右键或者中间的滚轮键,都是可以触发的。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

点击dom元素,三个事件的触发顺序是:mousedown、mouseup、click。

  上面得出的结论,我们可以在VueUse中同时得到验证;如果我们使用VueUse的onClickOutside,我们会发现它只有在鼠标左键时才会触发;而element-plus则是三键同时可以触发。

  打开VueUse源码中我们就会发现他注册的就是click事件:

1
2
3
4
5
const cleanup = [
useEventListener(window, 'click', listener, { passive: true, capture }),
// 省略其他代码
]
cleanup.forEach(fn => fn())

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  因此知道了三个事件的区别,回到我们的简易版本,我们就可以进行升级了;首先将click事件进行改写,分成mousedown和mouseup,不过这两个事件都有对应的事件对象e,我们先存一个下来,在eventHandler里面再对两个事件对象进行判断。

1
2
3
4
5
6
7
8
9
10
11
let startClick

on(document, 'mousedown', (e) => {
startClick = e
})

on(document, 'mouseup', (e) => {
for (const fn of nodeList.values()) {
fn(e, startClick)
}
})

  eventHandler也接收的不是click的ev对象了,而是mousedown/mouseup的:

1
2
3
4
5
6
7
8
9
10
11
12
13
function eventHandler(mouseup, mousedown) {
if (
el.contains(mouseup.target) ||
el === mouseup.target ||
el.contains(mousedown.target) ||
el === mousedown.target
) {
return false
}
if (binding.value && typeof binding.value === 'function') {
binding.value()
}
}

  这样我们的简易函数就升级完成了,也能同时支持左中右键的事件了。

源码实现逻辑

  经过上面的简易版本的迭代升级,相信大家对ClickOutside整体的实现过程和原理应该有了一定的了解,基本也已经把源码讲了七七八八了;我们就来看下它的源码中还有哪些逻辑,无非是判断的更加全面一些,下面主要以naive-ui-admin源码中的clickOutside.ts为主。

  首先我们看下它主要的代码结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { on } from '@/utils/domUtils';
const nodeList = new Map();

let startClick: MouseEvent;

on(document, 'mousedown', (e: MouseEvent) => (startClick = e));
on(document, 'mouseup', (e: MouseEvent) => {
for (const { documentHandler } of nodeList.values()) {
documentHandler(e, startClick);
}
});

function createDocumentHandler(el, binding) {
return function (mouseup, mousedown) {
// ..
}
}

const ClickOutside: ObjectDirective = {
beforeMount(el, binding) {
nodeList.set(el, {
documentHandler: createDocumentHandler(el, binding),
bindingFn: binding.value,
});
},
updated(el, binding) {
nodeList.set(el, {
documentHandler: createDocumentHandler(el, binding),
bindingFn: binding.value,
});
},
unmounted(el) {
nodeList.delete(el);
},
};

export default ClickOutside;

  我们发现除了createDocumentHandler这个函数之外,其他的功能在上面简易版里都已经实现了;由于我们的handler函数中需要用到el和binding,这里的createDocumentHandler作用是创建一个匿名闭包handler函数,将handler函数存储到nodeList,就能引用el和binding了。

  因此我们重点来看下这个createDocumentHandler做了哪些事情,首先他接收了指令中的el和binding两个参数,它返回的匿名函数是在mouseup事件中被调用的,接收了mouseup和mousedown两个事件对象。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  我们继续看创建出来的documentHandler函数中做了哪些的处理,它里面主要有6个判断的flag,只要符合下面6个条件之一,即返回true,就不触发binding.value函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
return function (mouseup, mousedown) {
// ...
if (
isBound ||
isTargetExists ||
isContainedByEl ||
isSelf ||
isTargetExcluded ||
isContainedByPopper
) {
return;
}
binding.value();
}

  那么这六个条件是什么呢?我们逐一来解读;前两个判断是完整性判断,第一个检验条件是检查binding或binding.instance是否存在,不存在isBound为true;第二个检验条件是mouseup/mousedown的触发目标元素target是否都存在。

1
2
3
4
5
6
7
const mouseUpTarget = mouseup.target as Node;
const mouseDownTarget = mousedown.target as Node;

// 判断一
const isBound = !binding || !binding.instance;
// 判断二
const isTargetExists = !mouseUpTarget || !mouseDownTarget;

  第三第四个判断就是元素判断了,和我们的简易版本就有点类似,isContainedByEl判断mouseUpTarget和mouseDownTarget是否在el元素中,如果在则为true;isSelf则是判断触发元素是否是el自身。

1
2
3
4
// 判断三
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
// 判断四
const isSelf = el === mouseUpTarget;

  第五第六个判断是特殊情况的判断,判断五是事件的target是否被excludes中的元素包含,如果是,isTargetExcluded为true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 判断五
const isTargetExcluded =
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
(excludes.length && excludes.includes(mouseDownTarget as HTMLElement));

const popperRef = (
binding.instance as ComponentPublicInstance<{
popperRef: Nullable<HTMLElement>;
}>
).popperRef;

// 判断六
const isContainedByPopper =
popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));

  这里我们重点来讲一下excludes过滤数组的用法,正常情况下都是将绑定元素el下的dom节点判断过滤,但是还有些情况下,我们需要在点击时额外过滤其他的节点(这种特殊的情况,我们在下面一篇文章会看到);这个时候就要用到excludes数组了,那它是怎么来的呢?在创建documentHandler的时候,我们就从这个动态参数指令arg中拼了这个数组:

1
2
3
4
5
6
7
8
9
function createDocumentHandler(el, binding) {
let excludes = [];
if (Array.isArray(binding.arg)) {
excludes = binding.arg;
} else {
excludes.push(binding.arg);
}
// 其他判断条件
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  那么结合上面的动态参数指令,我们就可以使用一下这个exclude来额外添加过滤的dom:

1
2
3
4
5
6
7
8
9
10
11
<template>
<div v-click-outside:[excludeDom]="clickOut"></div>
</template>
<script setup>
const excludeDom = ref([])
const clickOut = () => {}

onMounted(() => {
excludeDom.value.push(document.querySelector(".some-class"));
});
</script>

总结

  本文总结了vue3下ClickOutside的实现逻辑,从工具函数封装,到自定义指令的学习,再到源码的深入学习;虽然ClickOutside的整体逻辑并不是很复杂,但是刚开始笔者阅读源码的时候,很难理解其中的一些用法;尤其是在事件的注册,为什么不用click,而是使用mouseup/mousedown两个事件组合;经过深入的思考和对比,才慢慢理解作者的用意。


本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。