最近产品妹子提出了一个体验issue —— 用 iOS 在手Q阅读书友交流区发表书评时,光标点击总是不好定位到正确的位置:

如上图,具体表现是较快点击时,光标总会跳到 textarea 内容的尾部。只有当点击停留时间较久一点(比如超过150ms)才能把光标正常定位到正确的位置。

一开始我以为是 iOS 原生的交互问题没太在意,但后来发现访问某些页面又是没有这种奇怪体验的。

然后怀疑是否 JS 注册了某些事件导致的问题,于是试着把业务模块移除了再跑一遍,发现问题照旧。

于是只好继续做排除法,把页面上的一些库一点点移掉再运行页面,结果发现捣乱的小鬼果然是嫌疑最大的 Fastclick。

然后呢,我试着按API所说,给 textarea 加上一个名为“needsclick”的类名,希望能绕过 fastclick 的处理直接走原生点击事件,结果讶异地发现屁用没有。。。

对此感谢后面我们小组的 kindeng 童鞋帮忙研究了下并提供了解决方案,不过我还想进一步研究到底是什么原因导致了这个坑、Fastclick 对我的页面做了神马~

所以昨晚花了点时间一口气把源码都蹂躏了一遍。

这会是一篇很长的文章,但会是注释非常详尽的剖析文。

文章带分析的源码我也挂在我的 github 仓库上了,有兴趣的童鞋可以去下载来看。

闲话不多说,咱们开始深入 FastClick 源码阵营。

我们知道,注册一个 FastClick 事件非常简单,它是这样的:

  1. if ('addEventListener' in document) {
  2. document.addEventListener('DOMContentLoaded', function() {
  3. var fc = FastClick.attach(document.body); //生成实例
  4. }, false);
  5. }

所以我们从这里着手,打开源码看下 FastClick .attach 方法:

  1. FastClick.attach = function(layer, options) {
  2. return new FastClick(layer, options);
  3. };

这里返回了一个 FastClick 实例,所以咱们拉到前面看看 FastClick 构造函数:

  1. function FastClick(layer, options) {
  2. var oldOnClick;
  3.  
  4. options = options || {};
  5.  
  6. //定义了一些参数...
  7.  
  8. //如果是属于不需要处理的元素类型,则直接返回
  9. if (FastClick.notNeeded(layer)) {
  10. return;
  11. }
  12.  
  13. //语法糖,兼容一些用不了 Function.prototype.bind 的旧安卓
  14. //所以后面不走 layer.addEventListener('click', this.onClick.bind(this), true);
  15. function bind(method, context) {
  16. return function() { return method.apply(context, arguments); };
  17. }
  18.  
  19. var methods = ['onMouse', 'onClick', 'onTouchStart', 'onTouchMove', 'onTouchEnd', 'onTouchCancel'];
  20. var context = this;
  21. for (var i = 0, l = methods.length; i < l; i++) {
  22. context[methods[i]] = bind(context[methods[i]], context);
  23. }
  24.  
  25. //安卓则做额外处理
  26. if (deviceIsAndroid) {
  27. layer.addEventListener('mouseover', this.onMouse, true);
  28. layer.addEventListener('mousedown', this.onMouse, true);
  29. layer.addEventListener('mouseup', this.onMouse, true);
  30. }
  31.  
  32. layer.addEventListener('click', this.onClick, true);
  33. layer.addEventListener('touchstart', this.onTouchStart, false);
  34. layer.addEventListener('touchmove', this.onTouchMove, false);
  35. layer.addEventListener('touchend', this.onTouchEnd, false);
  36. layer.addEventListener('touchcancel', this.onTouchCancel, false);
  37.  
  38. // 兼容不支持 stopImmediatePropagation 的浏览器(比如 Android 2)
  39. if (!Event.prototype.stopImmediatePropagation) {
  40. layer.removeEventListener = function(type, callback, capture) {
  41. var rmv = Node.prototype.removeEventListener;
  42. if (type === 'click') {
  43. rmv.call(layer, type, callback.hijacked || callback, capture);
  44. } else {
  45. rmv.call(layer, type, callback, capture);
  46. }
  47. };
  48.  
  49. layer.addEventListener = function(type, callback, capture) {
  50. var adv = Node.prototype.addEventListener;
  51. if (type === 'click') {
  52. //留意这里 callback.hijacked 中会判断 event.propagationStopped 是否为真来确保(安卓的onMouse事件)只执行一次
  53. //在 onMouse 事件里会给 event.propagationStopped 赋值 true
  54. adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) {
  55. if (!event.propagationStopped) {
  56. callback(event);
  57. }
  58. }), capture);
  59. } else {
  60. adv.call(layer, type, callback, capture);
  61. }
  62. };
  63. }
  64.  
  65. // 如果layer直接在DOM上写了 onclick 方法,那我们需要把它替换为 addEventListener 绑定形式
  66. if (typeof layer.onclick === 'function') {
  67. oldOnClick = layer.onclick;
  68. layer.addEventListener('click', function(event) {
  69. oldOnClick(event);
  70. }, false);
  71. layer.onclick = null;
  72. }
  73. }

