🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
&emsp;&emsp;现如今,在呈现一个页面时,在浏览器中会打开众多进程,包括浏览器、渲染、插件、GPU、网络等进程。 &emsp;&emsp;浏览器进程负责存储、界面、下载等管理。在渲染进程中,运行着熟知的主线程、合成线程、JavaScript 解释器、排版引擎等。 &emsp;&emsp;而呈现一个页面大致可分为 4 个步骤: 1. 浏览器进程处理用户在地址栏的输入,然后将 URL 发送给网络进程。 2. 网络进程发送 URL 请求,在接收到响应数据后进行解析,接着转发给浏览器进程。 3. 浏览器进程收到响应后,发送“提交导航”消息到渲染进程。 4. 渲染进程开始接收网络进程发送的数据,并进行文档渲染。 &emsp;&emsp;基于上述步骤可以联想到,呈现的优化分为两部分:资源和渲染。 &emsp;&emsp;像上一节的[图像](https://www.cnblogs.com/strick/p/17080155.html)其实也属于资源部分,只是内容比较多就单独创建了章节。 &emsp;&emsp;本文所用的示例代码已上传至[Github](https://github.com/pwstrick/pe)。 ## 一、资源 &emsp;&emsp;HTTP Archive 关于 2022 年页面大小的[报告](https://almanac.httparchive.org/en/2022/page-weight)指出,按大小升序后,排在中间位置的移动页面大概有 70 个请求。 &emsp;&emsp;包括 22 个图像、21 个脚本、7 个 CSS以及 2 个 HTML,脚本和 CSS 占了 40% 的请求。 &emsp;&emsp;除了对这些资源进行尺寸优化之外,还可以对它们的加载进行优化。 **1)优先级** &emsp;&emsp;浏览器会给不同资源给予不同的请求优先级。 &emsp;&emsp;以 Chrome 为例,分为多个等级,包括 Highest 、High、Low 和 Lowest 等,如下图所示。 :-: ![](https://img.kancloud.cn/0f/1b/0f1bfa58a579fae278c7bf0229fa3b82_1580x606.png =800x) &emsp;&emsp;HTML 和 head 元素中的 CSS 优先级是最高的,head 元素中的脚本是高优先级,异步请求的脚本是低优先级。 &emsp;&emsp;若优先级不符合预期,可以通过一些配置修改优先级,例如为 script 元素声明 async/defer,它的优先级就会变成低。 &emsp;&emsp;在 img 元素中,新增了一个[fetchPriority](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/fetchPriority)属性(如下所示),当值是 high 时,意味着这是一张重要的图像,浏览器会提升优先级立即开始请求。 ~~~html <img src="hero.png" fetchpriority="high" /> ~~~ **2)link 元素** &emsp;&emsp;link 元素常用来加载 CSS 文件,但它还支持些其他功能,接下来会一一介绍。 &emsp;&emsp;当 link 的 rel 属性值为[preload](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload)时,就能预加载资源,如下所示。 ~~~html <link rel="preload" href="demo.js" as="script" /> ~~~ &emsp;&emsp;as 属性是告知浏览器加载的资源类型,包括 style、script、font、image 等。 &emsp;&emsp;预加载可提升资源的优先级,不过当资源在几秒后未使用时,浏览器会发出告警。 &emsp;&emsp;2023-11-20 高版本的浏览器已经原生支持 JavaScript 模块化(即 import 语法),这意味着可以在浏览器内直接基于模块编写 JavaScript 而不用编译和打包。 &emsp;&emsp;但是模块依赖项会带来加载问题,因为浏览器需要先等待模块加载,然后才能找到其依赖项。 &emsp;&emsp;从 Chrome 66 开始,rel 属性支持一个新的关键字:[modulepreload](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/modulepreload),解决上述问题。 &emsp;&emsp;它用于声明预加载模块,也就是预加载依赖项,以便浏览器提前知道相关文件。 &emsp;&emsp;当 link 的 rel 属性值为[preconnect](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preconnect)时,就能预连接站点,如下所示。 ~~~html <link rel="preconnect" href="https://www.pwstrick.com" /> ~~~ &emsp;&emsp;另一个与连接相关的类型是[dns-prefetch](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/dns-prefetch)(如下所示),用来处理 DNS 查询,即 DNS 预解析。 ~~~html <link rel="dns-prefetch" href="https://www.pwstrick.com" /> ~~~ &emsp;&emsp;当 link 的 rel 属性值为[prefetch](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/prefetch)时,就能预提取资源,如下所示。 ~~~html <link rel="prefetch" href="demo.js" /> ~~~ &emsp;&emsp;预提取会让资源的优先级降为最低,用于让某些非关键资源提前请求,可为用户的下一步交互做准备。 &emsp;&emsp;2023-03-23 当 link 的 rel 属性值为[prerender](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/prerender)时,就能预渲染指定的网站,如下所示。 ~~~html <link rel="prerender" href="https://www.pwstrick.com" /> ~~~ &emsp;&emsp;不过,该参数的兼容性有限,Safari 和 Firefox 都不支持,如下图所示。 :-: ![](https://img.kancloud.cn/48/25/48257a005353368f95810aeeca491d1d_1357x221.png =800x) &emsp;&emsp;有个名为[Tachyon](https://fasterthanlight.net/)的开源库,基于 prerender,对页面之间的导航进行了提速。 &emsp;&emsp;在用户将鼠标移动到链接时,会通过创建 link 元素,并赋予 prerender,实现指定地址的预渲染。 **3)script 元素** &emsp;&emsp;延迟(defer)和异步(async)的出现是为了解决 script 元素阻塞 HTML 解析的问题,下图描绘了 script 元素的 3 种运行机制。 :-: ![](https://img.kancloud.cn/f6/4c/f64c9584717884ad8fe052240c5b1e33_1581x245.png) &emsp;&emsp;第一行是默认的运行机制,在解析HTML文档时,一遇到 script 元素就停止解析,改成下载外部脚本,然后执行脚本,执行完后才会继续解析。 &emsp;&emsp;第二行是使用了 defer 属性后的运行机制,HTML 文档的解析和外部脚本的下载是同时进行的,解析完后才会执行脚本。 &emsp;&emsp;第三行是使用了async 属性后的运行机制,HTML 文档的解析和外部脚本的下载也是同时进行,但下载完后就开始执行脚本,执行完后才会继续解析。 &emsp;&emsp;2023-11-20 有个库叫 [Partytown.js](https://github.com/BuilderIO/partytown),比较有意思,可以将第三方脚本迁移到 Web Worker 中执行,防止阻塞主线程,不过这还只是个测试性的库。 **4)数据预请求** &emsp;&emsp;在客户端的 WebView 中,每次请求后端接口大概要花 100~200ms,如果把这段时间省下来,那么也能减少白屏时间。 &emsp;&emsp;数据预请求是将请求时机由业务发起提前到用户点击时,并行发送数据请求,缩短数据等待时间,如下图所示。 :-: ![](https://img.kancloud.cn/be/db/bedbd7b2a749a61f9cd1e7ea547f3f09_1274x372.png =600x) &emsp;&emsp;这种改造需要客户端配合,现在简单介绍下我们公司当时实现的方案,流程图如下所示。 :-: ![](https://img.kancloud.cn/9b/f8/9bf87beac1a3f77c86445fe860c811ac_705x750.png =600x) &emsp;&emsp;首屏数据的接口信息,可以通过一些配置关联起来,比如一个单独的配置接口。 &emsp;&emsp;客户端在拿到数据后,就会缓存到一个全局变量中,等待脚本读取。 &emsp;&emsp;注意,到底是客户端先拿到数据,还是网页先拿到,这个无法确定,并且预请求只能以 get 方法通信。 &emsp;&emsp;具体的实现方案如下: * 客户端分析出当前 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)字体** &emsp;&emsp;CSS3 提供了[@font-face](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face)规则允许为网页指定自定义字体,其声明和使用如下所示。 ~~~css @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"; } ~~~ &emsp;&emsp;上述字体来源于[iconfont](https://www.iconfont.cn/),为了兼容性考虑,往往会提供多个格式的字体。 &emsp;&emsp;其中 ttf 是一种未压缩的格式,另外两种内部都做过压缩。在 2022 年大概有[75%~78%](https://almanac.httparchive.org/en/2022/fonts#performance)的网页在使用 woff2 格式的字体。 &emsp;&emsp;使用字体除了改变文字外形之外,还有一种普遍用法是用来显示 icon 小图标。 &emsp;&emsp;2023-11-27 因为这样就能让 icon 成为矢量图,所以缩放既不会变形,也不会影响流量。 &emsp;&emsp;CSS3 提供了[font-display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display)属性用于指定字体的渲染方式,在 @font-face 中声明,2022 年用的最多的值是 swap。 &emsp;&emsp;swap 会让文字先按浏览器默认的字体展示,当字体加载完成后,再将其替换掉。在慢网中,会看到字体的前后变化。 &emsp;&emsp;所以应该尽快加载字体,才能让用户享受到最优的体验。 &emsp;&emsp;浏览器在解析 CSS 文件时,并不会马上下载 @font-face 中的字体文件。 &emsp;&emsp;只有当发现 HTML 中有非空节点使用该字体时,才会开始下载。 &emsp;&emsp;如果要提早下载,那么可以使用预加载,如下所示。 ~~~html <link rel="preload" href="../../assets/font/dakai.woff2" as="font" crossorigin="anonymous"/> ~~~ &emsp;&emsp;crossorigin 属性是必填的,表示允许跨域,若省略,就会有告警。 &emsp;&emsp;还有一种优化方法是提取字体的子集(即有选择性的将需要的字符组合在一起),减小字体文件的尺寸,像图标就比较适合这样自定义。 ## 二、渲染过程 &emsp;&emsp;浏览器的渲染过程大致可分为 8 个阶段,如[下图](https://cabulous.medium.com/how-browser-works-part-i-process-and-thread-f63a9111bae9)所示。 :-: ![](https://img.kancloud.cn/8a/44/8a44a4900621fb22fef2b83ec0087153_1752x621.png =600x) &emsp;&emsp;下面的 1~5 步涉及主线程(main thread),6~8 步涉及合成线程(compositor thread)。 1. 将 HTML 解析成 DOM 树,并将其存储在内存中,同时下载解析到的资源。 2. 将 CSS 解析成样式表(style sheets),即生成 CSSOM,在此阶段会计算节点样式,并把相对的值和单位都转换成像素。 3. 通过 DOM 和样式表生成布局树(layout tree),在此阶段会计算元素的尺寸和坐标,并且在树中不包含隐藏元素,但会包含 CSS 中创建的内容。 4. 对布局树进行分层,生成分层树(layer tree),可控制绘画顺序,裁剪元素内容,CSS 中的 transform、z-index、will-change 等属性都与层相关。 5. 通过布局树和分层树生成绘制列表,并将其提交给合成线程。 6. 通过绘制列表和图层生成图块(tile),因为渲染所有图块会比较昂贵,所以会划分优先级,例如视口中的可见图块优先级会高。 7. 图块在提交到光栅化(raster)线程池后,会被转移到 GPU 中,加速光栅化处理,即转换成位图(bitmap),最终结果会存储在 GPU 内存中。 8. GPU 将位图传送回合成线程后,就会生成合成帧,处理完所有位图后,合成器线程向浏览器发送 Draw Quad 命令,开始在屏幕上显示页面。 &emsp;&emsp;虽然这 8 个阶段的执行过程比较复杂,但是在现代浏览器中,它们会在 1/60 秒(即 16.67 毫秒)内完成,下图描述了整个渲染过程。 :-: ![](https://img.kancloud.cn/21/48/214839522706fd4b654c03153a872a43_1230x1534.png =600x) &emsp;&emsp;优化渲染过程的核心就是缩短某个阶段的执行时间,或者直接跳过某些阶段。 **1)流式渲染** &emsp;&emsp;HTTP/1.1 协议支持分块传输编码(chunked transfer encoding),允许服务器将网页数据分成多块后再进行传输。 &emsp;&emsp;在响应头中设置 Transfer-Encoding: chunked 就会启用分块传输编码的响应格式。 &emsp;&emsp;浏览器在知道 HTML 会被流式返回后,就不用等到 HTML 下载完成后再开始解析了。 &emsp;&emsp;不过,目前流行的客户端渲染(Client Side Render)其实并不需要专门的流式渲染,因为 HTML 的内容本来就少。 &emsp;&emsp;若改成服务端渲染(Server Side Render),那就可根据实际情况进行流式渲染的优化了。 &emsp;&emsp;具体的实现过程,本文不再赘述,可参考网上相关的方案,例如 Vue SSR 指南中的[流式渲染](https://v2.ssr.vuejs.org/zh/guide/streaming.html)。 **2)DOM** &emsp;&emsp;HTML 在被解析时,一旦遇到 JavaScript,那么就会被阻塞,如下图所示。 :-: ![](https://img.kancloud.cn/2c/3c/2c3c8e0e4602d9105487c5098894c368_720x465.png =600x) &emsp;&emsp;当遇到外部脚本时,还会停止 DOM 树的构建,转由网络进程去请求 JavaScript 脚本地址。 &emsp;&emsp;CSS 本身并不会阻塞 DOM 树的构建,但在与 JavaScript 结合使用时,会出现阻塞。 &emsp;&emsp;在下面的示例中,JavaScript 会修改 demo.css 文件中的样式。 ~~~html <link rel="stylesheet" href="demo.css" /> <div id='root'>内容</div> <script> const root = document.getElementById('root'); root.style.color = 'red'; </script> ~~~ &emsp;&emsp;主线程在执行脚本之前,需要先计算节点样式(即解析 CSS 文件),因此 DOM 树就无法被继续构建了。 &emsp;&emsp;若要优化 DOM 树的构建,除了尽量避免上述不科学的写法之外,还可以从两方面入手:减少关键资源请求的数量和大小。 &emsp;&emsp;所谓关键资源(key resource),更确切的说就是网页首屏的核心资源,没有它们,那么首屏将无法正确的呈现。 &emsp;&emsp;减少资源的请求数量可以通过 2 个方法: * 将 CSS 或 JavaScript 内联到 HTML 结构中,例如移动端的屏幕适配脚本就比较适合内联。 * 脚本元素可以增加 async 或 defer 的标记,具体可以参考上一节的 script 元素。 &emsp;&emsp;关键资源的大小除了进行压缩外,就是只提取首屏需要的代码。 &emsp;&emsp;将其他部分的代码合并到另一个文件,待需要时再加载,或者使用上一节所说的预提取。 **3)重排和重绘** &emsp;&emsp;重排(reflow)也叫回流,是指修改元素的几何属性后引起的重新渲染,涉及 7 个阶段,如下图所示,修改了元素的高度。 :-: ![](https://img.kancloud.cn/f3/d9/f3d91d50d5423396f9e48d984edc8d3e_2043x826.png =600x) &emsp;&emsp;触发重排的情况有添加或删除可见的元素、修改位置、边距或内容等。 &emsp;&emsp;重绘(repaint)是指修改元素的背景颜色后引起的重新渲染,但与重排不同,重绘将直接进入 Paint 阶段,如下图所示。 :-: ![](https://img.kancloud.cn/37/58/37580acca37444f23b38f16c63e79199_2041x853.png =600x) &emsp;&emsp;重排和重绘都会降低渲染性能,因为它们都发生在主线程中,并且布局、分层和绘制 3 个阶段的计算过程比较昂贵。 &emsp;&emsp;当在脚本中获取元素的尺寸、位置等排版相关的信息时,就有可能触发强制重排,例如调用 offsetTop、clientWidth、getComputedStyle() 等属性或方法。 &emsp;&emsp;优化它们的方式包括使用 cssText 或 CSS 类修一次性修改多个 CSS 属性,批量修改 DOM,例如使用文档片段 fragment、先隐藏元素再显示等。 &emsp;&emsp;在众多的 CSS 属性中,有两个 CSS 属性(transform 和 opacity)可以避开重排和重绘,直接进入合成阶段。 &emsp;&emsp;例如用 transform 属性实现的元素变化,就不会占用主线程,而是由合成线程处理,如下图所示。 :-: ![](https://img.kancloud.cn/4c/c5/4cc5a8c5d6f854d63898bf93e7ae17ec_2041x834.png =600x) &emsp;&emsp;值得一提的是,早期在脚本中实现动画,都会借助定时器,但定时器无法精确的配置动画帧之间的时间间隔。 &emsp;&emsp;按屏幕刷新率为每秒 60 次计算,那么理论上每帧的间隔约等于是 16.67 毫秒。 &emsp;&emsp;但实际情况比较复杂,间隔不一定是这个值,有可能出现丢帧,从而造成动画不够平滑流畅。 &emsp;&emsp;为了解决动画问题,浏览器提供了[requestAnimationFrame()](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)方法,在每一帧的开始执行配置的回调。 &emsp;&emsp;注意,只有当浏览器 GPU 生成位图和屏幕显示位图保持同步时,才会触发 requestAnimationFrame() 的回调。 &emsp;&emsp;在下面的示例中,让绝对定位的 span 元素通过 requestAnimationFrame() 向右偏移。 ~~~html <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> ~~~ &emsp;&emsp;注意,requestAnimationFrame() 也是运行在主线程中,如果主线程繁忙,那么也有可能延迟回调,造成动画的卡顿。 &emsp;&emsp;并且如果其回调比较耗时(超过一帧),那么就会阻碍后续的任务。 **4)离屏渲染** &emsp;&emsp;2023-11-20 离屏渲染相当于开辟一个缓冲区,将屏幕外的内容提前绘制好,在滚动或需要时就能直接呈现对应内容。 &emsp;&emsp;一种实现方式是维护两个 Canvas,当前渲染的 Canvas 与隐藏的缓存 Canvas 两者交替绘制,这不仅复杂,并且还要消耗更多的计算资源。 &emsp;&emsp;另一种是使用 [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) 实现真正的离屏,这是一个实验中的新功能,提升 Canvas 在 2D/3D 绘图时的渲染性能和使用体验。 &emsp;&emsp;与 Canvas 只能在主线程中执行不同,OffscreenCanvas 既可以在主线程中执行,也可以在 Web Worker 中执行,这让不影响主线程的离屏渲染成为可能。 ## 总结 &emsp;&emsp;本文的第一章节详细描述了资源的优化,并在开篇指出资源都存在着优先级,浏览器会按优先级进行请求。 &emsp;&emsp;预加载可提升资源的优先级,预提取可降低资源的优先级,预连接可提前进行 TCP 连接或 DNS 查询。 &emsp;&emsp;script 元素有延迟和异步两种运行机制,可有效地防止 HTML 解析的阻塞。 &emsp;&emsp;数据预请求需要与客户端配合,本文给出了一份解决方案可供参考。 &emsp;&emsp;自定义字体在页面开发中有着广泛的应用,常用的优化手段是预加载和减小尺寸。 &emsp;&emsp;在第二章节中详细分析了浏览器的渲染过程,这个过程大致可分为 8 个阶段。 &emsp;&emsp;围绕这些阶段,引出了流式渲染、DOM 树构建的优化。 &emsp;&emsp;在重排和重绘中,详细说明了它们影响的阶段,并且列举了触发原因,以及优化手段。 &emsp;&emsp;最后提到了合成动画,并且对比了 JavaScript 动画的两种实现方式。 ***** > 原文出处: [博客园-前端性能精进](https://www.cnblogs.com/strick/category/2267607.html) [知乎专栏-前端性能精进](https://www.zhihu.com/column/c_1610941255021780992) 已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎阅读。 ![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200) 推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。