极客时间已完结课程限时免费阅读

25 | 页面性能:如何系统地优化页面?

25 | 页面性能:如何系统地优化页面?-极客时间

25 | 页面性能:如何系统地优化页面?

讲述:李兵

时长13:15大小10.59M

在前面几篇文章中,我们分析了页面加载和 DOM 生成,讨论了 JavaScript 和 CSS 是如何影响到 DOM 生成的,还结合渲染流水线来讲解了分层和合成机制,同时在这些文章里面,我们还穿插说明了很多优化页面性能的最佳实践策略。通过这些知识点的学习,相信你已经知道渲染引擎是怎么绘制出帧的,不过之前我们介绍的内容比较零碎、比较散,那么今天我们就来将这些内容系统性地串起来。
那么怎么才能把这些知识点串起来呢?我的思路是从如何系统优化页面速度的角度来切入。
这里我们所谈论的页面优化,其实就是要让页面更快地显示和响应。由于一个页面在它不同的阶段,所侧重的关注点是不一样的,所以如果我们要讨论页面优化,就要分析一个页面生存周期的不同阶段。
通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段
加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和 JavaScript 脚本。
交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是 JavaScript 脚本。
关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。
这里我们需要重点关注加载阶段和交互阶段,因为影响到我们体验的因素主要都在这两个阶段,下面我们就来逐个详细分析下。

加载阶段

我们先来分析如何系统优化加载阶段中的页面,还是先看一个典型的渲染流水线,如下图所示:
加载阶段渲染流水线
观察上面这个渲染流水线,你能分析出来有哪些因素影响了页面加载速度吗?下面我们就先来分析下这个问题。
通过前面文章的讲解,你应该已经知道了并非所有的资源都会阻塞页面的首次绘制,比如图片、音频、视频等文件就不会阻塞页面的首次渲染;而 JavaScript、首次请求的 HTML 资源文件、CSS 文件是会阻塞首次渲染的,因为在构建 DOM 的过程中需要 HTML 和 JavaScript 文件,在构造渲染树的过程中需要用到 CSS 文件。
我们把这些能阻塞网页首次渲染的资源称为关键资源。基于关键资源,我们可以继续细化出来三个影响页面首次渲染的核心因素。
第一个是关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。比如上图中的关键资源个数就是 3 个,1 个 HTML 文件、1 个 JavaScript 和 1 个 CSS 文件。
第二个是关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。上图中关键资源的大小分别是 6KB、8KB 和 9KB,那么整个关键资源大小就是 23KB。
第三个是请求关键资源需要多少个 RTT(Round Trip Time)。那什么是 RTT 呢? 在《02 | TCP 协议:如何保证页面文件能被完整送达浏览器?》这篇文章中我们分析过,当使用 TCP 协议传输一个文件时,比如这个文件大小是 0.1M,由于 TCP 的特性,这个数据并不是一次传输到服务端的,而是需要拆分成一个个数据包来回多次进行传输的。RTT 就是这里的往返时延。它是网络中一个重要的性能指标,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常 1 个 HTTP 的数据包在 14KB 左右,所以 1 个 0.1M 的页面就需要拆分成 8 个包来传输了,也就是说需要 8 个 RTT。
我们可以结合上图来看看它的关键资源请求需要多少个 RTT。首先是请求 HTML 资源,大小是 6KB,小于 14KB,所以 1 个 RTT 就可以解决了。至于 JavaScript 和 CSS 文件,这里需要注意一点,由于渲染引擎有一个预解析的线程,在接收到 HTML 数据之后,预解析线程会快速扫描 HTML 数据中的关键资源,一旦扫描到了,会立马发起请求,你可以认为 JavaScript 和 CSS 是同时发起请求的,所以它们的请求是重叠的,那么计算它们的 RTT 时,只需要计算体积最大的那个数据就可以了。这里最大的是 CSS 文件(9KB),所以我们就按照 9KB 来计算,同样由于 9KB 小于 14KB,所以 JavaScript 和 CSS 资源也就可以算成 1 个 RTT。也就是说,上图中关键资源请求共花费了 2 个 RTT。
了解了影响加载过程中的几个核心因素之后,接下来我们就可以系统性地考虑优化方案了。总的优化原则就是减少关键资源个数,降低关键资源大小,降低关键资源的 RTT 次数
如何减少关键资源的个数?一种方式是可以将 JavaScript 和 CSS 改成内联的形式,比如上图的 JavaScript 和 CSS,若都改成内联模式,那么关键资源的个数就由 3 个减少到了 1 个。另一种方式,如果 JavaScript 代码没有 DOM 或者 CSSOM 的操作,则可以改成 async 或者 defer 属性;同样对于 CSS,如果不是在构建页面之前加载的,则可以添加媒体取消阻止显现的标志。当 JavaScript 标签加上了 async 或者 defer、CSSlink 属性之前加上了取消阻止显现的标志后,它们就变成了非关键资源了。
如何减少关键资源的大小?可以压缩 CSS 和 JavaScript 资源,移除 HTML、CSS、JavaScript 文件中一些注释内容,也可以通过前面讲的取消 CSS 或者 JavaScript 中关键资源的方式。
如何减少关键资源 RTT 的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用 CDN 来减少每次 RTT 时长。
在优化实际的页面加载速度时,你可以先画出优化之前关键资源的图表,然后按照上面优化关键资源的原则去优化,优化完成之后再画出优化之后的关键资源图表。