在初始通过 FastClick.notNeeded 方法判断是否需要做后续的相关处理:

  1. //如果是属于不需要处理的元素类型,则直接返回
  2. if (FastClick.notNeeded(layer)) {
  3. return;
  4. }

我们看下这个 FastClick.notNeeded 都做了哪些判断:

  1. //是否没必要使用到 Fastclick 的检测
  2. FastClick.notNeeded = function(layer) {
  3. var metaViewport;
  4. var chromeVersion;
  5. var blackberryVersion;
  6. var firefoxVersion;
  7.  
  8. // 不支持触摸的设备
  9. if (typeof window.ontouchstart === 'undefined') {
  10. return true;
  11. }
  12.  
  13. // 获取Chrome版本号,若非Chrome则返回0
  14. chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
  15.  
  16. if (chromeVersion) {
  17.  
  18. if (deviceIsAndroid) { //安卓
  19. metaViewport = document.querySelector('meta[name=viewport]');
  20.  
  21. if (metaViewport) {
  22. // 安卓下,带有 user-scalable="no" 的 meta 标签的 chrome 是会自动禁用 300ms 延迟的,所以无需 Fastclick
  23. if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
  24. return true;
  25. }
  26. // 安卓Chrome 32 及以上版本,若带有 width=device-width 的 meta 标签也是无需 FastClick 的
  27. if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) {
  28. return true;
  29. }
  30. }
  31.  
  32. // 其它的就肯定是桌面级的 Chrome 了,更不需要 FastClick 啦
  33. } else {
  34. return true;
  35. }
  36. }
  37.  
  38. if (deviceIsBlackBerry10) { //黑莓,和上面安卓同理,就不写注释了
  39. blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/);
  40.  
  41. if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) {
  42. metaViewport = document.querySelector('meta[name=viewport]');
  43.  
  44. if (metaViewport) {
  45. if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
  46. return true;
  47. }
  48.  
  49. if (document.documentElement.scrollWidth <= window.outerWidth) {
  50. return true;
  51. }
  52. }
  53. }
  54. }
  55.  
  56. // 带有 -ms-touch-action: none / manipulation 特性的 IE10 会禁用双击放大,也没有 300ms 时延
  57. if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') {
  58. return true;
  59. }
  60.  
  61. // Firefox检测,同上
  62. firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
  63.  
  64. if (firefoxVersion >= 27) {
  65.  
  66. metaViewport = document.querySelector('meta[name=viewport]');
  67. if (metaViewport && (metaViewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) {
  68. return true;
  69. }
  70. }
  71.  
  72. // IE11 推荐使用没有“-ms-”前缀的 touch-action 样式特性名
  73. if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') {
  74. return true;
  75. }
  76.  
  77. return false;
  78. };

基本上都是一些能禁用 300ms 时延的浏览器嗅探,它们都没必要使用 Fastclick,所以会返回 true 回构造函数停止下一步执行。

由于安卓手Q的 ua 会被匹配到 /Chrome\/([0-9]+)/,故带有 'user-scalable=no' meta 标签的安卓手Q页会被 FastClick 视为无需处理页。

这也是为何在安卓手Q里没有开头提及问题的原因。

