深入了解现代浏览器四


Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第四篇。

概述

前几章介绍了浏览器的基础进程、线程以及它们之间协同的关系,并重点说到了渲染进程是如何处理页面绘制的,那么最后一章也就深入到了浏览器是如何处理页面中事件的。

全篇站在浏览器实现的视角思考问题,非常有趣。

输入进入合成器

这是第一小节的标题。乍一看可能不明白在说什么,但这句话就是本文的核心知识点。为了更好的理解这句话,先要解释输入与合成器是什么:

  • 输入:不仅包括输入框的输入,其实所有用户操作在浏览器眼中都是输入,比如滚动、点击、鼠标移动等等。
  • 合成器:第三节说过的,渲染的最后一步,这一步在 GPU 进行光栅化绘图,如果与浏览器主线程解耦的化效率会非常高。

所以输入进入合成器的意思是指,在浏览器实际运行的环境中,合成器不得不响应输入,这可能会导致合成器本身渲染被阻塞,导致页面卡顿。

“non-fast” 滚动区域

由于 js 代码可以绑定事件监听,而且事件监听中存在一种 preventDefault() 的 API 可以阻止事件的原生效果比如滚动,所以在一个页面中,浏览器会对所有创建了此监听的区块标记为 “non-fast” 滚动区域。

注意,只要创建了 onwheel 事件监听就会标记,而不是说调用了 preventDefault() 才会标记,因为浏览器不可能知道业务什么时候调用,所以只能一刀切。

为什么这种区域被称为 “non-fast”?因为在这个区域触发事件时,合成器必须与渲染进程通信,让渲染进程执行 js 事件监听代码并获得用户指令,比如是否调用了 preventDefault() 来阻止滚动?如果阻止了就终止滚动,如果没有阻止才会继续滚动,如果最终结果是不阻止,但这个等待时间消耗是巨大的,在低性能设备比如手机上,滚动延迟甚至有 10~100ms。

然而这并不是设备性能差导致的,因为滚动是在合成器发生的,如果它可以不与渲染进程通信,那么即便是 500 元的安卓机也可以流畅的滚动。

注意事件委托

更有意思的是,浏览器支持一种事件委托的 API,它可以将事件委托到其父节点一并监听。

这本是一个非常方便的 API,但对浏览器实现可能是一个灾难:

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

如果浏览器解析到上面的代码,只能用无语来形容。因为这意味着必须对全页面都进行 “non-fast” 标记,因为代码委托的是整个 document!这会导致滚动非常慢,因为在页面任何地方滚动都要发生一次合成器与渲染进程的通信。

所以最好的办法就是不要写这种监听。但还有一种方案是,告诉浏览器你不会 preventDefault(),这是因为 chrome 通过对应用源码统计后发现,大约 80% 的事件监听没有 preventDefault(),而仅仅是做别的事情,所以合成器应该可以与渲染进程的事件处理并行进行,这样既不卡顿,逻辑也不会丢失。所以添加了一种 passive: true 的标记,标识当前事件可以并行处理:

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

这样就不会卡顿了,但 preventDefault() 也会失效。

检查事件是否可取消

对于 passive: true 的情况,事件就实际上变得不可取消了,所以我们最好在代码里做一层判断:

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

然而这仅仅是阻止执行没有意义的 preventDefault(),并不能阻止滚动。这种情况下,最好的办法是通过 css 申明来阻止横向移动,因为这个判断不会发生在渲染进程,所以不会导致合成器与渲染进程的通信:

#area {
  touch-action: none;
}

事件合并

由于事件触发频率可能比浏览器帧率还要高(1 秒 120 次),如果浏览器坚持对每个事件都进行响应,而一次事件都必须在 js 里响应一次的话,会导致大量事件阻塞,因为当 FPS 为 60 时,一秒也仅能执行 60 次事件响应,所以事件积压是无法避免的。

为了解决这个问题,浏览器在针对可能导致积压的事件,比如滚动事件时,将多个事件合并到一次 js 中,仅保留最终状态。

如果不希望丢掉事件中间过程,可以使用 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.
  }
});

精读

只要我们认识到事件监听必须运行在渲染进程,而现代浏览器许多高性能 “渲染” 其实都在合成层采用 GPU 做,所以看上去方便的事件监听肯定会拖慢页面流畅度。

但就这件事在 React 17 中有过一次讨论 Touch/Wheel Event Passiveness in React 17(实际上在即将到来的 18 该问题还在讨论中 React 18 not passive wheel / touch event listeners support),因为 React 可以直接在元素上监听 Touch、Wheel 事件,但其实框架采用了委托的方式在 document(后在 app 根节点)统一监听,这就导致了用户根本无从决定事件是否为 passive,如果框架默认 passive,会导致 preventDefault() 失效,否则性能得不到优化。

就结论而言,React 目前还是对几个受影响的事件 touchstart touchmove wheel 采用 passive 模式,即:

const Test = () => (
  <div
    // 没有用的,无法阻止滚动,因为委托处默认 passive
    onWheel={event => event.preventDefault()}
  >
    ...
  </div>
)

虽然结论如此而且对性能友好,但并不是一个让所有人都能满意的方案,我们看看当时 Dan 是如何思考,并给了哪些解决方案的。

首先背景是,React 16 事件委托绑定在 document 上,React 17 事件委托绑定在 App 根节点上,而根据 chrome 的优化,绑定在 document 的事件委托默认是 passive 的,而其它节点的不会,因此对 React 17 来说,如果什么都不做,仅改变绑定节点位置,就会存在一个 Break Change。

  1. 第一种方案是坚持 Chrome 性能优化的精神,委托时依然 pasive 处理。这样处理至少和 React 16 一样,preventDefault() 都是失效的,虽然不正确,但至少不是 BreakChange。
  2. 第二种方案即什么都不做,这导致原本默认 passive 的因为绑定到非 document 节点上而 non-passive 了,这样做不仅有性能问题,而且 API 会存在 BreackChange,虽然这种做法更 “原生”。
  3. touch/wheel 不再采用委托,意味着浏览器可以有更少的 “non-fast” 区域,而 preventDefault() 也可以生效了。

最终选择了第一个方案,因为暂时不希望在 React API 层面出现行为不一致的 BreakChange。

然而 React 18 是一次 BreakChange 的时机,目前还没有进一步定论。

总结

从浏览器角度看待问题会让你具备上帝视角而不是开发者视角,你不会再觉得一些奇奇怪怪的优化逻辑是 Hack 了,因为你了解浏览器背后是如何理解与实现的。

不过我们也会看到一些和实现强绑定的无奈,在前端开发框架实现时造成了不可避免的困扰。毕竟作为一个不了解浏览器实现的开发者,自然会认为 preventDefault() 绑定在滚动事件时,一定可以阻止默认滚动行为呀,但为什么因为:

  • 浏览器分为合成层和渲染进程,通信成本较高导致滚动事件监听会引发滚动卡顿。
  • 为了避免通信,浏览器默认为 document 绑定开启 passive 策略减少 “non-fast” 区域。
  • 开启了 passive 的事件监听 preventDefault() 会失效,因为这层实现在 js 里而不是 GPU。
  • React16 采用事件代理,把元素 onWheel 代理到 document 节点而非当前节点。
  • React17 将 document 节点绑定下移到了 App 根节点,因此浏览器优化后的 passive 失效了。
  • React 为了保持 API 不发生 BreakChange,因此将 App 根节点绑定的事件委托默认补上了 passive,使其表现与绑定在 document 一样。

总之就是 React 与浏览器实现背后的纠纷,导致滚动行为阻止失效,而这个结果链条传导到了开发者身上,而且有明显感知。但了解背后原因后,你应该能理解一下 React 团队的痛苦吧,因为已有 API 确实没有办法描述是否 passive 这个行为,所以这是个暂时无法解决的问题。

讨论地址是:精读《深入了解现代浏览器四》· Issue #381 · dt-fe/weekly

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


Author: xt_xiong
转载要求: 如有转载请注明出处 :根据 CC BY 4.0 告知来自 xt_xiong !
评论
  标题