交互阶段

接下来我们再来聊聊页面加载完成之后的交互阶段以及应该如何去优化。谈交互阶段的优化,其实就是在谈渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。因此讨论页面优化实际上就是讨论渲染引擎是如何渲染帧的,否则就无法优化帧率。
我们先来看看交互阶段的渲染流水线(如下图)。和加载阶段的渲染流水线有一些不同的地方是,在交互阶段没有了加载关键资源和构建 DOM、CSSOM 流程,通常是由 JavaScript 触发交互动画的。
交互阶段渲染流水线
结合上图,我们来一起回顾下交互阶段是如何生成一个帧的。大部分情况下,生成一个新的帧都是由 JavaScript 通过修改 DOM 或者 CSSOM 来触发的。还有另外一部分帧是由 CSS 来触发的。
如果在计算样式阶段发现有布局信息的修改,那么就会触发重排操作,然后触发后续渲染流水线的一系列操作,这个代价是非常大的。
同样如果在计算样式阶段没有发现有布局信息的修改,只是修改了颜色一类的信息,那么就不会涉及到布局相关的调整,所以可以跳过布局阶段,直接进入绘制阶段,这个过程叫重绘。不过重绘阶段的代价也是不小的。
还有另外一种情况,通过 CSS 实现一些变形、渐变、动画等特效,这是由 CSS 触发的,并且是在合成线程上执行的,这个过程称为合成。因为它不会触发重排或者重绘,而且合成操作本身的速度就非常快,所以执行合成是效率最高的方式。
回顾了在交互过程中的帧是如何生成的,那接下来我们就可以讨论优化方案了。一个大的原则就是让单个帧的生成速度变快。所以,下面我们就来分析下在交互阶段渲染流水线中有哪些因素影响了帧的生成速度以及如何去优化。

1. 减少 JavaScript 脚本执行时间

有时 JavaScript 函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况我们可以采用以下两种策略:
一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。
另一种是采用 Web Workers。你可以把 Web Workers 当作主线程之外的一个线程,在 Web Workers 中是可以执行 JavaScript 脚本的,不过 Web Workers 中没有 DOM、CSSOM 环境,这意味着在 Web Workers 中是无法通过 JavaScript 来访问 DOM 的,所以我们可以把一些和 DOM 操作无关且耗时的任务放到 Web Workers 中去执行。
总之,在交互阶段,对 JavaScript 脚本总的原则就是不要一次霸占太久主线程。

