介绍
监听指定元素外的单击事件,一般用于弹窗。
使用
源码
简单实现
简单实现就是window监听单击事件。
以上实现功能时,会有个问题:当 target 元素的子元素被点击时,点击事件会首先在该子元素上触发,然后由于事件冒泡机制,这个事件会逐级向上传播,最终可能会到达 window 对象。如果在 window 对象上设置了监听器来处理外部点击事件,那么当子元素被点击时,这个监听器也会被触发。
因为当事件冒泡到 window 时,event.target 指向的是实际被点击的元素,即 target 的子元素,而不是 target 本身。这会导致我们错误地认为用户点击了 target 元素之外的地方,从而错误地执行了 listener 函数。
事件冒泡
当一个事件发生在一个元素上,它会首先运行在该元素上的处理程序,然后运行其父元素上的处理程序,然后一直向上到其他祖先上的处理程序。
点击内部的 <p> 会首先运行 onclick:
- 在该
<p>上的。 - 然后是外部
<div>上的。 - 然后是外部
<form>上的。 - 以此类推,直到最后的
document对象。
事件委托
事件委托模式是利用事件冒泡机制的一种事件优化方式,其原理是:如果我们有许多以类似方式处理的元素,那么就不必为每个元素分配一个处理程序 —— 而是将单个处理程序放在它们的共同祖先上。
此代码不会关心在 container 中有多少个 tile 元素。我们可以随时动态添加/移除 tile,单击 tile 时都会打印"click tile"。
缺陷
点击可能不是发生在 tile 上,而是发生在其内部。target 就不是 tile 而是内部元素,因此不会打印 "click tile":
因为当 tile 的 span 元素被点击时,点击事件会首先在该元素上触发,然后由于事件冒泡机制,这个事件会逐级向上传播,当达 container 元素时,会触发 container 元素的单击事件。但事件中的 event.target 指向的是 span 元素对象,而不是带有 tile 元素,因此不会打印 "click tile"
当我希望这种情况下也能打印 "click tile" 该如何解决呢?
第一种方式, 我们可以将标识放入处理程序中的 event 对象,并在另一个事件冒泡的父元素事件中读取该数据,这样我们就可以向父处理程序传递有关下层处理程序的信息。
这种方式需要在每个元素上绑定事件,事件委托目的就是为了避免在每个元素上绑定事件,此方式违背了事件委托的目的,实际开发中完全不会使用这种方式,但其中 event 对象可以在事件冒泡中传递信息的功能在一些场景下会使用到,vue3源码中事件绑定中就用到了。
第二种方式,查找 container 元素的子孙元素是否包含 event.target 元素。
这种方式思路简单,缺点就是需要挨个查找 tile 元素的子类元素,查找操作过多。
我们还可以通过 Node.contains() 来进行优化。
Node.contains():返回一个布尔值,表示一个节点是否是给定节点的后代,即该节点本身、其直接子节点(childNodes)、子节点的直接子节点等。
第三种方式,查找 event.target 的祖先元素是否包含 container 对象。
这种方式要比上一种方式查找的次数更少一些,因为只要依次查找 event.target 祖先元素是否有 tile 元素就可以了,不用查询所有带有 tile 元素。性能要比上一种更好些。
我们可以使用 Element.closest() 来进一步优化。
Element.closest() 方法用来获取匹配特定选择器且离当前元素最近的祖先元素(也可以是当前元素本身)。如果匹配不到,则返回 null
代码更加简洁,但兼容性会稍微差一点,chrome 41以上版本
第四种方式,使用 Event.composedPath()
Event.composedPath() 是 Event 接口的一个方法,当对象数组调用该侦听器时返回事件路径。
Element.closest() 要在chrome 53以上版本使用。
对比来说 Element.closest() 是最简单的高效的方式。但对 onClickOutside 来说并不是最好选择,因为onClickOutside 的第一个参数是目标对象。如果用 Element.closest() 实现代码如下:
由于 Element.closest() 的参数是指定的选择器,需要 target 对象要有 id 来组装选择器,这样的话就多了一个限定条件,反而限制了工具函数的使用。
这里最合适的 Event.composedPath() 方式:
当然 composedPath 有兼容性的风险,onClickOutside 也提供了一种兼容老版本浏览器的写法。
移除事件
上篇讲到的 useEventListener 来进行事件绑定并返回移除事件的函数。
click 的触发条件
click 的触发条件是:如果使用的是鼠标左键,则在同一个元素上的 mousedown 及 mouseup 相继触发后,触发该事件。
我们来看个例子:
在 modal 中使用 onClickOutside 时会有个缺陷,当 modal 中按住鼠标左键然后鼠标移出 modal 区域再松开鼠标左键,这时不会触发 modal 的单击事件,因为要在同一个元素上的 mousedown 及 mouseup 相继触发后才会执行 click 事件,但是会触发 window 上的 click 事件,因为 modal 元素是在 window 内的,鼠标左键按下时 mousedown 事件冒泡到 window 上,鼠标松开时虽然不是在 modal 内,但是在 window 区域内, mouseup 事件也会冒泡到 window 上,这样 window 上相继触发了 mousedown 及 mouseup 因此也就触发了 click 事件,并且 event.target 是指向 window,onClickOutside 代码中 target === event.target 为 false ,因此会执行 listener 函数,如果 listener 函数是关闭弹窗的话,平时使用弹窗的时候会很容易误操作而关闭弹窗。
因此解决这种情况方式是 window 上添加 pointerdown 事件:
ignore 配置项
onClickOutside 中还增加了配置项 ignore 来指定不触发事件的元素列表。
这里我有个没搞懂的地方为什么要加 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 声明。
第二种方式实现相对简单些: