一篇长文深入分析前端性能优化及优化方案

1,333 阅读30分钟

前言

你是否经历过以下场景:

  • 面试中
    • 谈谈你对性能优化的理解
    • 输入URL整个页面经历了什么过程?
    • ...
  • 工作中
    • 页面加载好慢,不知道是前端问题还是后端问题
    • 页面交互卡顿,不知道具体哪里出了问题
    • ...

如果有遇到此类型问题,希望我近期对性能优化的知识沉淀能够帮助到你,这篇文章先分析渲染过程,再分享优化方案。

首先将浏览器接受到URL后整个过程分为一下几大阶段:

  1. 网络请求线程开启
  2. 建立HTTP请求(DNS解析,TCP连接)
  3. 前后端交互(后端处理流程,浏览器缓存)
  4. 关键渲染路径(HTML,CSS,JS组织一起渲染)

渲染过程分析

网络请求线程开启

浏览器接受到我们输入的URL到网络请求线程,这个阶段是在浏览器内部完成的。 首先浏览器会对URL解析

标题名称备注
Protocol协议头说明浏览器该如何处理要打开的文件
常见的有HTTP,FTP,Telnet
Host主机域名/IP地址主机域名或经过DNS解析为IP地址
Port端口号请求程序和相应程序之间连接用的标识
Path目录路径请求的目录或文件名
Query查询参数请求所传递的参数
Fragment片段通常作为前端路由或锚点

URL结构 Protocol://Host:Port/Path?Query#Fragment,示例:http://example.com/users/1?foo=bar#abc

解析URL后,如果是HTTP协议,浏览器就会新建一个网络请求线程去下载所需的资源,对于线程和进程的学习,可以参考阮一峰老师的博客文章 线程和进程的一个简单解释

DNS解析

DNS解析主要通过查询将URL中的HOST字段转化成网络中具体的IP地址,我们常看到的域名只是为了方便帮助记忆,IP地址才是所访问服务器在网络中的“门牌号”

DNS解析过程:

流程图如下: 4c32d727eb171441.jpg

首先查询浏览器自身的DNS缓存,如果查到IP地址就接受解析,如果浏览器缓存中没找到,就会搜索系统自身的DNS缓存,如果还没找到,接着尝试从系统中的hosts文件中查找。

如果在本机主机进行的查询都没获取到,接下来便会到本地域名服务器上查询。如果也找不到,则本地域名服务器便会采取迭代的方式去依次查询根域名服务器、COM顶级域名服务器和权限域名服务器等,最终将所要访问的目标服务器IP地址放回到主机,此时,如果还是找不到该IP地址,则返回报错信息。

由此可见,DNS解析过程是个很耗时的过程,如果需要解析的域名过多的话,势必会影响到首屏渲染时间。

TCP连接

经过DNS解析之后,接下来开始TCP连接,由于TCP是面向有连接的通信协议,所以在数据传输之前需要建立好客户端和服务端之间的连接,即通常说的“三次握手”。

“三次握手”分析图:

tcp.jpg

“三次握手”详细分析:

  1. 第一次握手:客户端生成一个随机数seq,假设值为t,并将标志位SYN设置为1,将这些数据发送给服务器端,紧接着客户端进入等待状态。
  2. 第二次握手:服务器端接受到客户端发来SYN=1后,服务器端就知道客户端在请求连接,就设置SYN和ACK均为1,并将客户端发送过来的seq的随机值t+1,赋值给ack,随后服务器端也生成一个随机数seq=k,将这些数据打包发送给客户端,作为对客户端请求连接的确认应答。
  3. 第三次握手:客户端接收到服务器端发送过来的数据,检查ack是否为t+1,ACK是否等于1,若都正确,就将服务器端发送过来的seq随机数k+1赋值到ack,将这些数据发送给服务器端以确认服务器端的应答,服务器端根据ack是否等于k+1来决定是否建立连接。

当用户关闭掉标签或者请求完成后,TCP连接就会进行“四次挥手

“四次挥手“分析图:

3d0f784fead1b175.jpg

“四次挥手”详细分析:

  1. 第一次挥手: 先由客户端给服务器端发送FIN=M的指令,随后进入等待状态FIN_WAIT_1,表明客户端已经没有再向服务器端请求数据了,但若服务端器还有未完成的数据,可以继续发送。
  2. 第二次挥手: 服务器端接受到客户端的FIN报文后,发送ack=M+1,告知客户端关闭请求已收到,但可能由于服务端器还有数据没发送完成,请客户端继续等待。
  3. 第三次挥手: 当服务器端完成发送所有数据后,便发送带有FIN=N报文给客户端,通知准备关闭连接了,同时在等待客户端的最终关闭连接请求。
  4. 第四次挥手: 客户端收到FIN=N报文后,可进行关闭操作,但是为了数据正确性,会回传一个ack=N+1给服务端,服务器端收到报文后才真正断开连接,客户端在发送了确认报文后一段时间,如果没有收到服务器端的任何信息,则认为服务器端连接已关闭,也可关闭客户端信息了。

前后端交互

在建立TCP连接成功后,便可通过HTTP等协议进行前后端的通信。

后端处理流程:

对于后端的处理细节,本人不是很了解,有兴趣的可以多去了解,但最终还是会以一个HTTP相应数据包的形式发送回给前端。

