深入了解现代浏览器-系列四

574 阅读7分钟

合成器处理输入事件

这是关于浏览器内部原理系列(研究浏览器如何处理我们的代码来渲染网页)的最后一部分。上一篇文章中,我们研究了渲染过程并了解了合成器。在这篇文章,我们将研究合成器如何实现在处理用户输入的同时保持网页流畅的交互。

从浏览器的角度看输入事件

你可能认为“输入事件”只是在文本框中输入字符或单击鼠标,但从浏览器的角度来看,输入是来自用户的任何操作。鼠标滚轮滚动、鼠标悬停或触摸等都是输入事件。

当用户在屏幕上进行触摸等手势时,浏览器进程首先接收该手势。但是,浏览器进程只知道该手势发生的位置,因为选项卡内的内容由渲染器进程处理。因此浏览器进程将事件类型(如touchstart)及其坐标发送到渲染器进程。渲染器进程通过查找事件目标并运行其事件监听器来处理事件。

image.png 图 1:输入事件通过浏览器进程发送到渲染器进程

合成器接收输入事件

在上一篇文章中,我们研究了合成器如何通过合成光栅化图层来平滑地处理滚动。如果没有事件监听器附加到 DOM,合成器线程可以创建一个完全独立于主线程的新合成帧。但是如果某些事件监听器附加到 DOM 上呢?合成器线程如何确定事件是否需要处理?

理解非快速滚动区域

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

image.png 图 3:非快速滚动区域的描述输入图

编写事件处理程序的注意事项

Web 开发中常见的事件处理模式是事件委托。由于事件冒泡,可以在最顶层元素附加一个事件处理程序,并根据事件目标委派任务。我们经常能看到或编写如下代码。

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

由于只需为所有元素编写一个事件处理程序,因此这种事件委托模式很具有吸引力。但是,如果从浏览器的角度来看这段代码,结果是整个页面都被标记为非快速可滚动区域。这意味着即使应用程序不关心来自页面某些部分的输入事件,合成器线程也必须与主线程通信并在每次输入事件进入时等待它。如此,合成器的平滑滚动能力就失效了。

image.png 图 4:覆盖整个页面的非快速可滚动区域

为了避免这种情况发生,我们可以在事件监听器中传递 passive: true 选项。这向浏览器暗示我们仍然希望在主线程中侦听事件,但合成器也可以继续合成新帧。

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

检查事件是否可以取消

image.png

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

想象这样一个场景,页面中有一个框,我们希望这个框内可以发生水平滚动。

在监听 pointermove 事件中使用 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;
}

寻找事件目标

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

当合成器线程向主线程发送输入事件时,主线程首先要做命中测试(hit test)来找到事件目标。 命中测试使用渲染过程中生成的绘制记录数据来找出发生事件的点坐标下方的内容。

发送最少的事件到主线程

在上一篇文章中,我们讨论了常见的显示器每秒刷新屏幕 60 次,以及我们需要如何做来跟上这个频率来展示流畅的动画。对于输入事件,常见的触摸屏设备每秒会产生 60-120 次触摸事件,常见的鼠标每秒产生 100 次事件。输入事件的触发频率高于我们的屏幕的刷新频率。

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

image.png 图 7:事件过多的涌向一个帧的时间段内导致页面卡顿

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

image.png 图 8:与之前相同的时间线,但事件被合并和延迟

任何不连续事件,如keydownkeyupmouseupmousedowntouchstarttouchend 都会立即调度。

使用 getCoalescedEvents 获取帧内事件

对于大多数 Web 应用,合并事件足以提供良好的用户体验。但如果你正在构建,诸如绘图应用程序或基于 touchmove 坐标绘制路径之类的东西,可能会丢失绘制平滑线条的中间坐标。在这种情况下,你可以在指针事件中使用 getCoalescedEvents 方法来获取有关这些合并事件的信息。

image.png 图 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.
    }
});

下一步

在本系列中,我们介绍了 Web 浏览器的内部工作原理。如果你从未想过为什么 DevTools 建议在事件处理程序中添加 {passive: true},或者为什么可能要在脚本标签中写入 async 属性,我希望这个系列能够阐明为什么浏览器需要这些信息来提供更快更流畅的网站体验。

使用 Lighthouse

如果想让你的代码对浏览器友好,但又不知道从哪里开始,Lighthouse 是一种可以对任何网站进行审核(audit)的工具,并且会提供一份关于哪些方面做得好以及哪些方面需要改进的报告。通读审核列表还可以让你了解浏览器关心的事情。

了解如何衡量性能

不同网站的性能优化手段会有所不同,因此衡量网站的性能并确定最适合的方法至关重要。Chrome DevTools 团队有一些关于如何衡量网站的性能 的教程。

向你的网站添加 Feature Policy

如果你想更进一步优化网站,Feature Policy 是一项新的 Web 平台功能,可以在你构建项目时为你提供护栏。启用 feature policy 可保证应用程序拥有特定的行为并防止你犯错误。 例如,如果你想确保应用永远不会阻止解析,你可以在同步脚本策略(synchronous scripts policy)上运行应用。 启用 sync-script: 'none' 时,阻塞解析的 JavaScript 将不会被执行。 这可以防止你的任何代码阻塞解析器,浏览器无需考虑是否要暂停解析器。

总结

image.png

当我开始构建网站时,我几乎只关心如何编写代码以及如何提高工作效率。这些东西很重要,但我们也应该考虑浏览器如何处理我们编写的代码。现代浏览器一直并将继续致力于为用户提供更好的网络体验。通过组织我们的代码对浏览器友好,反过来又可以改善用户体验。我希望你和我一起努力对浏览器友好!(be nice to the browsers!)

出处

本文为 Inside look at modern web browser (part 3) 的译文,若想要有更好的理解请查看原文。