事件合成器
有了丝滑的页面,我们还需要一个丝滑的交互,渲染进程用以生成页面,那如何处理用户的交互行为呢?一次点击,一个滑动都需要不同的呈现,浏览器是如何做到的?
console.info
本文翻译自 Google
的官方文档,该系列共 4
篇文章,从内部观察现代浏览器 (Chrome)
,同时解答了浏览器的内部架构,讲述了浏览器从输入 url
到页面呈现的全过程。
前言
这是 从内部观察现代浏览器 (Chrome) 系列文章的第四篇。在上一篇文章中,我们知道了浏览器是如何将代码转化成网页的。在这篇文章中,我们将会知道,事件合成器是如何顺畅的处理用户的交互。
从浏览器的角度看待用户的输入
何为输入?使用鼠标在文本框中输入或是使用鼠标进行点击?是的,这些都是,但是从浏览器的角度看这个问题,输入意味着用户与网页交互的所有行为。比如:鼠标滚动、鼠标移动、滚轮滚动、触摸事件等等。
那么如何处理输入?当用户触发了 touch
事件时,浏览器进程是第一个知道 touch
事件发生的,但它却仅仅知道 touch
事件发生的坐标,因为选项卡中的内容完全是由渲染进程所控制,浏览器进程并不知道。因此浏览器进程只能将事件类型和发生坐标通过 IPC
发送给渲染进程,由渲染进程进一步处理。渲染进程通过事件类型和坐标寻找元素,并触发与元素绑定的事件回调。

合成器处理输入事件
在前一篇文章中,我们知道合成器通过分别光栅化分层,使得页面平滑流程的滚动。如果页面上不需要处理用户输入,那么合成线程可以独立的生成新的合成帧,而不需要通过主线程(不需要执行 javascript
代码)。但如果页面上的元素绑定了相关事件处理用户的输入,那么合成线程该如何处理呢?因为它不具备 javascript
代码的执行能力,只能通过渲染进程中的主线程来处理。
非快速可滚动区域
当 javascript
代码在主线程中执行,生成分层,合成线程将分层合成时,合成线程会判断出分层中需要处理用户输入的区域(绑定了事件的元素)并标记,这些区域被称为:非快速可滚动区域。合成线程在处理用户输入时,如果用户的输入发生在这些标记的区域时,合成线程就会通知主线程处理用户输入,但如果发生在非标记区域,那么合成线程仅需要重新生成合成帧即可。

绑定事件时需要知道的注意点
浏览器通过事件委托来实现事件的绑定。由于事件的冒泡机制,开发者可以通过在页面的根元素上绑定事件来处理所有的事件。如同下面的代码:
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault();
}
});
通过上面的代码,页面上所有元素的 touchstart
事件都会在这里处理。但是在浏览器看来,页面的根元素( body
),绑定了事件,那么它所对应的分层,以及被它包含的分层就都是非快速可滚动区域。这就意味着,即使这个页面不需要处理用户的输入,在用户输入时,合成线程都要通知主线程处理事件,即使绑定的处理函数不会改变页面的布局。而这个过程避免不了需要耗时,因此合成线程的分层处理的意义也将削弱。

当然解决办法也不是没有,开发者可以通过 passive: true
选项来告知浏览器,事件需要监听,但是合成线程不需要等待主线程的执行完毕直接继续生成新的合成帧。
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, {passive: true});
事件的取消
假设手机页面上有一个元素,你想限制他的滚动,仅可以用手指水平拖动,而不允许进行垂直拖动。

通过上面的介绍,使用 passive: true
选项可以让元素流程的滚动,但是主线程执行回调时元素其实已经有了滚动(合成线程和主线程同时执行)。就会照成行为上的偏差,并且由于滚动已经发生,使用 preventDefault
已经是来不及了,因此如果使用了 passive: true
,那么事件的回调中将不能使用 preventDefault
。以下代码会在浏览器中触发警告:
document.body.addEventListener('pointermove', event => {
event.preventDefault(); // Unable to preventDefault inside passive event listener invocation.
}
}, {passive: true});
原文有误,因为 cancelable
这个属性并没有变化。该段为查看了 MDN 文档上关于 passive 属性的介绍得出的结果。
确定触发节点
当合成线程将事件通知主线程前,还需要确定触发节点。合成线程使用事件的触发坐标结合主线程生成的绘制记录确定出用户触发事件时对应的 DOM
元素。

降低触发频率
在上一篇文章中,我们讨论过,通常屏幕的刷新频率为 1
秒 60
帧,为确保流程的页面效果,代码也应该按照这个频率执行,但是在触摸屏上,touch
事件每秒可以触发 60-120
次,鼠标事件也可以达到每秒触发 100
次。因此用户的输入频率远比屏幕的刷新频率来的高。
以 touchmove
事件来说,该事件每秒触发 120
次,会照成合成线程多次的寻找触发节点并通知主线程,但这其中绝大多数的事件其实是不需要触发的,因为按照屏幕的刷新频率很明显跟不上。

为了降低事件的触发频率,Chrome
将连续事件进行合并(比如:wheel
, mousewheel
, mousemove
, pointermove
, touchmove
),延迟触发,直到上一帧中的内容处理完毕,和使用 requestAnimationFrame
防止页面卡顿是一个原理。

但是,独立触发的事件(比如:keydown
, keyup
, mouseup
, mousedown
, touchstart
, touchend
),会直接触发。
获取合并事件
绝大多数的情况下,合并事件能够给用户一个良好的体验。但是,如果该网页通过事件进行绘画(根据 touchmove
),那么合并事件就会导致画出的线不是很流畅,因为很多的轨迹被合并掉了。在这种情况下,可以通过 getCoalescedEvents
方法来获取那些被合并掉的事件的内容。

实现右边绘画的大致代码如下:
window.addEventListener('pointermove', event => {
const events = event.getCoalescedEvents();
for (let event of events) {
const x = event.pageX;
const y = event.pageY;
// draw a line using x and y coordinates.
}
});
接下来?
在这个系列中,我们深入的了解了浏览器是如何将一个网页呈现在屏幕上,对于为什么 DevTools
建议开发者在事件上加 passive: true
,为什么要在 script
上需要加 async
属性。通过这个系列的文章,大家也应该明白浏览器需要这些信息才能提供更快,更流畅的 Web
体验。
使用 Lighthouse
如果你想优化你的代码,但又不知道从何做起。使用 lighthouse
是一个不错的选择。它可以检查你的网页,生成一个需要改进的列表。通过该列表你就可以知道浏览器需要你做什么,才能给用户一个流畅的体验。
衡量网页性能
不同网站对性能的要求会有所不同,因此衡量网站性能并制定出相应的策略至关重要。至于如何制定,可以查看 Chrome DevTools
团队的 how to measure your site's performance 这篇文章。
在站点中添加 Feature Policy
Feature Policy
是 Web 平台一个新的功能,可以确保应用顺利构建。启用功能策略可确保应用程序稳定的运行。比如,你想让 javascript
代码不中断文档解析,开启 synchronous scripts policy
(同步脚本策略)即可。 当启用 sync-script:'none'
时,解析器将会阻止 javascript
执行。这样你就可以不用考虑 javascript
代码是否会修改文档,浏览器也不用担心 javascript
的执行导致 DOM
发生变化。
总结
当我们开始构建网站是,我们往往只关心如何去编写代码,和寻找生产力工具来提高效率。但是考虑浏览器是如何处理我们编写的代码也是非常重要的。现代浏览器持续投入了大量的资源,为用户提供了良好的 Wed
体验。但是通过编写良好的代码,同样也能提升用户体验,这是我们共同的目标。