浏览器缓存 (在后续优化方案中会详细说明使用方法)

在HTTP的前后端交互过程中,使用缓存可以使性能得到显著提升,具体的缓存策略有两种:强缓存和协商缓存

强缓存:

强缓存就是当浏览器判断出本地缓存还没过期时,直接读取本地缓存,无须发起HTTP请求。

协商缓存: 协商缓存则需要浏览器向服务器端发起HTTP请求,来判断浏览器本地缓存的文件是否还是未修改状态,如果还没修改,则从缓存中拿,如果已经被修改,则需要重新发送给浏览器。

关键渲染路径(CRP)

当我们经历了网络请求过程,从服务器获取到了所访问的页面文件后,浏览器如何将这些 HTML、CSS 及 JS 文件组织在一起渲染出来呢?

构建对象模型

首先浏览器会通过解析 HTML 和 CSS 文件,来构建 DOM(文档对象模型)和 CSSOM(层叠样式表对象模型)

浏览器接收读取到的 HTML 文件来构建DOM(文档对象模型)大概预览图:

dom-tree.png

浏览器接收读取到的 CSS 文件来构建CSSOM(层叠样式表对象模型)大概预览图:

cssom-tree.png

这两个对象模型的构建过程是会花费时间的,可以通过chrome浏览器打开开发者工具选项卡,查看对应过程的耗时情况,如图所示:

微信截图_20210730175551.png

渲染绘制

当完成文档对象模型和层叠样式表对象模型的构建后,所得到的其实是描述最终渲染页面两个不同方面信息的对象:一个是展示的文档内容,另一个是文档对象对应的样式规则,接下来就需要将两个对象模型合并为渲染树,渲染树中只包含渲染可见的节点,该 HTML 文档最终生成的渲染树如图所示。

111.png

渲染绘制的步骤大致如下:

  1. 从所生成DOM树的根节点开始向下遍历每个子节点,忽略所有不可见的节点(JS脚本标记不可见,CSS隐藏不可见),因为不可见节点没有在渲染树中。
  2. 在CSSOM中为每个节点找到对应的规则并应用。
  3. 布局阶段,根据所得到的渲染树,计算他们在设备视图中的具体位置大小,这一步输出的是一个“盒模型”。
  4. 绘制阶段,将每个节点的具体绘制方式转化为屏幕上的像素。

优化方案

请求和响应的优化

DNS解析

一般来说,在前端优化中与DNS有关的有两点:

  • 减少DNS的请求次数
  • 进行DNS进行预解析:DNS Prefetch

DNS Prefetch

DNS-prefetch (DNS 预获取) 是尝试在请求资源之前解析域名。这可能是后面要加载的文件,也可能是用户尝试打开的链接目标。域名解析和内容载入是串行的网络操作,所以这个方式能减少用户的等待时间,提升用户体验 。

用法:

<link rel="dns-prefetch" href="https://example.com/">

注意事项:

  1. dns-prefetch仅对跨域域上的 DNS 查找有效,因此请避免使用它来指向您的站点或域。这是因为,到浏览器看到提示时,您站点域背后的IP已经被解析。
  2. dns-prefetch 需慎用,多页面重复 DNS 预解析会增加重复 DNS 查询次数。
  3. 默认情况下浏览器会对页面中和当前域名(正在浏览网页的域名)不在同一个域的域名进行预获取,并且缓存结果,这就是隐式的 DNS Prefetch。如果想对页面中没有出现的域进行预获取,那么就要使用显示 DNS Prefetch 了。

HTTP长连接

短连接

HTTP 协议的初始版本中,每进行一次 HTTP 通信就要断开一次 TCP 连接。

比如,使用浏览器浏览一个包含多张图片的 HTMl 页面时,在发送请求访问 HTMl 页面资源的同时,也会请求该 HTML 页面包含的其它资源。因此,每次的请求都会造成无谓的 TCP 连接建立和断开,增加通信录的开销。

image.png

为了解决这个问题,有些浏览器在请求时,用了一个非标准的 Connection 字段。

    Connection: keep-alive

这个字段要求服务器不要关闭 TCP 连接,以便其他请求复用。服务器同样回应这个字段。

长连接

1997 年 1 月,HTTP/1.1 版本发布,只比 1.0 版本晚了半年。它进一步完善了 HTTP 协议,直到现在还是最流行的版本。

HTTP 1.1 版的最大变化,就是引入了持久连接(HTTP Persistent Connections),即 TCP 连接默认不关闭,可以被多个请求复用,不用声明 Connection: keep-alive

image (1).png

持久连接的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。另外,减少开销的那部分时间,使 HTTP 请求和响应能够更早的结束,这样 Web 页面的显示速度也就相应提高了。

管道机制

HTTP 1.1 版还引入了管道机制(pipelining),即在同一个 TCP 连接里面,客户端可以同时发送多个请求。这样就进一步改进了 HTTP 协议的效率。

从前发送请求后需等待并接收响应,才能发送下一个请求。管线化技术出现后,不用等待响应即可直接发送下一个请求。这样就能够做到同时并行发送多个请求,而不需要一个接一个的等待响应了,与挨个连接相比,用持久连接可以让请求更快结束。而管线化技术则比持久连接还要快。请求数越多,时间差就越明显。

image (2).png

举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。管道机制则是允许浏览器同时发出 A 请求和 B 请求,但是服务器还是按照顺序,先回应A请求,完成后再回应 B 请求。

HTTP1.1长连接缺点

虽然 HTTP 1.1 版允许复用 TCP 连接,但是同一个 TCP 连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为"队头堵塞"(Head-of-line blocking)。

为了避免这个问题,只有两种方法:

  1. 一是减少请求数
  2. 二是同时多开持久连接

这导致了很多的网页优化技巧,比如合并脚本和样式表、将图片嵌入 CSS 代码等等。

HTTP2

2009 年,谷歌公开了自行研发的 SPDY 协议,主要解决 HTTP/1.1 效率不高的问题。

多工

HTTP/2 复用 TCP 连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了"队头堵塞"。

举例来说,在一个 TCP 连接里面,服务器同时收到了 A 请求和 B 请求,于是先回应 A 请求,结果发现处理过程非常耗时,于是就发送 A 请求已经处理好的部分, 接着回应 B 请求,完成后,再发送 A 请求剩下的部分。

这样双向的、实时的通信,就叫做多工(Multiplexing)。

这是一个对比 HTTP1 和 HTTP2 资源加载的在线示例:http2.akamai.com/demo

数据流

因为 HTTP/2 的数据包是不按顺序发送的,同一个连接里面连续的数据包,可能属于不同的回应。因此,必须要对数据包做标记,指出它属于哪个回应。

HTTP/2 将每个请求或回应的所有数据包,称为一个数据流(stream)。每个数据流都有一个独一无二的编号。数据包发送的时候,都必须标记数据流 ID,用来区分它属于哪个数据流。另外还规定,客户端发出的数据流,ID 一律为奇数,服务器发出的,ID 为偶数。

数据流发送到一半的时候,客户端和服务器都可以发送信号(RST_STREAM帧),取消这个数据流。1.1 版取消数据流的唯一方法,就是关闭 TCP 连接。这就是说,HTTP/2 可以取消某一次请求,同时保证 TCP 连接还打开着,可以被其他请求使用。

客户端还可以指定数据流的优先级。优先级越高,服务器就会越早回应。

头信息压缩

HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如 Cookie 和 User Agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。

HTTP/2 对这一点做了优化,引入了头信息压缩机制(header compression)。一方面,头信息使用 gzip 或 compress 压缩后再发送;另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。

服务器推送

HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送(server push)。

常见场景是客户端请求一个网页,这个网页里面包含很多静态资源。正常情况下,客户端必须收到网页后,解析 HTML 源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能会再请求静态资源,所以就主动把这些静态资源随着网页一起发给客户端了。

压缩传输的数据资源

数据压缩是提高 Web 站点性能的一种重要手段。对于有些文件来说,高达 70% 的压缩比率可以大大减低对于带宽的需求。随着时间的推移,压缩算法的效率也越来越高,同时也有新的压缩算法被发明出来,应用在客户端与服务器端。

HTTP 响应数据压缩

压缩 JS、CSS

这里所说的压缩指的是去除换行空格之类的压缩,文件内容不变。

使用 Gzip 压缩文本

浏览器和服务器之间会使用主动协商机制。浏览器发送Accept-Encoding首部,其中包含有它所支持的压缩算法,以及各自的优先级,服务器则从中选择一种,使用该算法对响应的消息主体进行压缩,并且发送Content-Encoding首部来告知浏览器它选择了哪一种算法。由于该内容协商过程是基于编码类型来选择资源的展现形式的,在响应中,Vary首部中至少要包含Accept-Encoding;这样的话,缓存服务器就可以对资源的不同展现形式进行缓存。

下面是一个请求响应的 HTTP 报文示例:

GET /encrypted-area HTTP/1.1 
Host: www.example.com 
Accept-Encoding: gzip, deflate
HTTP/1.1 200 OK 
Date: Tue, 27 Feb 2018 06:03:16 GMT 
Server: Apache/1.3.3.7 (Unix) (Red-Hat/Linux) 
Last-Modified: Wed, 08 Jan 2003 23:11:55 GMT 
Accept-Ranges: bytes 
Content-Length: 438 
Connection: close 
Content-Type: text/html; charset=UTF-8 
Content-Encoding: gzip

HTTP 请求数据压缩

头部数据压缩

HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如 Cookie 和 User Agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。

HTTP/2 对这一点做了优化,引入了头信息压缩机制(header compression)。一方面,头信息使用 gzip 或 compress 压缩后再发送;另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。

请求体数据压缩

实际的 Web 项目中,会存在请求正文非常大的场景,例如发表长篇博客,上报用于调试的网络数据等等。这些数据如果能在本地压缩后再提交,就可以节省网络流量、减少传输时间。如何对 HTTP 请求正文进行压缩,其中有如何在客户端压缩、如何在服务端解压两个部分。

常用的三种数据压缩格式: DEFLATEZLIBGZIP

下面是一个简单示例。

(1)压缩请求正文数据(客户端)

var rawBody = 'content=test'; 
var rawLen = rawBody.length; 
var bufBody = new Uint8Array(rawLen); 
for(var i = 0; i < rawLen; i++) { 
    bufBody[i] = rawBody.charCodeAt(i); 
} 
var format = 'gzip'; // gzip | deflate | deflate-raw 
var buf; 
switch(format) { 
    case 'gzip': 
        buf = window.pako.gzip(bufBody); 
        break; 
    case 'deflate': 
        buf = window.pako.deflate(bufBody); 
        break; 
    case 'deflate-raw': 
        buf = window.pako.deflateRaw(bufBody); 
        break; 
} 
var xhr = new XMLHttpRequest(); 
xhr.open('POST', '/node/'); 
xhr.setRequestHeader('Content-Encoding', format); 
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); 
xhr.send(buf);

(2)在 Node 中解压请求正文中的数据(服务端)

var http = require('http'); 
var zlib = require('zlib'); 
http.createServer(function (req, res) { 
    var zlibStream; 
    var encoding = req.headers['content-encoding']; 
    
    switch(encoding) { 
        case 'gzip': 
            zlibStream = zlib.createGunzip(); 
            break; 
        case 'deflate': 
            zlibStream = zlib.createInflate(); 
            break; 
        case 'deflate-raw': 
            zlibStream = zlib.createInflateRaw();
            break; 
    } 
    
    res.writeHead(200, {'Content-Type': 'text/plain'}); 
    req.pipe(zlibStream).pipe(res); 
}).listen(8361, '127.0.0.1');

HTTP缓存

HTTP 缓存应该算是前端开发中最常接触的缓存机制之一,它又可细分为强制缓存协商缓存,二者最大的区别在于判断缓存命中时,浏览器是否需要向服务器端进行询问以协商缓存的相关信息,进而判断是否需要就响应内容进行重新请求。下面就来具体看HTTP缓存的具体机制及缓存的决策策略。

强制缓存

对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无须与服务器进行任何通信。

在介绍强制缓存命中判断之前,我们首先来看一段响应头的部分信息:

access-control-allow-origin: * 
age: 734978 
content-length: 40830 
content-type: image/jpeg 
cache-control: max-age=31536000 
expires: Web, 14 Fed 2021 12:23:42 GMT

其中与强制缓存相关的两个字段是expirescache-control,expires是在 HTTP 1.0 协议中声明的用来控制缓存失效日期时间戳的字段,它由服务器端指定后通过响应头告知浏览器,浏览器在接收到带有该字段的响应体后进行缓存。

若之后浏览器再次发起相同的资源请求,便会对比expires与本地当前的时间戳,如果当前请求的本地时间戳小于expires的值,则说明浏览器缓存的响应还未过期,可以直接使用而无须向服务器端再次发起请求。只有当本地时间戳大于expires值发生缓存过期时,才允许重新向服务器发起请求。

从上述强制缓存是否过期的判断机制中不难看出,这个方式存在一个很大的漏洞,即对本地时间戳过分依赖,如果客户端本地的时间与服务器端的时间不同步,或者对客户端时间进行主动修改,那么对于缓存过期的判断可能就无法和预期相符。

为了解决expires判断的局限性,从 HTTP 1.1 协议开始新增了cache-control字段来对expires的功能进行扩展和完善。从上述代码中可见cache-control设置了maxage=31536000的属性值来控制响应资源的有效期,它是一个以秒为单位的时间长度,表示该资源在被请求到后的 31536000 秒内有效,如此便可避免服务器端和客户端时间戳不同步而造成的问题。除此之外,cache-control还可配置一些其他属性值来更准确地控制缓存,下面来具体介绍。

no-cache 和 no-store

设置no-cache并非像字面上的意思不使用缓存,其表示为强制进行协商缓存(后面会说),即对于每次发起的请求都不会再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存未过期,则会使用本地缓存。设置no-store则表示禁止使用任何缓存策略,客户端的每次请求都需要服务器端给予全新的响应。no-cacheno-store是两个互斥的属性值,不能同时设置。

Cache-Control: no-store 响应头可以关闭缓存。

指定Cache-Control: no-cacheCache-Control: max-age=0表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。这意味着每次都会发起 HTTP 请求,但当缓存内容仍有效时可以跳过 HTTP 响应体的下载。

协商缓存

协商缓存就是在使用本地缓存之前,需要向服务器端发起一次 GET 请求,与之协商当前浏览器保存的本地缓存是否已经过期。

通常是采用所请求资源最近一次的修改时间戳来判断的,为了便于理解,下面来看一个例子:假设客户端浏览器需要向服务器请求一个manifest.js的 JavaScript 文件资源,为了让该资源被再次请求时能通过协商缓存的机制使用本地缓存,那么首次返回该图片资源的响应头中应包含一个名为last-modified的字段,该字段的属性值为该 JavaScript 文件最近一次修改的时间戳,简略截取请求头与响应头的关键信息如下:

Request URL: http://localhost:3000/image.jpg 
Request Method: GET 
last-modified: Thu, 29 Apr 2021 03:09:28 GMT 
cache-control: no-cache

当我们刷新网页时,由于该 JavaScript 文件使用的是协商缓存,客户端浏览器无法确定本地缓存是否过期,所以需要向服务器发送一次 GET 请求,进行缓存有效性的协商,此次 GET 请求的请求头中需要包含一个ifmodified-since字段,其值正是上次响应头中last-modified的字段值。 当服务器收到该请求后便会对比请求资源当前的修改时间戳与if-modified-since字段的值,如果二者相同则说明缓存未过期,可继续使用本地缓存,否则服务器重新返回全新的文件资源,简略截取请求头与响应头的关键信息如下:

Request URL: http://localhost:3000/image.jpg 
Request Method: GET 
last-modified: Thu, 29 Apr 2021 03:09:28 GMT 
cache-control: no-cache

当我们刷新网页时,由于该 JavaScript 文件使用的是协商缓存,客户端浏览器无法确定本地缓存是否过期,所以需要向服务器发送一次 GET 请求,进行缓存有效性的协商,此次 GET 请求的请求头中需要包含一个ifmodified-since字段,其值正是上次响应头中last-modified的字段值。

当服务器收到该请求后便会对比请求资源当前的修改时间戳与if-modified-since字段的值,如果二者相同则说明缓存未过期,可继续使用本地缓存,否则服务器重新返回全新的文件资源,简略截取请求头与响应头的关键信息如下:

// 再次请求的请求头 
Request URL: http://localhost:3000/image.jpg 
Request Method: GET 
If-Modified-Since: Thu, 29 Apr 2021 03:09:28 GMT 
// 协商缓存有效的响应头 
Status Code: 304 Not Modified

这里需要注意的是,协商缓存判断缓存有效的响应状态码是304,即缓存有效响应重定向到本地缓存上。这和强制缓存有所不同,强制缓存若有效,则再次请求的响应状态码是200

last-modifed 的不足

通过last-modified所实现的协商缓存能够满足大部分的使用场景,但也存在两个比较明显的缺陷:

  • 首先它只是根据资源最后的修改时间戳进行判断的,虽然请求的文件资源进行了编辑,但内容并没有发生任何变化,时间戳也会更新,从而导致协商缓存时关于有效性的判断验证为失效,需要重新进行完整的资源请求。这无疑会造成网络带宽资源的浪费,以及延长用户获取到目标资源的时间。

  • 其次标识文件资源修改的时间戳单位是秒,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式来验证缓存的有效性,是无法识别出该次文件资源的更新的。

其实造成上述两种缺陷的原因相同,就是服务器无法仅依据资源修改的时间戳来识别出真正的更新,进而导致重新发起了请求,该重新请求却使用了缓存的 Bug 场景。

基于 ETag 的协商缓存

为了弥补通过时间戳判断的不足,从 HTTP 1.1 规范开始新增了一个 ETag 的头信息,即实体标签(Entity Tag)。

其内容主要是服务器为不同资源进行哈希运算所生成的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的ETag标签值就会不同,因此可以使用对文件资源进行更精准的变化感知。下面我们来看一个使用ETag进行协商缓存图片资源的示例,首次请求后的部分响应头关键信息如下。

Content-Type: image/jpeg 
ETag: "xxx" 
Last-Modified: Fri, 12 Jul 2021 18:30:00 GMT 
Content-Length: 9887

上述响应头中同时包含了last-modified文件修改时间戳和 ETag 实体标签两种协商缓存的有效性校验字段,因为 ETaglast-modified具有更准确的文件资源变化感知,所以它的优先级也更高,二者同时存在时以ETag为准。再次对该图片资源发起请求时,会将之前响应头中ETag的字段值作为此次请求头中If-None-Match字段,提供给服务器进行缓存有效性验证。请求头与响应头的关键字段信息如下。

再次请求头:

If-Modified-Since: Fri, 12 Jul 2021 18:30:00 GMT 
If-None-Match: "xxx" // 上次ETag的值

再次响应头:

Content-Type: image/jpeg ETag: "xxx" 
Last-Modified: Fri, 12 Jul 2021 18:30:00 GMT 
Content-Length: 9887

若验证缓存有效,则返回304状态码响应重定向到本地缓存,所以上面响应头中的内容长度Content-Length字段值也就为 0 了。

ETag 的不足

不像强制缓存中cache-control可以完全替代expires的功能,在协商缓存中,ETag并非last-modified的替代方案而是一种补充方案,因为它依旧存在一些弊端。

  • 一方面服务器对于生成文件资源的ETag需要付出额外的计算开销,如果资源的尺寸较大,数量较多且修改比较频繁,那么生成ETag的过程就会影响服务器的性能。
  • 另一方面ETag字段值的生成分为强验证和弱验证,强验证根据资源内容进行生成,能够保证每个字节都相同;弱验证则根据资源的部分属性值来生成,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率,所以恰当的方式是根据具体的资源使用场景选择恰当的缓存校验方式。
缓存决策示例

在使用缓存技术优化性能体验的过程中,有一个问题是不可逾越的:我们既希望缓存能在客户端尽可能久的保存,又希望它能在资源发生修改时进行及时更新。

这是两个互斥的优化诉求,使用强制缓存并定义足够长的过期时间就能让缓存在客户端长期驻留,但由于强制缓存的优先级高于协商缓存,所以很难进行及时更新;若使用协商缓存,虽然能够保证及时更新,但频繁与服务器进行协商验证的响应速度肯定不及使用强制缓存快。那么如何兼顾二者的优势呢?

我们可以将一个网站所需要的资源按照不同类型去拆解,为不同类型的资源制定相应的缓存策略,以下面的HTML文件资源为例:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTTP 缓存策略</title>
    <link rel="stylesheet" href="style.css">
</head>

<body> 
    <img src="photo.jpg" alt="poto">
    <script src="script.js"></script>
</body>

</html>

该 HTML 文件中包含了一个 JavaScript 文件script.js、一个样式表文件style.css和一个图片文件photo.jpg,若要展示出该 HTML 中的内容就需要加载出其包含的所有外链文件。据此我们可针对它们进行如下设置。

首先 HTML 在这里属于包含其他文件的主文件,为保证当其内容发生修改时能及时更新,应当将其设置为协商缓存,即为cache-control字段添加no-cache属性值;其次是图片文件,因为网站对图片的修改基本都是更换修改,同时考虑到图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可采用强制缓存且过期时间不宜过长,故可设置cache-control字段值为max-age=86400

接下来需要考虑的是样式表文件style.css,由于其属于文本文件,可能存在内容的不定期修改,又想使用强制缓存来提高重用效率,故可以考虑在样式表文件的命名中增加文件指纹或版本号(比如添加文件指纹后的样式表文件名变为了style.51ad84f7.css),这样当发生文件修改后,不同的文件便会有不同的文件指纹,即需要请求的文件 URL 不同了,因此必然会发生对资源的重新请求。

最后是 JavaScript 脚本文件,其可类似于样式表文件的设置。

从这个缓存策略的示例中我们可以看出,对不同资源进行组合使用强制缓存、协商缓存及文件指纹或版本号,可以做到一举多得:及时修改更新、较长缓存过期时间及控制所能进行缓存的位置。

代码示例

node.js为例

const http = require('http')
const fs = require('fs')
const url = require('url')
const etag = require('etag')

http.createServer((req, res) => {
    console.log(req.method, req.url)
    const { pathname } = url.parse(req.url)
    if (pathname === '/') {
        const data = fs.readFileSync('./index.html')
        res.end(data)
    } else if (pathname === '/img/01.jpg') { // 强缓存-Expires
        const data = fs.readFileSync('./img/01.jpg')
        res.writeHead(200, {
            // 缺点:客户端时间和服务器时间可能不同步
            Expires: new Date('2021-08-01 00:46').toUTCString()
        })
        res.end(data)
    } else if (pathname === '/img/02.jpg') {  // 强缓存 Cache-Control: max-age = 5
        const data = fs.readFileSync('./img/02.jpg')
        res.writeHead(200, {
            'Cache-Control': 'max-age=5' // 滑动时间,单位是秒
        })
        res.end(data)
    } else if (pathname === '/img/03.jpg') {
        const { mtime } = fs.statSync('./img/03.jpg')

        const ifModifiedSince = req.headers['if-modified-since']

        if (ifModifiedSince === mtime.toUTCString()) {
            // 缓存生效
            res.statusCode = 304
            res.end()
            return
        }

        const data = fs.readFileSync('./img/03.jpg')

        // 告诉客户端该资源要使用协商缓存
        //   客户端使用缓存数据之前问一下服务器缓存有效吗
        //   服务端:
        //     有效:返回 304 ,客户端使用本地缓存资源
        //     无效:直接返回新的资源数据,客户端直接使用
        res.setHeader('Cache-Control', 'no-cache')
        // 服务端要下发一个字段告诉客户端这个资源的更新时间
        res.setHeader('last-modified', mtime.toUTCString())
        res.end(data)
    } else if (pathname === '/img/04.jpg') {
        const data = fs.readFileSync('./img/04.jpg')
        // 基于文件内容生成一个唯一的密码戳
        const etagContent = etag(data)

        const ifNoneMatch = req.headers['if-none-match']

        if (ifNoneMatch === etagContent) {
            res.statusCode = 304
            res.end()
            return
        }

        // 告诉客户端要进行协商缓存
        res.setHeader('Cache-Control', 'no-cache')
        // 把该资源的内容密码戳发给客户端
        res.setHeader('etag', etagContent)
        res.end(data)
    } else {
        res.statusCode = 404
        res.end()
    }
}).listen(3000, () => {
    console.log('http://localhost:3000')
})

渲染优化

关键渲染路径优化

浏览器从获取 HTML 到最终在屏幕上显示内容需要完成以下步骤:

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个 render tree。
  4. 根据渲染树来布局,以计算每个节点的几何信息。
  5. 将各个节点绘制到屏幕上。

经过以上整个流程我们才能看见屏幕上出现渲染的内容,优化关键渲染路径就是指最大限度缩短执行上述第 1 步至第 5 步耗费的总时间,让用户最快的看到首次渲染的内容。

为尽快完成首次渲染,我们需要最大限度减小以下三种可变因素:

  • 关键资源的数量。
  • 关键路径长度。
  • 关键字节的数量。

关键资源是可能阻止网页首次渲染的资源。例如 JavaScript、CSS 都是可以阻塞关键渲染路径的资源,这些资源越少,浏览器的工作量就越小,对 CPU 以及其他资源的占用也就越少。

同样,关键路径长度受所有关键资源与其字节大小之间依赖关系图的影响: 某些资源只能在上一资源处理完毕之后才能开始下载,并且资源越大,下载所需的往返次数就越多。

最后,浏览器需要下载的关键字节越少,处理内容并让其出现在屏幕上的速度就越快。要减少字节数,我们可以减少资源数(将它们删除或设为非关键资源),此外还要压缩和优化各项资源,确保最大限度减小传送大小。

优化 DOM

