VueUse onClickOutside 解析

585 阅读6分钟

介绍

监听指定元素外的单击事件,一般用于弹窗。

使用

code1.png

源码

简单实现

简单实现就是window监听单击事件。

code2.png

以上实现功能时,会有个问题:当 target 元素的子元素被点击时,点击事件会首先在该子元素上触发,然后由于事件冒泡机制,这个事件会逐级向上传播,最终可能会到达 window 对象。如果在 window 对象上设置了监听器来处理外部点击事件,那么当子元素被点击时,这个监听器也会被触发。

因为当事件冒泡到 window 时,event.target 指向的是实际被点击的元素,即 target 的子元素,而不是 target 本身。这会导致我们错误地认为用户点击了 target 元素之外的地方,从而错误地执行了 listener 函数。

事件冒泡

当一个事件发生在一个元素上,它会首先运行在该元素上的处理程序,然后运行其父元素上的处理程序,然后一直向上到其他祖先上的处理程序。

code4.png

点击内部的 <p> 会首先运行 onclick

  • 在该 <p> 上的。
  • 然后是外部 <div> 上的。
  • 然后是外部 <form> 上的。
  • 以此类推,直到最后的 document 对象。

code3.png

事件委托

事件委托模式是利用事件冒泡机制的一种事件优化方式,其原理是:如果我们有许多以类似方式处理的元素,那么就不必为每个元素分配一个处理程序 —— 而是将单个处理程序放在它们的共同祖先上。

code5.png

此代码不会关心在 container 中有多少个 tile 元素。我们可以随时动态添加/移除 tile,单击 tile 时都会打印"click tile"。

缺陷

点击可能不是发生在 tile 上,而是发生在其内部。target 就不是 tile 而是内部元素,因此不会打印 "click tile":

code6.png

因为当 tilespan 元素被点击时,点击事件会首先在该元素上触发,然后由于事件冒泡机制,这个事件会逐级向上传播,当达 container 元素时,会触发 container 元素的单击事件。但事件中的 event.target 指向的是 span 元素对象,而不是带有 tile 元素,因此不会打印 "click tile"

当我希望这种情况下也能打印 "click tile" 该如何解决呢?

第一种方式, 我们可以将标识放入处理程序中的 event 对象,并在另一个事件冒泡的父元素事件中读取该数据,这样我们就可以向父处理程序传递有关下层处理程序的信息。

code7.png

这种方式需要在每个元素上绑定事件,事件委托目的就是为了避免在每个元素上绑定事件,此方式违背了事件委托的目的,实际开发中完全不会使用这种方式,但其中 event 对象可以在事件冒泡中传递信息的功能在一些场景下会使用到,vue3源码中事件绑定中就用到了。

第二种方式,查找 container 元素的子孙元素是否包含 event.target 元素。

code8.png

这种方式思路简单,缺点就是需要挨个查找 tile 元素的子类元素,查找操作过多。

我们还可以通过 Node.contains() 来进行优化。

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

code9.png

第三种方式,查找 event.target 的祖先元素是否包含 container 对象。

code10.png

这种方式要比上一种方式查找的次数更少一些,因为只要依次查找 event.target 祖先元素是否有 tile 元素就可以了,不用查询所有带有 tile 元素。性能要比上一种更好些。

我们可以使用 Element.closest() 来进一步优化。

Element.closest() 方法用来获取匹配特定选择器且离当前元素最近的祖先元素(也可以是当前元素本身)。如果匹配不到,则返回 null

code11.png

代码更加简洁,但兼容性会稍微差一点,chrome 41以上版本

第四种方式,使用 Event.composedPath()

Event.composedPath()Event 接口的一个方法,当对象数组调用该侦听器时返回事件路径。

code12.png

Element.closest() 要在chrome 53以上版本使用。

对比来说 Element.closest() 是最简单的高效的方式。但对 onClickOutside 来说并不是最好选择,因为onClickOutside 的第一个参数是目标对象。如果用 Element.closest() 实现代码如下:

code13.png

由于 Element.closest() 的参数是指定的选择器,需要 target 对象要有 id 来组装选择器,这样的话就多了一个限定条件,反而限制了工具函数的使用。

这里最合适的 Event.composedPath() 方式:

code14.png

当然 composedPath 有兼容性的风险,onClickOutside 也提供了一种兼容老版本浏览器的写法。

移除事件

上篇讲到的 useEventListener 来进行事件绑定并返回移除事件的函数。

code15.png

click 的触发条件

click 的触发条件是:如果使用的是鼠标左键,则在同一个元素上的 mousedownmouseup 相继触发后,触发该事件。

我们来看个例子:

code16.png

modal 中使用 onClickOutside 时会有个缺陷,当 modal 中按住鼠标左键然后鼠标移出 modal 区域再松开鼠标左键,这时不会触发 modal 的单击事件,因为要在同一个元素上的 mousedownmouseup 相继触发后才会执行 click 事件,但是会触发 window 上的 click 事件,因为 modal 元素是在 window 内的,鼠标左键按下时 mousedown 事件冒泡到 window 上,鼠标松开时虽然不是在 modal 内,但是在 window 区域内, mouseup 事件也会冒泡到 window 上,这样 window 上相继触发了 mousedownmouseup 因此也就触发了 click 事件,并且 event.target 是指向 windowonClickOutside 代码中 target === event.targetfalse ,因此会执行 listener 函数,如果 listener 函数是关闭弹窗的话,平时使用弹窗的时候会很容易误操作而关闭弹窗。

因此解决这种情况方式是 window 上添加 pointerdown 事件:

code17.png

ignore 配置项

onClickOutside 中还增加了配置项 ignore 来指定不触发事件的元素列表。

code18.png

这里我有个没搞懂的地方为什么要加 event.detail === 0, event.detail 表示单击的次数,触发 click 事件的event.detail 一定是大于 0 的,event.detail === 0永远是 false,也就是shouldListen = !shouldIgnore(event)这段代码是无效的,为什么要加这段,有知道的同学可以帮忙解答么?

ios下事件冒泡异常

单击事件在移动端Safari浏览器有时单击事件不会触发,而原因是事件冒泡出了问题:

iOS 上的 Safari 只允许鼠标事件(包括单击)在以下情况下冒泡: 1.事件的目标元素是链接或表单字段。 2.目标元素或其任何祖先(包括 <body> )都为任何鼠标事件设置了显式事件处理程序。此事件处理程序可能是一个空函数。 3.目标元素或其任何祖先(包括文档)都具有 cursor: pointer CSS 声明。

第二种方式实现相对简单些:

code19.png

iframe处理

code20.png