我们继续看构造函数,它直接给 layer(即body)添加了click、touchstart、touchmove、touchend、touchcancel(若是安卓还有 mouseover、mousedown、mouseup)事件监听:

  1. //安卓则做额外处理
  2. if (deviceIsAndroid) {
  3. layer.addEventListener('mouseover', this.onMouse, true);
  4. layer.addEventListener('mousedown', this.onMouse, true);
  5. layer.addEventListener('mouseup', this.onMouse, true);
  6. }
  7.  
  8. layer.addEventListener('click', this.onClick, true);
  9. layer.addEventListener('touchstart', this.onTouchStart, false);
  10. layer.addEventListener('touchmove', this.onTouchMove, false);
  11. layer.addEventListener('touchend', this.onTouchEnd, false);
  12. layer.addEventListener('touchcancel', this.onTouchCancel, false);

注意在这段代码上面还利用了 bind 方法做了处理,这些事件回调中的 this 都会变成 Fastclick 实例上下文。

另外还得留意,onclick 事件以及安卓的额外处理部分都是走的捕获监听。

咱们分别看看这些事件回调分别都做了什么。

1. this.onTouchStart

  1. FastClick.prototype.onTouchStart = function(event) {
  2. var targetElement, touch, selection;
  3.  
  4. // 多指触控的手势则忽略
  5. if (event.targetTouches.length > 1) {
  6. return true;
  7. }
  8.  
  9. targetElement = this.getTargetElementFromEventTarget(event.target); //一些较老的浏览器,target 可能会是一个文本节点,得返回其DOM节点
  10. touch = event.targetTouches[0];
  11.  
  12. if (deviceIsIOS) { //IOS处理
  13.  
  14. // 若用户已经选中了一些内容(比如选中了一段文本打算复制),则忽略
  15. selection = window.getSelection();
  16. if (selection.rangeCount && !selection.isCollapsed) {
  17. return true;
  18. }
  19.  
  20. if (!deviceIsIOS4) { //是否IOS4
  21.  
  22. //怪异特性处理——若click事件回调打开了一个alert/confirm,用户下一次tap页面的其它地方时,新的touchstart和touchend
  23. //事件会拥有同一个touch.identifier(新的 touch event 会跟上一次触发alert点击的 touch event 一样),
  24. //为避免将新的event当作之前的event导致问题,这里需要禁用事件
  25. //另外chrome的开发工具启用'Emulate touch events'后,iOS UA下的 identifier 会变成0,所以要做容错避免调试过程也被禁用事件了
  26. if (touch.identifier && touch.identifier === this.lastTouchIdentifier) {
  27. event.preventDefault();
  28. return false;
  29. }
  30.  
  31. this.lastTouchIdentifier = touch.identifier;
  32.  
  33. // 如果target是一个滚动容器里的一个子元素(使用了 -webkit-overflow-scrolling: touch) ,而且满足:
  34. // 1) 用户非常快速地滚动外层滚动容器
  35. // 2) 用户通过tap停止住了这个快速滚动
  36. // 这时候最后的'touchend'的event.target会变成用户最终手指下的那个元素
  37. // 所以当快速滚动开始的时候,需要做检查target是否滚动容器的子元素,如果是,做个标记
  38. // 在touchend时检查这个标记的值(滚动容器的scrolltop)是否改变了,如果是则说明页面在滚动中,需要取消fastclick处理
  39. this.updateScrollParent(targetElement);
  40. }
  41. }
  42.  
  43. this.trackingClick = true; //做个标志表示开始追踪click事件了
  44. this.trackingClickStart = event.timeStamp; //标记下touch事件开始的时间戳
  45. this.targetElement = targetElement;
  46.  
  47. //标记touch起始点的页面偏移值
  48. this.touchStartX = touch.pageX;
  49. this.touchStartY = touch.pageY;
  50.  
  51. // this.lastClickTime 是在 touchend 里标记的事件时间戳
  52. // this.tapDelay 为常量 200 (ms)
  53. // 此举用来避免 phantom 的双击(200ms内快速点了两次)触发 click
  54. // 反正200ms内的第二次点击会禁止触发其默认事件
  55. if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
  56. event.preventDefault();
  57. }
  58.  
  59. return true;
  60. };

