Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第一篇。
虽然本文写于 2018 年,但如今依然值得学习,因为浏览器实现非常复杂,从细节开始学习很容易迷失方向,缺乏整体感,而这篇文章从宏观层面开始介绍,几乎没有涉及代码实现,全都是思路性的描述,非常适合培养对浏览器整体框架性思维。
原文有非常多形象的插图与动图,便于加深对知识的理解,所以也推荐直接阅读原文。
文章先从 CPU、GPU、操作系统开始介绍,因为这些是浏览器运行的基座。
CPU 即中央处理器,可以处理几乎所有计算。以前的 CPU 是单核的,现在大部分笔记电脑都是多核的,专业服务器甚至有高达 100 多核的。CPU 计算能力很强,但只能一件件事处理,
GPU 一开始是为图像处理设计的,即主要处理像素点,所以拥有大量并行的处理简单事物的能力,非常适合用来做矩阵运算,而矩阵运算又是计算机图形学的基础,所以大量用在可视化领域。
CPU、GPU 都是计算机硬件,这些硬件各自都提供了一些接口供汇编语言调用;而操作系统则基于它们之上用 C 语言(如 linux)将硬件管理了起来,包括进程调度、内存分配、用户内核态切换等等;运行在操作系统之上的则是应用程序了,所以应用程序不直接和硬件打交道,而是通过操作系统间接操作硬件。
为什么应用程序不能直接操作硬件呢?这样做有巨大的安全隐患,因为硬件是没有任何抽象与安全措施的,这意味着理论上一个网页可以通过 js 程序,在你打开网页时直接访问你的任意内存地址,读取你的聊天记录,甚至读取历史输入的银行卡密码进行转账操作。
显然,浏览器作为一个应用程序,运行在操作系统之上。
为了让程序运行的更安全,操作系统创造了进程与线程的概念(linux 对进程与线程的实现是同一套),进程可以分配独立的内存空间,进程内可以创建多个线程进行工作,这些线程共享内存空间。
因为线程间共享内存空间,因此不需通信就能交流,但内存地址相互隔离的进程间也有通信需求,需通过 IPC(Inter Process Communication)进行通信。
进程之间相互独立,即一个进程挂了不会影响到其它进程,而在一个进程中可以创建一个新进程,并与之通信,所以浏览器就采用了这种策略,将 UI、网络、渲染、插件、存储等模块进程独立,并且任意挂掉后都可以被重新唤起。
浏览器可以拆分为许多独立的模块,比如:
- 浏览器模块(Browser):负责整个浏览器内行为协调,调用各个模块。
- 网络模块(Network):负责网络 I/O。
- 存储模块(Storage):负责本地 I/O。
- 用户界面模块(UI):负责浏览器提供给用户的界面模块。
- GPU 模块:负责绘图。
- 渲染模块(Renderer):负责渲染网页。
- 设备模块(Device):负责与各种本地设备交互。
- 插件模块(Plugin):负责处理各类浏览器插件。
基于这些模块,浏览器有两种可用的架构设计,一种是少进程,一种是多进程。
少进程是指将这些模块放在一个或有限的几个进程里,也就是每个模块一个线程,这样做的好处是最大程度共享了内存空间,对设备要求较低,但问题是只要一个线程挂了都会导致整个浏览器挂掉,因此稳定性较差。
多进程是指为每个模块(尽量)开辟一个进程,模块间通过 IPC 通信,因此任何模块挂掉都不会影响其它模块,但坏处是内存占用较大,比如浏览器 js 解析与执行引擎 V8 就要在这套架构下拷贝多份实例运行在每个进程中。
Chrome 尽量为每个 tab 单独创建一个进程,所以我们才能在某个 tab 未响应时,从容的关闭它,而其它 tab 不会受到影响。不仅是 tab 间,一个 tab 内的 iframe 间也会创建独立的进程,这样做是为了保护网站的安全性。
Chrome 并不满足于采用一种架构,而是在不同环境下切换不同的架构。Chrome 将各功能模块化后,就可以自由决定当前将哪些模块放在一个进程中,将哪些模块启动独立进程,即可以在运行时决定采用哪套进程架构。
这样做的好处是,可以在资源受限的机器上开启单进程模式,以尽量节约内存开销,实际上在手机应用上就是这么做的;而在资源丰富、内核数量充足的机器上采用独立进程模式,虽然消耗了更多资源,但获得了更好的稳定性。
site-isolation 将同一个 tab 内不同 iframe 包裹在不同的进程内运行,以确保 iframe 间资源的独占性,以及安全性。该功能直到 2018.7 才更新,是因为背后有许多复杂的工作要处理,比如开发者工具的调试、网页的全局搜索功能,都不能因为进程的隔离而受到影响,Chrome 必须让每个进程单独响应这些操作,并最终聚合在一起,让用户感受不到进程间的阻隔。
本文从浏览器如何基于操作系统提供的进程、线程概念构建自己的应用程序开始,从硬件、操作系统、软件的分层开始,介绍到浏览器是如何划分模块的,并且分配进程或线程给这些模块运行,这背后的思考非常有价值。
从宏观角度看,要设计一个安全稳定、高性能、具有拓展性的浏览器,首先要把各功能模块划分清楚,并定义好各模块的通信关系,在各业务场景下制定一套模块协作的流程。
类似应用程序的主从模式,浏览器的 Browser 模块可以看作主模块,它本身用于协调其它模块的运行,并维持其它各模块的正常工作,在其它模块失去响应时等待或重新唤起,或者在模块销毁时进行内存回收。
各从模块也分工明确,比如在浏览器敲击 URL 地址时,会先通过 UI 模块响应用户的输入,并判断输入是否为 URL 地址,因为输入的可能是其它非法参数,或一些查询或设置命令。若输入的确实是 URL 地址,则校验通过后,会通知 Network 网络模块发送请求,UI 模块就不再关心请求是如何处理了。Network 模块也是相对独立的,仅处理请求的发送与接收,如果接收到的是 HTML 网页,则交给 Renderer 模块进行渲染。
有了这些相对独立且分工明确的模块划分后,将这些模块作为线程或进程管理就都不会影响它们的业务逻辑了,唯一影响的就是内存是否共享,以及某个模块 crash 后是否会影响到其它模块了,所以基于这个架构,判断设备类型,以采用单进程或多进程模式就变得简单了很多,且这个进程弹性架构本身也不需要入侵各模块业务逻辑,本身就是一套独立的机制。
浏览器作为非常复杂的应用程序,想要持续维护,就必须对每个功能点都进行合理的设计,让模块间高内聚、低耦合,这样才不至于让任何修改牵一发而动全身。
微前端的沙箱隔离方案也比较火,这里可以和浏览器 tab/iframe 隔离做个对比。
基于 js 运行时的沙箱方案大多都因为吐槽 iframe 慢而诞生的,一般会基于 with
改变沙箱代码的上下文,修改访问的全局对象引用,但基于 js 原型链特征,为了阻断向原型链追溯到主应用代码,一般会采用 proxy
对 with
mock 的变量进行访问阻断。
还有一些方案利用创建空 iframe 获取到 document 变量传递给沙箱,一定程度做到了访问隔离,且对 document 添加的监听会随 iframe 销毁而销毁,便于控制。
还有一些更加彻底的尝试,将 js 代码扔到 web worker 运行,并通过 mock 模拟了 worker 运行时缺失的 dom API。
对比这些方案可以发现,只有最后 worker 的方案是最彻底的,因为浏览器创建的 worker 进程是完全资源隔离的,想要和浏览器主线程通信只能利用 postMessage
,虽然有一些基于 ArrayBuffer 的内存共享方案,但因为支持的数据类型具有针对性,也不会存在安全问题。
回到浏览器开发者的视角,为什么 iframe 隔离要花费九牛二虎之力拆分多进程,最后再费很大功夫拼接回来,还原出一个相对无缝的体验?浏览器厂商其实完全可以利用上面提到的 js 运行时能力,对 API 语法进行改造,创建一个逻辑上的沙盒环境。
我认为本质原因是浏览器要实现的沙盒必须是进程层面的,也就是对内存访问权限的绝对隔离,因为逻辑层面的隔离可能随着各浏览器厂商实现差异,或 API 本身存在的逻辑漏洞而导致越权情况的出现,所以如果需要构造一个完全安全的沙盒,最好利用浏览器提供的 API 创建新的进程处理沙盒代码。
本文介绍了浏览器是如何基于操作系统做宏观架构设计的,主要就说了一件事,即对进程,线程模型的弹性使用。同时在 tab、iframe 的设计中也要考虑到安全性要求,在必要的时候采用进程,在浏览器自身模块间因为没有安全性问题,所以可对进程模型进行灵活切换。
Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第二篇。
本篇重点介绍了 浏览器路由跳转后发生了什么,下一篇会介绍浏览器的渲染进程是如何渲染网页的,环环相扣。
在上一篇介绍了,browser process 包含 UI thread、network thread 和 storage thread,当我们在浏览器菜单栏输入网址并敲击回车时,这套动作均由 browser process 的 UI thread 响应。
接下来,按照几种不同的路由跳转场景,分别介绍了内部流程。
第一步,UI thread 响应输入,并判断是否为一个合法的网址,当然输入的也可能是个搜索协议,这就会导致分发到另外的服务处理。
第二步,如果第一步输入的是合法网址,则 UI thread 会通知 network thread 获取网页内容,network thread 会寻找合适的协议处理网络请求,一般会通过 DNS 协议 寻址,通过 TLS 协议 建立安全链接。如果服务器返回了比如 301 重定向信息,network thread 会通知 UI thread 这个信息,再启动一遍第二步。
第三步,读取响应内容,在这一步 network thread 会首先读取首部一些字节,即我们常说的响应头,其中包含 Content-Type 告知返回内容是什么。如果返回内容是 HTML,则 network thread 会将数据传送给 renderer process。这一步还会校验安全性,比如 CORB 或 cross-site 问题。
第四步,寻找 renderer process。一旦所有检查都完成,network thread 会通知 UI thread 已经准备好跳转了(注意此时并没有加载完所有数据,第三步只是检查了首字节),UI thread 会通知实力化 renderer process 进行渲染。为了提升性能,UI thread 在通知 network thread 的同时就会实力化一个 renderer process 等着,一旦 network thread 完毕后就可以立即进入渲染阶段,如果检查失败则丢弃提前实例化的 renderer process。
第五步,确认导航。第四步后,browser process 通过 IPC 向 renderer process 传送 stream(精读《web streams》)数据。此时导航会被确认,浏览器的各个状态(比如导航状态、前进后退历史)将会被修改,同时为了方便 tab 关闭后快速恢复,会话记录会被存储在硬盘。
额外步骤,加载完成。当 renderer process 加载完成后(具体做了什么下一篇会说明),会通知 browser process onLoad
事件,此时浏览器完成最终加载完毕状态,loading 圆圈也会消失,各类 onLoad 的回调触发。注意此时 js 可能会继续加载远程资源,但这都是加载状态完成后的事了。
当你准备跳转到别的网站时,在执行普通跳转流程前,还会响应 beforeunload 事件,这个事件注册在 renderer process,所以 browser process 需要检查 renderer process 是否注册了这个响应。注册 beforeunload
无论如何都会拖慢关闭 tab 的速度,所以如无必要请勿注册。
如果跳转是 js 发出的,那么执行跳转就由 renderer process 触发,browser process 来执行,后续流程就是普通的跳转流程。要注意的是,当执行跳转时,会触发原网站 unload
等事件(网页生命周期),所以这个由旧的 renderer process 响应,而新网站会创建一个新的 renderer process 处理,当旧网页全部关闭时,才会销毁旧的 renderer process。
也就是说,即便只有一个 tab,在跳转时,也可能会在短时间内存在多个 renderer process。
Service Worker 可以在页面加载前执行一些逻辑,甚至改变网页内容,但浏览器仍然把 Service Worker 实现在了 renderer process 中。
当 Service Worker 被注册后,会被丢到一个作用域中,当 UI thread 执行时会检查这个作用域是否注册了 Service Worker,如果有,则 network thread 会创建一个 renderer process 执行 Service Worker(因为是 js 代码)。然后网络响应会被 Service Worker 接管。
但这样会慢一步,所以 UI thread 往往会在注册 Service Worker 的同时告诉 network thread 发送请求,这就是 Navigation Preload 机制。
本文介绍了网页跳转时发生的步骤,涉及 browser process、UI thread、network thread、renderer process 的协同。
也许你会有疑问,为什么是 renderer process 而不是 renderer thread?因为相比 process(进程)相比 thread(线程),之间数据是被操作系统隔离的,为了网页间无法相互读取数据(mysite.com 读取你 baidu.com 正在输入的账号密码),浏览器必须为每个 tab 创建一个独立的进程,甚至每个 iframe 都必须是独立进程。
读完第二篇,应该能更深切的感受到模块间合理分工的重要性。
UI thread 处理浏览器 UI 的展现与用户交互,比如当前加载的状态变化,历史前进后退,浏览器地址栏的输入、校验与监听按下 Enter 等事件,但不会涉及诸如发送请求、解析网页内容、渲染等内容。
network thread 也仅处理网络相关的事情,它主要关心通信协议、安全协议,目标就是快速准确的找到网站服务器,并读取其内容。network thread 会读取内容头做一些前置判断,读取内容和 renderer process 做的事情是有一定重合的,但 network thread 读取内容头仅为了判断内容类型,以便交给渲染引擎还是下载管理器(比如一个 zip 文件),所以为了不让渲染引擎知道下载管理器的存在,读取内容头必须由 network thread 来做。
与 renderer process 的通信也是由 browser process 来做的,也就是 UI thread、network thread 一旦要创建或与 renderer process 通信,都会交由它们所在的 browser process 处理。
renderer process 仅处理渲染逻辑,它不关心是从哪来的,比如是网络请求过来的,还是 Service Worker 拦截后修改的,也不关心当前浏览器状态是什么,它只管按照约定的接口规范,在指定的节点抛出回调,而修改应用状态由其它关心的模块负责,比如 onLoad
回调触发后,browser process 处理浏览器的状态就是一个例子。
再比如 renderer process 里点击了一个新的跳转链接,这个事情发生在 renderer process,但会交给 browser process 处理,因为每个模块解耦的非常彻底,所以任何复杂工作都能找到一个能响应它的模块,而这个模块也只要处理这个复杂工作的一部分,其余部分交给其它模块就好了,这就是大型应用维护的秘诀。
所以在浏览器运行周期里,有着非常清晰的逻辑链路,这些模块必须事先规划设计好,很难想象这些模块分工是在开发中逐渐形成的。
最后提到加速优化,Chrome 惯用技巧就是,用资源换时间。即宁可浪费潜在资源,也要让事物尽可能的并发,这些从提前创建 renderer process、提前发起 network process 都能看出来。
深入了解现代浏览器二介绍了网页跳转时发生的,browser process 与 renderer process 是如何协同的。
也许这篇文章可以帮助你回答 “聊聊在浏览器地址栏输入 www.baidu.com 并回车后发生了什么事儿吧!”
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第三篇。
本篇宏观的介绍 renderer process 做了哪些事情。
浏览器 tab 内 html、css、javascript 内容基本上都由 renderer process 的主线程处理,除了一些 js 代码会放在 web worker 或 service worker 内,所以浏览器主线程核心工作就是解析 web 三剑客并生成可交互的用户界面。
首先 renderer process 主线程会解析 HTML 文本为 DOM(Document Object Model),只译为中文就是文档对象模型,所以首先要把文本结构化才能继续处理。不仅是浏览器,代码的解析也得首先经历 Parse 阶段。
对于 HTML 的 link、img、script 标签需要加载远程资源的,浏览器会调用 network thread 优先并行处理,但遇到 script 标签就必须停下来优先执行,因为 js 代码可能会改变任何 dom 对象,这可能导致浏览器要重新解析。所以如果你的代码没有修改 dom 的副作用,可以添加 async、defer 标签,或 JS 模块的方式使浏览器不必等待 js 的执行。
只有 DOM 是不够的,style 标签申明的样式需要作用在 DOM 上,所以基于 DOM,浏览器要生成 CSSOM,这个 CSSOM 主要是基于 css 选择器(selector)确定作用节点的。
有了 DOM、CSSOM 仍然不足以绘制网页,因为我们仅知道结构和样式,但不知道元素的位置,这就需要生成 LayoutTree 以描述布局的结构。
LayoutTree 和 DOM 结构很像了,但比如 display: none
的元素不会出现在 LayoutTree 上,所以 LayoutTree 仅考虑渲染结构,而 DOM 是一个综合描述结构,它不适合直接用来渲染。
原文特别提到,LayoutTree 有个很大的技术难点,即排版,Chrome 专门有一整个团队在攻克这个技术难题。为什么排版这么难?可以从这几个例子中体会冰山一角:盒模型间碰撞、字体撑开内容导致换行,引发更大区域的重新排版、一个盒模型撑开挤压另一个盒模型,但另一个盒模型大小变化后内容排版也随之变化,导致盒模型再次变化,这个变化又导致了外部其它盒模型的布局变化。
布局最难的地方在于,需要对所有奇奇怪怪的布局定式做一个尽量合理的处理,而很多时候布局定式间规则是相互冲突的。而且这还不考虑布局引擎的修改在数亿网页上引发未知 BUG 的风险。
有了 DOM、CSSOM、LayoutTree 就够了吗?还不行,还缺少最后一环 PaintRecord,这个指绘图记录,它会记录元素的层级关系,以决定元素绘制的顺序。因为 LayoutTree 仅决定了物理结构,但不决定元素的上下空间结构。
有了 DOM、CSSOM、LayoutTree、PaintRecord 之后,终于可以绘图了。然而当 HTML 变化时,重绘的代价是巨大的,因为上面任何一步的计算结果都依赖前面一步,HTML 改变时,需要对 DOM、CSSOM、LayoutTree、PaintRecord 进行重新计算。
大部分时候浏览器都可以在 16ms 内完成,使 FPS 保持在 60 左右,但当页面结构过于复杂,这些计算本身超过了 16ms,或其中遇到 js 代码的阻塞,都会导致用户感觉到卡顿。当然对于 js 卡顿问题可以通过 requestAnimationFrame
把逻辑运算分散在各帧空闲时进行,也可以独立到 web worker 里。
绘图的步骤称为 rasterizing(光栅化)。在 Chrome 最早发布时,采用了一种较为简单的光栅化方案,即仅渲染可是区域内的像素点,当滚动后,再补充渲染当前滚动位置的像素点。这样做会导致渲染永远滞后于滚动。
现在一般采用较为成熟的合成技术(compositing),即将渲染内容分层绘制与渲染,这可以大大提升性能,并可通过 CSS 属性 will-change
手动申明为一个新层(不要滥用)。
浏览器会根据 LayoutTree 分析后得到 LayerTree(层树),并根据它逐层渲染。
合成层会将绘图内容切分为多个栅格并交由 GPU 渲染,因此性能会非常好。
本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最长发生在的部分。
其实从性能优化角度来看,解析环节可以被替代为 JS 环节,因为现代 JS 框架往往没有什么 HTML 模版内容要解析,几乎全是 JS 操作 DOM,所以可以看作 5 个新环节:JS、样式、布局、绘图、合成。
值得注意的是,几乎每层的计算都依赖上层的结果,但并不是每层都一定会重复计算,我们需要尤其注意以下几种情况:
- 修改元素几何属性(位置、宽高等)会触发所有层的重新计算,因为这是一个非常重量级的修改。
- 修改某个元素绘图属性(比如颜色和背景色),并不影响位置,则会跳过布局层。
- 修改比如 transform 属性会跳过布局与绘图层,这看上去很不可思议。
对于第三点,由于 transform 的内容会提升到合成层并交由 GPU 渲染,因此并不会与浏览器主线程的布局、绘图放在一起处理,所以视觉上这个元素的确产生了位移,但它和修改 left
、top
的位移在实现上却有本质的不同。
所以站在浏览器开发者的角度,可以轻松理解为什么这种优化不是奇技淫巧了,因为本身浏览器的实现就把布局、绘图与合成层的行为分离开了,不同的代码底层方案不同,性能肯定会不同。你可以通过 csstriggers 查看不同 css 属性会引发哪些层的重计算。
当然作为开发者还是可以吐槽,为什么浏览器不能 “自动把 left
top
与 transform
的实现细节屏蔽,并自动进行合理的分层”,然而如果浏览器厂商做不到这一点,开发者还是主动去了解实现原理吧。
除了 transform
、will-change
属性外,还有很多种情况元素会提升到合成层,比如 video
、canvas
、iframe
,或 fixed
元素,但这些都有明确的规则,所以属于显示合成。
而隐式合成是指元素没有被特别标记,但也被提升到合成层的情况,这种情况常见发生在 z-index
元素产生重叠时,下方的元素显示申明提升到合成层,则浏览器为了保证 z-index
覆盖关系,就要隐式把上方的元素提升到合成层。
层爆炸是指隐式合成的原因,当 css 出现一些复杂行为时(比如轨迹动画),浏览器无法实时捕捉哪些元素位于当前元素上方,所以只好把所有元素都提升到合成层,当合成层数量过多,主线程与 GPU 的通信可能会成为瓶颈,反而影响性能。
浏览器也会支持层自动合并,比如隐式提升到合成层时,多个元素会自动合并在一个合成层里。但这种方式也并不总是靠谱,自动处理毕竟猜不到开发者的意图,所以最好的优化方式是开发者主动干预。
我们只要注意将所有显示提升到合成层的元素放在 z-index
的上方,这样浏览器就有了判断依据,不用再担惊受怕会不会这个元素突然移动到某个元素的位置,导致压住了那个元素,于是又不得不把这个元素给隐式提升到合成层以保证它们之间顺序的正确性,因为这个元素本来就位于其它元素的最上方。
读完这篇文章,希望你能根据浏览器在渲染进程的实现原理,总结出更多代码级别的性能优化经验。
最后想要吐槽的是,浏览器规范由于是逐步迭代的,因此看似都在描述位置的 css 属性其实背后实现原理是不同的,虽然这个规则体现在 W3C 规范上,但如果仅从属性名是很难看出来端倪的,因此想要做极致性能优化就必须了解浏览器实现原理。
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
Inside look at modern web browser 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第四篇。
前几章介绍了浏览器的基础进程、线程以及它们之间协同的关系,并重点说到了渲染进程是如何处理页面绘制的,那么最后一章也就深入到了浏览器是如何处理页面中事件的。
全篇站在浏览器实现的视角思考问题,非常有趣。
这是第一小节的标题。乍一看可能不明白在说什么,但这句话就是本文的核心知识点。为了更好的理解这句话,先要解释输入与合成器是什么:
- 输入:不仅包括输入框的输入,其实所有用户操作在浏览器眼中都是输入,比如滚动、点击、鼠标移动等等。
- 合成器:第三节说过的,渲染的最后一步,这一步在 GPU 进行光栅化绘图,如果与浏览器主线程解耦的化效率会非常高。
所以输入进入合成器的意思是指,在浏览器实际运行的环境中,合成器不得不响应输入,这可能会导致合成器本身渲染被阻塞,导致页面卡顿。
由于 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。
- 第一种方案是坚持 Chrome 性能优化的精神,委托时依然 pasive 处理。这样处理至少和 React 16 一样,
preventDefault()
都是失效的,虽然不正确,但至少不是 BreakChange。 - 第二种方案即什么都不做,这导致原本默认
passive
的因为绑定到非 document 节点上而non-passive
了,这样做不仅有性能问题,而且 API 会存在 BreackChange,虽然这种做法更 “原生”。 - 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
这个行为,所以这是个暂时无法解决的问题。
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)