0%

前端性能

前端性能优化的相关内容,好好掌握消化,其实这一部分实用性远高于应试性,很考验各位前端仔的实战水平的

1.RAIL模型

RAIL是一个以用户为中心的性能模型,它把用户的体验拆分成几个关键点(例如,tap,scroll,load),并且帮你定义好了每一个的性能指标。

有以下四个方面:

  • Response
  • Animation
  • Idle
  • Load

RAIL示意图

1.1 聚焦用户

以下是用户对性能延迟的感知:

延迟时间 用户感知
0-16ms 很流畅
0-100ms 基本流畅
100-1000ms 感觉到网站上有一些加载任务
1000ms or more 失去耐心了
10000ms or more 直接离开,不会再访问了

1.2 Response: 事件处理最好在50ms内完成

1.2.1 目标

  • 用户的输入到响应的时间不超过100ms,给用户的感受是瞬间就完成了。

1.2.2 优化方案

  • 事件处理函数在50ms内完成,考虑到idle task的情况,事件会排队,等待时间大概在50ms。适用于click,toggle,starting animations等,不适用于drag和scroll。
  • 复杂的js计算尽可能放在后台,如web worker,避免对用户输入造成阻塞
  • 超过50ms的响应,一定要提供反馈,比如倒计时,进度百分比等。

idle task:除了要处理输入事件,浏览器还有其它任务要做,这些任务会占用部分时间,一般情况会花费50ms的时间,输入事件的响应则排在其后。

下图是idle task对input response的影响:

Idle Task示意图

1.3 Animation: 在10ms内产生一帧

1.3.1 目标

  • 产生每一帧的时间不要超过10ms,为了保证浏览器60帧,每一帧的时间在16ms左右,但浏览器需要用6ms来渲染每一帧。
  • 旨在视觉上的平滑。用户对帧率变化感知很敏感。

1.3.2 优化方案

  • 在一些高压点上,比如动画,不要去挑战cpu,尽可能地少做事,如:取offset,设置style等操作。尽可能地保证60帧的体验。
  • 在渲染性能上,针对不同的动画做一些特定优化

动画不只是UI的视觉效果,以下行为都属于

  • 视觉动画,如渐隐渐显,tweens,loading等
  • 滚动,包含弹性滚动,松开手指后,滚动会持续一段距离
  • 拖拽,缩放,经常伴随着用户行为

1.4 Idle: 最大化空闲时间

1.4.1 目标

  • 最大化空闲时间,以增大50ms内响应用户输入的几率

1.4.2 优化方案

  • 用空闲时间来完成一些延后的工作,如先加载页面可见的部分,然后利用空闲时间加载剩余部分,此处可以使用 requestIdleCallback API
  • 在空闲时间内执行的任务尽量控制在50ms以内,如果更长的话,会影响input handle的pending时间
  • 如果用户在空闲时间任务进行时进行交互,必须以此为最高优先级,并暂停空闲时间的任务

1.5 Load: 传输内容到页面可交互的时间不超过5秒

如果页面加载比较慢,用户的交点可能会离开。加载很快的页面,用户平均停留时间会变长,跳出率会更低,也就有更高的广告查看率

1.5.1 目标

  • 优化加载速度,可以根据设备、网络等条件。目前,比较好的一个方式是,让你的页面在一个中配的3G网络手机上打开时间不超过5秒
  • 对于第二次打开,尽量不超过2秒

1.5.2 优化方案

1.6 分析RAIL用的工具

总结

RAIL是一个旅程,为了提升用户在网站的交互体验而不断探索。你需要去理解用户如何感知你的站点,这样才能设置最佳的性能目标

  • 聚焦用户
  • 100ms内响应用户的输入
  • 10ms内产生1帧,在滚动或者动画执行时
  • 最大化主线程的空闲时间
  • 5s内让网页变得可交互

参考

web.dev/rail/

2.前端性能优化

2.1 缓存

当浏览器想要获取远程的数据时,我们的性能之旅就开始了。然而,我们并不会立即动身(发送请求)。在计算机领域,很多性能问题都会通过增加缓存来解决,前端也不例外。和许多后端服务一样,前端缓存也是多级的。下面让我们一起来具体看一看。

2.1.1 本地数据存储

通过结合本地存储,可以在业务代码侧实现缓存。

对于一些请求,我们可以直接在业务代码侧进行缓存处理。缓存方式包括 localStoragesessionStorageindexedDB。把这块加入缓存的讨论也许会有争议,但利用好它确实能在程序侧达到一些类似缓存的能力。

例如,我们的页面上有一个日更新的榜单,我们可以做一个当日缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 当用户加载站点中的榜单组件时,可以通过该方法获取榜单数据
async function readListData() {
const info = JSON.parse(localStorage.getItem('listInfo'));
if (isExpired(info.time, +(new Date))) {
const list = await fetchList();
localStorage.setItem('listInfo', JSON.stringify({
time: +(new Date),
list: list
}));
return list;
}
return info.list;
}

localStorage 大家都比较了解了,indexedDB 可能会了解的更少一些。想快速了解 indexedDB 使用方式可以看这篇文章[1]。

从前端视角看,这是一种本地存储;但如果从整个系统的维度来看,很多时候其实也是缓存链条中的一环。对于一些特殊的、轻量级的业务数据,可以考虑使用本地存储作为缓存。

2.1.2. 内存缓存(Memory)

当你访问一个页面及其子资源时,有时候会出现一个资源被使用多次,例如图标。由于该资源已经存储在内存中,再去请求反而多此一举,浏览器内存则是最近、最快的响应场所。

memcache

内存缓存并无明确的标准规定,它与 HTTP 语义下的缓存关联性不大,算是浏览器帮我们实现的优化,很多时候其实我们意识不到。

对内存缓存感兴趣,可以在这篇文章[2]的 Memory Cache 部分进一步了解。

2.1.3. Cache API

当我们没有命中内存缓存时,是否就开始发送请求了呢?其实不一定。

在这时我们还可能会碰到 Cache API 里的缓存,提到它就不得不提一下 Service Worker 了。它们通常都是配合使用的。

首先明确一下,这层的缓存没有规定说该缓存什么、什么情况下需要缓存,它只是提供给了客户端构建请求缓存机制的能力。如果你对 PWA 或者 Service Worker 很了解,应该非常清楚是怎么一回事。如果不了解也没有关系,我们可以简单看一下:

首先,Service Worker 是一个后台运行的独立线程,可以在代码中启用

1
2
3
4
5
6
// index.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js').then(function () {
// 注册成功
});
}

之后需要处理一些 Service Worker 的生命周期事件,而其中与这里提到的缓存功能直接相关的则是请求拦截:

1
2
3
4
5
6
7
8
9
10
11
12
// sw.js
self.addEventListener('fetch', function (e) {
// 如果有cache则直接返回,否则通过fetch请求
e.respondWith(
caches.match(e.request).then(function (cache) {
return cache || fetch(e.request);
}).catch(function (err) {
console.log(err);
return fetch(e.request);
})
);
});

以上代码会拦截所有的网络请求,查看是否有缓存的请求内容,如果有则返回缓存,否则会继续发送请求。与内存缓存不同,Cache API 提供的缓存可以认为是“永久性”的,关闭浏览器或离开页面之后,下次再访问仍然可以使用。

Service Worker 与 Cache API 其实是一个功能非常强大的组合,能够实现堆业务的透明,在[兼容性](https://caniuse.com/#feat=serviceworkers&search=service worker)上也可以做成渐进支持。还是非常推荐在业务中尝试的。当然上面代码简略了很多,想要进一步了解 Service Worker 和 Cache API 的使用可以看这篇文章[3]。同时推荐使用 Google 的 Workbox

2.1.4 HTTP 缓存

如果 Service Worker 中也没有缓存的请求信息,那么就会真正到 HTTP request 的阶段了。这个时候出现的就是我们所熟知的 HTTP 缓存规范。

HTTP 有一系列的规范来规定哪些情况下需要缓存请求信息、缓存多久,而哪些情况下不能进行信息的缓存。我们可以通过相关的 HTTP 请求头来实现缓存。

HTTP 缓存大致可以分为强缓存与协商缓存。

2.1.4.1 强缓存

在强缓存的情况下,浏览器不会向服务器发送请求,而是直接从本地缓存中读取内容,这个“本地”一般就是来源于硬盘。这也就是我们在 Chrome DevTools 上经常看到的「disk cache」。

disk cache

与其相关的响应头则是 ExpiresCache-Control。在 Expires 上可以设置一个过期时间,浏览器通过将其与当前本地时间对比,判断资源是否过期,未过期则直接从本地取即可。而 Cache-Control 则可以通过给它设置一个 max-age,来控制过期时间。例如,max-age=300 就是表示在响应成功后 300 秒内,资源请求会走强缓存。

2.1.4.2 协商缓存

你可能也感觉到了,强缓存不是那么灵活。如果我在 300 秒内更新了资源,需要怎么通知客户端呢?常用的方式就是通过协商缓存。

我们知道,远程请求慢的一大原因就是报文体积较大。协商缓存就是希望能通过先“问一问”服务器资源到底有没有过期,来避免无谓的资源下载。这伴随的往往会是 HTTP 请求中的 304 响应码。下面简单介绍一下实现协商缓存的两种方式:

一种协防缓存的方式是:服务器第一次响应时返回 Last-Modified,而浏览器在后续请求时带上其值作为 If-Modified-Since,相当于问服务端:XX 时间点之后,这个资源更新了么?服务器根据实际情况回答即可:更新了(状态码 200)或没更新(状态码 304)。

上面是通过时间来判断是否更新,如果更新时间间隔过短,例如 1s 一下,那么使用更新时间的方式精度就不够了。所以还有一种是通过标识 —— ETag。服务器第一次响应时返回 ETag,而浏览器在后续请求时带上其值作为 If-None-Match。一般会用文件的 MD5 作为 ETag

作为前端工程师,一定要善于应用 HTTP 缓存。如果想要了解更多关于 HTTP 缓存的内容,可以阅读这篇文章[4]。

上面这些的各级缓存的匹配机制里,都是包含资源的 uri 的匹配,即 uri 更改后不会命中缓存。也正是如此,我们目前在前端实践中都会把文件 HASH 加入到文件名中,避免同名文件命中缓存的旧资源。

2.1.5. Push Cache

假如很不幸,以上这些缓存你都没有命中,那么你将会碰到最后一个缓存检查 —— Push Cache。

Push Cache 其实是 HTTP/2 的 Push 功能所带来的。简言之,过去一个 HTTP 的请求连接只能传输一个资源,而现在你在请求一个资源的同时,服务端可以为你“推送”一些其他资源 —— 你可能在在不久的将来就会用到一些资源。例如,你在请求 www.sample.com 时,服务端不仅发送了页面文档,还一起推送了 关键 CSS 样式表。这也就避免了浏览器收到响应、解析到相应位置时才会请求所带来的延后。

不过 HTTP/2 Push Cache 是一个比较底层的网络特性,与其他的缓存有很多不同,例如:

  • 当匹配上时,并不会在额外检查资源是否过期;
  • 存活时间很短,甚至短过内存缓存(例如有文章提到,Chrome 中为 5min 左右);
  • 只会被使用一次;
  • HTTP/2 连接断开将导致缓存直接失效;
  • ……

如果对 HTTP/2 Push 感兴趣,可以看看这篇文章[5]。


好了,到目前为止,我们可能还没有发出一个真正的请求。这也意味着,在缓存检查阶段我们就会有很多机会将后续的性能问题扼杀在摇篮之中 —— 如果远程请求都不必发出,又何须优化加载性能呢?

所以,审视一下我们的应用、业务,看看哪些性能问题是可以在源头上解决的。

2.2 发送请求

在前一部分,我们介绍了浏览器缓存。当一个请求走过了各级前端缓存后,就会需要实际发送一个请求了。

在 HTTP 缓存中,我们其实也有发送请求;或者是在 HTTP 2.0 Push 下,使用了之前连接中推送的资源。不过为了保证思路的连贯,我还是把「发送请求」这个章节整体放在「缓存」之后了。

2.2.1 避免多余重定向

重定向是一个比较常用的技术手段。在一些情况下,你可能进行了服务迁移,修改了原有的 uri。这时候就可以使用重定向,把访问原网址的用户重定向到新的 uri。还有是在一些登录场景下,会使用到重定向技术。

重定向分为 301 的永久重定向和 302 的临时重定向。建议贴合语义,例如服务迁移的情况下,使用 301 重定向。对 SEO 也会更友好。

同时也不要滥用重定向。曾今也见过有业务在访问后重定向 3 次的情况,其实里面有些是不必要的。每次重定向都是有请求耗时的,建议避免过多的重定向。

2.2.2 DNS 预解析

基本我们访问远程服务的时候,不会直接使用服务的出口 IP,而是使用域名。所以请求的一个重要环节就是域名解析。

DNS 服务本身是一个树状层级结构,其解析是一个递归与迭代的过程。例如 github.com 的大致解析流程如下:

  1. 先检查本地 hosts 文件中是否有映射,有则使用;
  2. 查找本地 DNS 缓存,有则返回;
  3. 根据配置在 TCP/IP 参数中设置 DNS 查询服务器,并向其进行查询,这里先称为本地 DNS;
  4. 如果该服务器无法解析域名(没有缓存),且不需要转发,则会向根服务器请求;
  5. 根服务器根据域名类型判断对应的顶级域名服务器(.com),返回给本地 DNS,然后重复该过程,直到找到该域名;
  6. 当然,如果设置了转发,本地 DNS 会将请求逐级转发,直到转发服务器返回或者也不能解析。

更详细的介绍可以看这篇文章[1]。

这里我们需要了解的是:

  • 首先,DNS 解析流程可能会很长,耗时很高,所以整个 DNS 服务,包括客户端都会有缓存机制,这个作为前端不好涉入;
  • 其次,在 DNS 解析上,前端还是可以通过浏览器提供的其他手段来“加速”的。

DNS Prefetch[2] 就是浏览器提供给我们的一个 API。它是 Resource Hint 的一部分。它可以告诉浏览器:过会我就可能要去 yourwebsite.com 上下载一个资源啦,帮我先解析一下域名吧。这样之后用户点击某个按钮,触发了 yourwebsite.com 域名下的远程请求时,就略去了 DNS 解析的步骤。使用方式很简单:

1
<link rel="dns-prefetch" href="//yourwebsite.com">

当然,浏览器并不保证一定会去解析域名,可能会根据当前的网络、负载等状况做决定。标准里也明确写了

user agent SHOULD resolve as early as possible

2.2.3 预先建立连接

我们知道,建立连接不仅需要 DNS 查询,还需要进行 TCP 协议握手,有些还会有 TLS/SSL 协议,这些都会导致连接的耗时。使用 Preconnect[3] 可以帮助你告诉浏览器:“我有一些资源会用到某个源(origin),你可以帮我预先建立连接。”

根据规范,当你使用 Preconnect 时,浏览器大致做了如下处理:

  • 首先,解析 Preconnect 的 url;
  • 其次,根据当前 link 元素中的属性进行 cors 的设置;
  • 然后,默认先将 credential 设为 true,如果 cors 为 Anonymous 并且存在跨域,则将 credential 置为 false
  • 最后,进行连接。

使用 Preconnect 只需要将 rel 属性设为 preconnect 即可:

1
<link rel="preconnect" href="//sample.com">

当然,你也可以设置 CORS:

1
<link rel="preconnect" href="//sample.com" crossorigin>

需要注意的是,标准并没有硬性规定浏览器一定要(而是 SHOULD)完成整个连接过程,与 DNS Prefetch 类似,浏览器可以视情况完成部分工作。

2.2.4 使用 CDN

当我们实际把网络包发向我们的目标地址时,肯定希望越快到达目的地越好(对应的,也会希望越快获得响应)。而网络传输是有极限的,同样一个北京的用户,访问北京的服务器显然要比广州快很多。同时,服务的负载也会影响响应的速度。

对于静态资源,我们可以考虑通过 CDN 来降低时延。

对于使用 CDN 的资源,DNS 解析会将 CDN 资源的域名解析到 CDN 服务的负载均衡器上,负载均衡器可以通过请求的信息获取用户对应的地理区域,从而通过负载均衡算法,在背后的诸多服务器中,综合选择一台地理位置近、负载低的机器来提供服务。例如为北京联通用户解析北京的服务器 IP。这样,用户在之后访问 CDN 资源时都是访问北京服务器,距离近,速度快。

想了解更多 CDN 的工作方式可以阅读这篇文章[4]。


下图是请求声明周期中各个阶段的示意图,可以帮助我们理解发送请求(以及接收响应)的流程。

resource timing line

在缓存没法满足我们的情况下,就要开始真正发送请求了。从前端性能优化视角,我们会关注重定向、DNS 解析等问题,从而加速请求。但这块还预留了一小部分 —— 服务端的处理与响应。

过去,我们会将前端局限在浏览器中,但是随着 NodeJS 的兴起,很多业务都引入了基于 NodeJS 的 BFF 来为前端(客户端端)提供服务。所以咱们这次的旅程也会简单聊一下,在这一阶段可以做的一些优化。

参考资料

  1. DNS 的解析过程
  2. Resource Hints - DNS Prefetch
  3. Resource Hints - Preconnect
  4. CDN 之我见:原理篇
  5. Understanding Resource Timing
  6. TCP 3-Way Handshake Process
  7. TCP 4 wave hands
  8. 图文还原HTTPS原理
  9. URL redirection (wikipedia)

2.3 服务端响应

把这一部分放进前端性能优化并不是很严谨:

  • 其一,服务端有着服务端的通用技术手段,这块深入去研究,会是一个不一样的领域;
  • 其二,我们既然在讨论前端性能优化,这部分主要还是指 NodeJS,但不是所有业务都使用 NodeJS。

所以这里只会提一些实践中碰到的小点,辅以一些拓展阅读,希望能帮助大家抛砖引玉,开拓思维。

2.3.1 使用流进行响应

目前,现代浏览器都支持根据流的返回形式来逐步进行页面内容的解析、处理。这就意味着,即使请求的响应没有完全结束,浏览器也可以从手里已有的响应结果中进行页面的解析与渲染。

例如 css-only-chat-node 就利用了这个特点来实现无刷新、无 JavaScript 的页面更新。

2.3.2 业务聚合

BFF 非常合适做的一件事就是后端服务的聚合。

如果你有一个两个接口服务:第一个服务是先获取产品信息,再根据产品信息中的上架时间通过第二个服务获取该时间后的产品列表。这个业务逻辑如果放在前端(浏览器)处理将会串行发送两个请求。假设每个请求 200ms,那么就需要等待 400ms。如果引入 NodeJS,这一层可以放在 NodeJS 中实现。NodeJS 部署的位置一般离其他后端服务“更近”,例如同一个局域网。这类服务间的请求耗时显然更低,可能只需要 200(浏览器) + 30(NodeJS) * 2 = 260ms。

此外,如果一个业务需要在前端并发三、四个请求来获取完整数据,那么放在 NodeJS 的 BFF 层也是一个不错的选择。

2.3.3 避免代码问题

代码问题其实就非常细节了。简单列举一些常见的问题:

  • async await 的不当使用导致并行请求被串行化了;
  • 频繁地 JSON.parseJSON.stringify 大对象;
  • 正则表达式的灾难性回溯;
  • 闭包导致的内存泄漏;
  • CPU 密集型任务导致事件循环 delay 严重;
  • 未捕获的异常导致进程频繁退出,守护进程(pm2/supervisor)又将进程重启,这种频繁的启停也会比较消耗资源;
  • ……

参考资料

  1. 你不知道的 Node.js 性能优化
  2. Keeping Node.js Fast: Tools, Techniques, And Tips For Making High-Performance Node.js Servers
  3. Backend-in-the-frontend: a pattern for cleaner code
  4. Node.js 应用故障排查手册
  5. Node.js Best Practices

4.页面解析与处理

主要工作

这一阶段浏览器需要处理的东西很多,为了更好地理解性能优化,我们主要将其分为几个部分:

  • 页面 DOM 的解析;
  • 页面静态资源的加载,包括了页面引用的 JavaScript/CSS/图片/字体等;
  • 静态资源的解析与处理,像是 JavaScript 的执行、CSSOM 的构建与样式合成等;

大致过程就是解析页面 DOM 结构,遇到外部资源就加载,加载好了就使用。但是由于这部分的内容比较多,所以在这一节里我们重点关注页面的解析(其他部分在写一节中介绍)。

4.1. 注意资源在页面文档中的位置

我们的目标是收到内容就尽快解析处理,页面有依赖的资源就尽快发送请求,收到响应则尽快处理。然而,这个美好的目标也有可能会被我们不小心破坏。

JavaScript 脚本和 CSS 样式表在关于 DOM 元素的属性,尤其是样式属性上都有操作的权利。这就像是一个多线程问题。服务端多线程编程中经常通过锁来保证线程间的互斥。回到咱们的前端,现在也是两方在竞争同一个资源,显然也是会有互斥的问题。这就带来了 DOM 解析、JavaScript 加载与执行、CSS 加载与使用之间的一些互斥关系。

仅仅看 DOM 与 CSS 的关系,则如下图所示:

pipeline for dom and css

HTML 解析为 DOM Tree,CSS 解析为 CSSOM,两者再合成 Render Tree,并行执行,非常完美。然而,当 JavaScript 入场之后,局面就变了:

pipeline for dom and css with js

根据标准规范,在 JavaScript 中可以访问 DOM。因此当遇到 JavaScript 后会阻塞 DOM 的解析。于此同时,为避免 CSS 与 JavaScript 之间的竞态,CSSOM 的构建会阻塞 JavaScript 的脚本执行。总结起来就是 ——

JavaScript 会阻塞 DOM 构建,而 CSSOM 的构建又回阻塞 JavaScript 的执行。

所以这就是为什么在优化的最佳实践中,我们基本都推荐把 CSS 样式表放在 <head> 之中(即页面的头部),把 JavaScript 脚本放在 <body> 的最后(即页面的尾部)。

关于这部分的一些解释可以看这篇文章[1]。

4.2. 使用 defer 和 async

上面提到了,当 DOM 解析遇到 JavaScript 脚本时,会停止解析,开始下载脚本并执行,再恢复解析,相当于是阻塞了 DOM 构建。

那除了将脚本放在 body 的最后,还有什么优化方法么?是有的。

可以使用 deferasync 属性。两者都会防止 JavaScript 脚本的下载阻塞 DOM 构建。但是两者也有区别,最直观的表现如下:

async defer

defer 会在 HTML 解析完成后,按照脚本出现的次序再顺序执行;而 async 则是下载完成就立即开始执行,同时阻塞页面解析,不保证脚本间的执行顺序。

根据它们的特点,推荐在一些与主业务无关的 JavaScript 脚本上使用 async。例如统计脚本、监控脚本、广告脚本等。这些脚本一般都是一份独立的文件,没有外部依赖,不需要访问 DOM,也不需要有严格的执行时机限制。在这些脚本上使用 async 可以有效避免这些非核心功能的加载影响页面解析速度。

4.3. 页面文档压缩

HTML 的文档大小也会极大影响响应体下载的时间。一般会进行 HTML 内容压缩(uglify)的同时,使用文本压缩算法(例如 gzip)进行文本的压缩。关于资源压缩这一块,在下一节的内容中还会再详细进行介绍。


说一句题外话,你知道与页面解析密切相关的 DOMContentLoaded 事件何时触发么?interactive/complete 等 readyState 具体代表什么么?如果不太了解可以从HTML spec[2]里看。

用原话来说就是:

Returns “loading” while the Document is loading, “interactive” once it is finished parsing but still loading subresources, and “complete” once it has loaded.

The readystatechange event fires on the Document object when this value changes.

The DOMContentLoaded event fires after the transition to “interactive” but before the transition to “complete”, at the point where all subresources apart from async script elements have loaded.


好了,在这一站我们又了解了页面的解析过程及其性能优化。

5.页面静态资源

首先还是从宏观上来了解一下:

5.1. 总体原则

这一部分会涉及到各类常见的静态资源:JavaScript 脚本、CSS 样式表、图片、字体等。不同资源的优化措施既有联系又有差别,后续会以各类资源为维度,针对性介绍其优化的关注点和手段。

但咱们还是要先从整体维度上进行一些分析。其实在总体原则上,各类资源的优化思路都是大体类似的,包括但不限于:

  • 减少不必要的请求
  • 减少包体大小
  • 降低应用资源时的消耗
  • 利用缓存

为了大家能更好理解各类优化实施策略从何而来,先初步扩展一下以上的思路。

5.1.1. 减少不必要的请求

核心是希望能够减少请求的数量,因为浏览器对同源请求有并发上限的限制(例如 Chrome 是6),所以在 HTTP/1.1 下,请求过多可能会导致请求被排队了。一个典型场景就是一些图库类型的网站,页面加载后可能需要请求十数张图片。

同时,TCP/IP 的拥塞控制也使其传输有慢启动(slow start)的特点,连接刚建立时包体传输速率较低,后续会渐渐提速。因此,发送过多的“小”请求可能也不是一个很好的做法。

减少不必要的请求主要分为几个维度:

  • 对于不需要使用的内容,其实不需要请求,否则相当于做了无用功;
  • 对于可以延迟加载的内容,不必要现在就立刻加载,最好就在需要使用之前再加载;
  • 对于可以合并的资源,进行资源合并也是一种方法。

5.1.2. 减少包体大小

包体大小对性能也是有直接影响的。显然同样速率下,包体越小,传输耗时越低,整体页面加载与渲染的性能也会更好。

减少包体大小常用的方式包括了:

  • 使用适合当前资源的压缩技术;
  • 避免再响应包体里“塞入”一些不需要的内容。

5.1.3. 降低应用资源时的消耗

以上主要的关注点都在页面资源加载的效率,其实有些时候,浏览器去执行或使用资源的也是有消耗的。例如在 JavaScript 执行了一段 CPU 密集的计算,或者进行频繁的 DOM 操作,这些都会让 JavaScript 的执行变成影响性能的一大问题。虽然今天的像 V8 这样的引擎已经很快了,但是一些不当的操作仍然会带来性能的损耗。

此外,像是 CSS 选择器匹配、图片的解析与处理等,都是要消耗 CPU 和内存的。也许这些不太常成为性能杀手,但是某些特性场合下,了解它们也许会对你有所帮助。

5.1.4. 利用缓存

还记得咱们这趟旅程从哪出发的么?没错,缓存。

在旅程的第一站,我们介绍了浏览器访问一个 url 时的多级缓存策略。千万不要忘了,这些静态子资源也是网络请求,它们仍然可以利用之前介绍的完整缓存流程。缓存在很多时候会是一个帮你解决性能问题的非常有效的手段。

由于第一站已经对缓存进行了详细介绍,所以缓存这部分,在这一站里只会在针对资源类型再补充一些内容。

5.2. 针对各类资源的性能优化 🚀

以上的原则可以指导我们针对性地优化各类资源。下面我就以资源类型为维度,详细介绍其中涉及到的优化点与优化措施。

5.2.1 JavaScript 优化

1. 减少不必要的请求

在进行 JavaScript 优化时,我们还是秉承总体思路,首先就是减少不必要的请求。

#1.1. 代码拆分(code split)与按需加载

相信熟练使用 webpack 的同学对这一特性都不陌生。

虽然整体应用的代码非常多,但是很多时候,我们在访问一个页面时,并不需要把其他页面的组件也全部加载过来,完全可以等到访问其他页面时,再按需去动态加载。核心思路如下所示:

1
2
3
4
5
6
document.getElementById('btn').addEventListener('click', e => {
// 在这里加载 chat 组件相关资源 chat.js
const script = document.createElement('script');
script.src = '/static/js/chat.js';
document.getElementsByTagName('head')[0].appendChild(script);
});

在按钮点击的监听函数中,我动态添加了 <script> 元素。这样就可以实现在点击按钮时,才加载对应的 JavaScript 脚本。

代码拆分一般会配合构建工具一起使用。以 webpack 为例,在日常使用时,最常见的方式就是通过 dynamic import[1] 来告诉 webpack 去做代码拆分。webpack 编译时会进行语法分析,之后遇到 dynamic import 就会认为这个模块是需要动态加载的。相应的,其子资源也会被如此处理(除非被其他非动态模块也引用了)。

在 webpack 中使用代码拆分最常见的一个场景是基于路由的代码拆分。目前很多前端应用都在使用 SPA(单页面应用)形式,或者 SPA 与 MPA(多页面应用)的结合体,这就会涉及到前端路由。而页面间的业务差异也让基于路由的代码拆分成为一个最佳实践。想了解如何在 react-router v4 中实现路由级别的代码拆分,可以看这篇文章[2]。

当然,如果你不使用 webpack 之类的构建工具,你也可以选择一个 AMD 模块加载器(例如 RequireJS)来实现前端运行时上的异步依赖加载。

#1.2. 代码合并

我们在总体思路里有提到,减少请求的一个方法就是合并资源。试想一个极端情况:我们现在不对 node_modules 中的代码进行打包合并,那么当我们请求一个脚本之前将可能会并发请求数十甚至上百个依赖的脚本库。同域名下的并发请求数过高会导致请求排队,同时还可能受到 TCP/IP 慢启动的影响。

当然,在很多流行的构建工具中(webpack/Rollup/Parcel),是默认会帮你把依赖打包到一起的。不过当你使用其他一些工具时,就要注意了。例如使用 FIS3 时,就需要通过配置声明,将一些 common 库或 npm 依赖进行打包合并。又或者使用 Gulp 这样的工具,也需要注意进行打包。

总之,千万不要让你的碎文件散落一地。

#2. 减少包体大小
#2.1. 代码压缩

JavaScript 代码压缩比较常见的做法就是使用 UglifyJS 做源码级别的压缩。它会通过将变量替换为短命名、去掉多余的换行符等方式,在尽量不改变源码逻辑的情况下,做到代码体积的压缩。基本已经成为了前端开发的标配。在 webpack 的 production 模式下是默认开启的;而在 Gulp 这样的任务流管理工具上也有 gulp-uglify 这样的功能插件。

另一个代码压缩的常用手段是使用一些文本压缩算法,gzip 就是常用的一种方式。

响应头

上图中响应头的 Content-Encoding 表示其使用了 gzip。

压缩效果

深色的数字表示压缩后的大小为 22.0KB,浅色部分表示压缩前的大小为 91.9KB,压缩比还是挺大的,很有效果。一般服务器都会内置相应模块来进行 gzip 处理,不需要我们单独编写压缩算法模块。例如在 Nginx 中就包含了 ngx_http_gzip_module[3] 模块,通过简单的配置就可以开启。

1
2
3
4
gzip            on;
gzip_min_length 1000;
gzip_comp_level 6;
gzip_types application/javascript application/x-javascript text/javascript;
#2.2. Tree Shaking

Tree Shaking 最早进入到前端的视线主要是因为 Rollup。后来在 webpack 中也被实现了。其本质是通过检测源码中不会被使用到的部分,将其删除,从而减小代码的体积。例如:

1
2
3
4
5
6
7
8
// 模块 A
export function add(a, b) {
return a + b;
}

export function minus(a, b) {
return a - b;
}
1
2
3
// 模块 B
import {add} from 'module.A.js';
console.log(add(1, 2));

可以看到,模块 B 引用了模块 A,但是只使用了 add 方法。因此 minus 方法相当于成为了 Dead Code,将它打包进去没有意义,该方法是永远不会被使用到的。

注意,我在上面的代码中使用了 ESM 规范的模块语法,而没有使用 CommonJS。这主要是由于 Tree Shaking 算是一种静态分析,而 ESM 本身是一种的静态的模块化规范,所有依赖可以在编译期确定。如果想要更好得在 webpack 中使用,可以在查看其官网上的这部分内容[4]。关于 Tree Shaking 的介绍也可以从这里了解下[5]。

注意,刚才说了 Tree Shaking 非常依赖于 ESM。像是前端流行的工具库 lodash 一般直接安装的版本是非 ESM 的,为了支持 Tree Shaking,我们需要去安装它的 ESM 版本 —— lodash-es实现 Tree Shaking[6]。

此外,Chrome DevTools 也可以帮助你查看加载的 JavaScript 代码的使用覆盖率[7]。

#2.3. 优化 polyfill 的使用

前端技术的一大特点就是需要考虑兼容性。为了让大家能顺畅地使用浏览器的新特性,一些程序员们开发了新特性对应的 polyfill,用于在非兼容浏览器上也能使用新特性的 API。后续升级不用改动业务代码,只需要删除相应的 polyfill 即可。

这种舒适的开发体验也让 polyfill 成为了很多项目中不可或缺的一份子。然而 polyfill 也是有代价的,它增加了代码的体积。毕竟 polyfill 也是 JavaScript 写的,不是内置在浏览器中,引入的越多,代码体积也越大。所以,只加载真正所需的 polyfill 将会帮助你减小代码体积。

首先,不是每个业务的兼容性要求都一样。因此,按你业务的场景来确定引入哪些 polyfill 是最合适的。然而,特性千千万,手动 import 或者添加 Babel Transformer 显然是一件成本极高的事。针对这点,我们可以通过 browserslist 来帮忙,许多前端工具(babel-preset-env/autoprefixer/eslint-plugin-compat)都依赖于它。使用方式可以看这里

其次,在 Chrome Dev Summit 2018 上还介绍了一种 Differential Serving[8] 的技术,通过浏览器原生模块化 API 来尽量避免加载无用 polyfill。

1
2
<script type="module" src="main.mjs"></script>
<script nomodule src="legacy.js"></script>

这样,在能够处理 module 属性的浏览器(具有很多新特性)上就只需加载 main.mjs(不包含 polyfill),而在老式浏览器下,则会加载 legacy.js(包含 polyfill)。

最后,其实在理想上,polyfill 最优的使用方式应该是根据浏览器特性来分发,同一个项目在不同的浏览器,会加载不同的 polyfill 文件。例如 Polyfill.io 就会根据请求头中的客户端特性与所需的 API 特性来按实际情况返回必须的 polyfill 集合。

#2.4. webpack

webpack 现在已经成为很多前端应用的构建工具,因此这里单独将其列了出来。我们可以通过 webpack-bundle-analyzer 这个工具来查看打包代码里面各个模块的占用大小。

webpack-bundle-analyzer

很多时候,打包体积过大主要是因为引入了不合适的包,对于如何优化依赖包的引入,这里有一些建议可以帮助你减小 bundle 的体积[9]。

#3. 解析与执行

除了 JavaScript 下载需要耗时外,脚本的解析与执行也是会消耗时间的。

#3.1. JavaScript 的解析耗时

很多情况下,我们会忽略 JavaScript 文件的解析。一个 JavaScript 文件,即使内部没有所谓的“立即执行函数”,JavaScript 引擎也是需要对其进行解析和编译的。

js 处理

上图可以看出,解析与编译消耗了好几百毫秒。所以换一个角度来说,删除不必要的代码,对于降低 Parse 与 Compile 的负载也是很有帮助的。

同时,我们从前一节已经知道,JavaScript 的解析、编译和执行会阻塞页面解析,延迟用户交互。所以有时候,加载同样字节数的 JavaScript 对性能的影响可能会高于图片,因为图片的处理可以放在其他线程中并行执行。

#3.2. 避免 Long Task

对于一些单页应用,在加载完核心的 JavaScript 资源后,可能会需要执行大量的逻辑。如果处理不好,可能会出现 JavaScript 线程长时间执行而阻塞主线程的情况。

long task

例如在上图中,帧率下降明显的地方出现了 Long Task,伴随着的是有一段超过 700 ms 的脚本执行时间。而性能指标 FCP 与 DCL 处于其后,一定程度上可以认为,这个 Long Task 阻塞了主线程并拖慢了页面的加载时间,严重影响了前端性能与体验。

想要了解更多关于 Long Task 的内容,可以看看 Long Task 相关的标准[10]。

#3.3. 是否真的需要框架

相信如果现在问大家,我们是否需要 React、Vue、Angular 或其他前端框架(库),大概率是肯定的。

但是我们可以换个角度来思考这个问题。类库/框架帮我们解决的问题之一是快速开发与后续维护代码,很多时候,类库/框架的开发者是需要在可维护性、易用性和性能上做取舍的。对于一个复杂的整站应用,使用框架给你的既定编程范式将会在各个层面提升你工作的质量。但是,对于某些页面,我们是否可以反其道行之呢?

例如产品经理反馈,咱们的落地页加载太慢了,用户容易流失。这时候你会开始优化性能,用上这次「性能之旅」里的各种措施。但你有没有考虑过,对于像落地页这样的、类似静态页的页面,是不是可以“返璞归真”?

也许你使用了 React 技术栈 —— 你加载了 React、Redux、React-Redux、一堆 Reducers…… 好吧,整个 JavaScript 可能快 1MB 了。更重要的是,这个页面如果是用于拉新的,这也代表着访问者并没有缓存可以用。好吧,为了一个静态页(或者还有一些非常简单的表单交互),用户付出了高额的成本,而原本这只需要 50 行不到的代码。所以有时候考虑使用原生 JavaScript 来实现它也是一种策略。Netflix 有一篇文章介绍了他们是如何通过这种方式大幅缩减加载与操作响应时间的[11]。

当然,还是强调一下,并不是说不要使用框架/类库,只是希望大家不要拘泥于某个思维定式。做工具的主人,而不是工具的“奴隶”。

#3.4. 针对代码的优化

请注意,截止目前(2019.08)以下内容不建议在生产环境中使用。

还有一种优化思路是把代码变为最优状态。它其实算是一种编译优化。在一些编译型的静态语言上(例如 C++),通过编译器进行一些优化非常常见。

这里要提到的就是 facebook 推出的 Prepack。例如下面一段代码:

1
2
3
4
5
(function () {
function hello() {return 'hello';}
function world() {return 'world';}
global.s = hello() + ' ' + world();
})();

可以优化为:

1
s = 'hello world';

不过很多时候,代码体积和运行性能是会有矛盾的。同时 Prepack 也还不够成熟,所以不建议在生产环境中使用。

#4. 缓存

JavaScript 部分的缓存与我们在第一部分里提到的缓存基本一致,如果你记不太清了,可以回到咱们的第一站

#4.1. 发布与部署

这里简单提一下:大多数情况下,我们对于 JavaScript 与 CSS 这样的静态资源,都会启动 HTTP 缓存。当然,可能使用强缓存,也可能使用协商缓存。当我们在强缓存机制上发布了更新的时候,如何让浏览器弃用缓存,请求新的资源呢?

一般会有一套配合的方式:首先在文件名中包含文件内容的 Hash,内容修改后,文件名就会变化;同时,设置不对页面进行强缓存,这样对于内容更新的静态资源,由于 uri 变了,肯定不会再走缓存,而没有变动的资源则仍然可以使用缓存。

上面说的主要涉及前端资源的发布和部署,详细可以看这篇内容[12],这里就不展开了。

#4.2. 将基础库代码打包合并

为了更好利用缓存,我们一般会把不容易变化的部分单独抽取出来。例如一个 React 技术栈的项目,可能会将 React、Redux、React-Router 这类基础库单独打包出一个文件。

这样做的优点在于,由于基础库被单独打包在一起了,即使业务代码经常变动,也不会导致整个缓存失效。基础框架/库、项目中的 common、util 仍然可以利用缓存,不会每次发布新版都会让用户花费不必要的带宽重新下载基础库。

所以一种常见的策略就是将基础库这种 Cache 周期较长的内容单独打包在一起,利用缓存减少新版本发布后用户的访问速度。这种方法本质上是将缓存周期不同的内容分离了,隔离了变化。

webpack 在 v3.x 以及之前,可以通过 CommonChunkPlugin 来分离一些公共库。而升级到 v4.x 之后有了一个新的配置项 optimization.splitChunks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'all',
minChunks: 1,
cacheGroups: {
commons: {
minChunks: 1,
automaticNamePrefix: 'commons',
test: /[\\/]node_modules[\\/]react|redux|react-redux/,
chunks: 'all'
}
}
}
}
}
#4.3. 减少 webpack 编译不当带来的缓存失效

由于 webpack 已经成为前端主流的构建工具,因此这里再特别提一下使用 webpack 时的一些注意点,减少一些不必要的缓存失效。

我们知道,对于每个模块 webpack 都会分配一个唯一的模块 ID,一般情况下 webpack 会使用自增 ID。这就可能导致一个问题:一些模块虽然它们的代码没有变化,但由于增/删了新的其他模块,导致后续所有的模块 ID 都变更了,文件 MD5 也就变化了。另一个问题在于,webpack 的入口文件除了包含它的 runtime、业务模块代码,同时还有一个用于异步加载的小型 manifest,任何一个模块的变化,最后必然会传导到入口文件。这些都会使得网站发布后,没有改动源码的资源也会缓存失效。

规避这些问题有一些常用的方式。

#4.3.1. 使用 Hash 来替代自增 ID

你可以使用 HashedModuleIdsPlugin 插件,它会根据模块的相对路径来计算 Hash 值。当然,你也可以使用 webpack 提供的 optimization.moduleIds,将其设置为 hash,或者选择其他合适的方式。

#4.3.2. 将 runtime chunk 单独拆分出来

通过 optimization.runtimeChunk 配置可以让 webpack 把包含 manifest 的 runtime 部分单独分离出来,这样就可以尽可能限制变动影响的文件范围。

1
2
3
4
5
6
7
8
9
// webpack.config.js
module.exports = {
//...
optimization: {
runtimeChunk: {
name: 'runtime'
}
},
}

如果你对 webpack 模块化 runtime 运行的原理不太了解,可以看看这篇文章[13]。

#4.3.3. 使用 records

你可以通过 recordsPath 配置来让 webpack 产出一个包含模块信息记录的 JSON 文件,其中包含了一些模块标识的信息,可以用于之后的编译。这样在后续的打包编译时,对于被拆分出来的 Bundle,webpack 就可以根据 records 中的信息来尽量避免破坏缓存。

1
2
3
4
5
// webpack.config.js
module.exports = {
//...
recordsPath: path.join(__dirname, 'records.json')
};

如果对上述避免或减少缓存失效的方法感兴趣,也可以再读一读这篇文章14。在 webpack v5.x 的计划中,也有针对 module 和 chunk ID 的一些工作计划来提高长期缓存


这一部分就到这里了,我们基于减少不必要的请求、减少包体大小、降低解析与执行消耗、利用缓存这四个基本思想,展开去看了 JavaScript 资源的性能优化措施有哪些。

下面我们就会进入前端三驾马车中的 CSS 部分了!


#参考资料

  1. Proposal Dynamic Import
  2. 在 react-router4 中进行代码拆分
  3. Module ngx_http_gzip_module
  4. Tree Shaking - webpack
  5. Tree Shaking 性能优化实践 - 原理篇
  6. Tree Shaking for Lodash
  7. CSS and JS code coverage - Chrome DevTools
  8. Chrome Dev Summit 2018
  9. Optimize your libraries with webpack
  10. Long Tasks API 1
  11. A Netflix Web Performance Case Study
  12. 大公司里怎样开发和部署前端代码?
  13. webpack进阶:前端运行时的模块化设计与实现
  14. Separating a Manifest
  15. The cost of JavaScript in 2019
  16. [译] 2019 年的 JavaScript 性能
  17. webpack 4: Code Splitting, chunk graph and the splitChunks optimization
  18. 文本压缩算法的对比和选择
  19. 简单聊聊 GZIP 的压缩原理与日常应用
  20. Text Compression
  21. Better tree shaking with deep scope analysis
  22. How we reduced our initial JS/CSS size by 67%

5.3CSS 优化

在这一部分,我们来详细看一看与 CSS 相关的优化内容。其中一部分内容与 JavaScript 部分类似,另一部分可能是 CSS 特有的一些优化或注意事项。

#1. 关键 CSS

在性能优化上,其实我们会更关注关键渲染路径(Critical Rendering Path,即 CRP),而不一定是最快加载完整个页面。

CRP 是指优先显示与当前用户操作有关的内容。由于 CSS 会“间接”阻塞页面的解析,所以在这个过程中的 CSS 也被称为关键 CSS。识别出当前业务中的关键 CSS,优先下载与解析它,将会帮助我们更好降低延迟。

所以我们首先还是需要先建立好概念:很多时候,我们并不是在追求整体页面的最快加载,而是最核心最关键的那部分。例如在视频网站上可能是播放器,在文档站点可能是阅读器。

由于很多时候,关键 CSS 不会太大,因此有一种常见的优化措施是,将关键 CSS 的内容通过 <style> 标签内联到 <head> 中,然后异步加载其他非关键 CSS。这样对于关键路径的渲染可以减少一次 RTT (Round-Trip Time)。用户可以更快看到一些页面初始的渲染结果。

经典的骨架屏[1]可以算是这种思路的一个延展。我们会生成一个不包含实际功能的静态页面,将必要的脚本、样式、甚至图片(base64)资源都内联到其中,当用户访问时直接先返回该页面,就可以很快让用户看到页面结果,之后在异步渐进加载预渲染,就会让用户感觉“很快”。

facebook skeleton

骨架屏可以手动编写[2],当然也可以通过编译插件来帮助你自动生成[3]骨架屏。

#2. 优化资源请求

#2.1. 按需加载

与 JavaScript 类似,我们的 CSS 也是可以按需加载的。尤其在当下组件化盛行的潮流里,组件的按需加载就可能会包括了 JavaScript 脚本、CSS 样式表、图标图片。在上一部分介绍的 webpack code split 也会包含这一部分。

除了使用一些构建工具以及对应的插件外,你也可以使用 loadCSS 这样的库来实现 CSS 文件的按需异步加载。

#2.2. 合并文件

同样的,参照 JavaScript,我们也可以把一些 CSS 文件进行合并来减少请求数。

#2.3. 请求的优先级排序

浏览器中的各类请求是有优先级排序的。低优请求会被排在高优之后再发送。

network priority

不过可惜的是,浏览器没有将优先级排序的能力给我们直接开放出来。但在一些场景下,我们可以通过更合理的使用媒体类型和媒体查询来实现资源加载的优先级。下面会介绍一下这种方法。

一些网站为了达到不同屏幕之间的兼容,可能会使用媒体查询的方式来构建它的样式系统。一般而言,我们都会把样式代码写在一起,例如导航的在各类屏幕下的样式都会放在 navigator.css 下,列表都会放在 list.css 下。

1
2
<link rel="stylesheet" href="navigator.css" />
<link rel="stylesheet" href="list.css" />

这里带来的一个问题就是,在宽度小于 400px 的场景下,其实并不需要应用宽度 400px 以上的 CSS 样式。针对这个问题,link 标签上其实有一个 media 属性来处理媒体查询下的加载优先级。浏览器会优先下载匹配当前环境的样式资源,相对的,其他非匹配的优先级会下降。

1
2
3
4
<link rel="stylesheet" href="navigator.css" media="all" />
<link rel="stylesheet" href="list.css" media="all" />
<link rel="stylesheet" href="navigator.small.css" media="(max-width: 500px)" />
<link rel="stylesheet" href="list.small.css" media="(max-width: 500px)" />

这样拆分后,当页面大于 500 px 时,navigator.small.csslist.small.css 的优先级会降低,同时,它们也不再会阻塞页面的渲染。需要注意的是,优先级降低代表可能会后加载,并非不加载。

css network priority

#2.4. 慎用 @import

CSS 提供了一个 @import 语法来加载外部的样式文件。然而,这会把你的请求变得串行化。

考虑 index.css 这个资源,页面上是这么引用的:

1
<link rel="stylesheet" href="index.css" />

而在 index.css 中引用了 other.css

1
2
/* index.css */
@import url(other.css);

这样浏览器只有当下载了 index.css 并解析到其中 @import 时,才会再去请求 other.css。这是一个串行过程。

而如果我们把它改造为

1
2
<link rel="stylesheet" href="index.css" />
<link rel="stylesheet" href="other.css" />

那就不需要等待 index.css 下载,几乎是并行执行了。

#2.5. 谨慎对待 JavaScript 脚本的位置

第四站「页面解析与处理」中,我们提到将 “JavaScript 脚本放到页面尾部、CSS 放到页面头部”的模式。这只是大多数情况的处理方式。对于一些特殊情况,我们还是需要特殊处理的。

还记得之前提到的一些统计类、监控类的第三方脚本么?一般而言,第三方会提供你如下一段脚本,然后推荐你内联到页面中:

1
2
3
4
5
<script>
var script = document.createElement('script');
script.src = 'vendor.lib.js';
document.getElementsByTagName('head')[0].appendChild(script);
</script>

我们希望通过这样的方式来尽快异步加载脚本。然而,如果我们一不小心出现了下面这样的操作,可能会事与愿违:

1
2
3
4
5
6
<link rel="stylesheet" href="navigator.css" />
<script>
var script = document.createElement('script');
script.src = 'vendor.lib.js';
document.getElementsByTagName('head')[0].appendChild(script);
</script>

这时,navigator.css 的加载会阻塞后面的 JavaScript 执行,这是为了防止后续脚本对样式的查询出现不确定性。所以,这两个资源就变成了串行加载。

要优化这个问题很简单 —— 调换一下顺序即可:

1
2
3
4
5
6
<script>
var script = document.createElement('script');
script.src = 'vendor.lib.js';
document.getElementsByTagName('head')[0].appendChild(script);
</script>
<link rel="stylesheet" href="navigator.css" />

这时,vendor.lib.jsnavigator.css 就会并行加载了。当然,你需要确保不需要查询 navigator.css 样式应用后的信息。

#3. 减少包体大小

#3.1. 压缩

CSS 同样可以进行压缩,与 JavaScript 类似,也有相应的 CSS uglify 工具,例如 clean-css,可以优化代码、删除多余的换行与空格。

同时,由于 CSS 同样是文本内容,因此针对文本的各类压缩算法同样适用,最常用到的就是 gzip。如何在 Nginx 上开启它之前也介绍过,这里就不赘述了。

#3.2. 选择合适的兼容性

对于 CSS 的 polyfill,同样可以配合 browserslist 来实现你的业务场景下的兼容性支持。比较常见的是配合 AutoprefixerPostCSS Preset Env 来使用。可以根据指定的浏览器范围,决定使用哪些 CSS polyfill 来帮助你将新的 CSS 代码转换为旧的浏览器能识别的内容。

#4. 解析与渲染树构建

#4.1. 简化选择器

浏览器的工作原理:新式网络浏览器幕后揭秘 一文中,作者介绍了样式计算的原理。虽然文章发布时间比较早了,但其中部分内容还是具有参考价值的。

其中指出了,样式数据是一个超大的结构,为每一个元素查找匹配的规则会造成性能问题,同时,复杂的层叠规则也会带来很高的复杂度。针对这些问题浏览器也做了很多优化[4]。

正是由于这些问题,我们应该尽量避免不必要的选择器复杂度。例如下面这个复杂选择器:

1
2
3
body > main.container > section.intro h2:nth-of-type(odd) + p::first-line a[href$=".pdf"] {
/* …… */
}

不过一般情况下我们是不会写出如此复杂的选择器的。但有一个情况还是需要注意一下,就是使用 SASS、LESS 这样的工具时,避免过多的嵌套。以 LESS 为例:

1
2
3
4
5
6
7
8
9
10
11
.list {
.item {
.product {
.intro {
.pic {
height: 200px;
}
}
}
}
}

由于过多的嵌套,编译后会产生如下选择器:

1
2
3
.list .item .product .intro .pic {
height: 200px;
}

当然,你也可以考虑使用类似 BEM 这样的方式来进行 CSS className 的组织与命名[5],避免过多的嵌套层级。这里有一篇文章[6]介绍了选择器的匹配成本。

不过千万要注意了,代码的可维护性还是最重要的,不要为了过分简化选择器而放弃了代码语义和可维护性。我们仅仅是要尽量避免像上面那样的一些过分复杂的、或者不必要的繁琐的选择器。

#4.2. 避免使用昂贵的属性

有一些 CSS 的属性在渲染上是有比较高的成本的,渲染速度相较而言也会慢些。在不同的浏览器上,具体的表现不太一致,但总体来说,下面一些属性是比较昂贵的:

  • border-radius
  • box-shadow
  • opacity
  • transform
  • filter
  • position: fixed
#4.3. 使用先进的布局方式

对于页面布局,我们有很多方法,例如 float、positioning、flex、grid[7] 等。float 本身设计出来并非是为了处理复杂的布局,但是通过大家的发掘和研究,已经可以通过它来实现很多种布局形式了。基于兼容性考虑,float 也成为了流行的布局方式。

不过,一些资料[8]也指出,使用新版的 flex 进行布局比我们用的一些“老式”方法性能更好(例如基于 float 的浮动布局)。 flex 在移动端具有不错的兼容性,很多移动场景下已经大规模使用 flex 进行页面布局。同时,虽然 flex 有兼容性要求,但由于很多 PC 站都不再兼容低版本 IE,因此也可以开始尝试使用它。

如果你之前对 flex 了解不多,这里有一个不错的教程可以帮你快速入门。

#5. 利用缓存

与其他静态资源类似,我们仍然可以使用各类缓存策略来加速资源的加载。

此外,如果使用 webpack 作为构建工具,我们一般会使用 css-loader 和 style-loader,这样可以直接在 JavaScript 代码中 import 样式文件。不过这样带来的一个问题就是样式代码其实是耦合在 JavaScript 代码中的,通过运行时添加 style 标签注入页面。

一个更好的做法是在生产环境中将样式信息单独抽离成 CSS 文件,这样也可以更好地利用缓存。在 webpack v4.x 之前的版本中,我们习惯于用 ExtractTextWebpackPlugin 插件。不过在 v4.x 之后,对于 CSS 的抽取,推荐使用 MiniCssExtractPlugin 插件。它可以将样式信息单独抽离出 CSS 文件来。基础的使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
plugins: [
new MiniCssExtractPlugin({
filename: '[contenthash:8].css',
chunkFilename: '[contenthash:8].css'
}),
],
module: {
rules: [{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}]
}
};

相较于 JavaScript,用户对 CSS 的可控性可能会稍弱一些,基础的优化点也许没有那么多。但随着 CSS 特性(例如 Houdini/CSS Paint API)的不断发展,相信也会有更多我们需要关注的优化点。

#参考资料

  1. Everything you need to know about skeleton screens
  2. Building Skeleton Screens with CSS Custom Properties
  3. 自动化生成 H5 骨架页面
  4. 浏览器的工作原理:新式网络浏览器幕后揭秘
  5. 使用 BEM-constructor 构建 CSS
  6. Optimizing CSS: ID Selectors and Other Myths
  7. 一篇全面的CSS布局学习指南(译)
  8. flex布局对性能的影响主要体现在哪方面?
  9. CSS and Network Performance
  10. Render Blocking CSS
  11. How to write CSS for a great performance web application
  12. 20 Tips for Optimizing CSS Performance

5.4图片优化

优质的图片可以有效吸引用户,给用户良好的体验,所以随着互联网的发展,越来越多的产品开始使用图片来提升产品体验。相较于页面其他元素,图片的体积不容忽视。下图是截止 2019 年 6 月 HTTP Archive[1] 上统计的网站上各类资源加载的体积:

overall

可以看到,图片占据了半壁江山。同样,在一篇 2018 年的文章中,也提到了图片在网站中体量的平均占比已经超过了 50%[2]。然而,随着平均加载图片总字节数的增加,图片的请求数却再减少,这也说明网站使用的图片质量和大小正在不断提高。

所以,如果单纯从加载的字节数这个维度来看性能优化,那么很多时候,优化图片带来的流量收益要远高于优化 JavaScript 脚本和 CSS 样式文件。下面我们就来看看,如何优化图片资源。

#1. 优化请求数

#1.1. 雪碧图

图片可以合并么?当然。最为常用的图片合并场景就是雪碧图(Sprite)[3]。

在网站上通常会有很多小的图标,不经优化的话,最直接的方式就是将这些小图标保存为一个个独立的图片文件,然后通过 CSS 将对应元素的背景图片设置为对应的图标图片。这么做的一个重要问题在于,页面加载时可能会同时请求非常多的小图标图片,这就会受到浏览器并发 HTTP 请求数的限制。我见过一个没有使用雪碧图的页面,首页加载时需要发送 20+ 请求来加载图标。将图标合并为一张大图可以实现「20+ → 1」的巨大缩减。

雪碧图的核心原理在于设置不同的背景偏移量,大致包含两点:

  • 不同的图标元素都会将 background-url 设置为合并后的雪碧图的 uri;
  • 不同的图标通过设置对应的 background-position 来展示大图中对应的图标部分。

你可以用 Photoshop 这类工具自己制作雪碧图。当然比较推荐的还是将雪碧图的生成集成到前端自动化构建工具中,例如在 webpack 中使用 webpack-spritesmith,或者在 gulp 中使用 gulp.spritesmith。它们两者都是基于于 spritesmith 这个库,你也可以自己将这个库集成到你喜欢的构建工具中。

#1.2. 懒加载

我们知道,一般来说我们访问一个页面,浏览器加载的整个页面其实是要比可视区域大很多的,也是什么我们会提出“首屏”的概念。这就导致其实很多图片是不在首屏中的,如果我们都加载的话,相当于是加载了用户不一定会看到图片。而图片体积一般都不小,这显然是一种流量的浪费。这种场景在一些带图片的长列表或者配图的博客中经常会遇到。

解决的核心思路就是图片懒加载 —— 尽量只加载用户正在浏览或者即将会浏览到的图片。实现上来说最简单的就是通过监听页面滚动,判断图片是否进入视野,从而真正去加载图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function loadIfNeeded($img) {
const bounding = $img..getBoundingClientRect();
if (
getComputedStyle($img).display !== 'none'
&& bounding.top <= window.innerHeight
&& bounding.bottom >= 0
) {
$img.src = $img.dataset.src;
$img.classList.remove('lazy');
}
}

// 这里使用了 throttle,你可以实现自己的 throttle,也可以使用 lodash
const lazy = throttle(function () {
const $imgList = document.querySelectorAll('.lazy');
if ($imgList.length === 0) {
document.removeEventListener('scroll', lazy);
window.removeEventListener('resize', lazy);
window.removeEventListener('orientationchange', lazy);
return;
}
$imgList.forEach(loadIfNeeded);
}, 200);

document.addEventListener('scroll', lazy);
window.addEventListener('resize', lazy);
window.addEventListener('orientationchange', lazy);

对于页面上的元素只需要将原本的 src 值设置到 data-src 中即可,而 src 可以设置为一个统一的占位图。注意,由于页面滚动、缩放和横竖方向(移动端)都可能会改变可视区域,因此添加了三个监听。

当然,这是最传统的方法,现代浏览器还提供了一个更先进的 Intersection Observer API[4] 来做这个事,它可以通过更高效的方式来监听元素是否进入视口。考虑兼容性问题,在生产环境中建议使用对应的 polyfill

如果想使用懒加载,还可以借助一些已有的工具库,例如 aFarkas/lazysizesverlok/lazyloadtuupola/lazyload 等。

在使用懒加载时也有一些注意点:

  • 首屏可以不需要懒加载,对首屏图片也使用懒加载会延迟图片的展示。
  • 设置合理的占位图,避免图片加载后的页面“抖动”。
  • 虽然目前基本所有用户都不会禁用 JavaScript,但还是建议做一些 JavaScript 不可用时的 backup。

对于占位图这块可以再补充一点。为了更好的用户体验,我们可以使用一个基于原图生成的体积小、清晰度低的图片作为占位图。这样一来不会增加太大的体积,二来会有很好的用户体验。LQIP (Low Quality Image Placeholders)[5] 就是这种技术。目前也已经有了 LQIPSQIP(SVG-based LQIP) 的自动化工具可以直接使用。

如果你想了解更多关于图片懒加载的内容,这里有一篇更详尽的图片懒加载指南[6]。

#1.3. CSS 中的图片懒加载

除了对于 <img> 元素的图片进行来加载,在 CSS 中使用的图片一样可以懒加载,最常见的场景就是 background-url

1
2
3
.login {
background-url: url(/static/img/login.png);
}

对于上面这个样式规则,如果不应用到具体的元素,浏览器不会去下载该图片。所以你可以通过切换 className 的方式,放心得进行 CSS 中图片的懒加载。

#1.4. 内联 base64

还有一种方式是将图片转为 base64 字符串,并将其内联到页面中返回,即将原 url 的值替换为 base64。这样,当浏览器解析到这个的图片 url 时,就不会去请求并下载图片,直接解析 base64 字符串即可。

但是这种方式的一个缺点在于相同的图片,相比使用二进制,变成 base64 后体积会增大 33%。而全部内联进页面后,也意味着原本可能并行加载的图片信息,都会被放在页面请求中(像当于是串行了)。同时这种方式也不利于复用独立的文件缓存。所以,使用 base64 需要权衡,常用于首屏加载 CRP 或者骨架图上的一些小图标。

#2. 减小图片大小

#2.1. 使用合适的图片格式

使用合适的图片格式不仅能帮助你减少不必要的请求流量,同时还可能提供更好的图片体验。

图片格式是一个比较大的话题,选择合适的格式[7]有利于性能优化。这里我们简单总结一些。

1) 使用 WebP:

考虑在网站上使用 WebP 格式[8]。在有损与无损压缩上,它的表现都会优于传统(JPEG/PNG)格式。WebP 无损压缩比 PNG 的体积小 26%,webP 的有损压缩比同质量的 JPEG 格式体积小 25-34%。同时 WebP 也支持透明度。下面提供了一种兼容性较好的写法。

1
2
3
4
5
<picture>
<source type="image/webp" srcset="/static/img/perf.webp">
<source type="image/jpeg" srcset="/static/img/perf.jpg">
<img src="/static/img/perf.jpg">
</picture>

2) 使用 SVG 应对矢量图场景:

在一些需要缩放与高保真的情况,或者用作图标的场景下,使用 SVG 这种矢量图非常不错。有时使用 SVG 格式会比相同的 PNG 或 JPEG 更小。

3) 使用 video 替代 GIF:

兼容性允许的情况下考虑,可以在想要动图效果时使用视频,通过静音(muted)的 video 来代替 GIF。相同的效果下,GIF 比视频(MPEG-4)大 5~20 倍Smashing Magazine 上有篇文章[9]详细介绍使用方式。

4) 渐进式 JPEG:

基线 JPEG (baseline JPEG) 会从上往下逐步呈现,类似下面这种:

baseline jpeg

而另一种渐进式 JPEG (progressive JPEG)[10] 则会从模糊到逐渐清晰,使人的感受上会更加平滑。

progressive jpeg

不过渐进式 JPEG 的解码速度会慢于基线 JPEG,所以还是需要综合考虑 CPU、网络等情况,在实际的用户体验之上做权衡。

#2.2. 图片质量的权衡

图片的压缩一般可以分为有损压缩(lossy compression)和无损压缩(lossless compression)。顾名思义,有损压缩下,会损失一定的图片质量,无损压缩则能够在保证图片质量的前提下压缩数据大小。不过,无损压缩一般可以带来更可观的体积缩减。在使用有损压缩时,一般我们可以指定一个 0-100 的压缩质量。在大多数情况下,相较于 100 质量系数的压缩,80~85 的质量系数可以带来 30~40% 的大小缩减,同时对图片效果影响较小,即人眼不易分辨出质量效果的差异。

jpeg quality

处理图片压缩可以使用 imagemin 这样的工具,也可以进一步将它集成至 webpackGulpGrunt 这样的自动化工具中。

#2.3. 使用合适的大小和分辨率

由于移动端的发展,屏幕尺寸更加多样化了。同一套设计在不同尺寸、像素比的屏幕上可能需要不同像素大小的图片来保证良好的展示效果;此外,响应式设计也会对不同屏幕上最佳的图片尺寸有不同的要求。

以往我们可能会在 1280px 宽度的屏幕上和 640px 宽度的屏幕上都使用一张 400px 的图,但很可能在 640px 上我们只需要 200px 大小的图片。另一方面,对于如今盛行的“2 倍屏”、“3 倍屏”也需要使用不同像素大小的资源。

好在 HTML5 在 <img> 元素上为我们提供了 srcsetsizes 属性,可以让浏览器根据屏幕信息选择需要展示的图片。

1
<img srcset="small.jpg 480w, large.jpg 1080w" sizes="50w" src="large.jpg" >

具体的使用方式可以看这篇文章[11]。

#2.4. 删除冗余的图片信息

你也许不知道,很多图片含有一些非“视觉化”的元信息(metadata),带上它们可会导致体积增大与安全风险[12]。元信息包括图片的 DPI、相机品牌、拍摄时的 GPS 等,可能导致 JPEG 图片大小增加 15%。同时,其中的一些隐私信息也可能会带来安全风险。

所以如果不需要的情况下,可以使用像 imageOptim 这样的工具来移除隐私与非关键的元信息。

#2.5 SVG 压缩

在 2.1. 中提到,合适的场景下可以使用 SVG。针对 SVG 我们也可以进行一些压缩。压缩包括了两个方面:

首先,与图片不同,图片是二进制形式的文件,而 SVG 作为一种 XML 文本,同样是适合使用 gzip 压缩的。

其次,SVG 本身的信息、数据是可以压缩的,例如用相比用 <path> 画一个椭圆,直接使用 <ellipse> 可以节省文本长度。关于信息的“压缩”还有更多可以优化的点[13]。SVGGO 是一个可以集成到我们构建流中的 NodeJS 工具,它能帮助我们进行 SVG 的优化。当然你也可以使用它提供的 Web 服务

#3. 缓存

与其他静态资源类似,我们仍然可以使用各类缓存策略来加速资源的加载。


图片作为现代 Web 应用的重要部分,在资源占用上同样也不可忽视。可以发现,在上面提及的各类优化措施中,同时附带了相应的工具或类库。平时我们主要的精力会放在 CSS 与 JavaScript 的优化上,因此在图片优化上可能概念较为薄弱,自动化程度较低。如果你希望更好得去贯彻图片的相关优化,非常建议将自动化工具引入到构建流程中。

除了上述的一些工具,这里再介绍两个非常好用的图片处理的自动化工具:SharpJimp


#参考资料

  1. HTTP Archive: Page Weight Report
  2. State of the Web: Top Image Optimization Strategies
  3. CSS Sprites: What They Are, Why They’re Cool, and How To Use Them
  4. IntersectionObserver’s Coming into View
  5. Introducing LQIP – Low Quality Image Placeholders
  6. The Complete Guide to Lazy Loading Images
  7. What Is the Right Image Format for Your Website?
  8. Using WebP Images
  9. Improve Animated GIF Performance With HTML5 Video
  10. 渐进式jpeg(progressive jpeg)图片及其相关
  11. 响应式图片srcset全新释义sizes属性w描述符
  12. An Overview of Image Metadata - How It Affects Web Performance and Security
  13. Understanding and Manually Improving SVG Optimization
  14. Essential Image Optimization Guide
  15. 见微知著,Google Photos Web UI 完善之旅
  16. Automating image optimization
  17. Lazy loading images using Intersection Observer
  18. Trust is Good, Observation is Better—Intersection Observer v2
  19. Image policies for fast load times and more

5.5字体优化

有些时候,内置的字体并不能满足我们的需求,如果我们希望使用一些更有设计性的字体,我们一般会使用 @font-face 来加载字体文件:

1
2
3
4
5
@font-face {
font-family: 'Samplefont';
src: url(/static/samplefont.woff2) format('woff2'),
url(/static/samplefont.woff) format('woff');
}

然而这种方式的一大问题在于,在字体加载的期间,浏览器页面是默认不展示文本内容的。即我们常说的 FOIT (Flash of Invisible Text)。在现代浏览器中,FOIT 持续至多 3 秒,会带来糟糕的用户体验。所以在字体这部分的性能优化中,主要关注点在于如何平滑的加载字体。下面有一些解决方案。

#1. font-display

你可以在 @font-face 中设置 font-display: swap,他可以让 FOIT 的默认行为变为 FOUT (Flash of Unstyled Text),即先会使用默认字体样式展示文本,字体加载完毕后再将文本的字体样式进行替换。

1
2
3
4
5
6
@font-face {
font-family: 'Samplefont';
src: url(/static/samplefont.woff2) format('woff2'),
url(/static/samplefont.woff) format('woff');
font-display: swap;
}

font-display 的取值包括 auto|block|swap|fallback|optional,这里详细介绍了各种值的使用场景[1]。不过目前该属性的兼容性一般

#2. 内联字体

我们在上一节介绍过,可以使用 base64 将图片“内联”到页面中。同样的,字体也可以使用这种方式,这样就避免异步加载字体时的 FOIT 或 FOUT。我们可以将字体文件转为 base64 的字符串,设置到 @font-face 里的 src 属性上:

1
2
3
4
@font-face {
font-family: 'Samplefont';
src: url('data:application/x-font-woff;charset=utf-8;base64,d09GRgABAAAAAHyoABMAAAAA4XQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABG…') format('woff2');
}

但这种方式的局限性在于,在一个 @font-face 中只能加载加载一种字体类型。同时,与使用内联图片一样,这也会将本可以并行请求的数据量变为串行。

#3. 使用 CSS Font Loading API

CSS Font Loading API 是浏览器提供的,可以用来自定义控制字体加载的 API。这样你就可以在 JavaScript 中进行字体的加载,等加载完成后,再将需要应用新字体的元素设置为对应的样式,例如添加一个对应的 className。这里介绍了如何使用 CSS Font Loading API[2]。

不过目前 CSS Font Loading API 的兼容性也不乐观。同时,由于一些困难也无法实现一个完美的 polyfill。因此如果想要使用类似的能力,可以考虑 Font Face Observer这个库。基本的使用方式如下:

1
2
3
4
5
6
const font = new FontFaceObserver('Samplefont');

font.load(null, 5000).then(
() => document.documentElement.classList.add('loaded'),
() => console.log('Font is not available')
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@font-face {
font-family: 'Samplefont';
src: url(/static/samplefont.woff2) format('woff2'),
url(/static/samplefont.woff) format('woff');
}

body {
font-family: sans-serif;
}

.loaded h1 {
font-family: Samplefont, sans-serif;
font-weight: 700;
}

#4. FOFT

在需要加载同一字体的粗体、斜体时,FOFT (Flash of Faux Text) 方法会非常有效。

首先你需要了解的是,对于一种字体,它的斜体与粗体是有专门的字符集的;与此同时,如果你指定了某种字体的粗体,但浏览器没有加载,那么你可以使用 font-synthesis 属性来让浏览器帮你模拟。而当实际的粗体或斜体加载完毕后,再使用实际的字体集。

具体实践起会借助上面提到的 CSS Font Loading API 或者 Font Face Observer,实现当字体加载完毕后的样式修改。


了解完字体的优化措施你会发现,它们主要集中于 如何通过加载策略来降低甚至消除 FOIT。当然上面提到的这些策略与技术你可以组合使用,以达到所需的优化效果。

如果还想了解更多关于字体加载的问题,可以看看这篇文章里总结的各类加载策略[3],它还随文提供了相应的代码示例。

font strategy

#参考资料

  1. Controlling Font Performance with font-display
  2. Getting started with CSS Font Loading
  3. A COMPREHENSIVE GUIDE TO FONT LOADING STRATEGIES

5.6视频优化

视频作为一种重要的媒体形态,在网站中使用可以提高网站内容的丰富性,但同时对网络加载来说也是一个负担。所以会出现一些如下针对 Web 上视频的优化。

#1. 使用合适的视频格式

与图片类似,不同的视频编码格式,其数据大小也大都不同。目前在 HTML5 Video 中常用的格式为 MPEG-4。除了 MPEG-4 之外,还支持一种叫 WebM 的新的视频格式。

WebM(VP9) 相较于 MPEG-4(x264) 来说会更小,不过兼容性相对来说也较差。因此可以考虑在 <video> 中指定多个 <source>

1
2
3
4
<video>
<source src="/static/video/me.webm" type="video/webm">
<source src="/static/video/me.mp4" type="video/mp4">
</video>

此外,使用 AV1 编码[1]会比 VP9(WebM) 小约30%,比 x264(MPEG-4) 小约45-50%[2]。

#2. 视频压缩

对于视频,我们也可以进行有损与无损压缩,同样可以有效减少视频大小。下面列举了一些常用的工具:

#3. 移除不必要的音轨信息

在上一节中我们提到,可以使用 <video> 代替 GIF 来实现动画,同时体积也会更小。由于在这种场景下本身就是不需要声音的,所以我们会将 <video> 设置为 muted

那么,既然不需要声音,我们是不是可以直接移除掉音轨的数据?是的,这样做也会帮助进一步缩减视频的体积。

#4. 使用“流”

尝试让浏览器使用“流”或者小分片的方式来播放你的视频,例如常用的 HLS (HTTP Live Streaming) 技术。简单来说,使用 HLS 技术,你的视频会包含一个 .m3u8 的索引文件和一系列包含播放内容的 .ts 分片。浏览器通过不断下载一小段的分片来进行视频播放,避免了完整视频下载的流量消耗。

你也可以尝试使用 MPEG-DASH[3] 这个技术,目前开源社区也有一个配套的客户端实现

#5. 移除不必要的视频

对于不需要使用视频的场景,最好的优化方法就是去掉视频。例如在小屏幕上,你可以通过媒体查询来避免下载视频:

1
2
3
4
5
@media screen and (max-width: 650px) {
#hero-video {
display: none;
}
}

