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

Mariko Kosaka

输入即将到达合成器

这是深入探究 Chrome 的 4 篇博文系列的最后一篇,我们将探究 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});

或者,您也可以使用 touch-action 等 CSS 规则来彻底消除事件处理脚本。

#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 属性,我希望本系列文章能帮助您了解浏览器为何需要这些信息来提供更快速、更流畅的 Web 体验。

使用 Lighthouse

如果您希望自己的代码对浏览器友好,但不知道从何入手,不妨使用 Lighthouse 这款工具来对任何网站运行审核,并生成报告,指出哪些做法正确、哪些需要改进。仔细阅读审核列表,您还可以了解浏览器关注哪些方面。

了解如何衡量效果

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

在您的网站上添加功能政策

如果您想采取额外的措施,不妨使用功能政策这项新的 Web 平台功能,将其作为构建项目时的保障措施。启用功能政策可保证应用的特定行为,并防止您出错。例如,如果您想确保应用绝不会阻止解析,可以采用同步脚本政策来运行应用。启用 sync-script: 'none' 后,系统会阻止执行解析器阻塞型 JavaScript。这样可以防止任何代码阻塞解析器,浏览器也不必担心暂停解析器。

小结

谢谢

刚开始构建网站时,我几乎只关心如何编写代码以及如何提高工作效率。这些方面很重要,但我们还应考虑浏览器如何处理我们编写的代码。现代浏览器一直在不断投资,以便为用户提供更好的网络体验。通过整理代码来善待浏览器,进而改善用户体验。希望您能加入我,一起为浏览器提供良好的体验!

非常感谢审核本系列图书早期草稿的所有人,包括(但不限于):Alex RussellPaul IrishMeggin KearneyEric BidelmanMathias BynensAddy OsmaniKinuko YasudaNasko Oskov 和 Charlie Reis。

您喜欢本系列文章吗?如果您对日后发布的博文有任何疑问或建议,欢迎在下方的评论区留言,或在 Twitter 上通过 @kosamari 与我联系。