移动端Web界面滚动性能优化: Passive event listeners

3,406 阅读6分钟
原文链接: blog.csdn.net

移动端Web界面滚动性能优化: Passive event listeners

今晚在阅读VueJS2的源码时,发现了下面的一段代码,感觉自己瞬间知识储备不够用了,所以决定深入研究一下,故总结得出此文。关于VueJS的源码解读,之后会整理出学习笔记。这里先简单记录一些碎片化的知识点。

 try {
    const opts = {}
    Object.defineProperty(opts, 'passive', ({
      get () {
        /* istanbul ignore next */
        supportsPassive = true
      }
    } : Object))
    window.addEventListener('test-passive', null, opts)
  } catch (e) {}

Passive event listeners 到底是什么,它有什么用

Passive event listeners是2016年Google I/O 上同 PWA 概念一起被提出,但是同PWA不同,Passive event listeners 的作用很简单,如果用简单一句话来解释就是:提升页面滑动的流畅度。

为什么会提出这个方案以及如何使用

想要很好的理解其用途,我们需要先来看一下传统的事件监听函数的写法,以及其执行流程,这里只做简单的复习,更多细节可以参考《JavaScript高级程序设计》(第三版)第13章。

addEventListener 用来在页面中监听事件,它的参数签名是这样的:

target.addEventListener(type, listener[, useCapture]);

但是如果你现在去查询 MDN 的文档却发现是这样写的:

target.addEventListener(type, listener[, options]);
target.addEventListener(type, listener[, useCapture]);
target.addEventListener(type, listener[, useCapture, wantsUntrusted  ]); // Gecko/Mozilla only

最后一个参数 useCapture 在很久之前是必填的,后来的规范将 useCapture 变成了选填。useCapture 参数用来控制监听器是在捕获阶段执行还是在冒泡阶段执行,true 为捕获阶段,false 为冒泡阶段,变成选填后默认值为 false(冒泡阶段),因为传 true 的情况太少了。

事件捕获 vs 事件冒泡

先来看看经典的事件捕获和冒泡模型:
事件捕获和冒泡模型
事件捕获和冒泡模型

上图反映了JavaScript事件的传播(冒泡)过程。如果我们为每一层的元素都绑定事件,那么在事件冒泡过程中,最底层的元素会最先响应事件,然后依次向父元素(上一层)冒泡。

在事件处理函数中,会传递 Event 对象作为事件处理函数的参数,而这个参数最常用的 2 个方法就是

event.preventDefault();  // 阻止事件继续传播
event.stopPropagation(); // 取消事件的默认行为

在移动网页中,我们经常使用的就是 touch 系列的事件,如:

touchstart
touchmove
touchend
touchcancel

通常我们使用如下方式绑定 touch 事件:

// 由于第三个参数没有传值,那么默认就是 false,事件会在冒泡阶段被处理
div.addEventListener("touchstart", function(e){
    // do sth.
});

接下来我们分析一下事件的执行流程:

  1. 如果我们在事件处理函数中调用了 stopPropagation(),那么之后的元素就无法接收这个事件,也即是剩余的事件处理函数永远不会得到执行。所以如果你不是非要那么做,请千万不要调用这个方法,否者你或者你的合作开发者会发现奇奇怪怪的Bug。

  2. 如果我们在事件处理函数中调用了 preventDefault(),那么元素的默认行为就会被取消。 举个例子来说明:一个 a 标签绑其 click 事件的默认行为是跳转到 href 指定的链接,但是如果我们在click事件处理函数里面调用了 preventDefault 方法后,其默认的的行为就被取消了。

移动端列表滚动的性能点

接着我们上面的分析来进行下面的关注点。同样的道理,如果我们在 touchstart 事件调用 preventDefault 那么整个列表的滚动就会被取消,我们会惊奇的发现我们的页面瘫痪了(所以在移动端,你不仅不能轻易使用 stopPropagation, 在可滚动元素的 touch 事件处理函数中,你使用 preventDefault 方法时也需要格外小心才行)。

那么问题来了:由于浏览器无法预先知道一个事件处理函数中会不会调用 preventDefault(),它需要等到事件处理函数执行完后,才能去执行默认行为,然而事件处理函数执行是要耗时的,这样一来就会导致页面卡顿,可以动手试试,比如在事件处理函数里面写一个耗时的循环试试,卡的你不要不要的(如果设置了css样式:-webkit-overflow-scrolling : touch; 在版本稍高一点的Chrome可能会为了优化性能而忽略 preventDefault 的调用,这也是一个可能的优化点哈)。

来看一段 Chrome 官方发布的数据统计:

For instance, in Chrome for Android 80% of the touch events that block scrolling never actually prevent it. 10% of these events add more than 100ms of delay to the start of scrolling, and a catastrophic delay of at least 500ms occurs in 1% of scrolls.
在 Android 版 Chrome 浏览器的 touch 事件监听器的页面中,80% 的页面都不会调用 preventDefault 函数来阻止事件的默认行为。在滑动流畅度上,有 10% 的页面增加至少 100ms 的延迟,1% 的页面甚至增加 500ms 以上的延迟。
也就是说,当浏览器等待执行事件的默认行为时,大部分情况是白等了。如果 Web 开发者能够提前告诉浏览器:“我不调用 preventDefault 函数来阻止事件事件行为”,那么浏览器就能快速生成事件,从而提升页面性能。

Chrome官方有个视频测试:www.youtube.com/watch?v=NPM…

介绍了那么多,终于可以告诉你 passive 就是为此而生的。在 WICG 的 demo 中提到,即使滚动事件里面写一个死循环,浏览器也能够正常处理页面的滑动

在最新的 DOM 规范中,事件绑定函数的第三个参数变成了对象:

target.addEventListener(type, listener[, options]);

我们可以通过传递 passive 为 true 来明确告诉浏览器,事件处理程序不会调用 preventDefault 来阻止默认滑动行为。亲测发现在 Chrome 浏览器中,如果发现耗时超过 100 毫秒的非 passive 的监听器,会在 DevTools 里面警告你加上 {passive: true}。Chrome 51 和 Firefox 49 已经支持 passive 属性。如果浏览器不支持,已经有牛人做了非常巧妙地 polyfill, 也就是文章开始提到的 VueJS 源码里面的那一段代码:

// Test via a getter in the options object to see 
// if the passive property is accessed
var supportsPassive = false;
try {
  var opts = Object.defineProperty({}, 'passive', {
    get: function() {
      supportsPassive = true;
    }
  });
  window.addEventListener("test", null, opts);
} catch (e) {}
// Use our detect's results. 
// passive applied if supported, capture will be false either way.
elem.addEventListener(
  'touchstart',
  fn,
  supportsPassive ? { passive: true } : false
);

如果想要了解给更多关于事件监听,处理的知识,墙裂建议仔细阅读 MDN-EventTarget.addEventListener API 文档