当前位置: 首页 > Web前端 > JavaScript

精读《深入了解现代浏览器四》

时间:2023-03-27 11:30:53 JavaScript

Insidelookatmodernwebbrowser是介绍浏览器实现原理的系列文章。共有4篇文章。本次精读介绍第四篇。概述前几章介绍了浏览器的基本进程、线程以及它们之间的协作关系,重点介绍了渲染进程是如何处理页面绘制的,所以最后一章也深入探讨了浏览器是如何处理页面中的事件的。整篇文章都站在浏览器实现的角度来思考问题,很有意思。输入合成器这是第一个小节的标题。乍一看,你可能不明白你在说什么,但这句话是本文的核心知识点。为了更好的理解这句话,先解释一下input和synthesizer是什么:input:不仅仅包括输入框的输入,实际上所有的用户操作在浏览器眼里都是输入,比如滚动,点击,鼠标运动等等。Compositor:在第三节中提到,渲染的最后一步是在GPU上进行光栅化。如果与浏览器主线程解耦,效率会很高。所以输入到合成器中意味着在浏览器实际运行的环境中,合成器要响应输入,这可能会导致合成器本身被渲染阻塞,导致页面卡顿。“非快速”滚动区域由于js代码可以绑定事件监听器,事件监听器中有一个preventDefault()API可以防止滚动等事件的原生效果,所以在一个页面中,浏览器会把所有创建的此侦听块被标记为“非快速”滚动区域。注意只要创建了onwheel事件监听,就会被标记,而不是调用preventDefault()的时候,因为浏览器不可能知道业务什么时候调用的,所以只能一刀切。为什么这个区域被称为“非快速”?因为当这个区域有事件触发时,合成器必须和渲染进程通信,让渲染进程执行js事件监听代码,获取用户指令,比如是否调用preventDefault()来防止滚动?如果被阻塞,则滚动终止,如果没有被阻塞,则继续滚动。如果最终结果没有被阻塞,但是这个等待时间消耗是巨大的。在手机等低性能设备上,滚动延迟甚至有10-100ms。不过这并不是设备性能不好造成的,因为滚动发生在合成器中,如果不能和渲染进程通信,那么即使是500元的安卓机也能流畅滚动。更有趣的是,事件委托是浏览器支持一个事件委托API,它可以将事件委托给它们的父节点并监听它们。这是一个非常方便的API,但对于浏览器实现来说可能是一场灾难:document.body.addEventListener('touchstart',event=>{if(event.target===area){event.preventDefault();}});如果浏览器解析上面的代码,只能用无语来形容。因为这意味着必须将整个页面标记为“非快速”,因为代码委托给了整个文档!这会导致滚动非常慢,因为每次滚动页面时都会发生合成器-渲染器进程通信。所以最好的办法就是不要写这种监控。但是另一种解决方案是告诉浏览器你不会preventDefault(),这是因为chrome在对应用程序源码进行统计后发现大约80%的事件监听器没有preventDefault(),而只是做其他事情,所以synthetic处理程序应该能够与渲染过程的事件处理并行运行,这样既不会卡顿,也不会丢失逻辑。所以添加了一个passive:true标志,表示当前事件可以并行处理:document.body.addEventListener('touchstart',event=>{if(event.target===area){event.preventDefault()}},{被动:真});这样虽然不会卡死,但是preventDefault()也会失效。检查事件是否可以取消。在passive:true的情况下,事件实际上变成了不可撤销的,所以我们最好在代码中进行判断:document.body.addEventListener('touchstart',event=>{if(event.cancelable&&event.target===area){event.preventDefault()}},{passive:true});但是,这只是阻止了无意义的preventDefault()的执行,并没有阻止滚动。在这种情况下,最好的办法就是通过css声明来防止水平移动,因为这个判断不会发生在渲染过程中,所以不会导致合成器和渲染进程通信:#area{touch-action:pan-x;}事件合并因为事件触发频率可能高于浏览器帧率(每秒120次),如果浏览器硬要对每个事件进行响应,而在js中每个事件必须响应一次,会造成很大的影响阻塞的事件个数,因为当FPS为60时,一秒钟只能进行60个事件响应,所以事件积压是不可避免的。为了解决这个问题,浏览器在针对滚动事件等可能造成积压的事件时,将多个事件合并到一个js中,只保留最终状态。如果不想丢失事件的中间过程,可以使用getCoalescedEvents从合并事件中获取事件每一步的状态:window.addEventListener('pointermove',event=>{constevents=event.getCoalescedEvents();for(leteventofevents){constx=event.pageX;consty=event.pageY;//使用x和y坐标画一条线。}});只要我们意识到事件监听器必须在渲染过程中运行,而现代浏览器很多性能“渲染”实际上是由GPU在合成层完成的,所以看似方便的事件监听肯定会拖慢页面的流畅度。但是在React17Touch/WheelEventPassivenessinReact17中有讨论过这个问题(其实这个问题在即将到来的18React18notpassivewheel/toucheventlistenerssupport中还在讨论中),因为React可以MonitorTouch而Wheel事件直接在元素上,但实际上框架是采用委托的方式在文档中(后面在app的根节点)统一监听,这样用户就无法判断事件是否是被动的。如果框架默认为passive,会导致preventDefault()无效,否则性能不会优化。就结论而言,React对几个受影响的事件touchstarttouchmovewheel还是采用被动方式,即:constTest=()=>(

