前端性能精进之浏览器(四)——呈现原创
现如今,在呈现一个页面时,在浏览器中会打开众多进程,包括浏览器、渲染、插件、GPU、网络等进程。
浏览器进程负责存储、界面、下载等管理。在渲染进程中,运行着熟知的主线程、合成线程、JavaScript 解释器、排版引擎等。
而呈现一个页面大致可分为 4 个步骤:
- 浏览器进程处理用户在地址栏的输入,然后将 URL 发送给网络进程。
- 网络进程发送 URL 请求,在接收到响应数据后进行解析,接着转发给浏览器进程。
- 浏览器进程收到响应后,发送“提交导航”消息到渲染进程。
- 渲染进程开始接收网络进程发送的数据,并进行文档渲染。
基于上述步骤可以联想到,呈现的优化分为两部分:资源和渲染。
像上一节的图像其实也属于资源部分,只是内容比较多就单独创建了章节。
本文所用的示例代码已上传至 Github。
一、资源
HTTP Archive 关于 2022 年页面大小的报告指出,按大小升序后,排在中间位置的移动页面大概有 70 个请求。
包括 22 个图像、21 个脚本、7 个 CSS以及 2 个 HTML,脚本和 CSS 占了 40% 的请求。
除了对这些资源进行尺寸优化之外,还可以对它们的加载进行优化。
1)优先级
浏览器会给不同资源给予不同的请求优先级。
以 Chrome 为例,分为多个等级,包括 Highest 、High、Low 和 Lowest 等,如下图所示。
HTML 和 head 元素中的 CSS 优先级是最高的,head 元素中的脚本是高优先级,异步请求的脚本是低优先级。
若优先级不符合预期,可以通过一些配置修改优先级,例如为 script 元素声明 async/defer,它的优先级就会变成低。
在 img 元素中,新增了一个 fetchPriority 属性(如下所示),当值是 high 时,意味着这是一张重要的图像,浏览器会提升优先级立即开始请求。
<img src="hero.png" fetchpriority="high" />
2)link 元素
link 元素常用来加载 CSS 文件,但它还支持些其他功能,接下来会一一介绍。
当 link 的 rel 属性值为 preload 时,就能预加载资源,如下所示。
<link rel="preload" href="demo.js" as="script" />
as 属性是告知浏览器加载的资源类型,包括 style、script、font、image 等。
预加载可提升资源的优先级,不过当资源在几秒后未使用时,浏览器会发出告警。
当 link 的 rel 属性值为 preconnect 时,就能预连接站点,如下所示。
<link rel="preconnect" href="https://www.pwstrick.com" />
另一个与连接相关的类型是 dns-prefetch(如下所示),用来处理 DNS 查询,即 DNS 预解析。
<link rel="dns-prefetch" href="https://www.pwstrick.com" />
当 link 的 rel 属性值为 prefetch 时,就能预提取资源,如下所示。
<link rel="prefetch" href="demo.js" />
预提取会让资源的优先级降为最低,用于让某些非关键资源提前请求,可为用户的下一步交互做准备。
2023-03-23 当 link 的 rel 属性值为 prerender 时,就能预渲染指定的网站,如下所示。
<link rel="prerender" href="https://www.pwstrick.com" />
不过,该参数的兼容性有限,Safari 和 Firefox 都不支持,如下图所示。
有个名为 Tachyon 的开源库,基于 prerender,对页面之间的导航进行了提速。
在用户将鼠标移动到链接时,会通过创建 link 元素,并赋予 prerender,实现指定地址的预渲染。
3)script 元素
延迟(defer)和异步(async)的出现是为了解决 script 元素阻塞 HTML 解析的问题,下图描绘了 script 元素的 3 种运行机制。
第一行是默认的运行机制,在解析HTML文档时,一遇到 script 元素就停止解析,改成下载外部脚本,然后执行脚本,执行完后才会继续解析。
第二行是使用了 defer 属性后的运行机制,HTML 文档的解析和外部脚本的下载是同时进行的,解析完后才会执行脚本。
第三行是使用了async 属性后的运行机制,HTML 文档的解析和外部脚本的下载也是同时进行,但下载完后就开始执行脚本,执行完后才会继续解析。
4)数据预请求
在客户端的 WebView 中,每次请求后端接口大概要花 100~200ms,如果把这段时间省下来,那么也能减少白屏时间。
数据预请求是将请求时机由业务发起提前到用户点击时,并行发送数据请求,缩短数据等待时间,如下图所示。
这种改造需要客户端配合,现在简单介绍下我们公司当时实现的方案,流程图如下所示。
首屏数据的接口信息,可以通过一些配置关联起来,比如一个单独的配置接口。
客户端在拿到数据后,就会缓存到一个全局变量中,等待脚本读取。
注意,到底是客户端先拿到数据,还是网页先拿到,这个无法确定,并且预请求只能以 get 方法通信。
具体的实现方案如下:
- 客户端分析出当前 URL 中的路径和参数,其中 refresh 参数(有的话)是一个时间戳(秒),这个参数用来控制客户端是否需要重新请求配置接口。
- 当分析的 URL 参数中无 refresh 字段时,访问 https://xxx.com/settings 接口,并将URL路径、客户端默认带的参数(包含用户ID等)和 URL 本身的参数全部传递过来(如下所示),然后本地缓存。
https://xxx.com/settings?path=game%2Fstrick&uid=xxxxx&refresh=1618451992
- 客户端会将 settings 接口的响应数据缓存到本地,而 key 就是当前 URL,也就是说 URL 不变的话,默认就不会去请求 settings 接口。若要穿透缓存,那么加上 refresh 参数,赋一个与之前不同的值即可。
- settings 接口返回的 JSON 格式,包含 urls 字段(如下所示),是个数组,由接口集合组成,已经拼接好参数。
{ "urls": [ "http://xxx.com/xx/xx?id=2", "http://xxx.com/yy/yy?uid=1" ] }
- 客户端将读取到的数据注入到 WebView 的全局对象中,可以用全局变量同步读取,名字可自行约定,例如叫 TheLClientResponse,读取方式:window.TheLClientResponse,JSON 格式如下,其中 key 是 api 的路径,如果无数据可以返回 null。
{ "xx/xx": { code: 0, msg: "test", data: { list: [] } }, "yy/yy": { code: 0, msg: "test", data: { list: [] } } }
5)字体
CSS3 提供了 @font-face 规则允许为网页指定自定义字体,其声明和使用如下所示。
@font-face { font-family: "iconfont"; src: url("../font/iconfont.woff2") format("woff2"), url("../font/iconfont.woff") format("woff"), url("../font/iconfont.ttf") format("truetype"); } .iconfont { font-family: "iconfont"; }
上述字体来源于 iconfont,为了兼容性考虑,往往会提供多个格式的字体。
其中 ttf 是一种未压缩的格式,另外两种内部都做过压缩。在 2022 年大概有 75%~78% 的网页在使用 woff2 格式的字体。
使用字体除了改变文字外形之外,还有一种普遍用法是用来显示 icon 小图标。
CSS3 提供了 font-display 属性用于指定字体的渲染方式,在 @font-face 中声明,2022 年用的最多的值是 swap。
swap 会让文字先按浏览器默认的字体展示,当字体加载完成后,再将其替换掉。在慢网中,会看到字体的前后变化。
所以应该尽快加载字体,才能让用户享受到最优的体验。
浏览器在解析 CSS 文件时,并不会马上下载 @font-face 中的字体文件。
只有当发现 HTML 中有非空节点使用该字体时,才会开始下载。
如果要提早下载,那么可以使用预加载,如下所示。
<link rel="preload" href="../../assets/font/dakai.woff2" as="font" crossorigin="anonymous"/>
crossorigin 属性是必填的,表示允许跨域,若省略,就会有告警。
还有一种优化方法是提取字体的子集(即有选择性的将需要的字符组合在一起),减小字体文件的尺寸,像图标就比较适合这样自定义。
二、渲染过程
浏览器的渲染过程大致可分为 8 个阶段,如下图所示。
下面的 1~5 步涉及主线程(main thread),6~8 步涉及合成线程(compositor thread)。
- 将 HTML 解析成 DOM 树,并将其存储在内存中,同时下载解析到的资源。
- 将 CSS 解析成样式表(style sheets),即生成 CSSOM,在此阶段会计算节点样式,并把相对的值和单位都转换成像素。
- 通过 DOM 和样式表生成布局树(layout tree),在此阶段会计算元素的尺寸和坐标,并且在树中不包含隐藏元素,但会包含 CSS 中创建的内容。
- 对布局树进行分层,生成分层树(layer tree),可控制绘画顺序,裁剪元素内容,CSS 中的 transform、z-index、will-change 等属性都与层相关。
- 通过布局树和分层树生成绘制列表,并将其提交给合成线程。
- 通过绘制列表和图层生成图块(tile),因为渲染所有图块会比较昂贵,所以会划分优先级,例如视口中的可见图块优先级会高。
- 图块在提交到光栅化(raster)线程池后,会被转移到 GPU 中,加速光栅化处理,即转换成位图(bitmap),最终结果会存储在 GPU 内存中。
- GPU 将位图传送回合成线程后,就会生成合成帧,处理完所有位图后,合成器线程向浏览器发送 Draw Quad 命令,开始在屏幕上显示页面。
虽然这 8 个阶段的执行过程比较复杂,但是在现代浏览器中,它们会在 1/60 秒(即 16.67 毫秒)内完成,下图描述了整个渲染过程。
优化渲染过程的核心就是缩短某个阶段的执行时间,或者直接跳过某些阶段。
1)流式渲染
HTTP/1.1 协议支持分块传输编码(chunked transfer encoding),允许服务器将网页数据分成多块后再进行传输。
在响应头中设置 Transfer-Encoding: chunked 就会启用分块传输编码的响应格式。
浏览器在知道 HTML 会被流式返回后,就不用等到 HTML 下载完成后再开始解析了。
不过,目前流行的客户端渲染(Client Side Render)其实并不需要专门的流式渲染,因为 HTML 的内容本来就少。
若改成服务端渲染(Server Side Render),那就可根据实际情况进行流式渲染的优化了。
具体的实现过程,本文不再赘述,可参考网上相关的方案,例如 Vue SSR 指南中的流式渲染。
2)DOM
HTML 在被解析时,一旦遇到 JavaScript,那么就会被阻塞,如下图所示。
当遇到外部脚本时,还会停止 DOM 树的构建,转由网络进程去请求 JavaScript 脚本地址。
CSS 本身并不会阻塞 DOM 树的构建,但在与 JavaScript 结合使用时,会出现阻塞。
在下面的示例中,JavaScript 会修改 demo.css 文件中的样式。
<link rel="stylesheet" href="demo.css" /> <div id='root'>内容</div> <script> const root = document.getElementById('root'); root.style.color = 'red'; </script>
主线程在执行脚本之前,需要先计算节点样式(即解析 CSS 文件),因此 DOM 树就无法被继续构建了。
若要优化 DOM 树的构建,除了尽量避免上述不科学的写法之外,还可以从两方面入手:减少关键资源请求的数量和大小。
所谓关键资源(key resource),更确切的说就是网页首屏的核心资源,没有它们,那么首屏将无法正确的呈现。
减少资源的请求数量可以通过 2 个方法:
- 将 CSS 或 JavaScript 内联到 HTML 结构中,例如移动端的屏幕适配脚本就比较适合内联。
- 脚本元素可以增加 async 或 defer 的标记,具体可以参考上一节的 script 元素。
关键资源的大小除了进行压缩外,就是只提取首屏需要的代码。
将其他部分的代码合并到另一个文件,待需要时再加载,或者使用上一节所说的预提取。
3)重排和重绘
重排(reflow)也叫回流,是指修改元素的几何属性后引起的重新渲染,涉及 7 个阶段,如下图所示,修改了元素的高度。
触发重排的情况有添加或删除可见的元素、修改位置、边距或内容等。
重绘(repaint)是指修改元素的背景颜色后引起的重新渲染,但与重排不同,重绘将直接进入 Paint 阶段,如下图所示。
重排和重绘都会降低渲染性能,因为它们都发生在主线程中,并且布局、分层和绘制 3 个阶段的计算过程比较昂贵。
当在脚本中获取元素的尺寸、位置等排版相关的信息时,就有可能触发强制重排,例如调用 offsetTop、clientWidth、getComputedStyle() 等属性或方法。
优化它们的方式包括使用 cssText 或 CSS 类修一次性修改多个 CSS 属性,批量修改 DOM,例如使用文档片段 fragment、先隐藏元素再显示等。
在众多的 CSS 属性中,有两个 CSS 属性(transform 和 opacity)可以避开重排和重绘,直接进入合成阶段。
例如用 transform 属性实现的元素变化,就不会占用主线程,而是由合成线程处理,如下图所示。
值得一提的是,早期在脚本中实现动画,都会借助定时器,但定时器无法精确的配置动画帧之间的时间间隔。
按屏幕刷新率为每秒 60 次计算,那么理论上每帧的间隔约等于是 16.67 毫秒。
但实际情况比较复杂,间隔不一定是这个值,有可能出现丢帧,从而造成动画不够平滑流畅。
为了解决动画问题,浏览器提供了 requestAnimationFrame() 方法,在每一帧的开始执行配置的回调。
注意,只有当浏览器 GPU 生成位图和屏幕显示位图保持同步时,才会触发 requestAnimationFrame() 的回调。
在下面的示例中,让绝对定位的 span 元素通过 requestAnimationFrame() 向右偏移。
<span id='container' style="position:absolute">内容</span> <script> let left = 0; const frame = () => { const container = document.getElementById('container'); container.style.left = `${left++}px`; if (left > 100) return; requestAnimationFrame(frame); }; requestAnimationFrame(frame); </script>
注意,requestAnimationFrame() 也是运行在主线程中,如果主线程繁忙,那么也有可能延迟回调,造成动画的卡顿。
并且如果其回调比较耗时(超过一帧),那么就会阻碍后续的任务。
总结
本文的第一章节详细描述了资源的优化,并在开篇指出资源都存在着优先级,浏览器会按优先级进行请求。
预加载可提升资源的优先级,预提取可降低资源的优先级,预连接可提前进行 TCP 连接或 DNS 查询。
script 元素有延迟和异步两种运行机制,可有效地防止 HTML 解析的阻塞。
数据预请求需要与客户端配合,本文给出了一份解决方案可供参考。
自定义字体在页面开发中有着广泛的应用,常用的优化手段是预加载和减小尺寸。
在第二章节中详细分析了浏览器的渲染过程,这个过程大致可分为 8 个阶段。
围绕这些阶段,引出了流式渲染、DOM 树构建的优化。
在重排和重绘中,详细说明了它们影响的阶段,并且列举了触发原因,以及优化手段。
最后提到了合成动画,并且对比了 JavaScript 动画的两种实现方式。