深入了解现代网络浏览器(第 4 部分)

小作幸子

输入将传送到合成器

本系列博文共 4 部分,深入探讨 Chrome 内部,本文将为您介绍 Chrome 如何处理用于显示网站的代码。在上一篇博文中,我们介绍了渲染过程并了解合成器。在本博文中,我们将介绍合成器如何在用户输入内容时实现流畅的互动。

来自浏览器的输入事件

当您听到“输入事件”时,您可能只会想到在文本框内输入内容或点击鼠标,但从浏览器的角度来看,输入是指用户执行的任何手势。鼠标滚轮滚动是一种输入事件,触摸或鼠标悬停也是输入事件。

当发生用户手势(如轻触屏幕)时,浏览器进程是最先收到该手势的进程。不过,浏览器进程只会知道该手势的发生位置,因为标签页中的内容是由渲染程序进程处理的。因此,浏览器进程会将事件类型(例如 touchstart)及其坐标发送到渲染程序进程。渲染程序进程通过查找事件目标并运行附加的事件监听器来适当地处理事件。

输入事件
图 1:通过浏览器进程路由到渲染程序进程的输入事件

合成器接收输入事件

图 2:悬停在页面图层上的视口

在上一篇博文中,我们了解了合成器如何通过合成光栅化图层来流畅地处理滚动。如果页面未附加任何输入事件监听器,合成器线程可以完全独立于主线程创建新的复合帧。但是,如果页面中附加了一些事件监听器,该怎么办?合成器线程如何确定是否需要处理该事件?

了解不可快速滚动的区域

由于运行 JavaScript 是主线程的作业,因此在合成页面时,合成器线程会将附加了事件处理程序的页面区域标记为“非快速滚动区域”。有了这些信息,合成器线程可以确保在输入事件发生时将该输入事件发送到主线程。如果输入事件来自此区域之外,则合成器线程会继续合成新帧,而不会等待主线程。

有限的非快速滚动区域
图 3:非快速可滚动区域的上述输入示意图

编写事件处理脚本时请注意

Web 开发中常见的事件处理模式是事件委托。由于事件会以气泡形式显示,因此您可以在最顶层的元素附加一个事件处理脚本,并根据事件目标委派任务。您可能已经看到或编写过如下代码。

document.body.addEventListener('touchstart', event => {
    if (event.target === area) {
        event.preventDefault();
    }
});

由于您只需为所有元素编写一个事件处理脚本,因此此事件委托模式的工效学设计非常具有吸引力。不过,如果您从浏览器的角度查看此代码,就会发现整个页面现在会被标记为非快速滚动区域。这意味着,即使应用并不关注来自页面某些部分的输入,合成器线程也必须与主线程通信,并在每次有输入事件传入时等待。因此,合成器的流畅滚动功能会受到影响。

整页不可快速滚动区域
图 4:覆盖整个页面的非快速可滚动区域的所描述输入示意图

为避免发生这种情况,您可以在事件监听器中传递 passive: true 选项。这会提示浏览器您仍想监听主线程中的事件,但合成器也可以继续合成新帧。

document.body.addEventListener('touchstart', event => {
    if (event.target === area) {
        event.preventDefault()
    }
 }, {passive: true});

检查活动是否可以取消

页面滚动
图 5:部分页面固定为水平滚动的网页

假设您在网页上有一个框,您希望将滚动方向限制为仅水平滚动。

在指针事件中使用 passive: true 选项意味着页面可以流畅滚动,但垂直滚动可能在您希望 preventDefault 时开始,以限制滚动方向。您可以使用 event.cancelable 方法对此进行检查。

document.body.addEventListener('pointermove', event => {
    if (event.cancelable) {
        event.preventDefault(); // block the native scroll
        /*
        *  do what you want the application to do here
        */
    }
}, {passive: true});

或者,您也可以使用 CSS 规则(如 touch-action)来完全删除事件处理脚本。

#area {
  touch-action: pan-x;
}

