移动端事件穿透的原理与解决方案
移动设备的流行,带动了移动互联网的快速发展,很多开发者开始进入移动开发领域。目前市面上主流的移动设备一般都使用触摸屏,触摸屏所使用的触摸事件模型与传统网页的鼠标事件模型有所区别,这种差异往往使初涉移动端的开发工程师陷入困境,事件穿透问题便是其中一个,本文将带你了解事件穿透及如何在实际项目中选择合适的方案解决事件穿透问题。
产生的原因
当今,主流的移动设备一般都使用触摸屏,Web 应用程序可以使用触摸事件(Touch Events)直接处理基于触摸的输入,或者应用程序可以使用可解释的鼠标事件以处理应用程序的输入。使用鼠标事件的缺点是它们不支持并发用户输入,而触摸事件支持多个同时输入(可能在触摸面上的不同位置),从而增强用户体验。
触摸事件有以下事件类型:
- touchstart:当触摸点放置在触摸面上时触发。
- touchmove:当触摸点沿触摸表面移动时触发。
- touchend:当触摸点从触摸表面移除时触发。
- touchcancel:当触摸点以实现特定的方式中断(例如,创建的触摸点太多)时触发。
在很多情况下,触摸事件和鼠标事件会同时被触发(目的是让没有对触摸设备优化的代码仍然可以在触摸设备上正常工作)。如下代码:
document.addEventListener('touchstart', () => {
console.log('touchstart')
})
document.addEventListener('touchend', () => {
console.log('touchend')
})
document.addEventListener('click', () => {
console.log('click')
})
事件触发的先后顺序是:touchstart -> touchend -> click。正是由于这种 click 事件的滞后性设计为事件穿透(点击穿透)埋下了伏笔。
什么是事件穿透
事件穿透是指触发某个目标元素的触摸事件时,会同时触发该目标元素相同位置中其他元素的鼠标点击事件。例如:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>事件穿透</title>
<style>
* {
margin: 0;
padding: 0;
}
div {
width: 100vw;
height: 100vh;
line-height: 100vh;
text-align: center;
}
.mask {
position: fixed;
top: 0;
left: 0;
background: #333;
opacity: 0.6;
}
</style>
</head>
<body>
<div>事件穿透</div>
<div class="mask"></div>
<script>
const $div = document.querySelector("div")
const $mask = document.querySelector(".mask")
$mask.addEventListener('touchstart', (e) => {
console.log('mask touchstart')
e.target.style.display = 'none'
})
$div.addEventListener('click', () => {
console.log('div click')
})
</script>
</body>
</html>
由于 mask 元素触发 touchstart 触摸事件并立即隐藏掉自身,之后应该按先后顺序触发 mask 元素的 touchend 和 click 事件。然而,当要触发 click 事件的时候由于 mask 元素已经隐藏掉了,于是触发了 div 的 click 事件。
常见的事件穿透场景:
- 目标元素触发触摸事件时隐藏或移除自身,对应位置元素触发 click 事件或 a 链接跳转。
- 目标元素使用触摸事件跳转至新页面,新页面中对应位置元素触发 click 事件或 a 链接跳转。
注意:a 标签的链接跳转事件属于 click 事件。
解决方法
市面上解决事件穿透的方法有很多,大致可以分为两类:第一种是禁止混用 click 和 touch 两种事件;另一种是延迟元素的隐藏或移除。
禁用 click 事件
这种方法是将页面内所有元素的 click 事件改用 touch 事件。这种方法的好处非常明显,既解决了 click 事件延迟造成体验不佳的问题又解决了事件穿透的问题,但是缺点也很明显,就是 a 标签的链接跳转的处理问题。
禁用 a 标签的点击事件,改用 touch 事件触发链接跳转。实现如下:
// 禁用 a 标签的点击事件
document.addEventListener('click', (e) => {
const href = e.target.getAttribute('href')
const nodeName = e.target.nodeName.toLowerCase()
if (nodeName === 'a' && href) {
e.preventDefault()
}
})
// 改用 touch 事件触发链接跳转
document.addEventListener('touchstart', (e) => {
const href = e.target.getAttribute('href')
const nodeName = e.target.nodeName.toLowerCase()
if (nodeName === 'a' && href) {
const target = e.target.getAttribute('target')
window.open(href, target || '_self')
}
})
看似很完美,然而,当 a 标签内包含后带元素的时候,后代元素的 click 事件通过冒泡还是会触发 a 标签的跳转。怎么解决?使用 pointer-events 禁用 a 标签所有后代元素的鼠标事件:
a[href] * {
pointer-events: none;
}
禁用 touch 事件
这种方法是将页面内所有元素的 touch 事件改用 click 事件。事件穿透不就是由于 touch 与 click 事件存在触发时间差造成的吗,全部都使用 click 事件就不会有问题。然而事实真的如此美好?当然不是的,首先要解决 click 事件延迟 300ms 的问题。解决点击事件延迟的问题可以使用以下的 CSS 代码实现:
html {
touch-action: manipulation;
}
这样已经很完美了。然而,什么是工作?工作就是不停的解决问题。当你不得不为项目添加手势功能,增加用户体验的时候(比如:左滑、右滑等等各种滑),你才会意识到完全禁用 touch 事件在实际项目中是不可能的事情。这个时候怎么办,推到从来,全部改用 touch 事件?当然不用这么麻烦,你可以在使用 touch 事件时通过调用 preventDefault() 阻止触发 click 事件。例如:
const $mask = document.querySelector(".mask")
$mask.addEventListener('touchstart', (e) => {
...
e.preventDefault()
})
总结
解决事件穿透还有通过设置动画过渡延迟元素消失等方法,由于这类方法影响用户体验,不一一介绍。在实际项目开发中,纯移动端项目优先推荐禁用 click 事件的方法,多端项目优先推荐禁用 touch 事件的方法。