event.preventDefault()}>...
)虽然结论是这样,而且性能友好,但并不是一个让所有人都满意的方案。看看Dan当时是怎么想的,又给出了哪些解决方案。首先介绍一下背景,React16的事件委托绑定的是文档,React17的事件委托绑定的是App根节点。根据chrome的优化,document绑定的事件委托默认是被动的,而其他节点的事件委托则不是,所以对于React17,如果只改变绑定节点的位置什么都不做,就会出现Break改变。第一种解决方案是坚持Chrome性能优化的精神,在委托时仍然进行被动处理。这至少和React16一样,preventDefault()无效,虽然不正确,但至少不是BreakChange。第二种解决方案是什么都不做,这会导致默认的被动是非被动的,因为它绑定到非文档节点。这不仅有性能问题,而且API也会有BreakChange,虽然这种做法更“原生”。touch/wheel不再使用delegation,这意味着浏览器可以有更少的“非快速”区域,而preventDefault()也可以生效,最后选择了第一个方案,因为在ReactAPI层面行为不一致的BreakChange暂时不期待,但是React18是BreakChange的一个机会,目前还没有进一步的定论总而言之,站在浏览器的角度看问题,会让你拥有上帝的视角,而不是开发者的视角,你不会再觉得一些奇葩的优化逻辑是hack,因为你了解浏览器是如何理解和实现的。但是,我们也会在强绑定的实现上看到一些无奈,这在前端开发框架实现的时候造成了不可避免的麻烦。毕竟作为一个不懂浏览器实现的开发者,自然会认为preventDefault()在绑定滚动事件的时候可以阻止默认的滚动行为,但为什么呢,因为:浏览器分为一个组合层和一个渲染进程,通信成本比较高导致滚动事件监听导致滚动卡顿。为了避免通信,浏览器默认启用文档绑定的被动策略以减少“非快速”区域。启用passive的事件监听器preventDefault()会失效,因为这一层是js实现的,不是GPU。React16使用事件代理将元素onWheel委托给文档节点而不是当前节点。React17将文档节点绑定下移到App根节点,因此浏览器优化被动无效。为了不让API被BreakChange影响,React默认给App根节点绑定的事件委托添加passive,使其行为与绑定document一样。简而言之,就是React和浏览器的实现背后的纷争,导致了滚动行为预防的失败,而这个结果链传递给了开发者,有明显的感知。但是了解了背后的原因之后,你应该能理解React团队的痛点,因为现有的API实在是没有办法描述这种行为是否是被动的,所以这是一个暂时无法解决的问题。讨论地址是:Jingdu《深入了解现代浏览器四》·Issue#381·dt-fe/weekly想参与讨论的请点这里,每周都有新话题,周末或周一发布。前端精读——帮你过滤靠谱的内容。关注前端精读微信公众号版权声明:免费转载-非商业-非衍生保留属性(CreativeCommons3.0License)