顺道看下这里的 this.updateScrollParent:

  1. /**
  2. * 检查target是否一个滚动容器里的子元素,如果是则给它加个标记
  3. */
  4. FastClick.prototype.updateScrollParent = function(targetElement) {
  5. var scrollParent, parentElement;
  6.  
  7. scrollParent = targetElement.fastClickScrollParent;
  8.  
  9. if (!scrollParent || !scrollParent.contains(targetElement)) {
  10. parentElement = targetElement;
  11. do {
  12. if (parentElement.scrollHeight > parentElement.offsetHeight) {
  13. scrollParent = parentElement;
  14. targetElement.fastClickScrollParent = parentElement;
  15. break;
  16. }
  17.  
  18. parentElement = parentElement.parentElement;
  19. } while (parentElement);
  20. }
  21.  
  22. // 给滚动容器加个标志fastClickLastScrollTop,值为其当前垂直滚动偏移
  23. if (scrollParent) {
  24. scrollParent.fastClickLastScrollTop = scrollParent.scrollTop;
  25. }
  26. };

另外要注意的是,在 onTouchStart 里被标记为 true 的 this.trackingClick 属性,都会在其它事件回调(比如 ontouchmove )的开头做检测,如果没被赋值过,则直接忽略:

  1. if (!this.trackingClick) {
  2. return true;
  3. }

当然在 ontouchend 事件里会把它重置为 false。

2. this.onTouchMove

这段代码量好少:

  1. FastClick.prototype.onTouchMove = function(event) {
  2. //不是需要被追踪click的事件则忽略
  3. if (!this.trackingClick) {
  4. return true;
  5. }
  6.  
  7. // 如果target突然改变了,或者用户其实是在移动手势而非想要click
  8. // 则应该清掉this.trackingClick和this.targetElement,告诉后面的事件你们也不用处理了
  9. if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
  10. this.trackingClick = false;
  11. this.targetElement = null;
  12. }
  13.  
  14. return true;
  15. };

看下这里用到的 this.touchHasMoved 原型方法:

  1. //判断是否移动了
  2. //this.touchBoundary是常量,值为10
  3. //如果touch已经移动了10个偏移量单位,则应当作为移动事件处理而非click事件
  4. FastClick.prototype.touchHasMoved = function(event) {
  5. var touch = event.changedTouches[0], boundary = this.touchBoundary;
  6.  
  7. if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) {
  8. return true;
  9. }
  10.  
  11. return false;
  12. };

3. onTouchEnd

  1. FastClick.prototype.onTouchEnd = function(event) {
  2. var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;
  3.  
  4. if (!this.trackingClick) {
  5. return true;
  6. }
  7.  
  8. // 避免 phantom 的双击(200ms内快速点了两次)触发 click
  9. // 我们在 ontouchstart 里已经做过一次判断了(仅仅禁用默认事件),这里再做一次判断
  10. if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
  11. this.cancelNextClick = true; //该属性会在 onMouse 事件中被判断,为true则彻底禁用事件和冒泡
  12. return true;
  13. }
  14.  
  15. //this.tapTimeout是常量,值为700
  16. //识别是否为长按事件,如果是(大于700ms)则忽略
  17. if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
  18. return true;
  19. }
  20.  
  21. // 得重置为false,避免input事件被意外取消
  22. // 例子见 https://github.com/ftlabs/fastclick/issues/156
  23. this.cancelNextClick = false;
  24.  
  25. this.lastClickTime = event.timeStamp; //标记touchend时间,方便下一次的touchstart做双击校验
  26.  
  27. trackingClickStart = this.trackingClickStart;
  28. //重置 this.trackingClick 和 this.trackingClickStart
  29. this.trackingClick = false;
  30. this.trackingClickStart = 0;
  31.  
  32. // iOS 6.0-7.*版本下有个问题 —— 如果layer处于transition或scroll过程,event所提供的target是不正确的
  33. // 所以咱们得重找 targetElement(这里通过 document.elementFromPoint 接口来寻找)
  34. if (deviceIsIOSWithBadTarget) { //iOS 6.0-7.*版本
  35. touch = event.changedTouches[0]; //手指离开前的触点
  36.  
  37. // 有些情况下 elementFromPoint 里的参数是预期外/不可用的, 所以还得避免 targetElement 为 null
  38. targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement;
  39. // target可能不正确需要重找,但fastClickScrollParent是不会变的
  40. targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
  41. }
  42.  
  43. targetTagName = targetElement.tagName.toLowerCase();
  44. if (targetTagName === 'label') { //是label则激活其指向的组件
  45. forElement = this.findControl(targetElement);
  46. if (forElement) {
  47. this.focus(targetElement);
  48. //安卓直接返回(无需合成click事件触发,因为点击和激活元素不同,不存在点透)
  49. if (deviceIsAndroid) {
  50. return false;
  51. }
  52.  
  53. targetElement = forElement;
  54. }
  55. } else if (this.needsFocus(targetElement)) { //非label则识别是否需要focus的元素
  56.  
  57. //手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
  58. //(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
  59. //这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
  60. //另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
  61. //会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
  62. if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
  63. this.targetElement = null;
  64. return false;
  65. }
  66.  
  67. this.focus(targetElement);
  68. this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms
  69.  
  70. //iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
  71. //有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
  72. if (!deviceIsIOS || targetTagName !== 'select') {
  73. this.targetElement = null;
  74. event.preventDefault();
  75. }
  76.  
  77. return false;
  78. }
  79.  
  80. if (deviceIsIOS && !deviceIsIOS4) {
  81.  
  82. // 滚动容器的垂直滚动偏移改变了,说明是容器在做滚动而非点击,则忽略
  83. scrollParent = targetElement.fastClickScrollParent;
  84. if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) {
  85. return true;
  86. }
  87. }
  88.  
  89. // 查看元素是否无需处理的白名单内(比如加了名为“needsclick”的class)
  90. // 不是白名单的则照旧预防穿透处理,立即触发合成的click事件
  91. if (!this.needsClick(targetElement)) {
  92. event.preventDefault();
  93. this.sendClick(targetElement, event);
  94. }
  95.  
  96. return false;
  97. };

