在移动端,网页上的点击穿透问题导致了非常糟糕的用户体验。那么该如何解决这个问题呢?

问题产生的原因

移动端浏览器的点击事件存在300ms的延迟执行,这个延迟是由于移动端需要通过在这个时间段用户是否两次触摸屏幕而触发放大屏幕的功能。那么由于click事件将延迟300ms的存在,开发者在页面上做一些交互的时候往往会导致点击穿透问题(可以能是层之间的,也可以是页面之间的)。

解决问题

之前遇到这个问题的时候,有在网上看了一些关于解决移动端点击穿透的问题,也跟着网上提出的方式进行了各项测试,最终还是觉得使用fastclick插件比较靠谱些,其他几种方法多多少少会存在一些其他问题(当然,fastclick也不是说完全兼容各项,但相对于其他一些方法不会造成较明显的问题)

使用方式:

<!-- 引入文件 -->
<script src="fastclick.js"></script>

js:

// fastclick 使用
/*
@params layer 需要处理click事件的视图
@params options 一些配置
{
touchBoundary: 10 // 点击事件边界线
tapDelay: 200 // tap最小延时
tapTimeout: 700 // tap最大延时
}
*/ FastClick.attach(layer,options) // 一般使用 FastClick.attach(document.body)

fastclick实现过程

首先,扔上注解文件:fastclick-read.js 中文注解文件 ,下面内容提取部分代码

1.拦截给定视图区域的各项事件,绑到layer处理

// Set up event handlers as required
// 配置需要用到的操作事件/给指定绑定各项事件监听
if (deviceIsAndroid) {
layer.addEventListener('mouseover', this.onMouse, true);
layer.addEventListener('mousedown', this.onMouse, true);
layer.addEventListener('mouseup', this.onMouse, true);
} layer.addEventListener('click', this.onClick, true);
layer.addEventListener('touchstart', this.onTouchStart, false);
layer.addEventListener('touchmove', this.onTouchMove, false);
layer.addEventListener('touchend', this.onTouchEnd, false);
layer.addEventListener('touchcancel', this.onTouchCancel, false);

2.判断是否需要对触发事件的标签生成一个针对该标签的click事件

// onTouchStart 代码行

// 注册click事件追踪
this.trackingClick = true;
this.trackingClickStart = event.timeStamp;
this.targetElement = targetElement; this.touchStartX = touch.pageX;
this.touchStartY = touch.pageY; // 若干行代码... // 防止快速的两次tap导致的点透
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
event.preventDefault();
} // onTouchMove 代码行 // 如果touch事件是移动的,取消点击事件跟踪
if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
this.trackingClick = false;
this.targetElement = null;
} // onTouchEnd 代码行 // 阻止快速双击导致触发第二次屏幕点击事件 (issue #36)
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
this.cancelNextClick = true;
return true;
} // 如果超出最大延时,事件继续执行
if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
return true;
} // 重新设置cancelNextClick,阻止input的事件被某些异常所取消 (issue #156)
this.cancelNextClick = false; this.lastClickTime = event.timeStamp; trackingClickStart = this.trackingClickStart;
this.trackingClick = false;
this.trackingClickStart = 0; // 舍去一些兼容处理的代码 // else if (this.needsFocus(targetElement)) 判断且满足needsFocus条件 如果点击元素是为了聚焦 this.focus(targetElement);
this.sendClick(targetElement, event); // 这里生成click // 判断且满足needsClick条件 如果点击元素是为了点击
// 阻止真实的点击继续执行 -- 除非该标签被标记为允许真实点击(class="needsclick")
if (!this.needsClick(targetElement)) {
event.preventDefault();
this.sendClick(targetElement, event); // 这里生成click
} // onTouchCancel 代码行 FastClick.prototype.onTouchCancel = function() {
this.trackingClick = false;
this.targetElement = null;
}; // onClick 代码行 FastClick.prototype.onClick = function(event) {
var permitted; // It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44).
// In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early.
// 点击事件在被fastclick触发之前已经被其他类似fastclick功能的第三方代码库触发的情况下,尽早的为事件跟踪标签返回一个false值,同时也能够尽早结束onTouchEnd事件
if (this.trackingClick) {
this.targetElement = null;
this.trackingClick = false;
return true;
} // Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks
// the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target.
// IOS的异常现象(issue #18) : 当表单内存在submit按钮,在ios模拟器点击"enter"或者在弹出键盘中点击"go",将会触发一次"伪装"成submit按钮的点击事件将表单提交
if (event.target.type === 'submit' && event.detail === 0) {
return true;
} permitted = this.onMouse(event); // Only unset targetElement if the click is not permitted. This will ensure that the check for !targetElement in onMouse fails and the
// browser's click doesn't go through.
// 如果不允许click,那么只设置targetElement。确保onMouse中!targetElement的判断结果有值,并且浏览器的点击失效
if (!permitted) {
this.targetElement = null;
} // If clicks are permitted, return true for the action to go through.
// 如果允许click,返回true用以点击动作的传递
return permitted;
}; // onMouse 代码行 FastClick.prototype.onMouse = function(event) { // If a target element was never set (because a touch event was never fired) allow the event
// 如果不存在targetElement(触摸事件未被触发),返回true
if (!this.targetElement) {
return true;
} if (event.forwardedTouchEvent) {
return true;
} // Programmatically generated events targeting a specific element should be permitted
// 代码触发的事件,并且针对有明确的元素,则返回true
if (!event.cancelable) {
return true;
} // Derive and check the target element to see whether the mouse event needs to be permitted;
// unless explicitly enabled, prevent non-touch click events from triggering actions,
// to prevent ghost/doubleclicks.
// 检查目标元素,确定鼠标事件是否需要被允许
// 除非明确指定事件可行,不然就阻止非点击事件,主要用于元素重叠情况下双击产生异常而触发不必要的事件。
if (!this.needsClick(this.targetElement) || this.cancelNextClick) { // Prevent any user-added listeners declared on FastClick element from being fired.
// 阻止fastclick元素上其他的定义的事件被触发
if (event.stopImmediatePropagation) {
event.stopImmediatePropagation();
} else { // Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
// hack 为了一些不支持Event#stopImmediatePropagation的客户端(如 Android 2)
event.propagationStopped = true;
} // Cancel the event
// 取消事件
event.stopPropagation();
event.preventDefault(); return false;
} // If the mouse event is permitted, return true for the action to go through.
// 如果允许该鼠标事件,返回true用以点击动作的传递
return true;
}; // sendClick 代码行 -- 生成click事件 // 模拟一次点击事件,同时添加上forwardedTouchEvent,表明可被跟踪
clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
clickEvent.forwardedTouchEvent = true;
targetElement.dispatchEvent(clickEvent);

以上就是重点的一些判断及实现的代码,可点击"github - fastclick-read 源码注释"理解更详细内容,建议动手测试,完整的跟一次click事件在fastclick代码内的执行流程。

源码中一些挺实用的基础知识点

事件冒泡和事件捕获

事件冒泡:事件在某个节点被触发,将会随着DOM树向上冒泡并根据当前节点是否满足冒泡触发的条件来进行同类型事件的触发,直至根节点(html)。

事件捕获:事件在根节点上被触发,开始向子元素传播并根据当前节点是否满足捕获触发的条件来进行同类型事件的触发,直至实际触发该事件的节点。

首先,我们给出页面结构:

<html>
<body>
<div class="div-outside">
<div class="div-inside">
<span class="span-click">a span for click</span>
</div>
</div>
</body>
</html>

addEventListener的第三个参数决定该事件是否在捕获阶段执行,Event.cancelBubble 属性值(true/false)决定该事件是否冒泡,推荐使用Event.stopPropagation()阻止冒泡

事件捕获执行及效果:

document.querySelector('.span-click').addEventListener('click',function(e){
console.log("span");
},!0);
document.querySelector('.div-inside').addEventListener('click',function(e){
console.log("inside");
},!0);
document.querySelector('.div-outside').addEventListener('click',function(e){
console.log("outside");
},!0);
document.body.addEventListener('click',function(e){
console.log("body");
},!0);
document.addEventListener('click',function(e){
console.log("html");
},!0); // 输出
/*
* html
* body
* outside
* inside
* span
*/

事件冒泡执行及效果:

document.querySelector('.span-click').addEventListener('click',function(e){
console.log("span");
},!1);
document.querySelector('.div-inside').addEventListener('click',function(e){
console.log("inside");
},!1);
document.querySelector('.div-outside').addEventListener('click',function(e){
console.log("outside");
},!1);
document.body.addEventListener('click',function(e){
console.log("body");
},!1);
document.addEventListener('click',function(e){
console.log("html");
},!1); // 输出
/*
* span
* inside
* outside
* body
* html
*/

事件触发时,哪个优先?

document.querySelector('.span-click').addEventListener('click',function(e){
console.log("span");
},!1);
document.querySelector('.div-inside').addEventListener('click',function(e){
console.log("inside");
},!0);
document.querySelector('.div-outside').addEventListener('click',function(e){
console.log("outside");
},!1);
document.body.addEventListener('click',function(e){
console.log("body");
},!1);
document.addEventListener('click',function(e){
console.log("html");
},!1); // 输出
/*
* inside
* span
* outside
* body
* html
*/

显而易见...

Event.stopPropagation 和 Event.stopImmediatePropagation

Event.stopPropagation:阻止事件向上传播(冒泡)

Event.stopImmediatePropagation:阻止该标签上的同类型事件被触发并阻止事件向上传播(冒泡)

html:

<body>
<div class="div-outside">
<div class="div-inside">
a div for click
</div>
</div>
</body>

不做任何处理的执行及效果:

document.querySelector('.div-inside').addEventListener('click',function(e){
console.log("inside first print");
},!1);
document.querySelector('.div-inside').addEventListener('click',function(e){
console.log("inside second print");
},!1);
document.querySelector('.div-outside').addEventListener('click',function(e){
console.log("outside");
},!1);
document.body.addEventListener('click',function(e){
console.log("body");
},!1); // 输出
/*
* inside first print
* inside second print
* outside
* body
*/

Event.stopPropagation执行及效果:

document.querySelector('.div-inside').addEventListener('click',function(e){
e.stopPropagation();
console.log("inside first print");
},!1);
document.querySelector('.div-inside').addEventListener('click',function(e){
console.log("inside second print");
},!1);
document.querySelector('.div-outside').addEventListener('click',function(e){
console.log("outside");
},!1);
document.body.addEventListener('click',function(e){
console.log("body");
},!1); // 输出
/*
* inside first print
* inside second print
*/

Event.stopImmediatePropagation执行及效果:

document.querySelector('.div-inside').addEventListener('click',function(e){
e.stopImmediatePropagation();
console.log("inside first print");
},!1);
document.querySelector('.div-inside').addEventListener('click',function(e){
console.log("inside second print");
},!1);
document.querySelector('.div-outside').addEventListener('click',function(e){
console.log("outside");
},!1);
document.body.addEventListener('click',function(e){
console.log("body");
},!1); // 输出
/*
* inside first print
*/

参考文档:

MDN Event.stopPropagation

MDN Event.stopImmediatePropagation

Window.getSelection()

该方法返回一系列选择文本的参数,如选择范围,字符当前索引等

html:

<div class="text first-text">hello world!</div>
<div class="text second-text">hello world!</div>

js:

var elems = document.querySelectorAll('.text');
var len = elems.length;
while(len){
elems[len-1].addEventListener('mouseup',function(){
console.log(window.getSelection());
},!1);
len--;
}

效果:

fastclick 源码注解及一些基础知识点-LMLPHP

如上图所示,参数有:

· anchorNode:选择范围开始的node

· anchorOffset:anchorNode中的起始索引

· focusNode:选择范围结束的node

· focusOffset:focusNode中的结束索引

· isCollapsed:起始和结束是否在一个点,返回true/false

· rangeCount:选择段的段数,貌似一直为1段,尝试按住shift选择多段,然而并不行

· type:操作类型,如:选择:Range,插入符:Caret(input中) 等情况...

· 其他暂时不去深究,嘿嘿!!!

参考文档:

MDN Window.getSelection()

 事件操作 --- 创建 -> 配置 -> 派遣

如needsClick里的代码,创建一次不带任何handle的click事件,然后将该事件在指定元素上触发,以触发该元素上的同类型事件。

html:

<div id="click-one">click-one</div>
<div id="click-two">click-two</div>

比如,点击click-one,给click-two创建个click事件并执行,用以触发click-two上我们写的点击事件。

··· 方式一(MDN并不推荐,标明被移出web标准):

Document.creatEvent();

MouseEvent.initMouseEvent();

EventTarget.dispatchEvent();

js:

document.getElementById('click-one').addEventListener('click',function(e){
console.log("click-one");
var evt = document.createEvent('MouseEvents');
evt.initMouseEvent('click', true, true, window, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
document.getElementById('click-two').dispatchEvent(evt);
},!1);
document.getElementById('click-two').addEventListener('click',function(e){
console.log("click-two");
},!1); /*
语法.参数,值得注意的是最后一个参数,相关标签,mouseover和mouseout使用,其他情况传null
event.initMouseEvent(type, canBubble, cancelable, view,
detail, screenX, screenY, clientX, clientY,
ctrlKey, altKey, shiftKey, metaKey,
button, relatedTarget);
*/

···方式二(MDN推荐,但貌似兼容性暂时捉急):

new Event();

EventTarget.dispatchEvent();

js:

document.getElementById('click-one').addEventListener('click',function(e){
console.log("click-one");
var evt = new Event('click',{"bubbles":true, "cancelable":true});
document.getElementById('click-two').dispatchEvent(evt);
},!1);
document.getElementById('click-two').addEventListener('click',function(e){
console.log("click-two");
},!1);
/*
语法.参数
new Event(typeArg,eventInit);
typeArg:事件名称
eventInit:
bubbles 是否冒泡
cancelable 是否可被取消
scoped 是否冒泡,如果该值为true,则deepPath将只包含目标节点
composed 是否触发shadow root之外的监听,默认fasle 同时求教 shadow root 在这里指的是?
*/

参考文档:

MDN Document.creatEvent()

MDN Event.initEvent()

MDN EventTarget.dispatchEvent()

MDN Event

本文涉及的知识点比较基础,且看且勿喷吧。

如有不正之处,感谢指出... 同时欢迎讨论交流

05-06 12:04