2. 避免强制同步布局

在介绍强制同步布局之前,我们先来聊聊正常情况下的布局操作。通过 DOM 接口执行添加元素或者删除元素等操作后,是需要重新计算样式和布局的,不过正常情况下这些操作都是在另外的任务中异步完成的,这样做是为了避免当前的任务占用太长的主线程时间。为了直观理解,你可以参考下面的代码:
<html>
<body>
<div id="mian_div">
<li id="time_li">time</li>
<li>geekbang</li>
</div>
<p id="demo">强制布局demo</p>
<button onclick="foo()">添加新元素</button>
<script>
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
}
</script>
</body>
</html>
对于上面这段代码,我们可以使用 Performance 工具来记录添加元素的过程,如下图所示:
Performance 记录添加元素的执行过程
从图中可以看出来,执行 JavaScript 添加元素是在一个任务中执行的,重新计算样式布局是在另外一个任务中执行,这就是正常情况下的布局操作。
理解了正常情况下的布局操作,接下来我们就可以聊什么是强制同步布局了。
所谓强制同步布局,是指 JavaScript 强制将计算样式和布局操作提前到当前的任务中。为了直观理解,这里我们对上面的代码做了一点修改,让它变成强制同步布局,修改后的代码如下所示:
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
//由于要获取到offsetHeight,
//但是此时的offsetHeight还是老的数据,
//所以需要立即执行布局操作
console.log(main_div.offsetHeight)
}
将新的元素添加到 DOM 之后,我们又调用了main_div.offsetHeight来获取新 main_div 的高度信息。如果要获取到 main_div 的高度,就需要重新布局,所以这里在获取到 main_div 的高度之前,JavaScript 还需要强制让渲染引擎默认执行一次布局操作。我们把这个操作称为强制同步布局。
同样,你可以看下面通过 Performance 记录的任务状态:
触发强制同步布局 Performance 图
从上图可以看出来,计算样式和布局都是在当前脚本执行过程中触发的,这就是强制同步布局。
为了避免强制同步布局,我们可以调整策略,在修改 DOM 之前查询相关值。代码如下所示:
function foo() {
let main_div = document.getElementById("mian_div")
//为了避免强制同步布局,在修改DOM之前查询相关值
console.log(main_div.offsetHeight)
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
document.getElementById("mian_div").appendChild(new_node);
}

3. 避免布局抖动

还有一种比强制同步布局更坏的情况,那就是布局抖动。所谓布局抖动,是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作。为了直观理解,你可以看下面的代码:
function foo() {
let time_li = document.getElementById("time_li")
for (let i = 0; i < 100; i++) {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("time.geekbang")
new_node.appendChild(textnode);
new_node.offsetHeight = time_li.offsetHeight;
document.getElementById("mian_div").appendChild(new_node);
}
}
我们在一个 for 循环语句里面不断读取属性值,每次读取属性值之前都要进行计算样式和布局。执行代码之后,使用 Performance 记录的状态如下所示:
Performance 中关于布局抖动的表现
从上图可以看出,在 foo 函数内部重复执行计算样式和布局,这会大大影响当前函数的执行效率。这种情况的避免方式和强制同步布局一样,都是尽量不要在修改 DOM 结构时再去查询一些相关值。

4. 合理利用 CSS 合成动画

合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被 JavaScript 或者一些布局任务占用,CSS 动画依然能继续执行。所以要尽量利用好 CSS 合成动画,如果能让 CSS 处理动画,就尽量交给 CSS 来操作。
另外,如果能提前知道对某个元素执行动画操作,那就最好将其标记为 will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。

5. 避免频繁的垃圾回收

我们知道 JavaScript 使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。
所以要尽量避免产生那些临时垃圾数据。那该怎么做呢?可以尽可能优化储存结构,尽可能避免小颗粒对象的产生。

总结