查找事件目标

点击测试
图 6:查看绘制记录的主线程,询问在 x.y 点上绘制了什么

当合成器线程向主线程发送输入事件时,首先要运行的是查找事件目标的命中测试。点击测试会使用在渲染过程中生成的绘制记录数据,找出事件发生点坐标下方的内容。

尽量减少分派给主线程的事件

在上一篇博文中,我们讨论了典型的显示屏每秒刷新 60 次屏幕,以及如何跟上流畅动画的节奏。对于输入,典型触摸屏设备每秒传送触摸事件 60-120 次,典型鼠标每秒传送事件 100 次。输入事件的保真度高于屏幕可以刷新的保真度。

如果像 touchmove 这样的连续事件每秒发送到主线程 120 次,则可能会触发过多的命中测试和 JavaScript 执行(与屏幕刷新速度相比)。

未经过滤的事件
图 7:在帧时间轴上泛洪导致页面卡顿的事件

为了最大限度地减少对主线程的过多调用,Chrome 会合并连续事件(例如 wheelmousewheelmousemovepointermovetouchmove),并将调度延迟到下一个 requestAnimationFrame 之前。

合并的事件
图 8:与之前相同的时间轴,但事件合并并延迟

系统会立即分派 keydownkeyupmouseupmousedowntouchstarttouchend 等离散事件。

使用 getCoalescedEvents 获取帧内事件

对于大多数 Web 应用,合并事件应足以提供良好的用户体验。不过,如果您要构建应用以及根据 touchmove 坐标设置路径等,为了绘制平滑线条,可能会丢失两者之间的坐标。在这种情况下,您可以在指针事件中使用 getCoalescedEvents 方法来获取有关这些合并事件的信息。

getCoalescedEvents
图 9:左侧是平滑触摸手势路径,右侧是合并的有限路径
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.
    }
});

后续步骤

在本系列视频中,我们介绍了网络浏览器的内部工作原理。如果您未曾想过开发者工具为何建议在事件处理脚本中添加 {passive: true},或者为何可能会在脚本标记中编写 async 属性,希望本系列文章能帮助您解释为什么浏览器需要这些信息来提供更快、更顺畅的网络体验。

使用 Lighthouse

如果您希望让代码更适合浏览器使用,但却不知道从何处着手,可以使用 Lighthouse 这款工具对任何网站进行审核,并为您提供有关正确做法和需要改进的报告。浏览审核列表还可以让您了解浏览器关注的方面。

了解如何衡量效果

性能调整可能会因网站而异,因此请务必衡量网站的性能,并确定最适合您网站的方案。Chrome 开发者工具团队提供了一些有关如何衡量网站性能的教程。

向您的网站添加功能政策

如果您想执行额外的步骤,功能政策是一项新的 Web 平台功能,可在构建项目时为您提供保障。开启功能政策可保证您的应用的特定行为,并防止您犯错。例如,如果您希望确保应用永远不会阻止解析,则可以使用同步脚本政策运行应用。启用 sync-script: 'none' 后,系统将阻止执行阻止解析器的 JavaScript。这可以防止您的任何代码阻止解析器,而且浏览器无需费心暂停解析器。

小结

谢谢

开始构建网站时,我几乎只关心如何编写代码以及哪些方法可以提高我的工作效率。这些都很重要,但我们还应考虑浏览器如何接受我们编写的代码。现代浏览器一直在并将继续致力于为用户提供更好的网络体验。通过整理我们的代码来保持对浏览器友好的态度反过来又可以提升您的用户体验。希望您和我一起追求善待浏览器!

衷心感谢审核本系列初稿的所有人,包括(但不限于):Alex RussellPaul IrishMeggin KearneyEric BidelmanMathias BynensAddy OsmaniKinuko Charandiskovli.osuda

你喜欢这个系列吗?如果您对日后发布的博文有任何疑问或建议,欢迎通过下方的评论部分或 Twitter 上的 @kosamari 提出您的想法。