在关键渲染路径中,构建渲染树(Render Tree)的第一步是构建 DOM,所以我们先讨论如何让构建 DOM 的速度变得更快。

HTML 文件的尺寸应该尽可能的小,目的是为了让客户端尽可能早的接收到完整的 HTML。通常 HTML 中有很多冗余的字符,例如:JS 注释、CSS 注释、HTML 注释、空格、换行。更糟糕的情况是我见过很多生产环境中的 HTML 里面包含了很多废弃代码,这可能是因为随着时间的推移,项目越来越大,由于种种原因从历史遗留下来的问题,不过不管怎么说,这都是很糟糕的。对于生产环境的HTML来说,应该删除一切无用的代码,尽可能保证 HTML 文件精简。

总结起来有三种方式可以优化 HTML:缩小文件的尺寸(Minify)、使用gzip压缩(Compress)、使用缓存(HTTP Cache)

本质上,优化 DOM 其实是在尽可能的减小关键路径的长度与关键字节的数量

优化 CSSOM

CSS 是构建渲染树的必备元素,首次构建网页时,JavaScript 常常受阻于 CSS。确保将任何非必需的 CSS 都标记为非关键资源(例如打印和其他媒体查询),并应确保尽可能减少关键 CSS 的数量,以及尽可能缩短传送时间。

阻塞渲染的 CSS

CSS 是关键资源,它会阻塞关键渲染路径也并不奇怪,但通常并不是所有的 CSS 资源都那么的『关键』。

举个例子:一些响应式 CSS 只在屏幕宽度符合条件时才会生效,还有一些 CSS 只在打印页面时才生效。这些 CSS 在不符合条件时,是不会生效的,所以我们为什么要让浏览器等待我们并不需要的 CSS 资源呢?

针对这种情况,我们应该让这些非关键的 CSS 资源不阻塞渲染

<link href="style.css" rel="stylesheet"> 
<link href="print.css" rel="stylesheet" media="print"> 
<link href="other.css" rel="stylesheet" media="(min-width: 40em)"> 
<link href="portrait.css" rel="stylesheet" media="orientation:portrait">
  • 第一个声明阻塞渲染,适用于所有情况。
  • 第二个声明只在打印网页时应用,因此网页首次在浏览器中加载时,它不会阻塞渲染。
  • 声明提供由浏览器执行的“媒体查询”: 符合条件时,浏览器将阻塞渲染,直至样式表下载并处理完毕。
  • 最后一个声明具有动态媒体查询,将在网页加载时计算。根据网页加载时设备的方向,portrait.css 可能阻塞渲染,也可能不阻塞渲染。

最后,请注意“阻塞渲染”仅是指浏览器是否需要暂停网页的首次渲染,直至该资源准备就绪。无论哪一种情况,浏览器仍会下载 CSS 资产,只不过不阻塞渲染的资源优先级较低罢了。

为获得最佳性能,您可能会考虑将关键 CSS 直接内联到 HTML 文档内。这样做不会增加关键路径中的往返次数,并且如果实现得当,在只有 HTML 是阻塞渲染的资源时,可实现“一次往返”关键路径长度。

避免在 CSS 中使用@import

大家应该都知道要避免使用@import加载 CSS,实际工作中我们也不会这样去加载 CSS,但这到底是为什么呢?

这是因为使用@import加载 CSS 会增加额外的关键路径长度。举个例子:

<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
  <title>Demos</title>
  <link rel="stylesheet" href="http://127.0.0.1:8887/style.css">
  <link rel="stylesheet" href="https://lib.baomitu.com/CSS-Mint/2.0.6/css-mint.min.css">
</head>

<body>
  <div class="cm-alert">Default alert</div>
</body>

</html>

上面这段代码使用link标签加载了两个 CSS 资源。这两个 CSS 资源是并行下载的。

现在我们改为使用@import加载资源,代码如下:

/* style.css */ 
@import url('https://lib.baomitu.com/CSS-Mint/2.0.6/css-mint.min.css'); 
body{
    background:red;
}
<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Demos</title>
    <link rel="stylesheet" href="http://127.0.0.1:8887/style.css">
</head>
<body>
    <div class="cm-alert">Default alert</div>
</body>
</html>

代码中使用 link 标签加载一个 CSS,然后在 CSS 文件中使用@import加载另一个 CSS。

可以看到两个 CSS 变成了串行加载,前一个 CSS 加载完后再去下载使用@import导入的 CSS 资源。这无疑会导致加载资源的总时间变长。从上图可以看出,首次绘制时间等于两个 CSS 资源加载时间的总和。

所以避免使用@import是为了降低关键路径的长度。

优化 JavaScript 的使用

所有文本资源都应该让文件尽可能的小,JavaScript 也不例外,它也需要删除未使用的代码、缩小文件的尺寸(Minify)、使用 gzip 压缩(Compress)、使用缓存(HTTP Cache)。

  • 异步加载 JavaScript
  • 避免同步请求
  • 延迟解析 JavaScript
  • 避免运行时间长的 JavaScript
使用 defer 延迟加载 JavaScript

与 CSS 资源相似,JavaScript 资源也是关键资源,JavaScript 资源会阻塞 DOM 的构建。并且 JavaScript 会被 CSS 文件所阻塞。