好了,今天就介绍到这里,下面我来总结下本文的主要内容。
我们主要讲解了如何系统优化加载阶段和交互阶段的页面。
在加载阶段,核心的优化原则是:优化关键资源的加载速度,减少关键资源的个数,降低关键资源的 RTT 次数。
在交互阶段,核心的优化原则是:尽量减少一帧的生成时间。可以通过减少单次 JavaScript 的执行时间、避免强制同步布局、避免布局抖动、尽量采用 CSS 的合成动画、避免频繁的垃圾回收等方式来减少一帧生成的时长。

思考时间

那你来分析下新浪官网(https://www.sina.com.cn/ )在加载阶段和交互阶段所存在的一些性能问题。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 21

提建议

上一篇
24 | 分层和合成机制:为什么CSS动画比JavaScript高效?
下一篇
26 | 虚拟DOM:虚拟DOM和实际的DOM有何不同?
unpreview
 写留言

精选留言(50)

  • HB
    2019-10-01
    为老师点赞,假期不断更

    作者回复: 争取快点把主干课程写完,然后还要整理几百条留言,来系统地答疑

    共 2 条评论
    32
  • Luke
    2019-10-10
    我们在进行优化的时候,其中有一条策略就是使用离线dom减少重排与重绘,就是将dom先隐藏再修改,修改完后再显示出来。但是浏览器的渲染策略更倾向于将一个大的任务拆分成多个小的任务进行实时多次的渲染。那么离线dom的这种优化是不是有可能成为一种负优化?将多个渲染任务都集中到一次渲染中,导致页面渲染不够及时,当前帧占用时间过长,页面也会出现卡顿?
    共 4 条评论
    22
  • 阿哲
    2019-10-03
    加载阶段渲染流水线的配图中,css解析器和v8引擎是不是写反了?
    共 10 条评论
    19
  • mfist
    2019-10-03
    加载阶段: 通过分析network中关键资源(html文件 js文件 css文件)的大小,个数,只找到一个可能性能问题:html文件是128kb比较大,网站本身已经开启gzip http2 多个静态资源域名、开启缓存等多个优化手段 交互阶段 新浪首页页面加载完成后,滚动页面查看次屏页面,没有太多的交互,查看performance没有发现太明显的性能问题 今日总结: 一个页面从生命周期的维度主要分为三个阶段:加载阶段、交互阶段、关闭阶段。 1. 加载阶段影响网页首次渲染的关键资源几个指标:个数、大小、RTT(round trip time)。通常一个HTTP的数据包在14kb左右。 2. 交互阶段的优化主要是指渲染进程渲染帧速度。如何让单个帧生成的速度变快呢? * 减少JavaScript脚本执行时间 * 避免强制同步布局,添加 删除dom后计算样式布局是在另外一个任务中执行的,这时候获取样式信息,会将其变成同步任务。 * 避免布局抖动 * 合理利用CSS合成动画(标识 will-change 单独生成一个图层) * 避免频繁的垃圾回收。(尽量避免临时垃圾数据,优化存储结构,避免小颗粒对象产生)
    展开
    共 1 条评论
    14
  • L2
    2020-05-08
    避免强制同步布局那里,我的疑问是既然需要知道offsetHeight肯定是想要最新的啊! 如果我先获取再执行dom操作,还有什么意义?
    共 8 条评论
    13
  • 安静
    2019-10-08
    请问老师:当 JavaScript 标签加上了 sync 或者 defer、CSSlink 属性之前加上了取消阻止显现的标志后,它们就变成了非关键资源了。 这段里面取消阻止显现标记是什么标记呀
    共 4 条评论
    10
  • Geek_fd81b3
    2020-09-09
    老师,如果操作dom引起了重拍或重绘,是整个页面从0开始吗,哪些没变化的地方也会重拍重绘吗?比如我把页面最后的元素大小改变了,上面的都没发生变化,这样上面哪些没有变化的也会重拍重绘吗?
    共 4 条评论
    6
  • 小蛋糕
    2020-02-12
    真的不太会看,但是还是想表达一下自己的看法: 1. 加载阶段 1.1 关键资源方面 - 首页 html 文件体积有 500+KB,我比较了一下搜狐只有 200+KB。 - DCL 有 2.67s,搜狐 1.23s。 - 有很多外部 js 文件,但都没有发现 async 或 defer,应该不会每个都在操作 DOM 或 CSSOM 吧。 1.2 RTT - 大量 14kb 以上的关键资源,所以 RTT 的次数应该比较多。 1.3 Memory - 11MB 不确定好坏,搜狐 6MB 2. 交互阶段 - event: scroll 不知写了什么,每次都好几十 ms,有点小卡,而搜狐的首页 event: scroll 仅仅 1 ms 左右。 其他的交互也没啥啦,照不出来啦。
    展开
    共 2 条评论
    5
  • 江霖
    2020-06-23
    这个流水线的图和前面有点不一样,不是应该先生成cssom,然后再执行,javascript么 另外还有一个问题就是,如果一个页面中有很多外联的css,页面中的js会等所有css加载完成生成cssom之后在执行么?
    共 3 条评论
    4
  • Sevens 些粉
    2020-03-06
    CSSlink 属性之前加上了取消阻止显现的标志后。 这个取消阻止显现的标志指??
    共 1 条评论
    4
  • Liber
    2021-08-13
    1 个 HTTP 的数据包在 14KB 左右吗,根据阮一峰写的,不应该是1400字节,即1.4KB左右吗? https://www.ruanyifeng.com/blog/2017/06/tcp-protocol.html
    共 3 条评论
    3
  • 卡尔
    2020-06-19
    新浪的关键资源index.css排队5.70s,停滞有108.13ms,Content Download有50.66ms。感觉这个文件应该有优化的空间。
    3
  • 匡晨辉
    2019-12-18
    老师,加载阶段的那个图画是不是失误画错了?加载css生成cssod 应该是在执行js前面,应为js可能会操作css依赖cssod的生成这是前几张讲过的内容
    3
  • bai
    2019-11-21
    有疑惑,js文件加上defer应该也不能变成非关键资源吧。defer的js的执行时间是domcontentloaded事件之前,此时还没有执行布局和绘制,也就是首屏页面还没有展示。也就是说defer的js还是会执行之后再进行布局和绘制。
    共 1 条评论
    3
  • This
    2019-10-05
    老师的课程是我遇到干货最满的,希望老师陆续出其他课程
    3
  • call me baby
    2020-11-07
    老师,可以回答一下,重排或者重绘是指整个dom还是只是发生改变的当前层的?
    共 2 条评论
    2
  • 孜孜
    2020-05-27
    包的大小不是由mtu定的吗?为什么http一般是14kb啊?不能简单的用文件大小除以14kb吧。
    3
  • AMIR
    2019-12-30
    为了避免强制同步布局,我们可以调整策略,在修改 DOM 之前查询相关值,但是老师,修改之前查询的DOM值只是修改之前的啊,我要查询修改之后的不还是得放到后面??
    共 4 条评论
    2
  • 付伟超
    2021-01-02
    新浪官网:首页加载,关键资源太多,多个html页面文件size过大,内部注释等需要minify,iframe过多拉长了Loaded的时间;好在多用的http2协议,可以多路复用一个tcp连接,还有cdn加速,资源rtt时间较短; 交互阶段:没有大的交互事件,看不出效果
    1
  • 君自兰芳
    2020-11-12
    “JavaScript 和 CSS 是同时发起请求的,所以它们的请求是重叠的,那么计算它们的 RTT 时,只需要计算体积最大的那个数据就可以了“ 这里有个疑问,如果同时请求的js和css有几十个,每个都是14kb以下,那也只用了一个RTT吗?
    共 1 条评论
    1