移动端事件基础
移动端有哪些事件
-
在 PC 端的时候,已经接触过事件了,而到了移动端,又有了新的事件,分别为:
- touchstart:手指按下事件,类似 mousedown
- touchmove:手指移动事件,类似 mousemove
- touchend:手指抬起事件,类似 mouseup
-
例子:
<style> * { margin: 0; padding: 0; } .container { width: 200px; height: 200px; background-color: red; } </style> <div class="container"></div> <script> var box = document.querySelector('.container'); box.addEventListener('touchstart', () => { console.log('手指按下去了'); }); box.addEventListener('touchmove', () => { console.log('手指滑动了'); }); box.addEventListener('touchend', () => { console.log('手指抬起了'); }); </script>
事件对象
-
通过事件对象,可以获取到本次事件发生时,一些更加具体的信息,例如:
box.addEventListener('touchstart', (e) => { console.log(e); });
-
此时打印传入的事件对象,可以看到如下的信息:
-
可以看到,事件对象中一如既往的包含了很多信息,其中对于开发来讲比较重要的就是如下 3 个信息:都是 TouchList(手指列表)
- changedTouches:触发当前事件的手指列表,也就是涉及当前(引发)事件的触摸点的列表
- targetTouches:位于当前 DOM 元素上的手指列表,也就是当前对象上所有触摸点的列表
- touches:位于当前屏幕上的所有手指列表(必需至少有 1 个手指在添加触发事件的元素上),也就是当前屏幕上所有触摸点的列表
-
通过一个例子来区分一下触摸事件中的这三个属性:
- 比如 div1、div2 只有 div2 绑定了 touchstart 事件,第一次放下一个手指在 div2 上,触发了 touchstart 事件,这个时候,三个集合的内容是一样的,都包含这个手指的 touch,然后,再放下两个手指一个在 div1 上,一个在 div2 上,这个时候又会触发事件,但 changedTouches 里面只包含第二个第三个手指的信息,因为第一个没有发生变化,而 targetTouches 包含的是在第一个手指和第三个在 div2 上的手指集合,touches 包含屏幕上所有手指的信息,也就是三个手指。
- 网上有个灵魂画手对此做了一个绘制:
移动端事件和 PC 端事件区别
-
虽说有了移动端专属的事件,并不意味着原本 PC 端的浏览器事件就不能用了,但是还是推荐尽量使用移动端的专属事件,因为 PC 端的事件并不是专门为移动端设计的,因此会存在各种各样的问题,其中一个比较出名的就是移动端 300ms 延迟;
-
为什么移动端点击事件要加 300ms 延迟呢?
- 早在 2007 年初,苹果公司在发布首款 iPhone 前夕,遇到一个问题:当时的网站都是为大屏幕设备所设计的,于是苹果的工程师们做了一些约定,应对 iPhone 这种小屏幕浏览桌面端站点的问题;
- 这当中最出名的,当属双击缩放( double tap to zoom ),这也是会有上述 300 毫秒延迟的主要原因;
- 双击缩放,顾名思义,即用手指在屏幕上快速点击两次,ios 自带的 Safari 浏览器会将网页缩放至原始比例;那么这和 300 毫秒延迟有什么联系呢?
- 假定这么一个场景: 用户在 ios Safari 里边点击了一个链接,由于用户可以进行双击缩放或者双击滚动的操作,当用户一次点击屏幕之后,浏览器并不能立刻判断用户是确实要打开这个链接,还是想要进行双击操作;因此,ios Safari 就等待 300 毫秒,以判断用户是否再次点击了屏幕;
- 鉴于 iPhone 的成功,其他移动浏览器都复制了 iPhone Safari 浏览器的多数约定,包括双击缩放,几乎现在所有的移动端浏览器都有这个功能;
-
下面看一下这个问题
var box = document.querySelector('.container'); box.addEventListener('click', () => { console.log('click 事件触发'); }); box.addEventListener('touchstart', () => { console.log('touchstart 事件触发'); }); // 点击后输出,没有了 300ms 的问题 // touchstart 事件触发 // click 事件触发
在 2014 年,从 Chrome32 开始 Google 已经解决这个 300ms 延迟问题,只要定义了 viewport 就不会有 300ms 延迟问题
-
除此之外,来看一下移动端事件和 PC 端事件之间的其他区别;虽然上面介绍的 3 个移动端事件,都能找到其在 PC 端中相似的事件,但是仔细一比较,也是存在如下区别的:
- 触发点区别
- 触发顺序的区别
- touchstart 与 click 的区别
触发点区别
PC 端
- mousemove:不需要鼠标按下,但是必需在元素上才能触发
- mouseup:必需在元素上抬起才能触发
移动端
- touchmove:必需手指按下才能触发,但是,按下后不在元素上也能触发
- touchend:不需要在元素上抬起就能触发
触发顺序
触发顺序依次为:touchstart → touchend → mousedown → click → mouseup
PC 的事件在移动端里会有 300ms左右 延迟(只要定义了 viewport 就不会有 300ms 延迟问题)
touchstart 与 click 的区别
touchstart 手指碰到元素就触发
click 手指碰到元素并且抬起才会触发
事件穿透
所谓事件穿透,是移动端上面一个非常有名的 Bug;
其出现场景为:有两层重叠的元素,上面的元素有 touch 事件(点击后要消失),下面是一个默认会触发 click 事件的元素(a、表单元素、带 click 事件的元素),此时点击上一层的元素,下一层也同样会被点击;
-
来看一个例子:
<style> * { margin: 0; padding: 0; } .container { width: 200px; height: 200px; background: green; color: #fff; position: absolute; left: 0; top: 0; opacity: 0.5; } input { border: 1px solid #000; } </style> <a href="http://www.baidu.com/">度娘</a><br> <input type="text"> <p>这是一个段落</p> <div class="container"></div> <script> var box = document.querySelector('.container'); box.addEventListener('touchstart', () => { box.style.display = 'none'; }); const p = document.querySelector('p'); p.addEventListener('click', () => { alert('该段落被点击了'); }); </script>
-
为什么会存在事件穿透呢?
- 这是因为在移动端浏览器,事件执行的顺序是 touchstart → touchmove → touched → click;
- 而 click 事件有 300ms 的延迟,当 touchstart 事件把上层元素隐藏之后,隔了 300ms,浏览器触发了 click 事件,但是此时上层元素不见了,所以该事件被派发到了下层元素身上;
-
那么既然存在这个问题,该如何解决呢?
- 有一个最简单的解决方式,那就是取消事件的默认行为,如下:
box.addEventListener('touchstart', ev => { box.style.display = 'none'; ev.preventDefault(); // 取消事件的默认动作 });
- 当阻止了 touchstart 事件的默认行为后,事件穿透也就随即消失;
- 有一个最简单的解决方式,那就是取消事件的默认行为,如下:
阻止默认行为带来的影响
在移动端,不仅元素身上绑定了默认事件,在 document 身上也绑定了默认事件,因此可以利用冒泡事件来阻止默认事件,也就是说哪怕你元素本身没有阻止默认事件,你触发了默认事件,但是通过冒泡父元素身上取消了默认事件,那么最终的结果默认事件也会被取消掉,因为根据事件流,元素身上的事件在目标阶段触发,冒泡事件在冒泡阶段触发,冒泡阶段在目标阶段之后,最终结果取决于后执行的事件;
利用这个思想可以通过在 document 身上取消默认事件,从而阻止所有的默认事件;
虽然阻止掉了所有的默认事件,但是这种方法也带来了新的问题;
touchmove
阻止了浏览器的滚动条
阻止了用户双指缩放
touchstart
解决 IOS10+ 及部分安卓机通过设置 viewport 禁止用户缩放的功能(双指滑动、双击)
解决事件点透问题
阻止图片文字被选中
阻止了长按元素会弹出系统菜单
阻止了浏览器回弹的效果
阻止了浏览器的滚动条
阻止了鼠标的事件
阻止了 input 框的输入功能
-
通过下面的例子看到部分功能已经失效:
<style> body { margin: 0; } ul { margin: 0; padding: 0; list-style: none; } li { line-height: 50px; } li a { font-size: 30px; } input { border: 1px solid #000; } </style> <div class="page"> <img src="./ok.png" alt="" width="100%"> <input type="text"> <ul> <li><a href="http://www.baidu.com">度娘</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> <li><a href="#">这是一个很长很长的链接</a></li> </ul> </div> <script> const page = document.querySelector('.page'); page.addEventListener('touchstart', ev => { ev.preventDefault(); }); </script>
-
另外,如果在 document 上面阻止默认事件,例如将上面的 JavaScript 修改如下:
document.addEventListener('touchstart',ev=>{ ev.preventDefault(); });
-
会发现在 chrome 移动端模拟器或者手机浏览器上事件的默认行为并没有成功阻止,其中 preventDefault 不生效问题就是由 passive 这个参数引起的;
tartget.addEventListener(type, listener, { capture: Booolean, once: Boolean, passive: Boolean, signal: AbortSignal })
-
为什么呢?
- 事件监听器在监听事件时,并不能提前知道回调函数中是否会阻止默认行为,因此若想知道是否会阻止就需要等待函数执行完,这段时间虽然很短,但等待仍会让人感到卡顿;
- 于是可以通过传递 passive 为 true 来明确告诉浏览器,事件处理程序不会调用 preventDefault 来阻止默认滑动行为,而大部分事件监听器是不会阻止默认行为的,因此某些浏览器就将一些节点(例如 document)事件的 passive 默认设置为为 true;
- 因此要解决上面 document 上无法阻止默认行为的情况,只需要将 passive 设置为 false 即可,即明确告诉浏览器,事件处理程序会调用 preventDefault 来阻止默认滑动行为;
document.addEventListener('touchstart', ev => { ev.preventDefault(); }, { passive: false });
- 事件监听器在监听事件时,并不能提前知道回调函数中是否会阻止默认行为,因此若想知道是否会阻止就需要等待函数执行完,这段时间虽然很短,但等待仍会让人感到卡顿;
-
回到之前的话题,在父元素上面阻止元素默认行为,会导致很多元素的默认行为也没有了,那么此时该怎么办呢?
- 比如在获取验证码时,通常用户希望验证码是能够复制的,因此会触发长按复制的默认事件,这个事件我们希望在这个元素身上不被阻止掉,那么应该如何实现?
- 有一种方案是阻止冒泡,如上面所述,是通过冒泡来借助父元素阻止掉默认事件,那么如果切断冒泡,那么父元素身上的阻止默认事件就无法被触发,元素本身的默认事件就无法被阻止掉了;但是这样也只是解决了其中一个问题,上面还罗列出了很多其他问题,例如所有的滚动条失效,这些问题该怎么解决呢?
- 实际上,移动端进行开发时一上来就需要阻止所有的默认事件,后面的很多功能都需要自定义来实现;
滑屏操作与轮播图
滑屏操作
在移动端中要阻止所有的默认事件,这样就能解决一些诸如事件穿透的 Bug,但是这随之而来的带来一些新的问题:那就是阻止了默认行为之后,很多东西都失效了,例如滚动条失效,因此,很多功能都需要自定义来实现;
这里就来实现一个滑屏操作;
实际上,滑屏操作的原理并不难理解,就和以前在 PC 端所书写的拖动是一样的;
-
首先复习一下事件对象中的 changedTouches 属性,该属性可以获取当前(引发)事件的触摸点的列表,列表的每一项为一个 Touch 对象,里面有那么一些属性,如下:
关于 Touch 对象各属性的含义,可以参阅 MDN
-
示例代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <style> body { margin: 0; padding: 0; } ul { list-style: none; } #wrap { height: 100vh; overflow: hidden; border: 5px solid red; box-sizing: border-box; } #list{ transition: .4s; } </style> </head> <body> <div id="wrap"> <ul id="list"> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表10</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表20</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表10</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表1</a></li> <li><a href="#">这是一个很长很长的列表20</a></li> </ul> </div> <script src="./transform.js"></script> <script> // 手动实现滑动效果 // 获取 DOM 元素 var wrap = document.querySelector("#wrap"); // 外层的 div var list = document.querySelector("#list");; // 里面的 ul var startPointY = 0, // 手指按下时的 Y 坐标 startTop = 0, // 要滑动的元素默认的 top 值 movePointY = 0; // 手指移动时的坐标 // 这里我们用到了腾讯的第三方库 transform,通过 Transform(DOM节点) 进行一个初始化 // 之后我们就可以非常方便的获取以及设置该 DOM 节点和 transform 相关的属性值 Transform(list); console.log(list.translateY); wrap.addEventListener('touchstart', ev => { startPointY = ev.changedTouches[0].pageY; // 手指按下时的坐标 startTop = list.translateY; // list 元素垂直轴移动的距离 }) wrap.addEventListener('touchmove', ev=>{ // 坐标移动的距离 = 当前的距离 - 按下时的距离 movePointY = ev.changedTouches[0].pageY - startPointY; // 元素移动的距离 = 按下时元素的 top + 坐标移动的距离 list.translateY = startTop + movePointY; }) document.addEventListener('touchstart',ev=>{ ev.preventDefault(); }, { passive : false }) </script> </body> </html>
轮播图
-
接下来在滑屏操作上更进一步,来实现一个轮播图,其原理如下图所示:
-
代码如下:附件下载
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>轮播图</title> <style> body { margin: 0; } #banner { position: relative; width: 100vw; overflow: hidden; } .wrap { width: 300vw; display: flex; } .wrap a { width: 100vw; } .wrap a img { width: 100%; vertical-align: middle; } .circle { position: absolute; bottom: 3vw; width: 100vw; display: flex; justify-content: center; } .circle span { width: 3vw; height: 3vw; background: #ddd; border-radius: 50%; margin: 0 1.5vw; } .circle span.active { background: grey; opacity: .8; } </style> </head> <body> <section id="banner"> <!-- 轮播图 --> <div class="wrap"> <a href="#"><img src="./img/banner_01.jpg" alt=""></a> <a href="#"><img src="./img/banner_02.jpg" alt=""></a> <a href="#"><img src="./img/banner_03.jpg" alt=""></a> </div> <!-- 小圆点 --> <div class="circle"> <span class="active"></span> <span></span> <span></span> </div> </section> <script src="./transform.js"></script> <script> // 获取一些 DOM 节点 var banner = document.querySelector("#banner"); // 最外层容器 var wrap = document.querySelector(".wrap"); // 轮播图图片容器 var spans = document.querySelectorAll(".circle span"); // 获取所用的小圆点 // 初始化一些变量 var imgWidth = banner.offsetWidth; // 一张图片的宽度 var startPointX = 0; // 手指按下时的坐标 var disPointX = 0; // 手指移动的距离 var startEleX = 0; // 按下时元素的位置 var cn = 0; // 当前图片的索引值 var ln = 0; // 上一个图片的索引值 Transform(wrap); // 因为要实现的是无缝滚动,所以需要复制一份图片在后面 wrap.innerHTML += wrap.innerHTML; // 复制了一份 wrap.style.width = wrap.children.length * imgWidth + "px"; // 手指按下的时候要做的事情 banner.addEventListener("touchstart", ev => { startPointX = ev.changedTouches[0].pageX; // 记录手指按下去的时候的 X 坐标 // 需要判断当前是第几张图,如果是第一张或者是最后一张,那么我们是要做特殊处理的 // 因为我们并不知道用户是往左边还是右边,所以我们针对第一张和最后一张直接进行跳转 if (cn === 0) { cn = wrap.children.length / 2; } if (cn === wrap.children.length - 1) { cn = wrap.children.length / 2 - 1; } wrap.style.transition = ""; // 去除 wrap 的过渡,否则一会儿拖动的时候就会因为过渡感觉慢半拍 // 因为现在图片的下标已经更新了,所以我们需要根据新的下标修正 wrap 的 translate 移动距离 wrap.translateX = - imgWidth * cn; // 还需要更新一下元素的移动距离 startEleX = wrap.translateX; ev.preventDefault(); }); // 手指移动的时候要做的事情 banner.addEventListener("touchmove", ev => { disPointX = ev.changedTouches[0].pageX - startPointX; // 获取手指移动的距离 wrap.translateX = startEleX + disPointX; }); // 手指抬起的时候要做的事情 banner.addEventListener("touchend", ev => { // 当用户手指抬起的时候,需要判断要不要切换图片 // 这个就根据用户手指移动的距离,如果用户手指移动的距离很短,我们就回弹图片 // 我们将整个图片宽度分为 8 份,如果用户手指移动的距离大于八分之一,我们就切换,否则我们就回弹 var backWidth = imgWidth / 8; if (Math.abs(disPointX) > backWidth) { // 大于八分之一,那我们就切换图片 // 分为往左还是往右 if (disPointX < 0) { // 往左边拖,想看下一张图片 cn++ } if (disPointX > 0) { // 往右边拖,想看上一张图片 cn--; } } // 至此,图片的下标已经更新 wrap.style.transition = ".3s"; wrap.translateX = - imgWidth * cn; // 最后一个事情,就是更新小圆点 // 这里还是根据图片的下标来做 // 首先去除上一次圆点身上的 class spans[ln].className = ""; // 给当前的下标添加上 class // 图片当前的索引:0 1 2 3 4 5 ==> 0 1 2 0 1 2 spans[cn % (wrap.children.length / 2)].className = "active"; // 更新上一个索引 ln = cn % (wrap.children.length / 2) }) </script> </body> </html>
移动端第三方库
Swiper.js 轮播图库
有一些比较成熟的库,可以提升开发效率;
这是一个非常方便的制作轮播图的的第三方库,支持各种姿势的轮播,很多大厂也是在自己的移动端网页中使用这个库;
移动端手势库
开源的移动端第三方库,Hammer.js 不需要依赖任何其他的 JS 框架或者库,并且整个框架非常小,v2.0.4 版本只有 3.96kb;
可以完美的实现在移端开发的大多数事件,如:点击、滑动、拖动、多点触控等事件;
📱 响应式
上一篇