在不久前的 Google I/O 2016 Mobile Web Talk 中,Google 公布了一个让页面滑动更流畅的新特性 Passive Event Listeners。该特性目前已经集成到 Chrome51 版本中。
Chrome51 上使用 Passive Event Listener 特性前后的效果对比
链接地址:https://www.youtube.com/watch?v=65VMej8n23A
从效果对比视频中可以明显看到,使用 Passive Event Listeners 特性后,页面的滑动流畅度相对使用之前提升了很多。
看完 Passive Event Listeners 特性这么给力的效果后,相信大部分童鞋脑海中都会产生以下几个问题:
1. Passive Event Listeners 是什么?
2. 为什么需要 Passive Event Listeners?
3. Passive Event Listeners 是怎么实现的?
接下来,我们将围绕上面的这 3 个问题来深入理解 Passive Event Listeners 特性。
Passive Event Listeners 是什么?
Passive event listeners are a new feature in the DOM spec that enable developers to opt-in to better scroll performance by eliminating the need for scrolling to block on touch and wheel event listeners. Developers can annotate touch and wheel listeners with {passive: true} to indicate that they will never invoke preventDefault.
Passive Event Listeners 是 Chrome 提出的一个新的浏览器特性:Web 开发者通过一个新的属性 passive 来告诉浏览器,当前页面内注册的事件监听器内部是否会调用 preventDefault 函数来阻止事件的默认行为,以便浏览器根据这个信息更好地做出决策来优化页面性能。当属性 passive 的值为 true 的时候,代表监该听器内部不会调用 preventDefault 函数来阻止默认滑动行为,Chrome 浏览器称这类型的监听器为被动(passive)监听器。目前 Chrome 主要利用该特性来优化页面的滑动性能,所以 Passive Event Listeners 特性当前仅支持 mousewheel/touch 相关事件。
如下面的 Html 代码中,页面通过调用 document.addEventListener 来添加一个 mousewheel 事件的监听器 handler,并通过设置 passive 属性的值为 true 来声明监听器 handler 是被动监听 mousewheel 事件,即 handler 内部不会调用事件的 preventDefault 函数。
为什么需要 Passive Event Listeners 特性?
Passive Event Listeners 特性是为了提高页面的滑动流畅度而设计的,页面滑动流畅度的提升,直接影响到用户对这个页面最直观的感受。这个不难理解,想象一下你想要滑动某个页面浏览内容,当你用鼠标滚轮或者用手指触摸屏幕上下滑动的时候,页面并没有按你的预期进行滚动,此时你内心往往会感觉到一丝不爽,甚至想放弃该页面。Facebook 之前做了一项试验,他们将页面滑动的响应刷新率从 60FPS 降低到 30FPS 的时候,发现用户的参与度急速下降。
由前面对 Passive Event Listeners 特性的介绍可知,Passive Event Listenrers 特性是让 Web 开发者来告诉浏览器,当前页面内注册的 mousewheel/touch 事件监听器是否属于被动监听器,以便让浏览器更好地做决策来提高页面的滑动流畅度。那么 Chrome 浏览器为什么需要知道是否被动监听器这个信息呢?浏览器知道这个信息之后,它要做什么决策呢?要回答这个问题,有必要先了解一下目前 Chrome 浏览器的线程化渲染框架,它是 Passive Event Listeners 特性的基础。
在介绍 Chrome 浏览器的线程化渲染框架之前,我们先来简单了解本文涉及到的 Chrome 浏览器的一些概念。
绘制(Paint):将绘制操作转换成为图像的过程(比如软件模式下经过光栅化生成位图,硬件模式下经过光栅化生成纹理)。在 Chrome 中,绘制分为两部分实现:绘制操作记录部分(main-thread side)和绘制实现部分(impl-side)。绘制记录部分将绘制操作记录到 SKPicture 中,绘制实现部分负责将 SKPicture 进行光栅化转成图像;
图层(Paint Layer):在 Chrome 中,页面的绘制是分层绘制的,页面内容变化的时候,浏览器仅需要重新绘制内容变化的图层,没有变化的图层不需要重新绘制;
合成(Composite):将绘制好的图层图像混合在一起生成一张最终的图像显示在屏幕上的过程;
渲染(Render):绘制+合成=渲染;
UI 线程(UI Thread):浏览器的主线程,负责接收到系统派发给浏览器窗口的事件,资源下载等;
内核线程(Main/Render Thread):Blink 内核及 V8 引擎运行的线程,如 DOM 树构建,元素布局,绘制(main-thread side),JavaScript 执行等逻辑在该线程中执行;
合成线程(Compositor Thread):负责图像合成的线程,如绘制(impl-side),合成等逻辑在该线程中执行。
OK,了解完上面的几个概念后,我们正式开始 Chrome 线程渲染框架的介绍。
Chrome 浏览器的线程化渲染框架
我们回顾一下传统的单线程渲染框架,如下图所示,内核线程几乎包揽了页面内容渲染的所有工作,如 JavaScript 执行,元素布局,图层绘制,图层图像合成等,每项工作的执行耗时基本都跟页面内容相关,耗时一般在几十毫秒至几百毫秒不等。
对于这种单线程渲染框架,存在两个明显的问题:
流水线的执行方式,后面的工作必须等待前面工作执行完成才能处理,无法将相互独立的工作并行处理;
内核线程负责的工作太多且耗时,一旦遇上内核在执行耗时较长的工作,用户的输入事件是将无法立即得到响应的。
对于第 1 个问题,浏览器很难控制页面从内容变化到布局渲染整个过程的耗时(即新生成一帧内容的耗时),中间任何一项工作的执行都可能导致整体过程耗时变大,过大的耗时会导致页面内容的刷新率偏低,从而形成视觉上的卡顿。如浏览器收到 VSync 中断信号通知的时候,意味着页面需要立即对内容进行渲染,但这个时候内核线程可能还在执行一些业务的 JavaScript 代码,导致页面内容的渲染无法立即开始,如果页面无法在下一个 VSync 中断信号到来之前完成对内容的渲染,则页面会出现丢帧,用户感觉到页面操作出现卡顿。
注:VSync 信号中断的频率,一般跟设备屏幕的刷新率对齐,比如设备的刷新率为 60FPS(Frames Per Second),那么大概 16.67ms 会触发一下 Vsync 中断信号。Chrome 浏览器和 Android 系统等都是通过 VSync 中断信号来通知页面启动内容的渲染(BeginFrame)。
对于第 2 个问题,由于内核线程负责的工作太多,这将导致内核线程经常处于忙碌状态,无法快速处理外界的输入消息,表现为用户操作了页面,但是无法立即得到响应。
为了优化第 1 个问题,Chrome 浏览器对内核线程负责的工作进行拆分,通过多线程并发处理提高渲染效率减少丢帧,如内核线程仅负责 DOM 树构建、元素的布局、图层绘制记录部分(main-thread side)、JavaScript 的执行,而图层绘制实现部分(impl-side)、图层图像合成则是交给合成线程负责处理。这种多线程负责页面内容的渲染的框架,在 Chrome 中称为线程化渲染框架(Threaded Compositor Architecture)。
如上图所示,在 Chrome 的线程化渲染框架中,当内核线程完成第 1 帧(Frame#1)的布局和记录绘制操作,立即通知合成线程对第一帧(Frame#1)进行渲染,然后内核线程就开始准备第 2 帧(Frame#2)的布局和记录绘制操作。由此可以看出,内核线程在进行第 N+1 帧的布局和记录绘制操作同时,合成线程也在努力进行第 N 帧的渲染并交给屏幕展示,这里利用了 CPU 多核的特性进行并发处理,因此提高了页面的渲染效率。由此也可知,实际上用户看到的页面内容,是上一帧的内容快照,新的一帧还在处理中。
要优化第 2 个问题,对浏览器来说非常困难的。只要输入事件要在内核线程执行逻辑,那么遇到内核线程在忙,必然无法立即得到响应。如用户的大部分输入事件都跟页面元素有关系,一旦页面元素注册了对应事件的监听器,监听器的逻辑代码(JavaScript)必须在内核线程中执行(V8 引擎是运行在内核线程),因此这种输入事件经常无法立即得到响应的。
由上面的分析知道,用户的输入事件无法立即得到响应,是因为需要派发给内核线程处理。那有没有一些输入事件是可以不经过内核线程就能被快速处理的呢?答案是肯定的。
在 Chrome 中,这类可以不经过内核线程就能快速处理的输入事件为手势输入事件(滑动、捏合),手势输入事件是由用户连续的普通输入事件组合产生,如连续的 mousewheel/touchmove 事件可能会生成 GestureScrollBegin/GestureScrollUpdate 等手势事件。手势输入事件可以直接在已经渲染好的内容快照上操作,如滑动手势事件,直接对页面已经渲染好的内容快照进行滑动展示即可。由于线程化渲染框架的支持,手势输入事件可以不经过内核线程,直接由合成线程在内容快照上直接处理,所以即使此时内核线程在忙碌,用户的手势输入事件也是可以马上得到响应的。大家可以搞一个简单的 demo 验证一下 Chrome 浏览器的这个特性:如在一个有滚动条的页面内通过 JavaScript 执行一段死循环的代码(while-true 之类的),这个时候再去尝试上下滑动页面,你会发现此时页面仍能流畅地滑动。
由此可知,Chrome 浏览器对于手势输入事件的响应是非常快的,因为它可以不需要经过内核线程,直接由合成线程快速处理。然而手势输入事件的产生可能需要内核线程,这会导致 Chrome 对手势输入事件的优化效果大打折扣。由前面介绍知道,手势输入事件是由连续的普通输入事件组成,而这些普通的输入事件可能会被对应的事件监听器内部调用 preventDefault 函数来阻止掉事件的默认行为,在这种场景下是不会产生手势输入事件。如连续的 mousewheel 事件默认可以产生 GestureScrollUpdate 事件,但是如果监听器内部调用了 preventDefault 函数,那么这种情况下则不应该产生 GestureScrollUpdate 手势事件的。浏览器只有等内核线程执行到事件监听器对应的 JavaScript 代码时,才能知道内部是否会调用 preventDefault 函数来阻止事件的默认行为,所以浏览器本身是没有办法对这种场景进行优化的。这种场景下,用户的手势事件无法快速产生,会导致页面无法快速执行滑动逻辑,从而让用户感觉到页面卡顿。
而 Chrome 团队从统计数据中分析得出,注册了 mousewheel/touch 相关事件监听器的页面中,80%的页面内部都不会调用 preventDefault 函数来阻止事件的默认默认行为。对于这 80%的页面,即使监听器内部什么都没有做,相对没有注册 mousewheel/touch 事件监听器的页面,在滑动流畅度上,有 10%的页面增加至少 100ms 的延迟,1%的页面甚至增加 500ms 以上的延迟。Chrome 团队认为对于统计中的这 80%的页面来说,他们都是不希望因为注册 mousewheel/touch 相关事件监听器而导致滑动延迟增加的。点击这里(https://rbyers.github.io/scroll-latency.html)可以体验页面注册后导致的滑动延迟,如上图。
如果能让 Web 开发者来明确告诉浏览器,监听器内部不会调用 preventDefault 函数来禁止默认的事件行为,那么浏览器将能快速生成手势输入事件,从而让页面响应更快。
介绍完这里,大家应该明白 Chrome 浏览器为什么需要 Passive Event Listeners 特性了。接下来,我们来看看 Passive Event Listeners 特性是怎么实现的。
Passive Event Listeners 的实现
为了更好地理解 Passive Event Listeners 特性,我们接下来了解一下它的实现过程。如上面代码所示,假定页面中注册了 mousewheel 事件的被动监听器,此时用户开始滑动鼠标滚轮来滑动页面。
如上图所述,用户的鼠标滚轮事件(WM_MouseWheel)由操作系统内核捕捉后,操作系统会将该事件派发给浏览器的 UI 线程处理。UI 线程内部将系统的 WM_MouseWheel 事件转换为 Chrome 的 WebInputEvent::MouseWheel 事件后,接着通过 IPC 通道派发给合成线程的输入事件处理器处理。
合成线程的输入事件处理器收到 WebInputEvent::MouseWheel 事件后,内部先会查询 MouseWheel 事件监听器的类型属性,然后根据监听器的类型属性值来进行不同逻辑的处理。
目前 Chrome 中监听器的类型属性值主要有四种:EventListenerProperties::kNone,EventListenerProperties::kPassive,EventListenerProperties::kBlocking,EventListenerProperties::kBlockingAndPassive,如下代码所述。
在 Chrome 中,kBlocking 和 kBlockingAndPassive 类型属性的处理逻辑是一样的,这个不难理解,只要存在一个非 passive 类型的事件监听器,那么都有可能阻止事件的默认行为。接下来,我们了解一下不同类型属性监听器的实现逻辑。
场景 1: EventListenerProperties::kNone 类型
当事件监听器的类型属性为 EventListenerProperties::kNone 时,意味着当前页面内没有注册对应事件的监听器。对于这种场景(如上图中的 MouseWheel Handlers:No 分支),合成线程会马上发送一个 MouseWheel 的 ACK 消息给 UI 线程,UI 线程收到 MouseWheel 的 ACK 消息后,会判断该事件是否被消费(Comsumed,即调用了 preventDefault),如果已经被消费,则什么都不做。否则,UI 线程会产生一个滑动手势事件(如果当前不是在滑动过程,手势事件为 GestureScrollBegin,否则为 GestureScrollUpdate),并滑动手势事件通过 IPC 通道派发给合成线程处理,合成线程收到该滑动手势事件之后,直接对内容快照进行滑动处理,并展示给到屏幕上。这种场景下,由于没有涉及到内核线程处理,用户的输入响应会非常及时。
在 Chrome 中,用户的输入事件主要分为两大类:普通输入事件和手势输入事件。手势输入事件是由用户连续的普通输入事件组合产生,如连续的 touchmove/mousewheel 事件会生成 GestureScrollBegin/GestureScrollUpdate 等事件。
场景 2: EventListenerProperties::kBlockingAndPassive 或 cc::EventListenerProperties::kBlocking 类型
当事件监听器的类型属性为 EventListenerProperties::kBlockingAndPassive 或 EventListenerProperties::kBlocking 时,意味着当前页面至少存在一个非 passive 类型的事件监听器。对应这种场景(如上图中的 MouseWheel Handlers:YES-Passive:No 分支),合成线程无法知道对应的监听器内部是否会调用 preventDefault 函数来阻止默认行为,此时合成线程只能将该输入事件派发给内核线程处理(Dispatch Event to Main Thread)。等内核线程执行完监听器的处理逻辑后(Run JS Handler),再发送一个 MouseWheel 的 ACK 消息给 UI 线程,UI 线程收到 Mouse Wheel 的 ACK 消息后的处理逻辑跟场景 1 一致。这种场景下,手势输入事件必须等待事件监听器逻辑处理完成后才会产生并派发给合成线程处理,由于事件监听器逻辑的执行时机不确定,将非常容易导致用户的输入事件无法立即响应。
场景 3: EventListenerProperties::kPassive 类型
当事件监听器的类型属性为 EventListenerProperties::kPassive 时,意味着当前页面只存在 passive 类型的事件监听器。对于这种场景(如上图中的 MouseWheel Handlers:YES-Passive:YES 分支),合成线程首先会发送一个 MouseWheel 的 ACK 消息给 UI 线程,执行跟场景 1 中一样的逻辑,同时将该事件派发给内核线程处理,执行跟场景 2 相似的逻辑,但是在 Run JS Handlers 完成后,不会再发送 Mouse Wheel 事件的 ACK 消息。这种场景下,实际上是场景 2 和场景 3 的组合,两个场景是并行处理的,因此用户的 MouseWheel 输入事件能会被立刻响应,也不会受到内核线程的事件监听器处理逻辑影响。
对于场景 1 和场景 3 的滑动,在 Chrome 中称为 fast scroll 模式,而场景 2 则称为 slow scroll 模式。
总结
经过上面的分析,我们了解到了 Passive Event Listeners 特性是什么,Passive Event Listeners 特性产生的背景及 Passive Event Listeners 特性的实现逻辑,这其中涉及到了 Chrome 的多线程渲染框架、输入事件处理等知识。
本文转载自公众号小时光茶舍(ID:gh_7322a0f167b5)。
原文链接:
https://mp.weixin.qq.com/s/u2IROszftxiBd1IwrlRM-Q
评论