当浏览器加载 HTML 时遇到<script>...</script>标签,浏览器就不能继续构建 DOM。它必须立刻执行此脚本。对于外部脚本<script src="..."></script>也是一样的:浏览器必须等脚本下载完,并执行结束,之后才能继续处理剩余的页面。

这会导致两个重要的问题:

  • 脚本不能访问到位于它们下面的 DOM 元素,因此,脚本无法给它们添加处理程序等。
  • 如果页面顶部有一个笨重的脚本,它会“阻塞页面”。在该脚本下载并执行结束前,用户都不能看到页面内容
<p>...content before script...</p>

<script src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>

<!-- This isn't visible until the script loads -->
<p>...content after script...</p>

这里有一些解决办法。例如,我们可以把脚本放在页面底部。此时,它可以访问到它上面的元素,并且不会阻塞页面显示内容:

<body>
  ...all content is above the script...

  <script src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>
</body>

但是这种解决方案远非完美。例如,浏览器只有在下载了完整的 HTML 文档之后才会注意到该脚本(并且可以开始下载它)。对于长的 HTML 文档来说,这样可能会造成明显的延迟。

这对于使用高速连接的人来说,这不值一提,他们不会感受到这种延迟。但是这个世界上仍然有很多地区的人们所使用的网络速度很慢,并且使用的是远非完美的移动互联网连接。

幸运的是,这里有两个script特性(attribute)可以为我们解决这个问题:deferasync

defer特性告诉浏览器不要等待脚本。相反,浏览器将继续处理 HTML,构建 DOM。脚本会“在后台”下载,然后等 DOM 构建完成后,脚本才会执行。

这是与上面那个相同的示例,但是带有defer特性:

<p>...content before script...</p>

<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>

<!-- 立即可见 -->
<p>...content after script...</p>

换句话说:

  • 具有 defer 特性的脚本不会阻塞页面。
  • 具有 defer 特性的脚本总是要等到 DOM 解析完毕。

具有 defer 特性的脚本保持其相对顺序,就像常规脚本一样。

假设,我们有两个具有 defer 特性的脚本:long.js 在前,small.js 在后。

<script defer src="https://javascript.info/article/script-async-defer/long.js"></script> <script defer src="https://javascript.info/article/script-async-defer/small.js"></script>

浏览器扫描页面寻找脚本,然后并行下载它们,以提高性能。因此,在上面的示例中,两个脚本是并行下载的。small.js 可能会先下载完成。

……但是,defer 特性除了告诉浏览器“不要阻塞页面”之外,还可以确保脚本执行的相对顺序。因此,即使 small.js 先加载完成,它也需要等到 long.js 执行结束才会被执行。

当我们需要先加载 JavaScript 库,然后再加载依赖于它的脚本时,这可能会很有用。

使用 async 延迟加载 JavaScript

async 特性与 defer 有些类似。它也能够让脚本不阻塞页面。但是,在行为上二者有着重要的区别。

async 特性意味着脚本是完全独立的:

  • 浏览器不会因 async 脚本而阻塞(与 defer 类似)。
  • 其他脚本不会等待 async 脚本加载完成,同样,async 脚本也不会等待其他脚本。

换句话说,async 脚本会在后台加载,并在加载就绪时运行。DOM 和其他脚本不会等待它们,它们也不会等待其它的东西。async 脚本就是一个会在加载完成时执行的完全独立的脚本。

资源加载优化

图片延迟加载

什么是延迟加载

首先来想象一个场景,当浏览一个内容丰富的网站时,比如电商的商品列表页、主流视频网站的节目列表等,由于屏幕尺寸的限制,每次只能查看到视窗中的那部分内容,而要浏览完页面所包含的全部信息,就需要滚动页面,让屏幕视窗依次展示出整个页面的所有局部内容。

显而易见,对于首屏之外的内容,特别是图片和视频,一方面由于资源文件很大,若是全部加载完,既费时又费力,还容易阻塞渲染引起卡顿;另一方面,就算加载完成,用户也不一定会滚动屏幕浏览到全部页面内容,如果首屏内容没能吸引住用户,那么很可能整个页面就将遭到关闭。

既然如此,本着节约不浪费的原则,在首次打开网站时,应尽量只加载首屏内容所包含的资源,而首屏之外涉及的图片或视频,可以等到用户滚动视窗浏览时再去加载。

以上就是延迟加载优化策略的产生逻辑,通过延迟加载“非关键”的图片及视频资源,使得页面内容更快地呈现在用户面前。这里的“非关键”资源指的就是首屏之外的图片或视频资源,相较于文本、脚本等其他资源来说,图片的资源大小不容小觑。

实现图片延迟加载

参考阮一峰老师的博客文章intersectionobserver_api

图片优化

  • 适合用矢量图的地方首选矢量图。
  • 使用位图时首选WebP,对不支持的浏览器场景进行兼容处理。
  • 尽量为位图图像格式找到最佳质量设置。
  • 对图像文件进行必要的压缩。
  • 为图像提供多种缩放尺寸的响应式资源。
  • 对工程化通用图像处理流程尽量自动化。

构建优化

  • webpack 优化
  • 代码拆分
  • 代码压缩
  • 持久化缓存
  • 监测与分析
  • 按需加载

结束语

此文章内容涉及的知识点比较广,如果有哪里说错还望大佬指教,如果有对知识点有疑问的,欢迎在评论区留言。