console.info

本文翻译自 Google 的官方文档,该系列共 4 篇文章,从内部观察现代浏览器 (Chrome),同时解答了浏览器的内部架构,讲述了浏览器从输入 url 到页面呈现的全过程。

原文链接: inside-browser-part4


前言

这是 从内部观察现代浏览器 (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 元素。

合成线程寻找需要触发事件的元素
合成线程寻找需要触发事件的元素

降低触发频率

在上一篇文章中,我们讨论过,通常屏幕的刷新频率为 160 帧,为确保流程的页面效果,代码也应该按照这个频率执行,但是在触摸屏上,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 体验。但是通过编写良好的代码,同样也能提升用户体验,这是我们共同的目标。