这段比较长,我们主要看这段:

  1. } else if (this.needsFocus(targetElement)) { //非label则识别是否需要focus的元素
  2.  
  3. //手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
  4. //(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
  5. //这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
  6. //另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
  7. //会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
  8. if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
  9. this.targetElement = null;
  10. return false;
  11. }
  12.  
  13. this.focus(targetElement);
  14. this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms
  15.  
  16. //iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
  17. //有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
  18. if (!deviceIsIOS || targetTagName !== 'select') {
  19. this.targetElement = null;
  20. event.preventDefault();
  21. }
  22.  
  23. return false;
  24. }

其中 this.needsFocus 用于判断给定元素是否需要通过合成click事件来模拟聚焦:

  1. //判断给定元素是否需要通过合成click事件来模拟聚焦
  2. FastClick.prototype.needsFocus = function(target) {
  3. switch (target.nodeName.toLowerCase()) {
  4. case 'textarea':
  5. return true;
  6. case 'select':
  7. return !deviceIsAndroid; //iOS下的select得走穿透点击才行
  8. case 'input':
  9. switch (target.type) {
  10. case 'button':
  11. case 'checkbox':
  12. case 'file':
  13. case 'image':
  14. case 'radio':
  15. case 'submit':
  16. return false;
  17. }
  18.  
  19. return !target.disabled && !target.readOnly;
  20. default:
  21. //带有名为“bneedsfocus”的class则返回true
  22. return (/\bneedsfocus\b/).test(target.className);
  23. }
  24. };

另外这段说明了为何稍微久按一点(超过100ms)textarea ,我们是可以把光标定位在正确的地方(会绕过后面调用 this.focus 的方法)

  1. //手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
  2. //(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
  3. //这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
  4. //另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
  5. //会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
  6. if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
  7. this.targetElement = null;
  8. return false;
  9. }

接着咱们看看这两行很重要的代码:

  1. this.focus(targetElement);
  2. this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms

所涉及的两个原型方法分别为:

⑴ this.focus

  1. FastClick.prototype.focus = function(targetElement) {
  2. var length;
  3.  
  4. // 组件建议通过setSelectionRange(selectionStart, selectionEnd)来设定光标范围(注意这样还没有聚焦
  5. // 要等到后面触发 sendClick 事件才会聚焦)
  6. // 另外 iOS7 下有些input元素(比如 date datetime month) 的 selectionStart 和 selectionEnd 特性是没有整型值的,
  7. // 导致会抛出一个关于 setSelectionRange 的模糊错误,它们需要改用 focus 事件触发
  8. if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') {
  9. length = targetElement.value.length;
  10. targetElement.setSelectionRange(length, length);
  11. } else {
  12. //直接触发其focus事件
  13. targetElement.focus();
  14. }
  15. };

注意,我们点击 textarea 时调用了该方法,它通过 targetElement.setSelectionRange(length, length) 决定了光标的位置在内容的尾部(但注意,这时候还没聚焦!!!)。

⑵ this.sendClick

真正让 textarea 聚焦的是这个方法,它合成了一个 click 方法立刻在textarea元素上触发导致聚焦:

  1. //合成一个click事件并在指定元素上触发
  2. FastClick.prototype.sendClick = function(targetElement, event) {
  3. var clickEvent, touch;
  4.  
  5. // 在一些安卓机器中,得让页面所存在的 activeElement(聚焦的元素,比如input)失焦,否则合成的click事件将无效
  6. if (document.activeElement && document.activeElement !== targetElement) {
  7. document.activeElement.blur();
  8. }
  9.  
  10. touch = event.changedTouches[0];
  11.  
  12. // 合成(Synthesise) 一个 click 事件
  13. // 通过一个额外属性确保它能被追踪(tracked)
  14. clickEvent = document.createEvent('MouseEvents');
  15. clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
  16. clickEvent.forwardedTouchEvent = true; // fastclick的内部变量,用来识别click事件是原生还是合成的
  17. targetElement.dispatchEvent(clickEvent); //立即触发其click事件
  18. };
  19.  
  20. FastClick.prototype.determineEventType = function(targetElement) {
  21.  
  22. //安卓设备下 Select 无法通过合成的 click 事件被展开,得改为 mousedown
  23. if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
  24. return 'mousedown';
  25. }
  26.  
  27. return 'click';
  28. };

经过这么一折腾,咱们轻点 textarea 后,光标就自然定位到其内容尾部去了。但是这里有个问题——排在 touchend 后的 focus 事件为啥没被触发呢?

如果 focus 事件能被触发的话,那肯定能重新定位光标到正确的位置呀。

咱们看下面这段:

  1. //iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
  2. //有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
  3. if (!deviceIsIOS || targetTagName !== 'select' ) {
  4. this.targetElement = null;
  5. event.preventDefault();
  6. }

通过 preventDefault 的阻挡,textarea 自然再也无法拥抱其 focus 宝宝了~

于是乎,我们在这里做个改动就能修复这个问题:

  1. var _isTextInput = function(){
  2. return targetTagName === 'textarea' || (targetTagName === 'input' && targetElement.type === 'text');
  3. };
  4.  
  5. if ((!deviceIsIOS || targetTagName !== 'select') && !_isTextInput()) {
  6. this.targetElement = null;
  7. event.preventDefault();
  8. }

或者:

  1. if (!deviceIsIOS4 || targetTagName !== 'select') {
  2. this.targetElement = null;
  3. //给textarea加上“needsclick”的class
  4. if((!/\bneedsclick\b/).test(targetElement.className)){
  5. event.preventDefault();
  6. }
  7. }

这里要吐槽下的是,Fastclick 把 this.needsClick 放到了 ontouchEnd 末尾去执行,才导致前面说的加上了“needsclick”类名也无效的问题。

虽然问题原因找到也解决了,但咱们还是继续看剩下的部分吧。

4. onMouse 和 onClick

  1. //用于决定是否允许穿透事件(触发layer的click默认事件)
  2. FastClick.prototype.onMouse = function(event) {
  3.  
  4. // touch事件一直没触发
  5. if (!this.targetElement) {
  6. return true;
  7. }
  8.  
  9. if (event.forwardedTouchEvent) { //触发的click事件是合成的
  10. return true;
  11. }
  12.  
  13. // 编程派生的事件所对应元素事件可以被允许
  14. // 确保其没执行过 preventDefault 方法(event.cancelable 不为 true)即可
  15. if (!event.cancelable) {
  16. return true;
  17. }
  18.  
  19. // 需要做预防穿透处理的元素,或者做了快速(200ms)双击的情况
  20. if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
  21. //停止当前默认事件和冒泡
  22. if (event.stopImmediatePropagation) {
  23. event.stopImmediatePropagation();
  24. } else {
  25.  
  26. // 不支持 stopImmediatePropagation 的设备(比如Android 2)做标记,
  27. // 确保该事件回调不会执行(见126行)
  28. event.propagationStopped = true;
  29. }
  30.  
  31. // 取消事件和冒泡
  32. event.stopPropagation();
  33. event.preventDefault();
  34.  
  35. return false;
  36. }
  37.  
  38. //允许穿透
  39. return true;
  40. };
  41.  
  42. //click事件常规都是touch事件衍生来的,也排在touch后面触发。
  43. //对于那些我们在touch事件过程没有禁用掉默认事件的event来说,我们还需要在click的捕获阶段进一步
  44. //做判断决定是否要禁掉点击事件(防穿透)
  45. FastClick.prototype.onClick = function(event) {
  46. var permitted;
  47.  
  48. // 如果还有 trackingClick 存在,可能是某些UI事件阻塞了touchEnd 的执行
  49. if (this.trackingClick) {
  50. this.targetElement = null;
  51. this.trackingClick = false;
  52. return true;
  53. }
  54.  
  55. // 依旧是对 iOS 怪异行为的处理 —— 如果用户点击了iOS模拟器里某个表单中的一个submit元素
  56. // 或者点击了弹出来的键盘里的“Go”按钮,会触发一个“伪”click事件(target是一个submit-type的input元素)
  57. if (event.target.type === 'submit' && event.detail === 0) {
  58. return true;
  59. }
  60.  
  61. permitted = this.onMouse(event);
  62.  
  63. if (!permitted) { //如果点击是被允许的,将this.targetElement置空可以确保onMouse事件里不会阻止默认事件
  64. this.targetElement = null;
  65. }
  66.  
  67. //没有多大意义
  68. return permitted;
  69. };
  70.  
  71. //销毁Fastclick所注册的监听事件。是给外部实例去调用的
  72. FastClick.prototype.destroy = function() {
  73. var layer = this.layer;
  74.  
  75. if (deviceIsAndroid) {
  76. layer.removeEventListener('mouseover', this.onMouse, true);
  77. layer.removeEventListener('mousedown', this.onMouse, true);
  78. layer.removeEventListener('mouseup', this.onMouse, true);
  79. }
  80.  
  81. layer.removeEventListener('click', this.onClick, true);
  82. layer.removeEventListener('touchstart', this.onTouchStart, false);
  83. layer.removeEventListener('touchmove', this.onTouchMove, false);
  84. layer.removeEventListener('touchend', this.onTouchEnd, false);
  85. layer.removeEventListener('touchcancel', this.onTouchCancel, false);
  86. };

常规需要阻断点击事件的操作,我们在 touch 监听事件回调中已经做了处理,这里主要是针对那些 touch 过程(有些设备甚至可能并没有touch事件触发)没有禁用默认事件的 event 做进一步处理,从而决定是否触发原生的 click 事件(如果禁止是在 onMouse 方法里做的处理)。

小结

1. 在 fastclick 源码的 addEventListener 回调事件中有很多的 return false/true。它们其实主要用于绕过后面的脚本逻辑,并没有其它意义(它是不会阻止默认事件的)。

所以千万别把 jQuery 事件、或者 DOM0 级事件回调中的 return false 概念,跟 addEventListener 的混在一起了。

2. fastclick 的源码其实很简单,有很大部分不外乎对一些怪异行为做 hack,其核心理念不外乎是——捕获 target 事件,判断 target 是要解决点透问题的元素,就合成一个 click 事件在 target 上触发,同时通过 preventDefault 禁用默认事件。

3. fastclick 虽好,但也有一些坑,还是得按需求对其修改,那么了解其源码还是很有必要的。

