[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 = () => (