介绍
监听指定元素外的单击事件,一般用于弹窗。
使用
源码
简单实现
简单实现就是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 声明。
第二种方式实现相对简单些: