[Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part4) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第四篇。 ## 概述 前几章介绍了浏览器的基础进程、线程以及它们之间协同的关系,并重点说到了渲染进程是如何处理页面绘制的,那么最后一章也就深入到了浏览器是如何处理页面中事件的。 全篇站在浏览器实现的视角思考问题,非常有趣。 ### 输入进入合成器 这是第一小节的标题。乍一看可能不明白在说什么,但这句话就是本文的核心知识点。为了更好的理解这句话,先要解释输入与合成器是什么: - 输入:不仅包括输入框的输入,其实所有用户操作在浏览器眼中都是输入,比如滚动、点击、鼠标移动等等。 - 合成器:第三节说过的,渲染的最后一步,这一步在 GPU 进行光栅化绘图,如果与浏览器主线程解耦的化效率会非常高。 所以输入进入合成器的意思是指,在浏览器实际运行的环境中,合成器不得不响应输入,这可能会导致合成器本身渲染被阻塞,导致页面卡顿。 ### "non-fast" 滚动区域 由于 js 代码可以绑定事件监听,而且事件监听中存在一种 `preventDefault()` 的 API 可以阻止事件的原生效果比如滚动,所以在一个页面中,浏览器会对所有创建了此监听的区块标记为 "non-fast" 滚动区域。 注意,只要创建了 `onwheel` 事件监听就会标记,而不是说调用了 `preventDefault()` 才会标记,因为浏览器不可能知道业务什么时候调用,所以只能一刀切。 为什么这种区域被称为 "non-fast"?因为在这个区域触发事件时,合成器必须与渲染进程通信,让渲染进程执行 js 事件监听代码并获得用户指令,比如是否调用了 `preventDefault()` 来阻止滚动?如果阻止了就终止滚动,如果没有阻止才会继续滚动,如果最终结果是不阻止,但这个等待时间消耗是巨大的,在低性能设备比如手机上,滚动延迟甚至有 10~100ms。 然而这并不是设备性能差导致的,因为滚动是在合成器发生的,如果它可以不与渲染进程通信,那么即便是 500 元的安卓机也可以流畅的滚动。 ### 注意事件委托 更有意思的是,浏览器支持一种事件委托的 API,它可以将事件委托到其父节点一并监听。 这本是一个非常方便的 API,但对浏览器实现可能是一个灾难: ```js document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault(); } }); ``` 如果浏览器解析到上面的代码,只能用无语来形容。因为这意味着必须对全页面都进行 "non-fast" 标记,因为代码委托的是整个 document!这会导致滚动非常慢,因为在页面任何地方滚动都要发生一次合成器与渲染进程的通信。 所以最好的办法就是不要写这种监听。但还有一种方案是,告诉浏览器你不会 `preventDefault()`,这是因为 chrome 通过对应用源码统计后发现,大约 80% 的事件监听没有 `preventDefault()`,而仅仅是做别的事情,所以合成器应该可以与渲染进程的事件处理并行进行,这样既不卡顿,逻辑也不会丢失。所以添加了一种 `passive: true` 的标记,标识当前事件可以并行处理: ```js document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault() } }, {passive: true}); ``` 这样就不会卡顿了,但 `preventDefault()` 也会失效。 ### 检查事件是否可取消 对于 `passive: true` 的情况,事件就实际上变得不可取消了,所以我们最好在代码里做一层判断: ```js document.body.addEventListener('touchstart', event => { if (event.cancelable && event.target === area) { event.preventDefault() } }, {passive: true}); ``` 然而这仅仅是阻止执行没有意义的 `preventDefault()`,并不能阻止滚动。这种情况下,最好的办法是通过 css 申明来阻止横向移动,因为这个判断不会发生在渲染进程,所以不会导致合成器与渲染进程的通信: ```css #area { touch-action: none; } ``` ### 事件合并 由于事件触发频率可能比浏览器帧率还要高(1 秒 120 次),如果浏览器坚持对每个事件都进行响应,而一次事件都必须在 js 里响应一次的话,会导致大量事件阻塞,因为当 FPS 为 60 时,一秒也仅能执行 60 次事件响应,所以事件积压是无法避免的。 为了解决这个问题,浏览器在针对可能导致积压的事件,比如滚动事件时,将多个事件合并到一次 js 中,仅保留最终状态。 如果不希望丢掉事件中间过程,可以使用 `getCoalescedEvents` 从合并事件中找回每一步事件的状态: ```js 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](https://github.com/facebook/react/issues/19651)(实际上在即将到来的 18 该问题还在讨论中 [React 18 not passive wheel / touch event listeners support](https://github.com/facebook/react/issues/22794)),因为 React 可以直接在元素上监听 Touch、Wheel 事件,但其实框架采用了委托的方式在 document(后在 app 根节点)统一监听,这就导致了用户根本无从决定事件是否为 `passive`,如果框架默认 `passive`,会导致 `preventDefault()` 失效,否则性能得不到优化。 就结论而言,React 目前还是对几个受影响的事件 `touchstart` `touchmove` `wheel` 采用 `passive` 模式,即: ```tsx const Test = () => (
event.preventDefault()} > ...
) ``` 虽然结论如此而且对性能友好,但并不是一个让所有人都能满意的方案,我们看看当时 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](https://github.com/dt-fe/weekly/issues/381) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))