FastClick 填坑及源码解析的更多相关文章

  1. 多线程爬坑之路-Thread和Runable源码解析之基本方法的运用实例

    前面的文章:多线程爬坑之路-学习多线程需要来了解哪些东西?(concurrent并发包的数据结构和线程池,Locks锁,Atomic原子类) 多线程爬坑之路-Thread和Runable源码解析 前面 ...

  2. Exynos 4412 Uboot源码解析

    原文地址:http://www.cnblogs.com/jacklu/p/6226330.html Exynos 4412 Uboot的汇编代码就不贴了,没有的可以私信我. 这是我当时阅读代码时的思维 ...

  3. 神经网络中 BP 算法的原理与 Python 实现源码解析

    最近这段时间系统性的学习了 BP 算法后写下了这篇学习笔记,因为能力有限,若有明显错误,还请指正. 什么是梯度下降和链式求导法则 假设我们有一个函数 J(w),如下图所示. 梯度下降示意图 现在,我们 ...

  4. 黄聪:WordPress动作钩子函数add_action()、do_action()源码解析

    WordPress常用两种钩子,过滤钩子和动作钩子.过滤钩子相关函数及源码分析在上篇文章中完成,本篇主要分析动作钩子源码. 然而,在了解了动作钩子的源码后你会发现,动作钩子核心代码竟然跟过滤钩子差不多 ...

  5. 给jdk写注释系列之jdk1.6容器(12)-PriorityQueue源码解析

    PriorityQueue是一种什么样的容器呢?看过前面的几个jdk容器分析的话,看到Queue这个单词你一定会,哦~这是一种队列.是的,PriorityQueue是一种队列,但是它又是一种什么样的队 ...

  6. 给jdk写注释系列之jdk1.6容器(4)-HashMap源码解析

    前面了解了jdk容器中的两种List,回忆一下怎么从list中取值(也就是做查询),是通过index索引位置对不对,由于存入list的元素时安装插入顺序存储的,所以index索引也就是插入的次序. M ...

  7. ArrayList源码解析

    ArrayList简介 ArrayList定义 1 public class ArrayList<E> extends AbstractList<E> implements L ...

  8. 2015.07.20MapReducer源码解析(笔记)

    MapReducer源码解析(笔记)   第一步,读取数据源,将每一行内容解析成一个个键值对,每个键值对供map函数定义一次,数据源由FileInputFormat:指定的,程序就能从地址读取记录,读 ...

  9. anki_vector SDK源码解析(教程)

    一:最近anki vector robot开放了Python SDK,我听到的第一时间就赶快上网查了查,先抛几个官网重要链接吧: Python编程API手册及环境搭建等: https://sdk-re ...

随机推荐

  1. Vue + Webpack + Vue-loader 系列教程(1)功能介绍篇

    原文地址:https://lvyongbo.gitbooks.io/vue-loader/content/ Vue-loader 是什么? vue-loader 是一个加载器,能把如下格式的 Vue ...

  2. WebApi接口 - 响应输出xml和json

    格式化数据这东西,主要看需要的运用场景,今天和大家分享的是webapi格式化数据,这里面的例子主要是输出json和xml的格式数据,测试用例很接近实际常用情况:希望大家喜欢,也希望各位多多扫码支持和点 ...

  3. HTML 5 应用程序缓存manifest

    什么是应用程序缓存(Application Cache)? HTML5 引入了应用程序缓存,这意味着 web 应用可进行缓存,并可在没有因特网连接时进行访问. 应用程序缓存为应用带来三个优势: 离线浏 ...

  4. Android之文件数据存储

    一.文件保存数据介绍 Activity提供了openFileOutput()方法可以用于把数据输出到文件中,具体的实现过程与在J2SE环境中保存数据到文件中是一样的.文件可用来存放大量数据,如文本.图 ...

  5. iOS之ProtocolBuffer搭建和示例demo

    这次搭建iOS的ProtocolBuffer编译器和把*.proto源文件编译成*.pbobjc.h 和 *.pbobjc.m文件时,碰到不少问题! 搭建pb编译器到时没有什么问题,只是在把*.pro ...

  6. maven 快照

    大型应用软件一般由多个模块组成,一般它是多个团队开发同一个应用程序的不同模块,这是比较常见的场景.例如,一个团队正在对应用程序的应用程序,用户界面项目(app-ui.jar:1.0) 的前端进行开发, ...

  7. 完美解决CodeSmith无法获取MySQL表及列Description说明注释的方案

    问题描述: CodeSmith是现在比较实用的代码生成器,但是我们发现一个问题: 使用CodeSmith编写MySQL模板的时候,会发现一个问题:MySQL数据表中的列说明获取不到,也就是column ...

  8. Mysql - 函数

    Mysql提供的函数是在是太多了, 很多我都见过, 别说用了. 园子里面, 有人弄了一个比较全的. MYSQL函数 我这里会将他写的完全拷贝下来, 中间会插入一些自己项目中使用过的心得 一.数学函数 ...

  9. vs生成pro

    1.修改.vcxproj文件   <PropertyGroup Label="Globals">    <ProjectGuid>{AAAA4039-13B ...

  10. C 盘的不速之客

      C 盘的报告内容既然上GB的空间 操作系统版本 原来是微软这个查找解决异常关闭解决方案生成的报告   参考 How To Disable Vista Error Reporting Feature ...