关于视频的优化这里只介绍了一些基本的手段,但对于一个重度的视频网站来说,会包含例如播放器 SDK 的优化、数据预取、码率自适应等更多的优化内容,在 2019 GMTC 上,[B站分享了他们的缩减首帧耗时的一系列优化措施](https://static001.geekbang.org/con/42/pdf/3841774823/file/谭兆歆—GMTC B站的视频体验进化之路 - bilibili .pdf)[4]。所以这里算是一个抛砖引玉。

此外,虽然上面介绍了一些视频处理的软件工具,但是如果有更高的定制化或集成需求,建议使用 FFmpeg[5] 或其背后的这些包

#参考资料

  1. a technial overview of the AV1
  2. Speed Essentials: Key Techniques for Fast Websites (Chrome Dev Summit 2018)
  3. Dynamic Adaptive Streaming over HTTP (Wikipedia)
  4. [B 站的视频体验进化之路](https://static001.geekbang.org/con/42/pdf/3841774823/file/谭兆歆—GMTC B站的视频体验进化之路 - bilibili .pdf)
  5. FFmepg
  6. 8 Video Optimization Tips for Faster Loading Times
  7. Optimizing MP4 Video for Fast Streaming
  8. Web Performance 101: Video Optimization

3.重排(reflow)和重绘(repaint)

3.1 页面生成的过程:

1.HTML 被 HTML 解析器解析成 DOM 树;

2.CSS 被 CSS 解析器解析成 CSSOM 树;

3.结合 DOM 树和 CSSOM 树,生成一棵渲染树(Render Tree),这一过程称为 Attachment;

4.生成布局(flow),浏览器在屏幕上“画”出渲染树中的所有节点;

5.将布局绘制(paint)在屏幕上,显示出整个页面。

第四步和第五步是最耗时的部分,这两步合起来,就是我们通常所说的渲染。

image

3.2 渲染

在页面的生命周期中,**网页生成的时候,至少会渲染一次。在用户访问的过程中,还会不断触发重排(reflow)和重绘(repaint)**,不管页面发生了重绘还是重排,都会影响性能,最可怕的是重排,会使我们付出高额的性能代价,所以我们应尽量避免。

3.3 重排比重绘大

大,在这个语境里的意思是:谁能影响谁?

  • 重绘:某些元素的外观被改变,例如:元素的填充颜色
  • 重排:重新生成布局,重新排列元素。

就如上面的概念一样,单单改变元素的外观,肯定不会引起网页重新生成布局,但当浏览器完成重排之后,将会重新绘制受到此次重排影响的部分。比如改变元素高度,这个元素乃至周边dom都需要重新绘制。

也就是说:重绘不一定导致重排,但重排一定会导致重绘

3.4 重排(reflow)

3.4.1 概念

当DOM的变化影响了元素的几何信息(元素的的位置和尺寸大小),浏览器需要重新计算元素的几何属性,将其安放在界面中的正确位置,这个过程叫做重排。

重排也叫回流,简单的说就是重新生成布局,重新排列元素。

3.4.2 下面情况会发生重排

  • 页面初始渲染,这是开销最大的一次重排
  • 添加/删除可见的DOM元素
  • 改变元素位置
  • 改变元素尺寸,比如边距、填充、边框、宽度和高度等
  • 改变元素内容,比如文字数量,图片大小等
  • 改变元素字体大小
  • 改变浏览器窗口尺寸,比如resize事件发生时
  • 激活CSS伪类(例如::hover
  • 设置 style 属性的值,因为通过设置style属性改变结点样式的话,每一次设置都会触发一次reflow
  • 查询某些属性或调用某些计算方法:offsetWidth、offsetHeight等,除此之外,当我们调用 getComputedStyle方法,或者IE里的 currentStyle 时,也会触发重排,原理是一样的,都为求一个“即时性”和“准确性”。
常见引起重排属性和方法
width height margin padding
display border-width border position
overflow font-size vertical-align min-height
clientWidth clientHeight clientTop clientLeft
offsetWudth offsetHeight offsetTop offsetLeft
scrollWidth scrollHeight scrollTop scrollLeft
scrollIntoView() scrollTo() getComputedStyle()
getBoundingClientRect() scrollIntoViewIfNeeded()

3.4.3 重排影响的范围

由于浏览器渲染界面是基于流失布局模型的,所以触发重排时会对周围DOM重新排列,影响的范围有两种:

  • 全局范围:从根节点html开始对整个渲染树进行重新布局。
  • 局部范围:对渲染树的某部分或某一个渲染对象进行重新布局

全局范围重排:

1
2
3
4
5
6
7
8
9
10
11
<body>
<div class="hello">
<h4>hello</h4>
<p><strong>Name:</strong>BDing</p>
<h5>male</h5>
<ol>
<li>coding</li>
<li>loving</li>
</ol>
</div>
</body>

当p节点上发生reflow时,hello和body也会重新渲染,甚至h5和ol都会收到影响。

局部范围重排:

用局部布局来解释这种现象:把一个dom的宽高之类的几何信息定死,然后在dom内部触发重排,就只会重新渲染该dom内部的元素,而不会影响到外界。

3.5 重绘(Repaints):

3.5.1 概念

当一个元素的外观发生改变,但没有改变布局,重新把元素外观绘制出来的过程,叫做重绘。

3.5.2 常见的引起重绘的属性

属性:
color border-style visibility background
text-decoration background-image background-position background-repeat
outline-color outline outline-style border-radius
outline-width box-shadow background-size

3.5.3 重排优化建议

重排的代价是高昂的,会破坏用户体验,并且让UI展示非常迟缓。通过减少重排的负面影响来提高用户体验的最简单方式就是尽可能的减少重排次数,重排范围。下面是一些行之有效的建议,大家可以用来参考。

3.5.3.1 减少重排范围

我们应该尽量以局部布局的形式组织html结构,尽可能小的影响重排的范围。

  • 尽可能在低层级的DOM节点上,而不是像上述全局范围的示例代码一样,如果你要改变p的样式,class就不要加在div上,通过父元素去影响子元素不好。
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局。那么在不得已使用table的场合,可以设置table-layout:auto;或者是table-layout:fixed这样可以让table一行一行的渲染,这种做法也是为了限制reflow的影响范围。
3.5.3.2 减少重排次数
1.样式集中改变

不要频繁的操作样式,对于一个静态页面来说,明智且可维护的做法是更改类名而不是修改样式,对于动态改变的样式来说,相较每次微小修改都直接触及元素,更好的办法是统一在 cssText 变量中编辑。虽然现在大部分现代浏览器都会有 Flush 队列进行渲染队列优化,但是有些老版本的浏览器比如IE6的效率依然低下。

1
2
3
4
5
6
7
8
9
10
11
12
// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top = top + "px";

// 当top和left的值是动态计算而成时...
// better
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";

// better
el.className += " className";
2.分离读写操作

DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// bad 强制刷新 触发四次重排+重绘
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';
div.style.right = div.offsetRight + 1 + 'px';
div.style.bottom = div.offsetBottom + 1 + 'px';


// good 缓存布局信息 相当于读写分离 触发一次重排+重绘
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
var curRight = div.offsetRight;
var curBottom = div.offsetBottom;

div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';
div.style.right = curRight + 1 + 'px';
div.style.bottom = curBottom + 1 + 'px';

原来的操作会导致四次重排,读写分离之后实际上只触发了一次重排,这都得益于浏览器的渲染队列机制:

当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。

3.将 DOM 离线

“离线”意味着不在当前的 DOM 树中做修改,我们可以这样做:

  • 使用 display:none

    一旦我们给元素设置 display:none 时(只有一次重排重绘),元素便不会再存在在渲染树中,相当于将其从页面上“拿掉”,我们之后的操作将不会触发重排和重绘,添加足够多的变更后,通过 display属性显示(另一次重排重绘)。通过这种方式即使大量变更也只触发两次重排。另外,visibility : hidden 的元素只对重绘有影响,不影响重排。

  • 通过 documentFragment 创建一个 dom 碎片,在它上面批量操作 dom,操作完成之后,再添加到文档中,这样只会触发一次重排。

  • 复制节点,在副本上工作,然后替换它!

4.使用 absolute 或 fixed 脱离文档流

使用绝对定位会使的该元素单独成为渲染树中 body 的一个子元素,重排开销比较小,不会对其它节点造成太多影响。当你在这些节点上放置这个元素时,一些其它在这个区域内的节点可能需要重绘,但是不需要重排。

5.优化动画
  • 可以把动画效果应用到 position属性为 absolutefixed 的元素上,这样对其他元素影响较小。

    动画效果还应牺牲一些平滑,来换取速度,这中间的度自己衡量: 比如实现一个动画,以1个像素为单位移动这样最平滑,但是Layout就会过于频繁,大量消耗CPU资源,如果以3个像素为单位移动则会好很多

  • 启用GPU加速 GPU 硬件加速是指应用 GPU 的图形性能对浏览器中的一些图形操作交给 GPU 来完成,因为 GPU 是专门为处理图形而设计,所以它在速度和能耗上更有效率。

    GPU 加速通常包括以下几个部分:Canvas2D,布局合成, CSS3转换(transitions),CSS3 3D变换(transforms),WebGL和视频(video)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /*
    * 根据上面的结论
    * 将 2d transform 换成 3d
    * 就可以强制开启 GPU 加速
    * 提高动画性能
    */
    div {
    transform: translate3d(10px, 10px, 0);
    }

3.6 如何在浏览器中查看页面渲染时间

3.6.1 打开开发者工具

点击 Performance 左侧有个小圆点 点击刷新页面会录制整个页面加载出来 时间的分配情况。如下图

image

  • 蓝色: 网络通信和HTML解析
  • 黄色: JavaScript执行
  • 紫色: 样式计算和布局,即重排
  • 绿色: 重绘

哪种色块比较多,就说明性能耗费在那里。色块越长,问题越大。

3.6.2 点击 Event Log

单独勾选 Loading 项会显示 html 和 css 加载时间。如下图:

image

3.解析完 DOM+CSSOM 之后会生成一个渲染树 Render Tree,就是 DOM 和 CSSOM 的一一对应关系。

4.通过渲染树中在屏幕上“画”出的所有节点,称为渲染。

小结

  • 渲染的三个阶段 Layout,Paint,Composite Layers。 Layout:重排,又叫回流。 Paint:重绘,重排重绘这些步骤都是在 CPU 中发生的。 Compostite Layers:CPU 把生成的 BitMap(位图)传输到 GPU,渲染到屏幕。
  • CSS3 就是在 GPU 发生的:Transform Opacity。在 GPU 发生的属性比较高效。所以 CSS3 性能比较高。

结语

非常感谢你看完了这篇很长的文章,也希望大家能重视重排的这些问题,在我们平时的开发中,也需要有意识的规避这些问题,才能让我们写出来的代码更规范!

参考文档

掌握浏览器重绘(repaint)重排(reflow))-前端进阶

csstriggers

CSS硬件加速的好与坏

4.白屏时间

4.1 概念

白屏时间:即用户点击一个链接或打开浏览器输入URL地址后,从屏幕空白到显示第一个画面的时间。

白屏时间的长短将直接影响用户对该网站的第一印象。

4.2 白屏时间的重要性

当用户点开一个链接或者是直接在浏览器中输入URL开始进行访问时,就开始等待页面的展示。页面渲染的时间越短,用户等待的时间就越短,用户感知到页面的速度就越快。这样可以极大的提升用户的体验,减少用户的跳出,提升页面的留存率。

4.3 白屏是一个怎样的过程呢?

让我们一起来揭秘:从打开一个页面,到页面的画面展示经历了怎样的过程!

4.3.1 DNS Lookup

DNS Lookup 即浏览器从DNS服务器中进行域名查询。

浏览器会先对页面进行域名解析,获取到服务器的IP地址后,进而和服务器进行通信。

Tips: 通常在整个加载页面的过程中,浏览器会多次进行DNS Lookup,包括页面本身的域名查询以及在解析HTML页面时加载的JS、CSS、Image、Video等资源产生的域名查询。

4.3.2 建立TCP请求连接

浏览器和服务端TCP请求建立的过程,是基于TCP/IP,该协议由网络层的IP和传输层的TCP组成。IP是每一台互联网设备在互联网中的唯一地址。

TCP通过三次握手建立连接,并提供可靠的数据传输服务。

4.3.3 服务端请求处理响应

在TCP连接建立后,Web服务器接受请求,开始进行处理,同时浏览器端开始等待服务器的处理响应。

Web服务器根据请求类型的不同,进行相应的处理。静态资源如图片、CSS文件、静态HTML直接进行响应;如其他注册的请求转发给相应的应用服务器,进行如数据处理、缓存中取数据,将数据按照约定好的格式响应给浏览器。

在大型应用中,通常为分布式服务架构,应用服务器的处理有可能经过很多个系统的中间件,最终获取到需要的数据

4.3.4 客户端下载、解析、渲染显示页面

在服务器返回数据后,客户端浏览器接收数据,进行HTML下载、解析、渲染显示。

  • a. 如果是Gzip包,则先解压为HTML
  • b. 解析HTML的头部代码,下载头部代码中的样式资源文件或脚本资源文件
  • c. 解析HTML代码和样式文件代码,构建HTML的DOM树以及与CSS相关的CSSOM树
  • d. 通过遍历DOM树和CSSOM树,浏览器依次计算每个节点的大小、坐标、颜色等样式,构造渲染树
  • e. 根据渲染树完成绘制过程

浏览器下载HTML后,首先解析头部代码,进行样式表下载,然后继续向下解析HTML代码,构建DOM树,同时进行样式下载。当DOM树构建完成后,立即开始构造CSSOM树。理想情况下,样式表下载速度够快,DOM树和CSSOM树进入一个并行的过程,当两棵树构建完毕,构建渲染树,然后进行绘制。

Tips:浏览器安全解析策略对解析HTML造成的影响:

  • 当解析HTML时遇到内联JS代码,会阻塞DOM树的构建
  • 特别悲惨的情况: 当CSS样式文件没有下载完成时,浏览器解析HTML遇到了内联JS代码,此时!!!根据浏览器的安全解析策略,浏览器暂停JS脚本执行,暂停HTML解析。直到CSS文件下载完成,完成CSSOM树构建,重新恢复原来的解析。 一定要合理放置JS代码!!!

4.4 白屏-性能优化

至此,我们已经了解了从浏览器在打开一个链接开始,到屏幕展示的过程-白屏时间的历程,那这对每个环节中发生的事情,我们可以有针对性的进行相关的优化。

4.4.1 DNS解析优化

针对DNS Lookup环节,我们可以针对性的进行DNS解析优化。

  • DNS缓存优化
  • DNS预加载策略
  • 稳定可靠的DNS服务器

4.4.2 TCP网络链路优化

针对网络链路的优化,好像除了花钱没有什么更好的方式!

4.4.3 服务端处理优化

服务端的处理优化,是一个非常庞大的话题,会涉及到如Redis缓存、数据库存储优化或是系统内的各种中间件以及Gzip压缩等…

4.4.4 浏览器下载、解析、渲染页面优化

根据浏览器对页面的下载、解析、渲染过程,可以考虑一下的优化处理:

  • 尽可能的精简HTML的代码和结构
  • 尽可能的优化CSS文件和结构
  • 一定要合理的放置JS代码,尽量不要使用内联的JS代码

5.图片加载优化方案

饿了么 App 中新零售项目主要是以图片展示为主,引导用户点击轮播广告栏或者店铺列表进入指定的商品页面,因此页面中包含了大量图片,如搜索框下面的轮播广告栏、中部的促销栏以及底部的店铺列表,这些区域中都有大量的展示图片。因此图片的加载速率直接影响页面的加载速度。下面将从图片加载存在的问题和原因、解决方案两个方面来阐述如何优化新零售图片的加载。

本文所有数据及图片都是通过 Charles 模拟 256 kbps ISDN/DSL 网络环境获取到的。在本案例中只考虑位图,因此文本中提及的图片都是指位图而非矢量图。

5.1 图片加载存在的问题和原因

5.1.1 问题一:启动页面时加载过多图片

img

图1: 新零售图片请求瀑布图

问题原因分析:如上图所示,页面启动时加载了大约 49 张图片(具体图片数量会根据后端返回数据而变化),而这些图片请求几乎是并发的,在 Chrome 浏览器,对于同一个域名,最多支持 6 个请求的并发,其他的请求将会推入到队列中等待或者停滞不前,直到六个请求之一完成后,队列中新的请求才会发出。上面的瀑布图中,在绿色的标记框中,我们看到不同长度的白色横柱,这些都是请求的图片资源排队等待时间。

5.1.2 问题二:部分图片体积过大

img

图2. 顶部轮播图中的一张图片加载图

问题原因分析:如图 1,红框中是搜索框下部的轮播广告中的一张图片,通过图 2 可以看到,该图片主要耗时在 Conent Download 阶段。在下载阶段耗时 13.50s。而该请求的总共时间也就 13.78s。产生该问题的原因从图 1 也能看出一些端倪,该图片体积 76.2KB图片体积过大,直接导致了下载图片时间过长。

5.2 前端解决方案

5.2.1 针对问题一的解决方案

由于新零售首页展示展示大量图片,其实在这大约 49 张图片中,大部分图片都不是首屏所需的,因此可以延迟首屏不需要的图片加载,而优先加载首屏所需图片。这儿首屏的含义是指打开新零售首页首先进入屏幕视窗内的区域范围。

判断图片是否是首屏内图片,首先想到的肯定是通过 getBoundingClientRect 方法,获取到图片的位置信息,判断其是否在 viewport 内部。可能的代码如下:

1
2
3
4
5
6
7
8
const inViewport = (el) => {
const rect = el.getBoundingClientRect()

return rect.top > 0
&& rect.bottom < window.innerHeight
&& rect.left > 0
&& rect.right < window.innerWidth
}

但是在项目中,我们并没有采用该方案来判断是否在首屏,其原因在于,只有当 DOM 元素插入到 DOM 树中,并且页面进行重排和重绘后,我们才能够知道该元素是否在首屏中。在项目中我们使用了 v-img 指令(新零售项目使用该指令对图片进行加载、并且将 hash 转换成 Url。项目已开源,在符合需求前提下欢迎使用),在 Vue 指令中包含两个钩子函数 bindinserted。官网对这两个钩子函数进行如下解释:

  • bind:只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  • inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。

由上面解释可知,我们只能够在 inserted 钩子函数中才能够获取到元素的位置,并且判断其是否在首屏中。在新零售项目中,经过笔者测试,这两个钩子函数的触发时差大约是200ms,因此如果在 inserted 钩子函数内再去加载图片就会比在 bind 钩子函数中加载晚大约200ms,在 4G 网络环境下,200ms 对于很多图片来说已经足够用来加载了,因此我们最终放弃了在 inserted 钩子函数中加载首屏图片的方案。

如果元素没有插入到 DOM 树中并渲染,怎么能够判断其是否在首屏中呢?

项目中使用了一种比较笨的方式来判断哪些是首屏图片,新零售页面布局是确定的,轮播广告栏下面是促销栏、再下面是店铺列表,这些组件的高度也都相对固定,因此这些组件是否在首屏中其实我们是事先知道的。因此在实际使用 v-img 指令的时候,通过传 defer 配置项来告诉 v-img 哪些图片需要提前加载,哪些图片等待提前加载的图片加载完毕后再加载。这样我们就能够在 bind 钩子函数中加载优先加载的图片了。比如说,轮播组件图片、促销组件图片、前两个店铺中的展示图片需要先加载,除此以外的其他图片,需等待首屏图片完全加载后再进行请求加载。实际实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const promises = [] // 用来存储优先加载的图片  
Vue.directive('img', {
bind(el, binding, vnode) {
// ...
const { defer } = binding.value
// ...
if (!defer) {
promises.push(update(el, binding, vnode))
}
},
inserted(el, binding, vnode) {
const { defer } = binding.value
if (!defer) return
if (inViewport(el)) {
promises.push(update(el, binding, vnode))
} else {
Vue.nextTick(() => {
Promise.all(promises)
.then(() => {
promises.length = 0
update(el, binding, vnode)
})
.catch(() => {})
})
}
},
// ...
})

首先通过声明一个数组 promises 用于存储优先加载的图片,在 bind 钩子函数内部,如果 defer 配置项为 false,说明不延时加载,那么就在 bind 钩子函数内部加载该图片,且将返回的 promise 推入到 promises 数组中。在 inserted 钩子函数内,对于延迟加载的图片(defer 为 true),但是其又在首屏内,那么也有优先加载权,在 inseted 钩子函数调用时就对其加载。而对于非首屏且延迟加载的图片等待 promises 数组内部所有的图片都加载完成后才加载。当然在实际代码中还会考虑容错机制,比如上面某张图片加载失败、或者加载时间太长等。因此我们可以配置一个最大等待时间。

优化后的图片加载瀑布图如下:

img

图3. 图片按需加载的瀑布图

如上图所示,下面红框的图片不是首屏图片,因此进行了延迟加载。可以看出,其是在上面所有图片(包括上面的红框中耗时最长的那张图)加载完成之后进行加载的。这样减少了首屏加载时的网络消耗,提升了图片下载速度。

优化前后对比

通过上面的优化方案,在预设的网络环境下(参见文末注),分别对优化前和优化后进行了 5 次平行清空缓存加载,平均数据如下:

img

通过上面表格可以看出,DOMContentLoadedLoaded 并没有多大参考价值,首屏的完整展现所需要的时间依然由加载最慢(一般都是体积最大那张图片)的图片决定,也就是上表的 Max_size_image 决定,上表可以看出,优化后比优化前最大体积图片的加载时间缩短了 5.74s。提速了整整 **41.41%**。加载最慢的图片加载速度的变化也很好的反应了首屏时间的变化。

当然上面的数据也不能够完全反应线上场景,毕竟测试的时间点及后端数据都有所不同。我们也不能够在同一时间点、同一网络环境下对优化前、优化后进行同时数据采集。

5.2.2 针对问题一还有些后续的解决方案

  • 在 HTTP/1.0 和 HTTP/1.1 协议下,由于 Chrome 只支持同域同时发送 6 个并发请求,可以进行域名切分,来提升并发的请求数量,或者使用 HTTP/2 协议。

5.2.3 针对问题二的解决方案

图片体积过大,导致下载时间过长。在保证清晰度的前提下尽量使用体积较小的图片。而一张图片的体积由两个因素决定,该图片总的像素数目和编码单位像素所需的字节数。因此一张图片的文件大小就等于图片总像素数目乘以编码单位像素所需字节数,也就是如下等式:

FileSize = Total Number Pixels * Bytes of Encode single Pixels

举个例子:

一张 100px * 100px 像素的图片,其包含该 100 * 100 = 10000 个像素点,而每个像素点通过 RGBA 颜色值进行存储,R\G\B\A 每个色道都有 0~255 个取值,也就是 2^8 = 256。正好是 8 位 1byte。而每个像素点有四个色道,每个像素点需要 4bytes。因此该图片体积为:10000 * 4bytes = 40000bytes = 39KB

有了上面的背景知识后,我们就知道怎么去优化一张图片了,无非就两个方向:

  • 一方面是减少单位像素所需的字节数
  • 另一方面是减少一张图片总的像素个数

单位像素优化

单位像素的优化也有两个方向,一个方向是「有损」的删除一些像素数据,另一个方面是做一些「无损」的图片像素压缩。正如上面例子所说,RGBA 颜色值可以表示 256^4 种颜色,这是一个很大的数字,往往我们不需要这么多颜色值,因此我们是否可以减少色板中的颜色种类呢?这样表示单位像素的字节数就减少了。而「无损」压缩是通过一些算法,存储像素数据不变的前提下,尽量减少图片存储体积。比如一张图片中的某一个像素点和其周围的像素点很接近,比如一张蓝天的图片,因此我们可以存储两个像素点颜色值的差值(当然实际算法中可能不止考虑两个像素点也许更多),这样既保证了像素数据的「无损」,同时也减少了存储体积。不过也增加了图片解压缩的开销。

针对单位像素的优化,衍生出了不同的图片格式,jpegpnggifwebp。不同的图片格式都有自己的减少单位像素体积的算法。同时也有各自的优势和劣势,比如 jpegpng 不支持动画效果,jpeg 图片体积小但是不支持透明度等。因此项目在选择图片格式上的策略就是,在满足自己需求的前提下选择体积最小的图片格式,新零售项目中已经统一使用的 WebP 格式,和 jpeg 格式相比,其体积更减少 30%,同时还支持动画和透明度。

图片像素总数优化

img

图4:图片加载尺寸和实际渲染尺寸对比

上图是新零售类目页在 Chrome 浏览器中的 iPhone 6 模拟器加载后的轮播展示的图片之一,展示的图片是 750 * 188 像素,但是图片的实际尺寸为 1440 * 360 像素,也就是说我们根本不需要这么大的图片,大图片不仅造成了图片加载的时长增加(后面会有数据说明),同时由于图片尺寸需要缩小增加CPU的负担。

上文中已经提及,项目中我们使用的 v-img 指令来加载项目中的所需图片,如果我们能够根据设备的尺寸来加载不同尺寸(像素总数不同)的图片,也就是说在保证图片清晰度的前提下,尽量使用体积小的图片,问题就迎刃而解了。项目中我们使用的是七牛的图片服务,七牛图片服务提供了图片格式转换、按尺寸裁剪等图片处理功能。只需要对 v-img 指令添加图片宽、高的配置,那么我们是不是可以对不同的设备加载不同尺寸的图片呢?

项目中我们使用的 lib-flexible 来对不同的移动端设备进行适配,lib-flexible 库在我们页面的html元素添加了两个属性,data-dprstyle。这儿我们主要会用到 style 中的 font-size 值,在一定的设备范围内其正好是html元素宽度的十分之一(具体原理参见:使用Flexible实现手淘H5页面的终端适配),也就是说我们可以通过style属性大概获取到设备的宽度。同时设计稿又是以 iPhone6 为基础进行设计的,也就是设计稿是宽度为 750px的设计图,这样在设计图中的图片大小我们也就能够转换成其他设备中所需的图片大小了。

举个例子:

设计稿中一张宽 200px 的图片,其对应的 iPhone 6 设备的宽度为 750px。我们通过 html 元素的 style 属性计算出 iPhone6 plus 的宽度为 1242px。这样也就能够计算中 iPhone6 plus 所需图片尺寸。计算如下:

200 * 1242 / 750 = 331.2px

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const resize = (size) => {
let viewWidth
const dpr = window.devicePixelRatio
const html = document.documentElement
const dataDpr = html.getAttribute('data-dpr')
const ratio = dataDpr ? (dpr / dataDpr) : dpr

try {
viewWidth = +(html.getAttribute('style').match(/(\d+)/) || [])[1]
} catch(e) {
const w = html.offsetWidth
if (w / dpr > 540) {
viewWidth = 540 * dpr / 10
} else {
viewWidth = w / 10
}
}

viewWidth = viewWidth * ratio

if (Number(viewWidth) >= 0 && typeof viewWidth === 'number') {
return (size * viewWidth) / 75 // 75 is the 1/10 iphone6 deivce width pixel
} else {
return size
}
}

上面 resize 方法用于将配置的宽、高值转换为实际所需的图片尺寸,也就是说,size 参数是 iphone 6 设计稿中的尺寸,resize 的返回值就是当前设备所需的尺寸,再把该尺寸配置到图片服务器的传参中,这样我们就能够获取到按设备裁剪后的图片了。

优化前后效果对比,有了上面的基础,我们在 Chrome 中的不同的移动端模拟器上进行了实验,我们对新零售类目页中的一张体积最大的广告图片在不同设备中的加载进行了数据统计(平行三次清空缓存加载),为什么选择体积最大的图片,上文也已经说过,其决定了首屏展现所需的时间。

img

上表格中,除去最后一行是未优化的加载数据,从上到下,设备屏幕尺寸逐渐变大,加载的图片尺寸也从 23.2kb增加到 65.5kb。而加载时间和下载时长也跟随着图片体积的加大而增加,下面的折线图更能够反应图片尺寸、加载时长、下载时长之间的正相关关系。TTFB(从发送请求到接收到第一个字节所需时长)却和图片大小没有明显的正相关关系,可能对于图片服务器在裁剪上述不同尺寸的图片所需时长差异不大。

img

图5:不同设备中对同一张图片进行加载,文件大小、加载和下载时长的折线变化

由上折线图我们还能看到,对于小屏幕设备的效果尤为明显,在不优化下,iPhone5 中图片的加载需要 14.85s,而优化后,加载时长缩短到了 3.90s。加载时长整整缩短了 73.73%**。而对于大屏幕的 iPhone6 plus 也有 **26.00% 时长优化。

当然上面的数据是建立在 256 kbps ISDN/DSL 的网络环境下的,该低速网络环境下,图片的加载时间主要是由于下载时间决定的,因此通过优化图片体积能够达到很好的效果。在 4G(Charles模拟)环境下,iPhone5 中的优化效果就会有些折扣,加载时长缩短 **69.15%**。其实也很容易想到,在高速的网络环境下,TTFB 对加载时长的影响会比低速网络环境下影响要大一些。

最后总结

通过上面的研究及数据结果表明,新零售图片加载缓慢的优化策略:

  • 首屏图片优先加载,等首屏图片加载完全后再去加载非首屏图片。
  • 对大部分图片,特别是轮播广告中的图片进行按设备尺寸裁剪,减少图片体积,减少网络开销,加快下载速率。

本文中没有过多的讨论代码实现细节,而是把重点放在了图片加载缓慢的原因分析,以及优化前后效果对比的数据分析上,如果想看更多代码细节,请移步 vue-img

6.从 URL 输入到页面展现到底发生了什么?

对浏览器原理有过了解的一定不会陌生这篇神文《How Browsers Work》中文翻译:浏览器原理:新式网络浏览器幕后揭秘。另外还有一篇 《What really happens when you navigate to a URL》。大神写的东西很长很复杂,阅读成本虽然大,但能学到东西。所以,我也试着用自己的理解去写一写,算是做个巩固。里面有很多参考,如涉及版权,侵权删!表述有误,请指正!

6.1 前端为什么要研究渲染原理?

像素完美(Pixel Perfection)、分辨率无关(Resolution Independent)和多平台体验一致性是设计师的追求。而访问性(Accessability)、加载性能和重构灵活性则是前端工程师必懂的技能。最重要的加载性能又与浏览器渲染机制深深挂钩,所以只有弄明白了浏览器背后的渲染机制,才能在日常的前端开发中明白如何进行性能优化。
还有就是像类似:
从输入 URL 到页面加载完成的过程中都发生了什么事情
从按下键盘到屏幕上出现字符,中间都发生了什么事情
用户反应网站卡,请问都有哪些可能性,以及解决方法
这样的问题面试官都是比较喜欢问的,今天尝试来详细说一下这个过程。

6.2 从输入 URL 到页面加载完成的过程中都发生了什么?

简单路径线

  1. 键盘或触屏输入URL并回车确认
  2. URL解析/DNS解析查找域名IP地址
  3. 网络连接发起HTTP请求
  4. HTTP报文传输过程
  5. 服务器接收数据
  6. 服务器响应请求/MVC
  7. 服务器返回数据
  8. 客户端接收数据
  9. 浏览器加载/渲染页面
  10. 打印绘制输出

实际上并没有这么简单,下面说说它的详细路径线

6.2.1 键盘或触屏输入URL并回车确认

当然故事其实并不是从输入一个URL或抓着鼠标点击一个链接开始的,事情的开端要追溯到服务器启动监听服务的时候,在某个未知的时刻,一台机房里普普通通的服务器,加上电,启动了操作系统,随着操作系统的就绪,服务器启动了 http 服务进程,这个 http 服务的守护进程(daemon),可能是 Apache、Nginx、IIS、Lighttpd中的一个,不管怎么说,这个 http 服务进程开始定位到服务器上的 www 文件夹(网站根目录),一般是位于 /var/www ,然后启动了一些附属的模块,例如 php,或者,使用 fastcgi 方式连接到 php 的 fpm 管理进程,然后,向操作系统申请了一个 tcp 连接,然后绑定在了 80 端口,调用了 accept 函数,开始了默默的监听,监听着可能来自位于地球任何一个地方的请求,随时准备做出响应。这个时候,典型的情况下,机房里面应该还有一个数据库服务器,或许,还有一台缓存服务器,如果对于流量巨大的网站,那么动态脚本的解释器可能还有单独的物理机器来跑,如果是中小的站点,那么,上述的各色服务,甚至都可能在一台物理机上,这些服务监听之间的关系,可以通过自己搭建一次 Apache PHP MySQL 环境来了解一下,不管怎么说,他们做好了准备,静候差遣。
然后是开始键盘或手机触屏输入URL,然后通过某种机制传到CPU(过程略),CPU进行内部处理(过程略),处理完后,再从CPU传到操作系统内核(过程略),然后再由操作系统GUI传到浏览器,再由浏览器到浏览器内核。这个过程因为涉及很多底层的知识,自己也只是了解皮毛,过程这里不多讲了,具体请参考我的另一篇博客《字符集历史和乱码问题》和以下书籍:
编码
操作系统概念
CPU自制入门
计算机体系结构
Linux内核设计与实现
精通Linux设备驱动程序开发
计算机体系结构:量化研究方法
计算机组成与设计:硬件/软件接口

上面一步操作系统 GUI 会将输入事件传递到浏览器中,在这过程中,浏览器可能会做一些预处理,甚至已经在智能匹配所有可能的URL了,他会从历史记录,书签等地方,找到已经输入的字符串可能对应的URL,来预估所输入字符对应的网站,然后给出智能提示,比如输入了「ba」,根据之前的历史发现 90% 的概率会访问「www.baidu.com 」,因此就会在输入回车前就马上开始建立 TCP 链接了。对于 Chrome这种变态的浏览器,他甚至会直接从缓存中把网页渲染出来,就是说,你还没有按下「回车」键,页面就已经出来了,再比如Chrome会在浏览器启动时预先查询10个你有可能访问的域名等等,这里面还有很多其它策略,不详细讲了。感兴趣的推荐阅读 High Performance Networking in Chrome

6.2.2 URL 解析/DNS 查询

接着是输入 URL 「回车」后,这时浏览器会对 URL 进行检查,这里需要对URL有个回顾,请见百科《URL》,完整的URL由几个部分构成:
协议、网络地址、资源路径、文件名、动态参数
协议/模式(scheme)是从该计算机获取资源的方式,一般有Http、Https、Ftp、File、Mailto、Telnet、News等协议,不同协议有不同的通讯内容格式,协议主要作用是告诉浏览器如何处理将要打开的文件;
网络地址指示该连接网络上哪一台计算机(服务器),可以是域名或者IP地址,域名或IP地址后面有时还跟一个冒号和一个端口号;
端口号如果地址不包含端口号,根据协议的类型会确定一个默认端口号。端口号之于计算机就像窗口号之于银行,一家银行有多个窗口,每个窗口都有个号码,不同窗口可以负责不同的服务。端口只是一个逻辑概念,和计算机硬件没有关系。一般如果你的端口号就是默认的,那么url是不需要输入端口号的,但如果你更改了默认端口号,你就必须要在url后输入新端口号才能正常访问。例如:http协议默认端口号是80。如果你输入的url是http://www.zhihu.com:8080/ ,那表示不使用默认的端口号,而使用指定的端口号8080。如果使用的就是默认端口号那么输入http://www.zhihu.com:80http://www.zhihu.com是一样的。有个特殊情况有所不同,比如本地IP 127.0.0.1 其实走的是 loopback,和网卡设备没关系。
资源路径指示从服务器上获取哪一项资源的等级结构路径,以斜线/分隔;
文件名一般是需要真正访问的文件,有时候,URL以斜杠“/”结尾,而隐藏了文件名,在这种情况下,URL引用路径中最后一个目录中的默认文件(通常对应于主页),这个文件常被称为 index.html 或 default.htm。
动态参数有时候路径后面会有以问号?开始的参数,这一般都是用来传送对服务器上的数据库进行动态询问时所需要的参数,有时候没有,很多为了seo优化,都已处理成伪静态了。要注意区分url和路由的区别。
URL完整格式为:协议://用户名:密码@子域名.域名.顶级域名:端口号/目录/文件名.文件后缀?参数=值#标志
例如:https://www.zhihu.com/question/55998388/answer/166987812
协议部分:https
网络地址:www.zhihu.com(依次为 子/三级域名.二级域名.顶/一级域名)
资源路径:/question/55998388/answer/166987812
浏览器对 URL 进行检查时首先判断协议,如果是 http/https 就按照 Web 来处理,另外还会对 URL 进行安全检查,然后直接调用浏览器内核中的对应方法,接下来是对网络地址进行处理,如果地址不是一个IP地址而是域名则通过DNS(域名系统)将该地址解析成IP地址。IP地址对应着网络上一台计算机,DNS服务器本身也有IP,你的网络设置包含DNS服务器的IP。 例如:www.zhihu.com域名请求对应获得的IP是 116.211.167.187。DNS 在解析域名的时候有两种方式:递归查询和迭代查询
递归查询的流程如下:
一般来说,浏览器会首先查询浏览器缓存(DNS 在各个层级都有缓存的,相应的,缓存当然有过期时间,Time to live),如果没有找到,就会检查系统缓存,检查本地硬盘的hosts文件,这个文件保存了一些以前访问过的网站的域名和IP对应的数据。它就像是一个本地的数据库。如果找到就可以直接获取目标主机的IP地址了(注意这个地方存在安全隐患,如果有病毒把一些常用的域名,修改 hosts 文件,指向一些恶意的IP,那么浏览器也会不加判断的去连接,是的,这正是很多病毒的惯用手法)。如果本地hosts也没有找到的话,则需要再向上层找路由器缓存,路由器有自己的DNS缓存,可能就包括了查询的内容;如果还是没有,需要接着往上找,查询ISP DNS 缓存(本地名称服务器缓存,就是客户端电脑TCP/IP参数中设置的首选DNS服务器,此解析具有权威性。一般情况下你在不同的地区或者不同的网络,如电信、联通、移动的情况下,转换后的IP地址很可能是不一样的,这涉及到负载均衡,通过DNS解析域名时会将你的访问分配到不同的入口,先找附近的本地 DNS 服务器去请求解析域名,尽可能保证你所访问的入口是所有入口中较快的一个,这和CDN还不一样,比如我们经常使用的114.114.114.114或Google的8.8.8.8就是本地名称服务器)。如果附近的本地DNS服务器还是没有缓存我们请求的域名记录的话,这时候会根据本地DNS服务器的设置(是否设置转发器)进行查询,如果未用转发模式,则本地名称服务器再以DNS客户端的角色发送与前面一样的DNS域名查询请求转发给上一层。这里可能经过一次或者多次转发,从本地名称服务器权威名称服务器再到顶级名称服务器最后到根名称服务器。(顺便一提,根服务器是互联网域名解析系统DNS中最高级别的域名服务器,全球一共13组,每组都只有一个主根名称服务器采用同一个IP。注意不是13个,前期是个现在已经是集群了,据说已经有上千台了,好多台用于负载均衡,备份等,全球有386台根物理服务器,被编号为A到M共13个标号。中国包括台港也持有其中5组14台辅根服务器或叫镜像也可以,386台根服务器总共只使用了13个IP,因此可以抵抗针对其所进行的分布式拒绝服务攻击DDoS。具体情况可以参看维基百科的 根域名服务器 条目)所以,最终请求到了根服务器后,根服务器查询发现我们这个被请求的域名是由类似A或者B这样的服务器解析的,但是,根服务器并不会送佛送到西地找A或B之类的直接去解析,因为它没有保存全部互联网域名记录,并不直接用于名称解析,它只是负责顶级名称服务器(如.com/.cn/.net等)的相关内容。所以它会把所查询得到的被请求的DNS域名中顶级域名所对应的顶级名称服务器IP地址返回给本地名称服务器。本地名称服务器拿到地址后再向对应的顶级名称服务器发送与前面一样的DNS域名查询请求。对应的顶级名称服务器在收到DNS查询请求后,也是先查询自己的缓存,如果有则直接把对应的记录项返回给本地名称服务器,然后再由本地名称服务器返回给DNS客户端,如果没有则向本地名称服务器返回所请求的DNS域名中的二级域名所对应的二级名称服务器(如baidu.com/qq.com/net.cn等)地址。然后本地名称服务器继续按照前面介绍的方法一次次地向三级(如www.baidu.com/www.qq.com/bbs.taobao.com等)、四级名称服务器查询,直到最终的对应域名所在区域的权威名称服务器返回最终记录给本地名称服务器。同时本地名称服务器会缓存本次查询得到的记录项(每层都应该会缓存)。再层层下传,最后到了我们的DNS客户端机子,一次 DNS 解析请求就此完成。如果最终权威名称服务器都说找不到对应的域名记录,则会向本地名称服务器返回一条查询失败的DNS应答报文,这条报文最终也会由本地名称服务器返回给DNS客户端。当然,如果这个权威名称服务器上配置了指向其它名称服务器的转发器,则权威名称服务器还会在转发器指向的名称服务器上进一步查询。另外,如果DNS客户端上配置了多个DNS服务器,则还会继续向其它DNS服务器查询的。

img

DNS递归解析示例图

所以,我们看到DNS的域名解析是递归的,递归的DNS首先会查看自己的DNS缓存,如果缓存能够命中,那么就从缓存中把IP地址返回给浏览器,如果找不到对应的域名的IP地址,那么就依此层层向上转发请求,从根域名服务器到顶级域名服务器再到极限域名服务器依次搜索对应目标域名的IP,最高达到根节点,找到或者全部找不到为止。然后把找到的这个域名对应的 nameserver 的地址拿到,再向这个 namserver 去请求域名对应的IP,最后把这个IP地址返回给浏览器,在这个递归查询的过程中,对于浏览器来说是透明的,如果DNS客户端的本地名称服务器不能解析的话,则后面的查询都会以本地名称服务器为中心,全交由本地名称服务器代替DNS客户端进行查询,DNS客户端只是发出原始的域名查询请求报文,然后就一直处于坐等状态,直到本地名称服务器最终从权威名称服务器得到了正确的IP地址查询结果并返回给它。虽然递归查询是默认的DNS查询方式,但是如果有以下情况发生的话,则会使用迭代的查询方式进行。
情况一:DNS客户端的请求报文中没有申请使用递归查询,即在DNS请求报头部的RD字段没有置1。
情况二:DNS客户端的请求报文中申请使用的是递归查询(也就是RD字段置1了),但在所配置的本地名称服务器上是禁用递归查询了(即在应答DNS报文头部的RA字段置0)。

迭代查询的流程如下
开始也是从浏览器缓存到系统缓存到路由缓存,如果还是没找到则客户端向本机配置的本地名称服务器(在此仅以首先DNS服务器为例进行介绍,其它备用DNS服务器的解析流程完全一样)发出DNS域名查询请求。本地名称服务器收到请求后,先查询本地的缓存,如果有该域名的记录项,则本地名称服务器就直接把查询的结果返回给客户端;如果本地缓存中没有该域名的记录,则向DNS客户端返回一条DNS应答报文,报文中会给出一些参考信息,如本地名称服务器上的根名称服务器地址等。DNS客户端在收到本地名称服务器的应答报文后,会根据其中的根名称服务器地址信息,向对应的根名称服务器再次发出与前面一样的DNS查询请求报文。根名称服务器在收到DNS查询请求报文后,通过查询自己的DNS数据库得到请求DNS域名中顶级域名所对应的顶级名称服务器信息,然后以一条DNS应答报文返回给DNS客户端。DNS客户端根据来自根名称服务器应答报文中的对应顶级名称服务器地址信息,向该顶级名称服务器发出与前面一样的DNS查询请求报文。顶级名称服务器在收到DNS查询请求后,先查询自己的缓存,如果有请求的DNS域名的记录项,则直接把对应的记录项返回给DNS客户端,否则通过查询后把对应域名中二级域名所对应的二级名称服务器地址信息以一条DNS应答报文返回给DNS客户端。然后DNS客户端继续按照前面介绍的方法一次次地向三级、四级名称服务器查询,直到最终的权威名称服务器返回到最终的记录。如果权威名称服务器也找不到对应的域名记录,则会向DNS客户端返回一条查询失败的DNS应答报文。当然,如果这个权威名称服务器上配置了指向其它名称服务器的转发器,则权威名称服务器还会在转发器指向的名称服务器上进一步查询。另外,如果DNS客户端上配置了多个DNS服务器,则还会继续向其它DNS服务器查询。

img

DNS迭代解析示意图

所以,我们发现在递归查询中后面的查询工作是由本地名称服务器替代DNS客户端进行的(以“本地名称服务器”为中心),只需要本地名称服务器向DNS客户端返回最终的查询结果即可。而DNS迭代查询的所有查询工作则全部是DNS客户端自己进行(以“DNS客户端”自己为中心)。

DNS递归查询和迭代查询的区别
递归查询是以本地名称服务器为中心的,是DNS客户端和服务器之间的查询活动,递归查询的过程中“查询的递交者” 一直在更替,其结果是直接告诉DNS客户端需要查询的网站目标IP地址。
迭代查询则是DNS客户端自己为中心的,是各个服务器和服务器之间的查询活动,迭代查询的过程中“查询的递交者”一直没变化,其结果是间接告诉DNS客户端另一个DNS服务器的地址。

img

递归和迭代查询

扩展阅读:
什么是DNS劫持?
什么是301重定向?301重定向设置教程
电脑上不了网将DNS改为114.114.114.114或8.8.8.8可以解决或加快网速的原理是什么?
局域网 IP 和公网 IP 有何差别?
根域名服务器的作用是什么?全球 13 组根域名服务器中有 10 组在美国,意味着什么?
递归和迭代的区别?

6.2.3 应用层客户端发送HTTP请求

互联网内各网络设备间的通信都遵循TCP/IP协议,利用TCP/IP协议族进行网络通信时,会通过分层顺序与对方进行通信。分层由高到低分别为:应用层、传输层、网络层、数据链路层。发送端从应用层往下走,接收端从数据链路层网上走。如图所示:

img

从上面的步骤中得到 IP 地址后,浏览器会开始构造一个 HTTP 请求,应用层客户端向服务器端发送的HTTP请求包括:请求报头和请求主体两个部分,其中请求报头(request header)包含了至关重要的信息,包括请求的方法(GET / POST和不常用的PUT / DELETE以及更不常用的HEAD / OPTION / TRACE,一般的浏览器只能发起 GET 或者 POST 请求)、目标url、遵循的协议(HTTP / HTTPS / FTP…),返回的信息是否需要缓存,以及客户端是否发送Cookie等信息。需要注意的是,因为 HTTP 请求是纯文本格式的,所以在 TCP 的数据段中可以直接分析 HTTP 文本的。

6.2.4 传输层TCP传输报文

当应用层的 HTTP 请求准备好后,浏览器会在传输层发起一条到达服务器的 TCP 连接,位于传输层的TCP协议为传输报文提供可靠的字节流服务。它为了方便传输,将大块的数据分割成以报文段为单位的数据包进行管理,并为它们编号,方便服务器接收时能准确地还原报文信息。TCP协议通过“三次握手”等方法保证传输的安全可靠。“三次握手”的过程是,发送端先发送一个带有SYN(synchronize)标志的数据包给接收端,在一定的延迟时间内等待接收的回复。接收端收到数据包后,传回一个带有SYN/ACK标志的数据包以示传达确认信息。接收方收到后再发送一个带有ACK标志的数据包给接收端以示握手成功。在这个过程中,如果发送端在规定延迟时间内没有收到回复则默认接收方没有收到请求,而再次发送,直到收到回复为止。

img

这里需要谈一下 TCP 的 Head-of-line blocking 问题:假设客户端的发送了 3 个 TCP 片段(segments),编号分别是 1、2、3,如果编号为 1 的包传输时丢了,即便编号 2 和 3 已经到达也只能等待,因为 TCP 协议需要保证顺序,这个问题在 HTTP pipelining 下更严重,因为 HTTP pipelining 可以让多个 HTTP 请求通过一个 TCP 发送,比如发送两张图片,可能第二张图片的数据已经全收到了,但还得等第一张图片的数据传到。为了解决 TCP 协议的性能问题,Chrome 团队提出了 QUIC 协议,它是基于 UDP 实现的可靠传输,比起 TCP,它能减少很多来回(round trip)时间,还有前向纠错码(Forward Error Correction)等功能。目前 Google Plus、 Gmail、Google Search、blogspot、Youtube 等几乎大部分 Google 产品都在使用 QUIC,可以通过chrome://net-internals/#spdy 页面来发现。另外,浏览器对同一个域名有连接数限制,大部分是 6,但并非将这个连接数改大后就会提升性能,Chrome 团队有做过实验,发现从 6 改成 10 后性能反而下降了,造成这个现象的因素有很多,如建立连接的开销、拥塞控制等问题,而像 SPDY、HTTP 2.0 协议尽管只使用一个 TCP 连接来传输数据,但性能反而更好,而且还能实现请求优先级。

6.2.5 网络层IP协议查询MAC地址

IP协议的作用是把TCP分割好的各种数据包封装到IP包里面传送给接收方。而要保证确实能传到接收方还需要接收方的MAC地址,也就是物理地址才可以。IP地址和MAC地址是一一对应的关系,一个网络设备的IP地址可以更换,但是MAC地址一般是固定不变的。ARP协议可以将IP地址解析成对应的MAC地址。当通信的双方不在同一个局域网时,需要多次中转才能到达最终的目标,在中转的过程中需要通过下一个中转站的MAC地址来搜索下一个中转目标。

6.2.6 数据到达数据链路层

在找到对方的MAC地址后,已被封装好的IP包再被封装到数据链路层的数据帧结构中,将数据发送到数据链路层传输,再通过物理层的比特流送出去。这时,客户端发送请求的阶段结束。

这些分层的意义在于分工合作,数据链路层通过 CSMA/CD 协议保证了相邻两台主机之间的数据报文传递,而网络层的 IP 数据包通过不同子网之间的路由器的路由算法和路由转发,保证了互联网上两台遥远主机之间的点对点的通讯,不过这种传输是不可靠,于是可靠性就由传输层的 TCP 协议来保证,TCP 通过慢开始,乘法减小等手段来进行流量控制和拥塞避免,同时提供了两台遥远主机上进程到进程的通信,最终保证了 HTTP 的请求头能够被远方的服务器上正在监听的 HTTP 服务器进程收到,终于,数据包在跳与跳之间被拆了又封装,在子网与子网之间被转发了又转发,最后进入了服务器的操作系统的缓冲区,服务器的操作系统由此给正在被阻塞住的 accept 函数一个返回,将他唤醒。

6.2.7 服务器接收数据

接收端的服务器在链路层接收到数据包,再层层向上直到应用层。这过程中包括在传输层通过TCP协议将分段的数据包重新组成原来的HTTP请求报文。

6.2.8 服务器响应请求并返回相应文件

服务接收到客户端发送的HTTP请求后,服务器上的的 http 监听进程会得到这个请求,然后一般情况下会启动一个新的子进程去处理这个请求,同时父进程继续监听。http 服务器首先会查看重写规则,然后如果请求的文件是真实存在,例如一些图片,或 html、css、js 等静态文件,则会直接把这个文件返回,如果是一个动态的请求,那么会根据 url 重写模块的规则,把这个请求重写到一个 rest 风格的 url 上,然后根据动态语言的脚本,来决定调用什么类型的动态文件脚本解释器来处理这个请求。

我们以 php 语言为例来说的话,请求到达一个 php 的 mvc 框架之后,框架首先应该会初始化一些环境的参数,例如远端 ip,请求参数等等,然后根据请求的 url 送到一个路由器类里面去匹配路由,路由由上到下逐条匹配,一旦遇到 url 能够匹配的上,而且请求的方法也能够命中的话,那么请求就会由这个路由所定义的处理方法去处理。

请求进入处理函数之后,如果客户端所请求需要浏览的内容是一个动态的内容,那么处理函数会相应的从数据源里面取出数据,这个地方一般会有一个缓存,例如 memcached 来减小 db 的压力,如果引入了 orm 框架的话,那么处理函数直接向 orm 框架索要数据就可以了,由 orm 框架来决定是使用内存里面的缓存还是从 db 去取数据,一般缓存都会有一个过期的时间,而 orm 框架也会在取到数据回来之后,把数据存一份在内存缓存中的。

orm 框架负责把面向对象的请求翻译成标准的 sql 语句,然后送到后端的 db 去执行,db 这里以 mysql 为例的话,那么一条 sql 进来之后,db 本身也是有缓存的,不过 db 的缓存一般是用 sql 语言 hash 来存取的,也就是说,想要缓存能够命中,除了查询的字段和方法要一样以外,查询的参数也要完全一模一样才能够使用 db 本身的查询缓存,sql 经过查询缓存器,然后就会到达查询分析器,在这里,db 会根据被搜索的数据表的索引建立情况,和 sql 语言本身的特点,来决定使用哪一个字段的索引,值得一提的是,即使一个数据表同时在多个字段建立了索引,但是对于一条 sql 语句来说,还是只能使用一个索引,所以这里就需要分析使用哪个索引效率最高了,一般来说,sql 优化在这个点上也是很重要的一个方面。

sql 由 db 返回结果集后,再由 orm 框架把结果转换成模型对象,然后由 orm 框架进行一些逻辑处理,把准备好的数据,送到视图层的渲染引擎去渲染,渲染引擎负责模板的管理,字段的友好显示,也包括负责一些多国语言之类的任务。对于一条请求在 mvc 中的生命周期,可以参考这里,临摹了一个 PHP MVC 框架,在视图层把页面准备好后,再从动态脚本解释器送回到 http 服务器,由 http 服务器把这些正文加上一个响应头,封装成一个标准的 http 响应包,再通过 tcp ip 协议,送回到客户机浏览器。

6.2.9 浏览器开始处理数据信息并渲染页面

历经千辛万苦,我们请求的响应终于成功到达了客户端的浏览器,响应到达浏览器之后,浏览器首先会根据返回的响应报文里的一个重要信息——状态码,来做个判断。如果是 200 开头的就好办,表示请求成功,直接进入渲染流程,如果是 300 开头的就要去相应头里面找 location 域,根据这个 location 的指引,进行跳转,这里跳转需要开启一个跳转计数器,是为了避免两个或者多个页面之间形成的循环的跳转,当跳转次数过多之后,浏览器会报错,同时停止。比如:301表示永久重定向,即请求的资源已经永久转移到新的位置。在返回301状态码的同时,响应报文也会附带重定向的url,客户端接收到后将http请求的url做相应的改变再重新发送。如果是 400 开头或者 500 开头的状态码,浏览器也会给出一个错误页面。比如:404 not found 就表示客户端请求的资源找不到。

当浏览得到一个正确的 200 响应之后,接下来面临的一个问题就是多国语言的编码解析了,响应头是一个 ascii 的标准字符集的文本,这个还好办,但是响应的正文本质上就是一个字节流,对于这一坨字节流,浏览器要怎么去处理呢?首先浏览器会去看响应头里面指定的 encoding 域,如果有了这个东西,那么就按照指定的 encoding 去解析字符,如果没有的话,那么浏览器会使用一些比较智能的方式,去猜测和判断这一坨字节流应该使用什么字符集去解码。相关的笔记可以看这里,字符集编码

接下来就是构建 dom 树了,在 html 语言嵌套正常而且规范的情况下,这种 xml 标记的语言是比较容易的能够构建出一棵 dom 树出来的,当然,对于互联网上大量的不规范的页面,不同的浏览器应该有自己不同的容错去处理。构建出来的 dom 本质上还是一棵抽象的逻辑树,构建 dom 树的过程中,如果遇到了由 script 标签包起来的 js 动态脚本代码,那么会把代码送到 js 引擎里面去跑,如果遇到了 style 标签包围起来的 css 代码,也会保存下来,用于稍后的渲染。如果遇到了 img 或 css 和 js等引用外部文件的标签,那么浏览器会根据指定的 url 再次发起一个新的 http 请求,去把这个文件拉取回来,值得一提的是,对于同一个域名下的下载过程来说,浏览器一般允许的并发请求是有限的,通常控制在两个左右,所以如果有很多的图片的话,一般出于优化的目的,都会把这些图片使用一台静态文件的服务器来保存起来,负责响应,从而减少主服务器的压力。

dom 树构造好了之后,就是根据 dom 树和 css 样式表来构造 render 树了,这个才是真正的用于渲染到页面上的一个一个的矩形框的树,网页渲染是浏览器最复杂、最核心的功能,对于 render 树上每一个框,需要确定他的 x y 坐标,尺寸,边框,字体,形态,等等诸多方面的东西,render 树一旦构建完成,整个页面也就准备好了,可以上菜了。需要说明的是,下载页面,构建 dom 树,构建 render 树这三个步骤,实际上并不是严格的先后顺序的,为了加快速度,提高效率,让用户不要等那么久,现在一般都并行的往前推进的,现代的浏览器都是一边下载,下载到了一点数据就开始构建 dom 树,也一边开始构建 render 树,构建了一点就显示一点出来,这样用户看起来就不用等待那么久了。

6.2.10 将渲染好的页面图像显示出来,并开始响应用户的操作

这一步主要涉及显卡,内存及显示器原理等知识,不做详细解说,大概就是从内存到 LCD/LED,再由光线进入人眼的一个过程。

以上过程简单讲主要是:从输入 URL 到浏览器接收(回车前),从浏览器接收到数据如何发送给网卡(回车后),再把接收的数据从本机网卡发送到服务器,服务器接收到数据后做了怎么的处理?服务器返回数据后浏览器又做了哪些处理?浏览器又是如何将处理好的页面展现在屏幕上的?的这么一个过程。
但只是最基本的一些步骤,实际不可能就这么简单,一些可选的步骤例如网页缓存、连接池、加载策略、加密解密、代理中转等等都没有提及。即使基本步骤本身也有很复杂的子步骤,TCP/IP、DNS、HTTP、HTML等等,还需要考虑很多情况,比如广播、拆包解包合并包丢包重传、路由表,NAT、TCP 状态机、CDN、HTTPS 证书校验与中间人攻击检测、RSA 密钥协商、AES 加解密、浏览器解析 HTTP 的有限自动状态机、GUI 库与绘图、OpenGL 绘图、GPU 加速(OpenCL 与 CUDA)、JIT(JavaScript 会把 JavaScript 代码编译成汇编代码)、服务器的数据库 NoSQL 或 SQL 查询、主从数据库同步、服务器和浏览器的内存管理(WebKit 实现的 fastMalloc(),服务器上可能是 TCMalloc 或者 JeMalloc)、服务器上的语言解释器(可能也是 JIT)、多媒体:傅里叶变换、H.264 解码(硬件解码,硬件解码的话 GPU 的处理单元又在计算…….或软件解码)、音频解码、WebGL 绘图、浏览器的 Sandbox、服务器的 SQL 注入检查、产生的键盘中断信号处理(或者是高级层面的输入输出驱动)、网卡驱动、网络栈的 TCP FastOpen、SYN Cookie 之类众多技术……每一个都可以展开成庞大的课题,而浏览器的基础——操作系统、编译器、硬件等更是一个比一个复杂。即便是计算机专业的同学看了也会头大,但我保证这里面的每一个步骤都经过深思熟虑和时间的考验的,并不是谁闲的蛋疼非要搞得那么复杂,不复杂也不行啊。你输入URL即可浏览互联网,而计算机系统在背后做了无数你看不到的工作,计算机各个子领域无数工程师为此付出了你难以想象的努力。有兴趣的可以阅读下
你刚才在淘宝上买了一件东西

img

浏览器解析渲染页面原理

http://coolshell.cn/articles/9666.html
http://www.jianshu.com/p/e305ace24ddf
https://segmentfault.com/a/1190000005169412
https://www.chengrang.com/how-browsers-work.html

参考资料

了解html页面的渲染过程
前端工程师手册
浏览器渲染那些事
手机上从输入URL到页面加载完成的过程中都发生了什么?
当页面渲染时,浏览器发生了什么?
浏览器工作原理分析与首屏加载
https://www.youtube.com/watch?v=eeS4brbDVuU

7.动画性能

先总结几个要点

  1. 精简DOM,合理布局
  2. 使用transform代替left、top减少使用引起页面重排的属性
  3. 开启硬件加速
  4. 尽量避免浏览器创建不必要的图形层
  5. 尽量减少js动画,如需要,使用对性能友好的requestAnimationFrame
  6. 使用chrome performance工具调试动画性能

我们知道网页动画的每一帧都是一次重新渲染,每秒低于24镇的动画,人眼就能感受到停顿,每秒30-60帧才能比较流畅 浏览器会按照大多数显示器的刷新频率60Hz来刷新动画, 如果想达到60FPS,就意味着每一帧的任务耗时不能高于16毫秒。

通过下图我们可以了解浏览器渲染每一帧的过程

img

1.JavaScript。一般来说,我们会使用 JavaScript 来实现一些视觉变化的效果。比如用 jQuery 的 animate 函数做一个动画、对一个数据集进行排序或者往页面里添加一些 DOM 元素等。当然,除了 JavaScript,还有其他一些常用方法也可以实现视觉变化效果,比如:CSS Animations、Transitions 和 Web Animation API。

2.样式计算。此过程是根据匹配选择器(例如 .headline 或 .nav > .nav__item)计算出哪些元素应用哪些 CSS 3. 规则的过程。从中知道规则之后,将应用规则并计算每个元素的最终样式。

3.布局。在知道对一个元素应用哪些规则之后,浏览器即可开始计算它要占据的空间大小及其在屏幕的位置。网页的布局模式意味着一个元素可能影响其他元素,例如 元素的宽度一般会影响其子元素的宽度以及树中各处的节点,因此对于浏览器来说,布局过程是经常发生的。

4.绘制。绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的。

5.合成。由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。

“生成布局”(flow)和”绘制”(paint)这两步,合称为”渲染”(render)。重新渲染就是需要重新生成布局和重新绘制。 有上述的渲染流水线我们可以得知重绘不一定需要重排,重排必然导致重绘

重排和重绘会不断触发,这是不可避免的。但是,它们非常耗费资源,是导致网页性能低下的根本原因。 提高网页性能,就是要降低”重排”和”重绘”的频率和成本,尽量少触发重新渲染。

重排还重绘会消耗大量的CPU和GPU资源,前端新能优化最主要的优化点就是尽量减少重绘和重排。

img

影响网页渲染的因素

其中最简单的,样式表越简单,重绘和重排越快,重绘和重排的DOM元素层级越高,成本就越高,所以我们在开发前端页面时就需要精简DOM元素,合理布局。

另外Table元素的重排和重绘成本要高于div,所以我们提倡使用div+css布局,尽量避免使用table布局。

还有其他对渲染性能有影响的操作,比如:

  • DOM元素读写分离
  • 让进行大量动画的元素脱离文档流,减少重排开销
  • 通过改变元素的class或csstext一次性的更改样式
  • 缓存DOM元素的位置信息,避免不必要的属性读取
  • 尽量使用离线DOM
  • 使用css3 transform优化动画性能

7.1 使用css3 transform

该CSS属性可以旋转,缩放,倾斜,或者上传给定的元素。这是通过修改CSS 可视格式模型的坐标空间来实现的。
如果该属性的值不是none,则会创建一个堆叠上下文。在这种情况下,该对象将充当position: fixed的包含块(所以position: fixed的元素将会被他覆盖)。

7.2 css3 transform 的执行效率

我们通过一个例子来解释为什么transform的动画执行效果更佳。

1
2
3
4
5
6
<!-- 对应图1-->
div { height: 100px; transition: height 1s linear; }
div:hover { height: 200px; }
<!-- 对应图2 -->
div { transform: scale(0.5); transition: transform 1s linear; }
div:hover { transform: scale(1.0); }

一个从 height: 100px 到 height: 200px 的 动画按照下面的流程图来执行各种操作 橙色方框的操作比较耗时,绿色方框的操作比较快速

img

img

因为每一帧的变化浏览器都在进行布局、绘制、把新的位图交给 GPU 内存,但是在将位图加载到GPU内存中的操作是个相对耗时的操作。

GPU 在如下方面很快:

  • 绘制位图到屏幕上
  • 可不断的绘制相同的位图
  • 将同一位图进行位移、旋转、缩放

我们看使用了transform属性的动画执行过程(图二),这个无疑是效率最优的执行方式。

7.3 层的引入(参考-无线性能优化:Composite

页面一旦在装入并解析完成后,就会表示为许多Web开发者所熟悉的结构:DOM。然而,在页面的渲染过程中,浏览器还具有一系列并不直接暴露给开发者的页面中间表示方式。这些表示方式中最重要的结构就是层。

在Chrome中实际上有几种不同类型的层:掌管DOM子树的渲染层(RenderLayer)以及掌管渲染层子树的图形层(GraphicsLayer),某些特殊的渲染层会被认为是合成层(Compositing Layers,合成层拥有单独的 GraphicsLayer。

拥有单独GraphicsLayer的层,都会将位图存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后 draw 到屏幕上。

什么渲染层会提升为合成层?Chrome在这方面采用的规则仍在随着时间推移逐渐发展变化,但在目前下面这些因素都会引起Chrome创建层:

  • 进行3D或者透视变换的CSS属性
  • 使用硬件加速视频解码的
  • 具有3D(WebGL)上下文或者硬件加速的2D上下文的元素
  • 组合型插件(即Flash)
  • 具有有CSS透明度动画或者使用动画式Webkit变换的元素
  • 具有硬件加速的CSS滤镜的元素
  • 子元素中存在具有组合层的元素的元素(换句话说,就是存在具有自己的层的子元素的元素)
  • 同级元素中有Z索引比其小的元素,而且该Z索引比较小的元素具有组合层(换句话说就是在组合层之上进行渲染的元素)

提升为合成层简单说来有以下几点好处

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

合成层的好处是不会影响到其他元素的绘制,因此,为了减少动画元素对其他元素的影响,从而减少 paint,我们需要把动画效果中的元素提升为合成层。

提升合成层的最好方式是使用 CSS 的 will-change 属性。从上一节合成层产生原因中,可以知道 will-change 设置为 opacity、transform、top、left、bottom、right 可以将元素提升为合成层。

对于那些目前还不支持 will-change 属性的浏览器,目前常用的是使用一个 3D transform 属性来强制提升为合成层:transofrm: translateZ(0)

通常情况下开启硬件加速会提高动画的流畅性,但是过多的合成层也会造成性能瓶颈,过多的合成层会占用大量的内存空间,

对于合成层占用内存的问题,这里有两个 demo 进行了验证。

demo 1demo 2 中,会创建 2000 个同样的 div 元素,不同的是 demo 2 中的元素通过 will-change 都提升为了合成层,而两个 demo 页面的内存消耗却有很明显的差别。

img

7.4 网页动画的渲染

有一些JavaScript方法可以调节重新渲染,大幅提高网页性能。

其中最重要的,就是 window.requestAnimationFrame() 方法。它可以将某些代码放到下一次重新渲染时执行。

1
window.requestAnimationFrame(fn);

**\**window.requestIdleCallback()\****也可以用来调节重新渲染。它指定只有当一帧的末尾有空闲时间,才会执行回调函数。只有当前帧的运行时间小于16.66ms时,函数fn才会执行。否则,就推迟到下一帧,如果下一帧也没有空闲时间,就推迟到下下一帧,以此类推

1
requestIdleCallback(fn);

它还可以接受第二个参数,表示指定的毫秒数。如果在指定 的这段时间之内,每一帧都没有空闲时间,那么函数fn将会强制执行。

1
requestIdleCallback(fn, 5000);

7.5 Chrome Devtool Performance

怎么去分析页面运行时的性能表现,Chrome Devtool Performance是一个很好的选择。这里请大家参考这篇文章全新Chrome Devtool Performance使用指南

8.渲染合成层

8.1 梳理浏览器渲染流程

首先简单了解一下浏览器请求、加载、渲染一个页面的大致过程:

  • DNS 查询
  • TCP 连接
  • HTTP 请求即响应
  • 服务器响应
  • 客户端渲染

这里主要将客户端渲染展开梳理一下,从浏览器器内核拿到内容(渲染线程接收请求,加载网页并渲染网页),渲染大概可以划分成以下几个步骤:

  • 解析html建立dom树
  • 解析css构建render树(将CSS代码解析成树形的数据结构,然后结合DOM合并成render树)
  • 布局render树(Layout/reflow),负责各元素尺寸、位置的计算
  • 绘制render树(paint),绘制页面像素信息
  • 浏览器会将各层的信息发送给GPU(GPU进程:最多一个,用于3D绘制等),GPU会将各层合成(composite),显示在屏幕上。

参考一张图(webkit渲染主要流程):

图片描述

这里先解释一下几个概念,方便大家理解:

  DOM Tree:浏览器将HTML解析成树形的数据结构。

  CSS Rule Tree:浏览器将CSS解析成树形的数据结构。

  Render Tree: DOM和CSSOM合并后生成Render Tree。

  layout: 有了Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的CSS定义以及他们的从属关系,从而去计算出每个节点在屏幕中的位置。

  painting: 按照算出来的规则,通过显卡,把内容画到屏幕上。

  reflow(回流):当浏览器发现某个部分发生了点变化影响了布局,需要倒回去重新渲染,内行称这个回退的过程叫 reflow。reflow 会从 这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置。reflow 几乎是无法避免的。现在界面上流行的一些效果,比如树状目录的折叠、展开(实质上是元素的显 示与隐藏)等,都将引起浏览器的 reflow。鼠标滑过、点击……只要这些行为引起了页面上某些元素的占位面积、定位方式、边距等属性的变化,都会引起它内部、周围甚至整个页面的重新渲 染。通常我们都无法预估浏览器到底会 reflow 哪一部分的代码,它们都彼此相互影响着。

  repaint(重绘):改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。

注意:

  1. display:none 的节点不会被加入Render Tree,而visibility: hidden
    则会,所以,如果某个节点最开始是不显示的,设为display:none是更优的。
  2. display:none 会触发 reflow,而 visibility:hidden 只会触发 repaint,因为没有发现位置变化。
  3. 有些情况下,比如修改了元素的样式,浏览器并不会立刻reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次reflow,这又叫异步 reflow 或增量异步 reflow。但是在有些情况下,比如resize窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

再参考一张图理解一下:

图片描述

细致分离两个环节,其他环节参考上述概念注解:

JavaScript:JavaScript实现动画效果,DOM元素操作等。
Composite(渲染层合并):对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

在实际场景下,大致会出现三种常见的渲染流程(Layout和Paint步骤是可避免的,可参考上一张图的注意部分理解):

图片描述

8.2 Composite

8.2.1 了解层

注意:首先说明,这里讨论的是 WebKit,描述的是 Chrome 的实现细节,而并非是 web 平台的功能,因此这里介绍的内容不一定适用于其他浏览器。

  • Chrome 拥有两套不同的渲染路径(rendering path):硬件加速路径和旧软件路径(older software path)
  • Chrome 中有不同类型的层: RenderLayer(负责 DOM 子树)和GraphicsLayer(负责 RenderLayer的子树),只有 GraphicsLayer 是作为纹理(texture)上传给GPU的。
  • 什么是纹理?可以把它想象成一个从主存储器(例如 RAM)移动到图像存储器(例如 GPU 中的 VRAM)的位图图像(bitmapimage)
  • Chrome 使用纹理来从 GPU上获得大块的页面内容。通过将纹理应用到一个非常简单的矩形网格就能很容易匹配不同的位置(position)和变形(transformation)。这也就是3DCSS 的工作原理,它对于快速滚动也十分有效。

整个图:

图片描述

在 Chrome 中其实有几种不同的层类型:

  • RenderLayers 渲染层,这是负责对应 DOM 子树
  • GraphicsLayers 图形层,这是负责对应 RenderLayers子树。

在浏览器渲染流程中提到了composite概念,在 DOM 树中每个节点都会对应一个 LayoutObject,当他们的 LayoutObject 处于相同的坐标空间时,就会形成一个 RenderLayers ,也就是渲染层。RenderLayers 来保证页面元素以正确的顺序合成,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示。

某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。

而每个GraphicsLayer(合成层单独拥有的图层) 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后显示到屏幕上。

8.2.2 如何变成合成层

合成层创建标准

什么情况下能使元素获得自己的层?虽然 Chrome的启发式方法(heuristic)随着时间在不断发展进步,但是从目前来说,满足以下任意情况便会创建层:

  • 3D 或透视变换(perspective transform) CSS 属性
  • 使用加速视频解码的
  • (WebGL) 上下文或加速的 2D 上下文的 元素
  • 混合插件(如 Flash)
  • 对自己的 opacity 做 CSS动画或使用一个动画变换的元素
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)
  • 元素有一个z-index较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)

8.2.3 合成层的优点

淘宝的例子举的很详细,值得一看,里面提到了一旦renderLayer提升为了合成层就会有自己的绘图上下文,并且会开启硬件加速,有利于性能提升,里面列举了一些特点

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

注意:

  1. 提升到合成层后合成层的位图会交GPU处理,但请注意,仅仅只是合成的处理(把绘图上下文的位图输出进行组合)需要用到GPU,生成合成层的位图处理(绘图上下文的工作)是需要CPU。
  2. 当需要repaint的时候可以只repaint本身,不影响其他层,但是paint之前还有style, layout,那就意味着即使合成层只是repaint了自己,但style和layout本身就很占用时间。
  3. 仅仅是transform和opacity不会引发layout 和paint,那么其他的属性不确定。

总结合成层的优势:一般一个元素开启硬件加速后会变成合成层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。

性能优化点:

  1. 提升动画效果的元素 合成层的好处是不会影响到其他元素的绘制,因此,为了减少动画元素对其他元素的影响,从而减少paint,我们需要把动画效果中的元素提升为合成层。 提升合成层的最好方式是使用 CSS 的 will-change属性。从上一节合成层产生原因中,可以知道 will-change 设置为opacity、transform、top、left、bottom、right 可以将元素提升为合成层。
  2. 使用 transform 或者 opacity 来实现动画效果, 这样只需要做合成层的合并就好了。
  3. 减少绘制区域 对于不需要重新绘制的区域应尽量避免绘制,以减少绘制区域,比如一个 fix 在页面顶部的固定不变的导航header,在页面内容某个区域 repaint 时,整个屏幕包括 fix 的 header 也会被重绘。而对于固定不变的区域,我们期望其并不会被重绘,因此可以通过之前的方法,将其提升为独立的合成层。减少绘制区域,需要仔细分析页面,区分绘制区域,减少重绘区域甚至避免重绘。

8.2.4 利用合成层可能踩到的坑

  1. 合成层占用内存的问题
  2. 层爆炸,由于某些原因可能导致产生大量不在预期内的合成层,虽然有浏览器的层压缩机制,但是也有很多无法进行压缩的情况,这就可能出现层爆炸的现象(简单理解就是,很多不需要提升为合成层的元素因为某些不当操作成为了合成层)。解决层爆炸的问题,最佳方案是打破 overlap 的条件,也就是说让其他元素不要和合成层元素重叠。简单直接的方式:使用3D硬件加速提升动画性能时,最好给元素增加一个z-index属性,人为干扰合成的排序,可以有效减少chrome创建不必要的合成层,提升渲染性能,移动端优化效果尤为明显。 在这篇文章中的demo可以看出其中厉害。

用chremo打开demo页面后,开启浏览器的开发者模式,再按照如图操作打开查看工具:

图片描述

开启 Rendering 的Layer borders后 观察点击为动画元素设置z-index复选框的页面提示变化:

图片描述

上图中可以明显看出:页面中设置了一个h1标题,应用了translate3d动画,使得它被放到composited layer中渲染,然后在这个元素后面创建了2000个list。在不为h1元素设置z-index的情况下,使得本不需要提升到合成层的ul元素下的每个li元素都提升为一个单独合成层(每个li元素的黄色提示边框),最终会导致GPU资源过度消耗页面滑动时很卡,尤其在移动端(安卓)上更加明显。

图片描述

如上图操作选中为动画元素设置z-index,可以看出ul下的每个li都回归到普通渲染层,不再是合成层也就不会消耗GPU资源去渲染,从而达到了优化页面性能优化的目的。

大家可以用支持『硬件加速』的『安卓』手机浏览器测试上述页面,给动画元素加z-index前后的性能差距非常明显。

最后

在实际的前端开发中尤其是移动端开发,很多小伙伴都很喜欢使用类似 translateZ(0)等属性来进行所谓的硬件加速,以提升性能,达到优化页面动态效果的目的,但还是要注意凡事过犹不及,应用硬件加速的同时也要注意到千万别踩坑。
关于合成层的更细致具体的讲解,可以仔细学习下下面的参考文章(尤其是前三篇哦)。
最后祝愿热爱技术的你我始终坚持在探索技术的路上奋力前行!

参考文章:
无线性能优化:Composite
DOM to Screen
CSS GPU Animation: Doing It Right
[web优化之composite](https://github.com/hello2dj/blog/blob/master/web优化之composite.md)
详谈层合成(composite)
CSS3硬件加速也有坑

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道