最体系化的前端性能优化详解

3,537 阅读1小时+

性能优化这个词我们经常会在前端的工作或面试中遇到,这个东西说难好像也并不怎么难,毕竟谁都能说上几点。但是如果你想在工作上遇到各种场景的性能瓶颈时都有直击本质的性能方案,或者在面试时让面试官眼前一亮,那就不能只拘泥于『想到哪说到哪』或者『说个大概』,而要有一套体系化的、各个角度的、深入了解的知识图谱。这篇文章也算对我个人的前端知识的一次归纳总结,因为『性能优化』不仅仅是『优化』,什么意思呢?实施优化方案之前,首先要知道为什么要这样优化,这么做的目的是什么。这就需要你上到框架、js、css,下到浏览器、js 引擎、网络等等原理都有不错的了解。所以性能优化真的涵盖了太多前端知识,甚至是绝大部分前端知识。

首先我们来谈一谈前端性能的本质,前端是一个网络应用,应用的性能好坏是它的运行效率决定的,前面再加上网络那就是再和网络效率有关。所以我认为前端性能的本质就是网络性能和运行性能。所以前端性能优化体系中的两个大分类就是:网络运行时,然后我们从这两大纲领中,再细分出各个小的领域,足以织成一个巨大的前端知识图谱。

网络层面

如果我们把网络连接比做一根水管,你现在要打开一个页面,就可以看作对面手上有一杯水,你想把水接到你杯子里。想要更快可以有 3 种办法:1. 让水管流量变大、流得更快;2. 让对面把杯子里的水变少;3. 哥自己杯子里有水,不需要你的了。水管流量也就是你的网络带宽、协议优化等影响网速的部分;杯子水变少就比如压缩、代码分割、懒加载等等减少请求的手段;最后一种就是用缓存。

先说网络速度,网络速度不仅由用户的运营商决定,也可以通过熟悉网络协议的原理,调优网络协议来优化其效率。

计算机网络理论上是 OSI 七层模型,实际可以看成五层(或四层模型),分别是物理层、数据链路层、网络层、传输层、应用层。每一层负责封装拆解解析自己的协议,做自己职责的任务。打个比方,就好像宫女在给皇上一层层的穿衣服脱衣服,你负责外套我负责内裤各司其职。作为前端,我们主要关注应用层和传输层,先从我们天天打交道的应用层 HTTP 协议讲起。

http 协议的优化

http/tls的常见优化手段

  1. 在 HTTP/1.1 下要避免达到浏览器同域名请求最大并发限制(chrome 一般是 6 个)
  • 页面资源请求数较多时,可以准备多个域名,用不同的域名请求绕过最大并发限制。
  • 多个小图标可以合并到一张大图里,这样多个图片资源只需要一个请求,前端通过 css 的background-position样式展示对应图标(也称雪碧图)
  1. 减少 HTTP header 的大小
  • 比如同 domain 的请求会自动携带 cookie,不需要身份验证的场景就很浪费,这种资源可以尽量不要和站点同 domain。
  1. 充分利用 HTTP 缓存。缓存可以直接省去请求,对网络性能提升巨大。
  • 浏览器可以使用cache-controlno-cachemax-stale等 http header 值来控制是否使用强缓存、协商缓存、缓存过期是否依然可用等功能。
  • 服务端通过cache-controlmax-agepublicstale-while-revalidate等 http header 值来控制强缓存时间、是否能被代理服务端缓存、缓存过期多久能自动刷新缓存等功能。
  1. 升级到 HTTP/2.0 或更高版本可以明显提升网络性能。(必须使用 TLS,即 https)

  2. 对 HTTPS 优化

    HTTPS 性能消耗比较大的主要有两个环节:

  • 第一个环节, TLS 协议握手过程;

  • 第二个环节,握手后的对称加密报文传输。

    对于第二环节,现在主流的对称加密算法 AES、ChaCha20 性能都是不错的,而且一些 CPU 厂商还针对它们做了硬件级别的优化,因此这个环节的加密性能消耗可以说非常地小。

    而第一个环节,TLS 协议握手过程不仅增加了网络延时(最长可以花费掉 2 个 RTT 网络往返时间),而且握手过程中的一些步骤也会产生性能损耗,比如:

    如果使用 ECDHE 密钥协商算法,握手过程中客户端和服务端都需要临时生成椭圆曲线公私钥; 客户端验证证书时,会访问 CA 服务器获取 CRL 或者 OCSP,目的是验证服务器的证书是否被吊销; 然后双方计算 Pre-Master,也就是对称加密密钥。 为了更清楚这些步骤在整个 TLS 协议握手的哪一个阶段,可以参考这幅图:

TLS握手

可以使用以下手段优化HTTPS:

  • 硬件优化:服务器使用支持 AES-NI 指令集的 CPU
  • 软件优化:升级 Linux 版本、TLS 版本。TLS/1.3 大幅优化了握手次数,只需要 1 RTT 时间,而且支持前向安全性(指现在或未来密钥被破解了,不会影响以前被截获的报文的安全)。
  • 证书优化:OCSP Stapling。正常情况下浏览器是需要向 CA 验证证书是否被吊销的,而服务器可以向 CA 周期性地查询证书状态,获得一个带有时间戳和签名的响应结果并缓存它。当有客户端发起连接请求时,服务器会直接把这个「响应结果」在 TLS 握手过程中发给浏览器,浏览器就不需要自己请求 CA 了。
  • 会话复用 1:Session ID,双方在内存里保留 session,下一次建立连接时 hello 消息里会带上 Session ID,服务器收到后就会从内存中找,如果找到就直接用该会话密钥恢复会话状态,跳过其余的过程。为了安全性,内存中的会话密钥会定期失效。但是它有两个缺点:1. 服务器必须保存每一个客户端的会话密钥,随着客户端的增多,服务器的内存占用也会越大。2. 现在网站服务一般是由多台服务器通过负载均衡提供服务的,客户端再次连接不一定会命中上次访问过的服务器,未能命中那台服务器还是要走完整的 TLS 握手过程。
  • 会话复用 2:Session Ticket,客户端与服务器首次建立连接时,服务器会加密「会话密钥」并作为 Ticket 发给客户端,客户端会保存该 Ticket。这类似 web 开发中验证用户身份的 token 方案。客户端再次连接服务器时,客户端会发送 Ticket,服务器能解密就可以获取上一次的会话密钥,然后验证有效期,如果没问题,就可以恢复会话了,直接开始加密通信。因为只有服务端可以加密解密这个密钥,所以只要能解密说明没有造假。对于集群服务器的话,要确保每台服务器加密 「会话密钥」的密钥是一致的,这样客户端携带 Ticket 访问任意一台服务器时,才都能恢复会话。

Session IDSession Ticket 都不具备前向安全性,因为一旦加密「会话密钥」的密钥被破解或者服务器泄漏了密钥,前面劫持的通信密文都可以被破解。同时面对重放攻击也很困难,所谓的重放攻击就是,假设中间人截获了 post 请求报文,虽然他无法解密其中的信息,但他可以重复使用该非幂等的报文对服务器请求,因为有ticket服务端可以直接复用https。为了减少重放攻击的危害可以将加密的会话密钥设定一个合理的过期时间。


下面是对 http 知识点的详细介绍。

HTTP/0.9

最初的版本非常简单,目的是为了快速推广使用,功能也只是简单的 get html,请求报文格式如下:

GET /index.html

HTTP/1.0

随着互联网发展,http 需要满足更多功能,于是有了我们熟悉的 http header、状态码、GET POST HEAD请求方式、缓存等,还能传输图片、视频等二进制文件。

这个版本的缺点是每次请求完都会断开 tcp 连接,下一次 http 请求需要 tcp 重新建连。于是有些浏览器增加了非标准的Connection: keep-alive头,服务器也会回复相同的头,通过这种约定让 tcp 保持长连接,之后的 http 请求可以复用这个 tcp,直到某一方主动关闭。

HTTP/1.1

1.1 版本是目前使用比较广泛的,在这个版本中默认使用 tcp 长连接,如果要关闭需主动添加头Connection: close

另外它还有管道机制(pipelining),客户端在同一个 tcp 连接里可以不需要等待 http 返回就连续发多个 http 请求。而以往 http 请求的设计是一个 tcp 连接里只能一次发送一个 http 请求,收到它的返回值以后才算这个 http 结束,才能再发送下一个 http。虽然 http/1.1 版本基于管道机制可以连续发送多个 http,但 1.1 在服务端依然只能按FIFO先进先出)的顺序返回响应,所以响应时如果第一个 http 很慢,后面的还是会被队首 http 阻塞。连续多个响应在接受时浏览器会通过Content-Length划分。

另外还新增了分块传输编码chunked transfer encoding),以stream 流的形式代替 buffer 形式。比如一个视频,不再需要完整把它读到内存然后再发送了,可以通过 stream 每读一小部分就发一小部分。使用Transfer-Encoding: chunked头开启,每个分块前面会有一个 16 进制的数字代表这个分块的长度,如果数字是 0 代表分块发送完了。在大文件传输或文件处理等场景,使用这一特性可以提高效率、减少内存占用。

这个版本有以下几个缺点:

  1. 队头阻塞。必须请求-响应才算一次完整 http 结束,然后才能发下一个 http。如果前一个 http 慢了,会影响下一个的发送时间。同时浏览器对同一域名的 http 请求有最大并发数量的限制,超出就必须等待前面的完成。
  2. http 头冗余。可能页面中每个 http 的请求头都基本一样,但每次都要带上这些文本,浪费网络资源。

其实 http1.1 的缺点本质上是因为它一开始的定位就是一个纯文本协议导致的。如果想做乱序发送,要么需要修改协议本身,比如在请求/响应里添加个唯一标识,然后对端做文本的解析,找到对应的顺序。要么需要对 http 协议再做一层封装,将文本转为二进制数据并进行额外的封装处理。根据开闭原则,新增优于修改,所以显然后者方案更合理一点。于是 http/2.0 里会把原数据分割成二进制帧的形式,方便做后续操作,相当于在原来的基础上多了一些步骤,原本的 http 核心没有改变。


HTTP/2.0

新增的改进不仅包括优化了 HTTP/1.1 中积弊已久的多路复用、修复队头阻塞问题,允许设定请求优先级,还包含了一个头部压缩算法(HPACK)。此外, HTTP/2 采用了二进制而非明文来打包、传输客户端和服务器之间的数据。

帧、消息、流和 TCP 连接

我们可以把 2.0 版本看作在 http 之下又加了一个二进制分帧层。消息(message)(一个完整的请求或响应称为消息)被分成很多帧(frame),帧包含:类型 Type, 长度 Length, 标记 Flags, 流标识 Stream 和有效载荷 frame payload这些部分。同时还增加了这个抽象的概念,每帧的流标识代表它属于哪个流,因为 http/2.0 之间不需要等待是可以乱序发送的,发送/接受方会根据流标识将乱序发送的数据组装起来。为了防止两端流 ID 重复而产生冲突,客户端发起的流是奇数 ID,服务器端发起的流是偶数 ID。原先协议的内容不受影响,http1.1 中的首部信息 header 封装到 Headers 帧中,request body 将被封装到 Data 帧中。多个请求只使用一个 tcp 通道。这个举措在实践中表明,相比 HTTP/1.1,新页面加载可以加快 11.81%到 47.7%。像多域名、雪碧图这些优化手段,在 http/2.0中就不需要了。

HPACK 算法

HPACK 算法是 HTTP/2 新引入的一个算法,用于对 HTTP 头部做压缩。其原理在于:

  • 客户端与服务端根据 RFC 7541 的附录 A,维护一份共同的静态字典(Static Table),其中包含了常见头部名及常见头部名称与值的组合的代码;
  • 客户端和服务端根据先入先出的原则,维护一份可动态添加内容的共同动态字典(Dynamic Table);
  • 客户端和服务端根据 RFC 7541 的附录 B,支持基于该静态哈夫曼码表的哈夫曼编码(Huffman Coding)。

服务器推送

以往浏览器要获取服务器数据,需要主动发起请求。这就要在网站中加入额外的 js 请求脚本,还需要等待 js 资源加载完才能调用。这就导致请求时机的延后、更多的 request。HTTP/2 支持了服务端主动推送,不需要浏览器主动发请求,节约了请求效率、也优化了开发体验。前端可以通过 EventSource监听服务端的推送事件


HTTP/3.0

HTTP/2.0 相比前任做出了大量的优化,比如多路复用、头部压缩等等,但因为底层基于 tcp,所以导致有些痛点是难以解决的。

队头阻塞

HTTP 是运行在 TCP 之上的,虽然二进制分帧已经可以做到 http 层面多个请求不阻塞了,但大家通过上面讲到 TCP 原理可以知道,TCP 中的也有队头阻塞和重传,如果前面的包的 ack 没有返回,后面的是不会发送的。所以 HTTP/2.0 只是解决了 HTTP 层面的队头阻塞,在整个网络链路中依然是阻塞的。如果能用一个新的协议可以在现代网络环境下更快传输就好了。

TCP、TLS 握手的延迟

TCP 有 3 次握手,TLS(1.2)有 4 次握手,一共需要 3 个 RTT 时延才能发出实际的 http 请求。同时因为 TCP 的拥塞避免机制是从慢启动开始的,所以还会进一步拖慢速度。

切换网络导致重新连接

我们知道 TCP 连接的唯一性是根据双端的 ip 和端口确定的。现在移动网络、交通都非常发达,进办公室或回家手机会自动连上 WIFI,在地铁、高铁上手机网络十几秒换一个信号基站是很常见的场景。它们都会导致 ip 变化,从而让之前的 TCP 连接失效。表现出来的就是一个网页打开了一半突然不能加载了,视频缓冲了一半后面的无法缓冲了。

QUIC 协议

上述的问题是 TCP 固有的,要想解决只能重新换一个协议,http/3.0 采用的是 QUIC 协议。完全新的协议是需要硬件支持的,这必然需要非常久的时间普及,于是 QUIC 建立在已有的一个协议UDP之上。

QUIC 协议的优点有很多,比如:

无队头阻塞

QUIC 协议也有类似 HTTP/2 的 Stream 和多路复用的概念,也是可以在同一条连接上并发传输多个 Stream,一个 Stream 可以认为就是一条 HTTP 请求。

由于 QUIC 使用的传输协议是 UDP,UDP 不关心数据包的顺序,如果数据包丢失,UDP 也不关心。

不过 QUIC 协议还是要保证数据包的可靠性,每个数据包都有一个序号唯一标识。当某个流中的一个数据包丢失了,即使该流的其他数据包到达了,数据也无法被 HTTP/3 读取,直到 QUIC 重传丢失的报文,数据才会交给 HTTP/3。

而只要某个流的数据报文被完整接收,HTTP/3 就可以读取到这个流的数据。这与 HTTP/2 不同,HTTP/2 某个流中的数据包丢失了,其他流也会因此受影响。

所以,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。

更快的连接建立

对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、OpenSSL 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。

HTTP/3 在传输数据前虽然也需要 QUIC 协议握手,但这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,比如连接迁移(例如因为 ip 切换导致网络需要迁移)就是基于连接 ID 实现的。

HTTP/3 的 QUIC 协议并不与 TLS 分层,而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS 1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。

如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT:

HTTP/3

连接迁移

当移动设备的网络从 4G 切换到 WiFi 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接,而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。如果你在高铁上可能你的 IP hUI连续变化,这会导致你的 TCP 连接不断重新连接。

而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。

简化帧结构、QPACK 优化头部压缩

HTTP/3 同 HTTP/2 一样采用二进制帧的结构,不同的地方在于 HTTP/2 的二进制帧里需要定义 Stream,而 HTTP/3 自身不需要再定义 Stream,直接使用 QUIC 里的 Stream,于是 HTTP/3 的帧的结构也变简单了。

HTTP/3帧

根据帧类型的不同,大体上分为数据帧和控制帧两大类,Headers 帧(HTTP 头部)和 DATA 帧(HTTP 包体)属于数据帧。

HTTP/3 在头部压缩算法这一方面也做了升级,升级成了 QPACK。与 HTTP/2 中的 HPACK 编码方式相似,HTTP/3 中的 QPACK 也采用了静态表、动态表及 Huffman 编码。

对于静态表的变化,HTTP/2 中的 HPACK 的静态表只有 61 项,而 HTTP/3 中的 QPACK 的静态表扩大到 91 项。

HTTP/2 和 HTTP/3 的 Huffman 编码并没有多大不同,但是动态表编解码方式不同。

所谓的动态表,在首次请求-响应后,双方会将未包含在静态表中的 Header 项(比如一些自定义的 Header)更新进各自的动态表,接着后续传输时仅用 1 个数字表示,然后对方可以根据这 1 个数字从动态表查到对应的数据,就不必每次都传输长长的数据,大大提升了编码效率。

可以看到,动态表是具有时序性的,如果首次出现的请求Header发生了丢包,后续的请求又遇到这个Header,发送方以为对方已经存进了动态表,于是就将Header压缩了,可对方无法解码出这个 HPACK 头部,因为对方还没建立好动态表,因此后续请求的解码必须阻塞到首次请求中丢失的数据包重传过来才可以正常解码

HTTP/3 的 QPACK 解决了这一问题,那它是如何解决的呢?

QUIC 会有两个特殊的单向流,所谓的单向流只有一端可以发送消息,传输 HTTP 消息时用的是双向流,而这两个单向流的用法:

一个叫 QPACK Encoder Stream,用于将一个字典(Key-Value)传递给对方,比如面对不属于静态表的 HTTP 请求头部,客户端可以通过这个 Stream 发送字典; 一个叫 QPACK Decoder Stream,用于响应对方,告诉它刚发的字典已经更新到自己的本地动态表了,后续就可以使用这个字典来编码了。 这两个特殊的单向流是用来同步双方的动态表,编码方收到解码方更新确认的通知后,才会使用动态表编码 HTTP 头部。假如动态表的更新消息丢包了,也只会导致某些 Header 不压缩而已,不会阻塞 HTTP 请求。


HTTP 缓存详解

如果一份网络资源不需要请求,直接从本地缓存拿到,那自然是最快的。http 协议里面定义了缓存机制,其中又分为本地缓存(大家也称它为强缓存)和需要通过请求来验证的缓存(大家也称它为协商缓存)。

本地缓存(强缓存)

在 http1.0 是用expires响应头表示返回值的过期时间,浏览器在这个时间之内可以不重新请求直接使用缓存。在 http1.1 之后,改为了Cache-Control响应头,从此可以满足更多的缓存要求,里面的max-age表示资源在请求 N 秒后过期。注意max-age不是浏览器收到响应后经过的时间,它是在源服务器上生成响应后经过的时间,和浏览器时间无关。因此,如果网络上的其他缓存服务器将响应存储 100 秒(使用响应报头字段 Age 表示),浏览器缓存将从其过期时间中扣除 100 秒。当缓存过期后(我们忽略 stale-while-revalidate、max-stale 等的影响),浏览器会发起条件请求验证资源是否更新(也称协商缓存)。

条件请求(协商缓存)

请求头会有If-Modified-SinceIf-None-Match字段,它们分别是上次请求响应头里的Last-ModifiedetagLast-Modified表示资源最后被修改的时间,单位秒。etag是特定版本资源的标识(比如对内容 hash 就可以生成一个 etag)。服务器当If-None-Match或者If-Modified-Since没有变化时会返回 304 状态码的响应,浏览器会认为资源没有更新从而复用本地缓存。由于Last-Modified记录的修改时间是秒为单位,如果修改频率发生在 1 秒内就不能准确判断是否更新了,所以etag的判断优先级要高于Last-Modified

Cache-Control中如果设置no-cache会强制不使用强缓存,直接走协商缓存,即 max-age=0。如果设置no-store会不使用任何缓存。

浏览器对请求的缓存策略简单来说就是这样,我们可以看出缓存是由响应头和请求头决定的,开发过程中一般已经由网关和浏览器帮我们自动设置好了,如果你有特定需求,可以定制化使用更多Cache-Control功能。

完整的 Cache-Control 功能

Cache-Control还有更多更细致的缓存控制能力,完整的响应头和请求头含义看下表。

响应头

名称含义
max-agemax-age 表示资源在请求 N 秒后过期。注意 max-age 不是浏览器收到响应后经过的时间,它是在源服务器上生成响应后经过的时间,和浏览器时间无关
s-maxage响应头特有,作用和 max-age 类似,但是只会被资源共享服务器(如 cdn)使用,如果它存在时将会忽略 max-age
no-cache表示请求可以被缓存,但每次都需要去服务器验证内容是否更新
no-store请求不会以任何形式缓存
no-transform禁止中介服务器转换内容(比如经过某些服务器时会降低画质以减少图片大小,但内容提供者不希望这样)
must-revalidate响应头特有,请求可以被缓存也可以使用强缓存,但缓存过期后必须重新验证更新(因为浏览器允许断线情况下使用过期的缓存),要么重新验证成功,要么返回 504
proxy-revalidate响应头特有,和 must-revalidate 相似,但只能在资源共享服务器(比如 cdn)使用
must-understand响应头特有,只在基于状态码理解缓存需求时才缓存,和 no-store 一起使用可以作为兜底
private响应头特有,只能缓存在私有缓存中,比如浏览器缓存,不会缓存在资源共享服务器(比如 cdn)中
public响应头特有,和 private 相反,需要特定身份的请求不能使用 public,它可能在任何地方被缓存然后被其他用户拿到
immutable响应头特有,缓存内容在有效期内是不会改变的,有效期内不需要有重新验证的协商请求。现代前端应用一般都是名称带 hash 的资源,内容更新 hash 变化后其实已经是另一个请求了,所以请求不变内容也是不变的
stale-while-revalidate响应头特有,当缓存过期后,在 stale-while-revalidate 指定秒数内,后台会重新验证缓存,这段时间内依然可以正常使用本地缓存
stale-if-error当缓存过期后,在 stale-if-error 指定秒数内,请求发生 500, 502, 503, 504 错误时(不管是服务器产生的还是本地产生的)可以使用本地缓存

请求头(仅列举响应头没有的) |max-stale|缓存过期不超过 max-stale 秒时依然可用| |min-fresh|要求缓存服务返回 min-fresh 秒时间内的新鲜缓存数据,否则就不使用本地缓存| |only-if-cached|浏览器要求仅在缓存服务器缓存了目标资源的情况下才返回|


TCP 协议的优化

在写 node 的时候可能会需要。没事,别焦虑,只对纯前端感兴趣的可以跳过:)

先直接给出不同问题的优化方法,具体的 tcp 原理以及为什么会出现这些现象后面会详细介绍。

以下的 tcp 优化一般发生在请求端

  1. 首个请求大小最好不要超过 14kb,可以高效利用 tcp 的慢启动,前端页面的首个包同样可以如此。
  • 假设 tcp 初始窗口是 10、MSS是 1460,那么第一个请求的资源大小就不要超过 14600 字节,也就是大概 14kb。这样对端的 tcp 一次性就可以发送完,否则至少分 2 次发送,需要一次额外的RTT(网络往返时间)。
  1. 频繁发送小数据包(小于 MSS)导致 tcp 阻塞怎么办?

这在游戏操作(虽然一般也不用 tcp协议)、命令行 ssh 中很常见

  • 关闭Nagel算法
  • 避免延迟 ack
  1. 如何优化 tcp 丢包重传
  • 通过net.ipv4.tcp_sack打开SACK(默认开启)
  • 通过net.ipv4.tcp_dsack打开D-SACK(默认开启)

以下的 tcp 优化一般发生在服务端

  1. 服务端收到的请求并发量太高或遭遇 SYN 攻击,导致 SYN 队列占满,无法再响应请求
  • 使用syn cookie
  • 减少 syn ack 重试次数
  • 增加 syn 队列大小
  1. TIME-WAIT 数量太多导致可用端口被占满,无法再发送请求
  • 使用操作系统的tcp_max_tw_buckets配置,控制并发的 TIME-WAIT 数量
  • 如果可以的话,增加客户端或服务端的端口范围和 ip 地址

以上的 tcp 优化方法是在了解 tcp 机制的基础上,调整操作系统参数,可以一定程度上实现网络性能优化。下面我们将从 tcp 的实现机制讲起,然后解释清楚这些优化手段到底做了什么。

我们都知道 tcp 传输前要先建立连接,但实际上网络传输是不需要建连的,网络在设计之初要求的特点就是突发性、随时发送,所以摈弃了电话网那样的设计。平时 t 所谓的 tcp 连接,其实只是两台设备之间保存彼此通信的一些状态,并不是真正意义上的连接。tcp 需要通过五元组区分是否是同一个连接,其中有一个元组是协议,剩下四个是,src_ip、src_port、dst_ip、dst_port(双端 ip 和端口号)。另外 tcp 报文段的首部有四个重要的东西,Sequence Number是包的序号(seq),表示这个 packet 的数据部分的第一位应该在整个 data stream 中所在的位置,用来解决网络包乱序问题。Acknowledgement Number(ack)表示的是这次收到的数据长度 + 这次收到的 seq,同时也是对方(发送方)的下一次 sequence number,用于确认收到,解决不丢包的问题。Window又叫 Advertised-Window,是滑动窗口,用于实现流量控制的。TCP Flag是包的类型,比如SYNFINACK这些,主要是用于操控 TCP 的状态机。

下面介绍原理部分

tcp 三次『握手』

三次握手的本质是为了知道双方的初始序列号、MSS、窗口等信息,这样才能在乱序情况下有序拼接数据、以及探明网络、硬件的最大承载能力等。

初始 seq 序列号(ISN)32 位,由虚拟时钟以 4 微秒的频率不断+1 生成,超过 2^32 又回到 0,循环一次需要 4.55 小时。之所以每次建连不固定从 0 开始,是为了避免断线重新建连后,新包和延迟到达的旧包 seq 冲突的问题。4.55 小时已超出 Maximum Segment Lifetime(MSL),旧包已经不存在了。

  1. 客户端发出 SYN(flags: SYN)包,假设初始 seq 是 x,所以 seq = x。客户端 tcp 进入 SYN_SEND 状态。
  2. 服务端 tcp 初始是 LISTEN 状态,收到后发送 ACK 包(flags: ACK, SYN),假设初始 seq 是 y,seq = y,ack = x + 1 ,这是因为 flags 有 SYN,占用 1 个长度,所以接下来客户端应该从 x + 1 开始。服务端进入 SYN_RECEIVED 状态。
  3. 客户端收到后发送 ACK 包,seq = x + 1,ack = y + 1。然后继续发送实际内容的 PSH 包(假设数据长度是 100),seq = x + 1,ack = y + 1。实际内容的 seq、ack 之所以和 ack 包没有变化是因为 Flag 是 ACK 仅用作确认,本身不占用长度。客户端进入 ESTABLISHED 状态。
  4. 服务端收到后发送 ACK 包,seq = y + 1,ack = x + 101。服务端进入 ESTABLISHED 状态。

seq、ack 的计算可以对照这个抓包图(图片来自网络,其中的序列号是相对序号)

tcp发送过程转存失败,建议直接上传图片文件

SYN 超时与攻击

服务端在三次握手时,收到 SYN 包、回了 SYN-ACK 后,tcp 处于半连接的中间状态,操作系统内核会将连接暂时放进 SYN 队列,三次握手成功后才会将连接放入完全连接的队列。如果服务端没有收到来自 client 的 ACK,会超时重试,默认重试 5 次,从 1s 开始翻倍,1s、2s、4s……知道第 5 次也超时一共需要 63s,这时 tcp 会断掉这个连接。有些攻击者会利用这个特性,大量对服务端发送 SYN 包然后断开,服务端要等 63s 才将连接从 SYN 队列清除,从而导致服务端 tcp 的 SYN 队列占满,无法再继续提供服务。正常的大并发情况下也可能出现这种情况。这时我们可以在 linux 下设置以下参数:

  1. tcp_syncookies,它可以在 SYN 队列满了之后将四元组信息、每 64s 递增一次的时间戳、MSS 选项值生成一个特别的 Sequence Number(也叫 cookie),将这个 cookie 作为 seq 发送给 client 则可以直接建连。tcp_syncookies 通过这种巧妙的方式不需要在本地就保存了 SYN 里的部分信息。细心的观众会发现,tcp_syncookies 似乎只需要两次握手就可以建连了,为什么不把它纳入 tcp 标准呢?因为它也是有缺点的,1. MSS 的编码只有 3 位,因此最多只能使用 8 种 MSS 值。2. 服务端必须拒绝客户端 SYN 报文中的其他只在 SYN 和 SYN+ ACK 中协商的选项,原因是服务端没有地方可以保存这些选项,比如 Wscale 和 SACK。3. 增加了密码学运算。所以在因为正常并发大而出现 SYN 队列满的情况时,不要使用这种方式,它只是一个阉割版的 tcp。

  2. tcp_synack_retries,用它减少 SYN-ACK 超时的重试次数,也就减少了 SYN 队列的清理时间。

  3. tcp_max_syn_backlog,增加最大 SYN 连接数,也就是增大 SYN 队列。

  4. tcp_abort_on_overflow,SYN 队列满就拒绝连接。

tcp 四次『挥手』

假设是客户端先断开连接,举例的 seq 接着上次的握手。

关闭前两端的 tcp 状态都是 ESTABLISHED

  1. 客户端发出 FIN 包(flags: FIN)表示可以关闭,seq = x + 101, ack = y + 1。客户端变为 FIN-WAIT-1 状态。
  2. 服务端收到这个 FIN,返回一个 ACK,seq = y + 1,ack = x + 102。服务端变为 CLOSE-WAIT 状态。客户端收到这个 ACK 后变为 FIN-WAIT-2 状态。
  3. 服务端可能有一些工作未做完,做完后也发送 FIN 包决定关闭,seq = y + 1,ack = x + 102。服务端变为 LAST-ACK 状态。
  4. 客户端收到 FIN 后返回确认 ACK,seq = x + 102,ack = y + 2。客户端变为 TIME-WAIT 状态
  5. 服务端收到客户端 ACK 后直接关闭连接,变为 CLOSED 状态。客户端等待 2*MSL 时间后没有再次收到服务端的 FIN,则关闭连接,变为 CLOSED 状态。

为何需要长时间的 TIME-WAIT?1. 可以避免复用该四元组的新连接接收到延迟的旧包 2. 可以确保服务端已经关闭

为何 TIME-WAIT 的时间是 2*MSL(最大 segment 存活时间,RFC793 定义了 MSL 为 2 分钟,Linux 设置成了 30s)?因为服务端在发送 FIN 后,如果等待 ACK 超时了会重发,FIN 最长存活 MSL 时间,重发一定发生在这之前,重发的 FIN 也是最长存活 MSL 时间。所以 2 倍的 MSL 时间后,客户端依然没有收到服务端的重发,说明服务端已经收到 ACK 关闭了,所以客户端就可以关闭了。

断连产生的 TIME-WAIT 数量太多怎么办

我们知道在 linux 里默认会等待 1 分钟才关闭连接,这时端口一直是被占用的状态。如果在大并发的短连接下,可能会出现 TIME-WAIT 数量过多导致端口被占满或者 cpu 占用过大的情况。

最后两个配置强烈建议不要用

  1. tcp_max_tw_buckets,控制并发的 TIME-WAIT 的数量,默认值是 180000,如果超过,系统会销毁并记录 log

  2. ip_local_port_range,增加客户端端口范围

  3. 如果可能的话,增加服务端的服务端口(tcp 连接是基于 ip 和端口的,它们越多,可用的连接越多)

  4. 如果可能的话,增加客户端或服务端的 ip

  5. tcp_tw_reuse,必须在客户端和服务端都开启 timestamp 才可使用,仅在客户端生效。开启后不用等待 TIME-WAIT,仅需 1s,新连接可以直接复用这个 socket。为什么需要开启 timestamp?因为旧连接的包可能兜兜转转终于到达了服务端,而复用该 socket 的新连接五元组和旧包是一样的,只要时间戳早于新包的肯定是旧连接的包,可以避免无用的旧包被误接受。

  6. tcp_tw_recycle,tcp_tw_recycle 处理更激进,它会快速回收 TIME_WAIT 状态的 socket 。只有当 tcp_timestamps 和 tcp_tw_recycle 都开启时,才会快速回收。当客户端通过 NAT 环境访服务器端时,服务器端主动关闭后会产生了 TIME_WAIT 状态,如果服务器端同时开启了 tcp_timestamps 和 tcp_tw_recycle 选项时,那么在 60 秒内来自同一源 IP 主机的 TCP 分段的时间戳必须递增,否则会丢弃。Linux 从 4.12 内核版本开始移除了 tcp_tw_recycle 配置。

tcp 滑动窗口与流量控制

操作系统给 tcp 开辟了一个缓存区,限制了 tcp 的最大收发数据包数量,可以形象的把它看作滑动的Window。发送端的叫发送窗口swnd,接收方的叫接收窗口rwnd。已发送但未收到 ack 的数据长度 + 待发送的缓存数据长度 = 发送窗口总长度。

发送窗口

握手时双端交换窗口值,最终会取其中的最小值。假设发送方窗口大小是 20,一开始发了 10 个包,还没有收到 ack,于是后续只能再放 10 个包进入缓存区。如果缓存区被占满了,就无法再发送数据了。接收方接收到数据也是放进缓存区,如果处理能力小于对端的发送能力,缓存区越堆越多,可用接收窗口也就变小了,ack 携带的窗口值会让发送方减少发送的数据量。此外,操作系统也会调整缓冲区的大小,这时可能发生一种情况,本来可用接收窗口是 10,已经通过 ack 告诉对端了,但是操作系统突然缩小了缓冲区,窗口减少 15,反而倒欠了 5 个。发送端前面收到了可用窗口是 10,于是依然会发送数据,但是数据无法被接收方处理,于是超时了。为了避免这种情况,tcp 强制规定,如果操作系统要修改缓冲区,必须提前先发送修改后的可用窗口。

我们通过上面的内容知道,tcp 是通过两端的窗口来限制发送流量的,如果窗口是 0 就代表应该暂时停止发送了。当接收方缓冲区满发送窗口为 0 的 ack 后,经过一段时间接收方有能力接收了,就会发一个窗口非 0 的 ack 通知发送方继续发送。如果这个 ack 丢了就会很严重,发送方一直不知道接收方可以接收了,就会一直等待,进入死锁情况。为了避免这个问题,tcp 的设计是,发送方在被通知停止发送后(也就是收到窗口 0 的 ack),会启动一个定时器,每隔 30-60 秒会发一个窗口探测 ( Window probe ) 报文,接收方收到后要回复当前的窗口。如果连续 3 次窗口探测都是 0 的话,有些 tcp 的实现里会发送RST包中断连接。

如果接收方窗口已经很小了,发送方依然会利用这点窗口发送数据,tcp 头 + ip 头 40 字节,可能数据就几字节,那就非常的不划算。怎么避免这种情况呢?下面看看小数剧包如何优化。

tcp 小数据包

对于接收方,只要不让它发送小窗口就行,接收方通常才有这种策略:接收窗口如果小于MSS、缓存空间/2的最小值,就告知对端窗口是 0,不要再发数据了,直到窗口大于那个条件。

对于发送方,使用 Nagle 算法,只有满足以下两个条件的其一才会发送:

  • 窗口大小 >= MSS 并且 总数据大小 >= MSS
  • 收到之前发送数据的 ack

如果一条都没满足,它就会一直积攒数据,然后达到某个条件一起发送。

伪代码如下

if there is new data to send then
    if the window size ≥ MSS and available data is ≥ MSS then
        send complete MSS segment now
    else
        if there is unconfirmed data still in the pipe then
            enqueue data in the buffer until an acknowledge is received
        else
            send data immediately
        end if
    end if
end if

Nagle 算法默认是打开的,但是在例如ssh这种数据小、交互多的场景下,Nagle 碰上延迟 ack 会很糟糕,所以需要关闭。(Nagle 算法没有系统全局配置,需要根据各自应用关闭)

说完小数据优化,现在再说回滑动窗口,其实 tcp 最终采用的窗口并不完全由滑动窗口决定,滑动窗口只是防止双端超出收发能力,还要考虑两端之间的网络情况,如果两端收发能力都很强,但此刻网络环境很差,发大量数据只会让网络更拥堵,所以还有一个拥塞窗口,tcp 会取滑动窗口和拥塞窗口的最小值。

tcp 慢启动与拥塞避免

首先要讲一下什么是 MSSMSS 是一个 tcp segment 最大允许的数据字节长度,是由 MTU(数据链路层最大数据长度,由硬件规定的)减去 ip 头 20 字节 减去 tcp 头 20 字节算出来的,一般是 1460。也就是代表一个 tcp 包最多携带 1460 字节上层的数据。tcp 握手时会在双端协商出最小的 MSS。在实际网络环境中,请求会经过很多中间设备,SYN 里的 MSS 还会被它们修改,最终会是整个路径中的最小值,而不仅仅是两端的最小值。

tcp 有一个cwnd(拥塞窗口)负责避免网络拥塞,它的值是整数倍 tcp 报文段大小,代表 tcp 一次性能发多少个包(为了方便起见我们从 1 开始表示它),它的初始值很小,会逐步增加直至发生丢包重传,以此探测可用网络传输资源。在经典慢启动算法中,快速确认模式下,每次成功收到确认 ack,会让cwnd + 1,因此cwnd呈指数级增长,1、2、4、8、16……直到达到慢启动阈值 ssthreshslow start threshold),ssthresh 一般等于 max(在外数据值/2, 2*SMSS),SMSS是发送方的最大段大小。当cwnd < ssthresh时使用的叫慢启动算法,当cwnd >= ssthresh时使用拥塞避免算法

拥塞避免算法,在每次收到确认 ack 后,cwnd会增加1/cwnd,即上次发出的包全部确认,cwnd + 1。不同于慢启动算法拥塞避免算法是线性增长,直到发生两种重传后下降,1. 发生超时重传、2. 发生快速重传。

快速/延迟 ack、超时重传与快速重传

快速确认模式是接收方收到包后立刻发送确认 ack,但 tcp 不会每次都收到一个包就返回确认 ack,这样对网络带宽是一种浪费。tcp 还可能进入延迟确认模式,接收端会启动延迟 ack 定时器,每隔 200ms 检查是否要发送 ack,如果有数据要发送,也可以和 ack 合并在一起。假设发送方一次性发了多个包,对端可能不会回复 10 个 ack,只会回复已收到的最大连续包的最后一个的 ack。比如传了 1、2、3,……10,接收端全部收到了,于是回复 10 的 ack,这样发送端就知道前 10 个全部接收了,于是下一个从 11 发起。如果中间有丢包的,就返回丢包的前一个的 ack。

超时重传:发送方发送后会启动一个定时器,超时时间(RTO)适合设置为略大于一个RTT(包往返时间),如果接收 ack 超时了,会重新发送数据包,如果重发的数据又超时了,超时计时会加倍。这时ssthresh变成cwnd/2cwnd重置为初始值,使用慢启动算法。可以看到cwnd断崖式下降,所以发生超时重传对网络性能影响很大。那是不是一定要等RTO才能重传呢?

快速重传:tcp 有一个快速重传的设计,接收方如果没有按序收到包,就回一次最大连续的那个 ack,发送方连续收到 3 次这样的 ack 就认为丢包了,可以快速把那个包重传一次而不回退到慢启动。举个例子,接收方收到了 1、2、4,于是回 2 的 ack,后面又接收到了 5、6,因为中间断了 3,所以还是回了两次 2 的 ack。发送端连续收到 3 次相同的 ack,于是知道 3 丢了,快速重传了 3。接收方收到 3,数据连续了,于是返回 6 的 ack,发送方就可以接着从 7 开始传了。就像下图所示:

tcp快速重传

发生快速重传会:

  1. ssthresh = cwnd/2cwnd = ssthresh + 3,开始重传丢失的包,进入快速恢复算法。+3 的原因是收到了 3 个重复的 ack,表明当前网络至少还能正常额外收发这 3 个包。
  2. 再收到重复的 ACK 时,拥塞窗口增加 1
  3. 当收到新的数据包的 ACK 时,把 cwnd 设置为第一步中的 ssthresh 的值。

快速重传算法首次出现在 4.3BSD 的 Tahoe 版本,快速恢复首次出现在 4.3BSD 的 Reno 版本,也称之为 Reno 版的 TCP 拥塞控制算法。 可以看出 Reno 的快速重传算法是针对一个包的重传情况的,然而在实际中,一个重传超时可能导致许多的数据包的重传,因此当多个数据包从一个数据窗口中丢失时并且触发快速重传和快速恢复算法时,问题就产生了。因此 NewReno 出现了,它在 Reno 快速恢复的基础上稍加了修改,可以恢复一个窗口内多个包丢失的情况。具体来讲就是:Reno 在收到一个新的数据的 ACK 时就退出了快速恢复状态了,而 NewReno 需要收到该窗口内所有数据包的确认后才会退出快速恢复状态,从而更一步提高吞吐量。

tcp 如何『精准』重传

如果出现了部分丢包的情况,发送方不知道究竟是哪些丢了部分还是全部丢了。比如接收端收到了 1、2,4,5, 6,发送端可以通过 ack 知道 3 以后的丢包了,触发快速重传。这时会有两种决策:1. 只重传第 3 个包。2. 不知道 4, 5, 6……是不是也丢包了,于是干脆把 3 以后的全部重传。这两种选择都不太好,如果只重发 3,万一后面真的也都丢了,每一个都得继续等待重传。但是如果直接全部重传,万一真的只有 3 丢了,也比较浪费,该怎么优化呢?

快速重传只是减少了触发超时重传的机会,无论快速重传还是超时重传都没有解决精确知道到底要重传一个还是全部的问题。还有一种更好的方法叫选择性确认 Selective Acknowledgment (SACK),它需要双端都支持,Linux 通过net.ipv4.tcp_sack参数开关。SACK会在 tcp 头里增加一段数据,告诉发送方除了最大连续的之外还收到了哪些数据段,这样发送方就知道那些数据可以不用重传了。一图胜千言:

SACK

另外还有Duplicate SACK(D-SACK)。如果接收方的确认 ACK 丢包了,发送发会误以为接收方没收到,触发超时重传,这时接收方会收到重复数据。或者由于发送包遇到网络拥堵了,重传的包比之前的包更早到达,接收方也会收到重复数据。这时可以在 tcp 头里加一段SACK数据,值是重复的数据段范围,因为数据段小于 ack,发送端就知道这些数据接收方已经收过了,不会再重传。

D-SACK

D-SACK在 Linux 中通过net.ipv4.tcp_dsack参数开关。

总结一下SACKD-SACK的作用就是:让发送方知道哪些包没收到、是否重复收包,可以判断出是数据包丢了、还是 ack 丢了、还是数据包被网络延迟了、还是网络中把数据包复制了。


更『厉害』的缓存:Service Worker

上面讲到的 HTTP 缓存控制权主要还是在后端,而且如果缓存过期了,虽然有协商缓存,但多多少少还是有一点请求的,这就要求必须有网络,同时它一般只能缓存 get 请求。这些限制使前端做不了像客户端那样的本地应用。那么有没有什么办法能让前端彻底的代理缓存,无论是静态资源还是 api 接口通通都可以由前端自己来决定,甚至可以把网页像 App 一样变成一个彻底的本地应用。这就是接下来要讲到的Service Worker,让我们看看它有哪些特性。

离线缓存

Service Worker可以看作是应用与网络请求之间的代理,它可以拦截请求,基于网络是否可用或者其他自定义逻辑来采取合适的行为。举个例子,你可以在应用第一次打开后,将 html、css、js、图片等资源都缓存起来,下一次打开网页时拦截请求并直接返回缓存,这样你的应用离线也可以打开了。如果后来设备联网了,你可以在后台请求最新资源并判断是否更新了,如果更新了你可以提醒用户刷新升级。在启动上,使用 Service Worker 的前端应用完全可以不需要网络,就像客户端 App 一样。

推送通知

Service Worker 除了代理请求外,还可以主动让浏览器发出通知,就像 App 的通知一样。你可以使用这个功能做『用户召回』、『热门通知』等。

禁止项

我们的主 js 代码是在渲染线程执行的,而 Service Worker 运行在另一个 worker 线程中,所以它不会阻塞主线程,但也导致有些 api 是不能使用的,比如:操作 dom。同时它被设计为完全异步,所以像XHRWeb Storage这样的同步 api 也是无法使用的,可以用fetch请求。动态import()也是不可以的,只可以静态 import 模块。

Service Worker 出于安全考虑只能运行在 HTTPS 协议上(用 localhost 可以允许 http),毕竟仅它能够接管请求这一功能就已经很强大了,如果被中间人恶意篡改对于普通用户来说可以做到这个网页永远也无法呈现正确的内容。在 FireFox 中,在无痕模式下也无法使用它。

使用方法

Service Worker 的代码应该是一个独立的 js 文件,并且可以通过 https 请求访问,如果你在开发环境,可以允许 http://localhost 这样的地址访问。准备好这些后,首先得在项目代码里注册它:

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/js/service-worker.js", {
    scope: "../",
  });
} else {
  console.log("浏览器不支持Service Worker");
}

假设你的网址是https://www.xxx.com,同时在https://www.xxx.com/js/service-worker.js准备了 Service Worker 的 js,/js/service-worker.js实际请求的就是https://www.xxx.com/js/service-worker.js。配置里的scope表示在什么路径下 Service Worker 生效,如果不设置 scope 默认根目录就生效,网页中任何路径都会使用 Service Worker。按例子中的写法,如果设置./则生效路径是/js/*../则是根目录。

Service Worker 会经过这 3 个生命周期

  1. Download
  2. Install
  3. Activate

首先是Download阶段。当进入一个受 Service Worker 控制的网页时,它会立刻开始下载。如果你之前已经下载过了,那么在这次下载后可能会判断更新,会在以下情况下判断更新:

  • 发生了一个在 scope 范围的页面跳转
  • Service Worker 内有事件被触发了,同时它在 24 小时内没有下载过

当下载的文件被发现是新的后就会试图安装Install,认为是新文件的判断标准是:第一次下载、和旧文件逐字节比较。

如果这是 Service Worker 第一次使用,则尝试安装,然后在成功安装后,将激活Activate它。

如果已经有一个旧的 Service Worker 在使用了,会在后台安装,安装完不会激活,这种情况叫做worker in waiting。试想一下新旧两个 js 可能逻辑有冲突,旧 js 已经运行过一段时间了,如果直接把旧的替换成新的继续运行网页可能会直接崩溃。

那新 Service Worker 什么时候才会激活使用呢?必须等到所有使用旧 Service Worker 的页面都关闭,新 Service Worker 才会变成active worker。你也可以使用ServiceWorkerGlobalScope.skipWaiting()直接跳过等待,Clients.claim()可以让新 Service Worker 控制当前已经存在的页面(那些使用旧 Service Worker 的页面)。

发生 install、activate 时可以通过监听事件知道,平时用的最多的事件是FetchEvent,当页面发起请求时触发,你还可以使用Cache缓存数据、使用FetchEvent.respondWith()返回你希望的请求返回值。下面给出一段缓存请求的常见写法:

// 缓存版本,可以升级版本让过去的缓存失效
const VERSION = 1;

const shouldCache = (url: string, method: string) => {
  // 你可以自定义shouldCache去控制哪些请求应该缓存
  return true;
};

// 监听每个请求
self.addEventListener("fetch", async (event) => {
  const { url, method } = event.request;
  event.respondWith(
    shouldCache(url, method)
      ? caches
          // 查找缓存
          .match(event.request)
          .then(async (cacheRes) => {
            if (cacheRes) {
              return cacheRes;
            }
            const awaitFetch = fetch(event.request);
            const awaitCaches = caches.open(VERSION);
            const response = await awaitFetch;
            const cache = await awaitCaches;
            // 放进缓存
            cache.put(event.request, response.clone());
            return response;
          })
          .catch(() => {
            return fetch(event.request);
          })
      : fetch(event.request)
  );
});

上面的代码缓存建立后就不会再更新了,如果你的内容是可能变化的,担心缓存会不新鲜,你可以先返回缓存,以保证用户最快看到内容,然后在 Service Worker 后台请求最新数据,更新到缓存里,最后通知主线程告诉用户有内容更新了,让用户自己决定是否要升级应用。后台请求、判断更新的代码可以自己试着写一下,这里主要讲一讲 Service Worker 如何告诉主线程请求的内容更新了,两个线程之间该如何通信呢?

Service Worker 如何与主线程通信

为什么需要通信呢,首先如果你想 debug,worker 线程里的 console.log 是不会出现在 DevTools 里的。其次,假如你的 Service Worker 资源更新了,是不是通知给主线程,这样你的页面才可以弹出消息提醒询问用户是否要更新。所以通信可能是业务上的刚需。由于 Service Worker 是单独的线程,所以是无法直接和我们的主线程通信的。不过一旦你解决了通信的问题,它就可以有很多妙用,比如多个同站点页面之间可以利用 Service Worker 线程跨页面通信。那么如何解决通信的问题呢,我们可以创建一个消息频道new MessageChannel(),它有两个端口,可以独立收发消息,将其中一个端口port2交给 Service Worker,port1端口留在主线程,那它们就可以通过这个频道通信了。下面的代码将展示如何让两个线程互相通信,从而做到『打印worker线程log』『通知内容更新』『升级应用』等功能。

主线程里的代码

const messageChannel = new MessageChannel();

// 将port2交给控制当前页面的那个Service Worker
navigator.serviceWorker.controller.postMessage(
  // "messageChannelConnection"是自定义的,用来区分消息类型
  { type: "messageChannelConnection" },
  [messageChannel.port2]
);

messageChannel.port1.onmessage = (message) => {
  // 你可以自定义消息格式来满足不同业务
  if (typeof message.data === "string") {
    // 可以打印来自worker线程的日志
    console.log("from service worker message:", message.data);
  } else if (message.data && typeof message.data === "object") {
    switch (message.data.classification) {
      case "content-update":
        // 你可以自定义不同的消息类型,来做出不同的UI表现,比如『通知用户更新』
        alert("有新内容哦,你可以刷新页面查看");
        break;
      default:
        break;
    }
  }
};

Service Worker 里的代码

let messageChannelPort: MessagePort;

self.addEventListener("message", onMessage);

// 收到消息
const onMessage = (event: ExtendableMessageEvent) => {
  if (event.data && event.data.type === "messageChannelConnection") {
    // 拿到了port2保存起来
    messageChannelPort = event.ports[0];
  } else if (event.data && event.data.type === "skip-waiting") {
    // 如果主线程发出了"skip-waiting"消息,这里就会直接更新Service Worker,也就让应用升级了。
    self.skipWaiting();
  }
};

// 发送消息
const postMessage = (message: any) => {
  if (messageChannelPort) {
    messageChannelPort.postMessage(message);
  }
};

文件压缩、图片性能、设备像素适配

js、css、图片等资源文件的压缩可以极大的减少大小,对网络性能提升很大。一般后端服务会自动帮我们配置好压缩头,不过我们也可以换成更高效的压缩算法得到更好的压缩比。

content-encoding

随便打开一个网站看它的资源 network 都会看到 response headers 里有个content-encoding头,它可以是gzipcompressdeflateidentitybr等值。除了identity代表不压缩,你可以设置其他值来压缩文件以加快 http 传输,其中最常见的是 gzip。在兼容性支持的情况下,你可以专门设置一些比较新的压缩格式比如 br(Brotli),来达到超过 gzip 的压缩率。

字体文件

如果页面中需要特殊字体,并且页面中的文字是固定的或小范围的(比如只有字母和数字),那么可以手动裁剪字体文件,让它只包含必须的文字,这样可以极大的减少文件大小。

如果页面中的字是动态的,你无法知道会是什么字。在合适的场景下,比如用户输入文字可以预览字体效果的场景。用户一般只会输入几个字,没必要引入整个字体包,但你又不知道用户会输入什么。所以你可以让后端(或者基于 nodejs 架设一层 bff)根据你要的字,动态生成只包含几个字的字体文件返回给你。虽然多了一次查询请求,但可以把几 Mb 甚至十几 Mb 的字体文件,减少为几 kb 的大小。

图片格式

图片一般是不通过上述方式压缩的,因为那些图片格式已经帮你压缩过一遍了,再次压缩效果不大。因此选择什么样的图片格式是影响图片大小和图片质量的关键。一般而言,压缩的越小,耗时越久,图片质量越差。但也不绝对,新格式可能每一项都比老格式做的好,但兼容性差。所以你需要寻找一个平衡。

从图片的格式上而言,除了常见的 PNG-8/PNG-24,JPEG,GIF 之外,我们更多的关注另外几个较新的图片格式:

  • WebP
  • JPEG XL
  • AVIF

通过一张表格从图片类型、透明通道、动画、编解码性能、压缩算法、颜色支持、内存占用、兼容性方面,对比它们:

图片类型Alpha 通道动画编解码性能压缩算法颜色支持内存占用兼容性
GIF支持支持较高无损压缩索引色(256)基本一致ALL
PNG-8/PNG-24支持不支持较高无损压缩索引色(256)\直接色基本一致ALL
JPEG不支持不支持较高有损压缩直接色基本一致ALL
WebP支持支持编解码性能差(低配设备更为显著)有损压缩\无损压缩直接色基本一致高版本 Chrome\Opera\Android
JPEG XL支持支持编解码性能优于 WebP有损压缩\无损压缩直接色基本一致部分高版本 Chrome\Opera\Firefox\Edge
AVIF支持支持编解码性能优于 WebP,低于 JPEG XL有损压缩\无损压缩直接色基本一致高版本 Chrome\Opera\Android\Edge

从技术发展角度来说,还是优先使用比较新的图片格式:WebPJPEG XLAVIF。JPEG XL 非常有望替代传统图片格式,不过目前兼容性还很差。AVIF 兼容性好于 JPEG XL,压缩后保留了很高的图片质量,避免了恼人的压缩伪影等问题。但解码和编码速度不如 JPEG XL,且不支持渐进式渲染。WebP 除了 IE 外基本全系浏览器支持,对于复杂的图像(比如照片)来说,WebP 无损编码表现并不好,但有损编码表现却非常棒。相近质量的图片解码速度 WebP 相距 JPEG XL 也已经相差不大了,而文件压缩比却能提升不少。所以目前看来,如果你希望提升网站的图片性能,使用 WebP 替代传统格式会好一些。

Picture 元素的使用

那么有没有什么可以自动帮我们在支持一些现代图片格式的浏览器上使用类似于上述我们提到的 WebP、AVIF 和 JPEG XL 等图片格式,而不支持的浏览器回退使用常规的 JPEG、PNG 的方法吗?HTML5 规范新增了 Picture Element。<picture> 元素通过包含零或多个 <source> 元素和一个 <img> 元素来为不同的显示/设备场景提供图像版本。浏览器会选择最匹配的子 <source> 元素,如果没有匹配的,就选择 <img> 元素的 src 属性中的 URL。然后,所选图像呈现在 <img> 元素占据的空间中。

<picture>
  <!-- 可能是一些对兼容性有要求的,但是性能表现更好的现代图片格式-->
  <source src="image.avif" type="image/avif" />
  <source src="image.jxl" type="image/jxl" />
  <source src="image.webp" type="image/webp" />

  <!-- 最终的兜底方案-->
  <img src="image.jpg" type="image/jpeg" />
</picture>

图片尺寸适配:物理像素、设备独立像素

如果你想得到很棒的图片性能,那么必然需要在不同尺寸的元素下使用合适的图片尺寸。如果在一个100*100像素的区域展示一个500*500的图像,这显然是一种浪费;反之在500*500像素下的100*100图片则十分模糊,降低了用户体验。在说尺寸适配之前,先要讲一下什么是设备独立像素物理像素,以及DPR是什么。

当我们在 css 写出width: 100px时,屏幕里显示的其实 100px 长度的设备独立像素(也成为逻辑像素),它并不一定就是屏幕上的 100 个像素点(物理像素)。在最初的显示器上,设备独立像素和物理像素是 1:1 的,也就是width: 1px就是对应了屏幕上的 1 个像素发光点。随着后来显示器技术发展,同尺寸屏幕的像素越来越精细,可能原来 1 个像素的位置现在是由 4 个像素组成的。这带来了更高的像素密度和更好的视觉体验,但也带来了一个问题。如果还像原来一样width: 1px代表一个像素发光点的话,由于现在的像素点更小了,同样的页面在这个设备上就会缩小。为了解决这个问题,厂商创造了设备独立像素这个概念,它并不是真实存在的像素,而是逻辑上的。假如设备上的 1 个像素点现在由 2 个更小的像素点替代了,那么此设备的设备像素比(DPR)就是 2,width: 1px描绘的图像会由 2 个像素点绘制,这样尺寸和以前会保持一致。同样的,在一个屏幕更精细的设备上,假设它是由 3 个更小像素点代替传统 1 像素的尺寸的,那么它的DPR就是 3,width: 1px实际是由 3 个像素点绘制。这下你也能明白为什么会面试官问『如何绘制 1px 边框』这种问题了吧,因为在高 DPR 下你的1px其实不是 1px。

因此我们可以得出这样的像素等式:1css像素 = 1设备独立像素 = 物理像素 * DPR


为不同 DPR 屏幕,提供合适的图片

所以,虽然我们的 img 元素都是 100px,但在不同 DPR 的设备上,我们需要展示最佳的图片尺寸其实是不同的。在 DPR = 2 时应该展示 200px 图片,在 DPR=3 时应该展示 300px 图片,否则就会出现模糊的情况。

那么,有哪些可行的解决方案呢?

方案一:简单粗暴多倍图

现在常见的设备里最高的 DPR 是 3,所以最简单的办法就是默认全部用最高的 3 倍图展示。但这会造成大量带宽的浪费,拖慢网络性能,降低用户体验,肯定不符合我们这篇文章的『格调』。

方案二:媒体查询

我们可以通过@media媒体查询来根据当前设备的 DPR 来应用不同的 css

#img {
  background: url(xxx@2x.png);
}
@media (device-pixel-ratio: 2) {
  #img {
    background: url(xxx@2x.png);
  }
}
@media (device-pixel-ratio: 3) {
  #img {
    background: url(xxx@3x.png);
  }
}

这个方案的优点是,可以实现不同 DPR 下展示不同倍率的图片。

这个方案的缺点是:

  1. 逻辑分支较多,而且市面上不止有 DPR = 2、3 的设备,甚至还有一些 DPR 是小数的设备,你需要覆盖全面得写很多代码。
  2. 语法兼容性问题,比如在一些浏览器里它是-webkit-min-device-pixel-ratio。你可以通过autoprefixer解决,但也引入了额外成本。

方案三:css image-set 语法

#img {
  /* 不支持 image-set 的浏览器*/
  background-image: url("../image@2x.png");

  /* 支持 image-set 的浏览器*/
  background-image: image-set(
    url("./image@2x.png") 2x,
    url("./image@3x.png") 3x
  );
}

其中的2x3x就是匹配不同 DPR 的。image-set方案的缺点和媒体查询一样,就不多说了。优点是相比媒体查询更加小众,可以让你装一波。

方案四:srcset 元素属性

<img src="image@default.png" srcset="image@1x.png 2x, image@2x.png 3x" />

里面的2x3x表示匹配不同的 DPR,image@default.png是兜底。优缺点和image-set一样,优点可能还多了一个不需要写 css,更简洁。

方案五:srcset 属性配合 sizes 属性

<img
  sizes="(min-width: 600px) 600px, 300px"
  src="image@default.png"
  srcset="image@1x.png 300w, image@2x.png 600w, image@3x.png 900w"
/>

sizes="(min-width: 600px) 600px, 300px"的意思是:如果屏幕当前的 CSS 像素宽度大于或者等于 600px,则图片的 CSS 宽度为 600px。反之,则图片的 CSS 宽度为 300px。因为你的布局可能是弹性的,所以在不同屏幕尺寸下,img 元素尺寸可能不一样,上面的其他方案都只能根据 DPR 判断,这一点不能做到。sizes 同时还需要@media 也根据宽度阈值实际对 img 做出宽度变化才行。

srcset="image@1x.png 300w, image@2x.png 600w, image@3x.png 900w" 里面的 300w,600w,900w 叫宽度描述符。如果你在一个 DPR 为 2 的设备中,经过sizes的判定,img 元素的 css 像素为 300,那么实际物理像素是 600,于是会采用600w的图片。

这个方案的缺点还是和前面一样,需要针对不同 DPR 写不同图片。但它有一个独特的优点,可以在响应式布局中根据 img 元素尺寸的不同,灵活的改变实际图片分辨率。因此我建议采用方案五。


图片懒加载与异步解码

图片懒加载的意思是,当页面还未滚动到目标区域时,那里的图片不做请求和展示,以此来加快可视区域内容的展示。当下的前端规范已经非常丰富了,我们有 js、html 等多种方式来实现图片懒加载。

方案一:js 中使用 onscroll

这是一种简单粗暴的方案,通过getBoundingClientRectAPI 获取页面所有图片距离视口顶部的距离,通过onscroll事件监听页面滚动,然后再根据视口高度算出哪些图片出现在可视区域,设置 img 元素的 src 属性值来控制图片加载。

这个方案的优点是,逻辑简单好理解,没有用到很新的 API,兼容性不错。

这个方案的缺点是:

  1. 需要引入 js,带来了一些代码量和计算成本
  2. 需要获取所有图片元素的位置信息,这可能会触发额外的回流
  3. 需要时刻监听 scroll,频繁触发回调
  4. 如果页面中嵌套了滚动列表,这种方案是无法知道嵌套滚动列表中的元素可见性的,需要更复杂的写法。

方案二:js 中使用 IntersectionObserver

通过 HTML5 的 IntersectionObserver API,Intersection Observer(交叉观察器) 配合监听元素的 isIntersecting 属性,判断元素是否在可视区内,能够实现比监听 onscroll 性能更佳的图片懒加载方案。被观察的元素在可视区域出现或消失时都会触发回调,并且还可以控制出现比例的阈值。具体可以看 mdn 文档。

这个方案的优点是:

  1. 性能比 onscroll 好很多,它不需要时刻监听滚动,也不需要获取元素位置,可见性是由 render 线程在绘制时就可以知道的,本就不需要通过 js 自己判断,这种写法更自然。

  2. 它真的能知道元素的可见性,比如一个元素被更高层元素遮挡了那就是不可见的,哪怕它已经出现在可视区域了。这是 onscroll 方案做不到的。

这个方案的缺点是:

  1. 需要引入 js,带来了一些代码量和计算成本

  2. 较老的设备不兼容,需要使用 polyfill

方案三:css 样式 content-visibility

设置了content-visibility: auto样式的元素如果目前不在屏幕上,就不会渲染该元素。这种方式可以减少非可见区域的元素的绘制渲染工作,但图片资源是在 html 解析的时候就请求了,所以这种 css 方案并不能真正实现图片懒加载。

方案四:HTML 属性 loading=lazy

<img src="xxx.png" loading="lazy" />

img 元素会在页面滚动到它附近时才请求和加载图片。这个属性在较新的所有浏览器(除了 IE)上都可以使用。算是一种不错的方案。

这个方案的优点是,不需要 js,直接在 html 层面实现懒加载,性能很好。写法也相当简洁。兼容性也不错。

这个方案的缺点是,IE 浏览器不支持,如果你一定要用 IE 浏览器,那么这个方案完全不能用,并且由于是 html,没有像 js 方案那样的 polyfill。

图片异步解码方案

众所周知,像 jpeg、png 等这些图片都是经过编码的,如果想让 gpu 认识它并渲染它,需要经过解码。如果某些图片格式解码很慢的话,就会影响其他内容的渲染。于是 HTML5 新增了decoding属性,用于告诉浏览器使用何种方式解析图像数据。

它的可选取值如下:

  • sync: 同步解码图像,保证与其他内容一起显示。
  • async: 异步解码图像,加快显示其他内容。
  • auto: 默认模式,表示不偏好解码模式。由浏览器决定哪种方式更适合用户。
<img src="xxx.jpeg" decoding="async" />

这样,浏览器便会异步解码图像,加快显示其他内容。这是图片优化方案中可选的一环。

图片性能优化总结

总的来说,对于图片的性能优化,你需要:

  1. 选择一个压缩率高、解码速度快、同时画质良好的图片格式
  2. 根据实际 DPR、元素尺寸适配恰当的图片分辨率
  3. 使用性能更好的方案去做图片懒加载,并看情况使用异步解码

构建工具的优化

现在流行的前端构建/打包工具有很多,比如老牌的webpackrollup,近几年火起来的vitesnowpack,新势力esbuildswcturbopack等等。其中有的是 js 实现的,有的是用 go、rust 等高性能语言写的,还有的构建工具用了 esm 特性做了按需打包。但这些都是针对开发时或构建时速度的优化,和用户端性能关系不大所以这里就不讲了,主要讲讲生产环境打包对网络性能的优化。这些工具虽然配置五花八门,但常用的优化点都是:代码压缩、代码分割、公共代码抽离、css 抽离、资源使用 cdn 等等,只不过配置方式不一样,这个查文档就好了,很多是开箱即用的。有些人可能对这几个词不是很理解,这里解释一下。

代码压缩这个没什么好解释的,就是变量名替换、去换行、去空格等,让代码体积更小。

代码分割的目的是,比如在 SPA 里,页面 A 是从首页通过本地路由跳转过去的,那这个 A 页面组件就没必要和首页主应用打包在一起,因为用户不一定会跳过去,打包在一起反而增加首页包的大小,影响首屏速度。于是在一些构建工具里,你可以使用动态 import(import('PageA.js')),构建工具会将首页引用的页面 A 代码打成一个新的包,比如叫a.js。当用户在首页点击跳转到 A 页面时,会自动请求a.js里面的组件代码,然后路由切换渲染出来。有些框架会开箱即用,不需要你写动态 import,直接定义好路由它就会自动帮你做代码分隔,比如 react 的 nextjs 框架。这只是代码分隔的一个使用场景,总之只要是你不想让某个模块代码和主应用打包在一起就可以分割它们,由此获得更好的首批 js 包性能。

公共代码抽离的目的是,假设你在写一个 SPA,在页面 A、B、C 中都使用了 ramda 这个库,并且这三个页面做了代码分割,现在它们是独立的 3 个包:a.jsb.jsc.js。因此按正常的逻辑,ramda 库作为它们的依赖,也会被打进这 3 个包里,也就是这 3 个页面平白无故多了重复的 ramda 代码。那这样就不太好了,最优的方式应该是 ramda 库作为一个单独的包放在主应用,这样只需要请求一次就行了,ABC 都可以使用这个库。这就是公共代码抽离所要做的事情,比如在 webpack 里你可以定义一个模块被重复依赖了多少次就会被当做公共 chunk 抽离到单独的包里。

optimization: {
  // split-chunk-plugin 是webpack内置的插件 作用是自动将多个入口用到的公共文件抽离出来单独打包
  splitChunks: {
    chunks: 'all',
    // 最小30kb
    minSize: 30000,
    // 被引用至少6次
    minChunks: 6,
  },
}

不过,从 webpack4 开始,它可以通过 mode 自动帮你优化,这个东西其实不需要关心了。可以多看看你使用的构建工具的文档,避免做多余的优化。

css 抽离的目的是,比如你在 webpack 中只用了 css-loader + style-loader,那你的 css 会被编译进 js 里,渲染样式时 js 帮你插入 style。那你的 js 无形中就变大了,并且 css 样式的渲染延后到了 js 执行的时候,而 js 一般是被打包在页面末尾的,也就是直到最后 js 请求、执行完之前,你的页面一直没有样式。理想情况应该是 css 和 dom 并行解析、渲染,这也是为什么要抽离 css,它会把 css 单独打包成 css 文件,放在 html 开头的 link 标签里,而不是放进 js 里。


Tree Shaking 的优化

我们知道打包工具在打包时会基于 esm 的 Tree Shaking 特性帮我们做无用代码去除(dead code removal)。

比如这里有个 bar.js

// bar.js
export const fn1 = () => {};

export const fn2 = () => {};

然后在 index.js 中使用了bar.js中的fn1函数

// index.js
import { fn1 } from "./bar.js";

fn1();

如果我们以 index.js 为入口打包,那么最终 fn2 会被自动移除,因为没有使用到。

tree shaking 在一些场景下会失效,它必须要求你的代码没有『副作用』,也就是初始化时不能对模块外部造成影响,类似函数式编程里的副作用。

看下面的例子:

// bar.js
export const fn3 = () => {};
console.log(fn3);

export const fn4 = () => {};
window.fn4 = fn4;

export const fn5 = () => {};
// index.js
import { fn5 } from "./bar.js";

fn5();

虽然index.js没有用到fn3fn4,但最终打包时会将它们都打包进去。因为在声明它们时产生了副作用:打印(console.log)、修改外部变量(window.fn4)。如果不保留它们就可能会出现与预期不一致的 bug。比如开发环境会打印、线上不打印;比如你以为 window 被改了,其实没改,对象属性是可以设置 setter 的,修改属性可以触发一些行为,这些行为是有用的,没有触发会引起意想不到的 bug。为了不引起这些问题,打包工具就会跳过tree shaking,把它们保留下来。如果历史遗留的代码实在不方便修改这些副作用写法,有些打包工具可以让你可以手动添加注释来让打包工具知道这些是没有用的。

另外还有这些写法也是不可以的:

// bar.js
const a = () => {};
const b = () => {};
export default { a, b };

// import o from './bar.js'
// o.a()
// bar.js
module.exports = {
  a: () => {},
  b: () => {},
};

// import o from './bar.js'
// o.a()

你不能把导出的东西放进一个对象,esmTree Shaking 是基于静态分析的,不能知道运行时用了什么。 还有如果使用了 commonjs 模块化语法,虽然打包工具可以兼容它们混用,但也很容易造成 Tree Shaking 失效。所以建议全部用esm模块化方案。

为了完全利用 Tree Shaking 特性,一定要注意写法。上线前可以用打包大小分析工具看看哪些包大小异常。

以上只是简单举了几个例子,更具体的可以看我之前写的一篇文章『从一次前端公共库的搭建中,深入谈谈tree shaking的相关问题』,里面对 Tree Shaking 的问题排查以及失效的各种情况都做了详细解释,在多个平台都有发布:github知乎掘金


前端技术栈的优化

技术栈的选型除了影响运行时的速度,对网络速度也可能有影响。

替换为更小的库。比如如果你使用了 lodash,就算你只用了里面的一个函数,它也会把所有内容打包进去,因为它是基于 commonjs 的,完全没做 Tree Shaking,对网页速度敏感的话你可以考虑用别的库替代。

开发方式导致的代码冗余。比如如果你用的是 sassless、原生 cssstyled-componentemotion 等这些样式方案。你很容易写出重复的样式代码,比如 A 组件和 B 组件都有width: 120px;,你大概率会写两遍,你很难做到细粒度的复用(几乎没人会连一行样式重复了都要抽出来,可能 7 8 行一样才会想到复用),项目越大年代越久,重复的样式代码越多,随之就是你的资源文件越来越大。你可以换成 tailwindcss,它是一个原子化的 css 库,如果你需要width: 120px;样式,在 react 里你可以这么写<div className="w-[120px]"></div>,所有同样式的地方都是这么写,它们都复用的同一个 class。使用 tailwind 可以使你的 css 资源足够小,并且没有任何运行时开销。同时由于它是跟随组件的,可以利用到 esm 的tree shaking,某些不再使用的组件会连同样式一起被自动从打包中去除。如果是sasscss等方案,很难做到在一个 css 文件中自动去除不再使用的样式。另外styled-componentemotioncss-in-js方案也可以做到tree shaking,不过它们有代码重复、运行时开销的问题。tailwind 的缺点也是有的,比如不支持低版本 nodejs、语法有学习成本。



运行时层面

运行时主要是指执行 JavaScript、页面渲染的过程,涉及到比如技术栈的优化、多线程的优化、V8 层面的优化、浏览器渲染优化等。


如何优化渲染时间

渲染时间不仅是你的 dom、样式复杂与否,它是受多个方面影响的。

渲染线程里的任务有很多种

在讲这一节之前,需要先讲一下任务的概念。一些人可能已经对宏任务有了一些了解,比如 script 里的代码、一些回调(事件、setTimeout、ajax 等)这些都是宏任务。但你可能只是在宏任务这个细节上了解,对任务缺少更宏观的认识,从更高层认识它才能真的理解为什么 js 和渲染必然是阻塞的,为什么紧挨着的两个宏任务之间也不一定能很快执行。

在你打开一个页面时,浏览器会开启一个渲染进程,里面有一个渲染线程。前端大部分东西都是运行在这个渲染线程上的,比如 dom、css 的渲染和 js 的执行。由于只有单线程,为了处理耗时任务而不阻塞,所以设计了一个任务队列,遇到请求、IO 之类的操作时,会交给其他线程做,完成后将回调放进队列里,渲染线程一直轮询这个队列中的头部任务并执行,js 的大部分任务可以理解为宏任务。但不止有 js,页面的渲染对渲染线程来说也是一个任务,你可以在 DevTools 里的 performance 里看到负责渲染的任务(它是由Parse HTMLlayoutpaint 等一系列任务组成的),js 的执行即所谓的宏任务其实就是里面的 Evaluate Script 任务(里面包括 Compile CodeCache Script Code 等子任务,负责运行时编译、缓存代码等),它最初会是Parse HTML任务里的子任务。另外还有很多内置任务,例如 GC 垃圾回收。还有一种特殊的任务叫微任务,在 performance 里是Run Microtasks,它在宏任务中产生,放在宏任务内部的微任务队列里。当该宏任务执行完、执行栈全部退出后,会有一个检查点,如果微任务队列里有微任务则全部执行。像Promise.thenqueueMicrotaskMutationObserver事件、node 里的nextTick等都可以创建微任务。

因此,既然我们了解了渲染线程里的任务,那么不难发现既然渲染本身也是任务,那么它必然和 js 任务以及其他任务在队列里有先后顺序,需要一个一个执行,阻塞也就是这么产生的。下面我们看看各种资源间的阻塞关系。

举一个典型的 js 阻塞渲染的例子,可以自己创建一个 html 文件试一下:

<html>
  <head>
    <title>Test</title>
  </head>
  <body>
    <script>
      const endTime = Date.now() + 3000;
      while (Date.now() <= endTime) {}
    </script>
    <div>This is page</div>
  </body>
</html>

渲染线程首先执行Parse HTML任务,解析 dom 过程中遇到 script,于是Evaluate Script,代码会执行 3 秒钟才结束,然后才会继续解析下面的<div>This is page</div>并渲染,于是页面 3 秒钟后才会出现。如果 script 是远程资源,请求也会阻塞下面的 dom 解析和渲染。

我们可以通过 script 的 defer 属性优化它,defer 会推迟 script 的执行时间到 dom 解析完之后、DOMContentLoaded 事件之前。

<html>
  <head>
    <title>Test</title>
  </head>
  <body>
    <script defer src="xxx.very_slow.js"></script>
    <div>This is page</div>
  </body>
</html>

这样就不用白白浪费时间在等待请求上,同时还可以保证 js 一定在 dom 解析之后,获取元素更加安全,并且多个 defer 脚本是会保证原先的执行顺序的。或者直接把 script 写在页面最底部也可以做到类似的效果。不用担心写在底部的脚本请求会被延后,浏览器一般都有优化机制,会提前扫描 html 中所有资源的请求,在解析文档开始时就已经预请求了。

script 还有另一个属性 async,如果 js 资源还在请求中,同样会跳过 js 请求和执行,先解析下面的内容,等 js 请求完立刻执行。所以它的执行时机是不固定的,和请求何时结束有关,而且也不会保证多个 async 脚本的执行顺序。

css 会阻塞渲染和 js 吗?

这里记住一个结论就好,css 的请求和解析是不会阻塞下面的 dom 解析的,但会阻塞 render tree 的渲染,也会阻塞 js 的执行。

至于为什么要设计成这样:

render tree 阻塞是因为它本来就是 dom 树应用了层叠样式表之后的产物,所以必然是要等 css 的,设计为不等待 css 虽然也没什么问题,可以先渲染 dom 树,然后再渲染完整的 render tree,但渲染两次比较浪费,而且直接出现光秃秃的 dom 树用户体验也不好。

js 会被 css 阻塞可能是因为 js 里面是可以修改样式的,假如让后面的 js 先执行并修改了样式、前面的 css 之后才应用,就会出现和代码书写顺序不符的样式结果,只能再二次渲染 js 里的样式以达到实际的预期效果,比较浪费。并且 js 里是可以获取元素样式的,如果 css 还没有请求解析完就去执行下面的 js,会出现获取的样式与实际不符的情况。

所以综上,css 虽然不会直接阻塞 dom 的解析,但是会阻塞 render tree 的渲染,以及通过阻塞 js 的执行来间接阻塞 dom 的解析。

有兴趣的可以自己搭建一个 node 服务实验一下,通过控制资源响应的时间可以测试各种资源的相互影响。


浏览器渲染为什么耗时?什么是渲染流水线

将一个 html 渲染成一个页面,大致需要以下几步:

  1. 生成 dom tree: 在拿到 html 时,浏览器到底做了什么才让页面出现的呢。首先预解析里面的所有资源请求并发出预请求。接着词法、语法解析 html,遇到<body><div>等元素标签以及classid等属性时,解析生成 dom 树。在此期间可能会遇到 css、js 的标签,比如<style><link><script>,css 的资源请求不会阻塞 dom 树解析,如果 dom 树解析完 css 还没有解析完成这时才阻塞 render 树以及后续的 layout 树等等的建立。如果遇到 js,不管是代码执行还是资源请求都会等全部执行完后才继续 dom 的解析,除非script标签有asyncdefer属性。如果 js 前面有 css 资源,那么直到 css 被请求/解析完才会执行 js,这会导致 css 间接阻塞 dom 的解析。如果遇到了 css 相关的代码,会执行下一步,将 css 解析成 stylesheet

  2. 生成 stylesheet: css 也是经过词法、语法解析,会对其中的一些值进行标准化。什么是标准化,比如你写的font-weight: boldflex-flow: column nowrap,其实不是标准的 css 样式,而是一种简写,它需要被转换成引擎能认识的数值:font-weight: 500flex-direction: columnflex-wrap: nowrap。最终由序列化文本变成结构化的 stylesheet。

  3. 生成 Render Tree: 有了 dom 树和 stylesheet,可以通过继承、css 选择器优先级等样式规则,向对应的 dom 添加样式,css 选择器会从右向左匹配条件,这样匹配次数相对最少,最终会形成一个具有样式的 render 树。

  4. layout(布局): 有些 dom 是不显示的,比如display: none,于是会根据 render 树形成一个 layout 树,它仅包含有将来会出现的节点,避免无效计算。同时 layout 阶段会计算每个元素的布局位置信息,这个是比较耗时的,元素之间位置会互相影响。

  5. layer(分层): 接着会根据一些特殊样式,比如position: absolutetransformopacity等,形成不同的层,根节点和滚动也会算一层。因为不同的层布局一般不会互相影响,分层可以让后续的更新减少 layout 成本,也方便后面的合成层对图层单独进行特殊变换。

  6. paint(绘制): 这里并不是真的往显示器绘制,而是对每个层生成各自的绘制命令,这些命令都是供 GPU 绘制使用的基础命令,比如画一条直线等。

  7. composite(合成): 到了这一步不再由 CPU 执行了,任务会交给由 GPU 处理,所以 js 如果阻塞了不会影响这个线程,CSS 硬件加速也是发生在这个线程。paint 阶段的绘制命令列表会交给合成层,合成层会将当前视口附近区域划定为图块,以 512px 为单位,优先渲染图块区域,其他不在视口附近范围的页面可以等空闲了再渲染。合成层通过光栅化线程池将绘制命令交给 GPU 绘制并输出位图,由于这些位图是各层的,这些图层还需要再由合成层合成成一张位图。一旦图层被光栅化,合成层可以对多个图层进行合成,将它们按照正确的顺序叠加在一起,形成最终的渲染结果。这个过程通常在 GPU 中进行,以减轻 CPU 的工作负担,提高渲染性能。

解释一下光栅化:光栅化是计算机图形学中的概念。在合成层中,将图层中的矢量图形、文本、图片等元素转换为位图或光栅图像。这使得这些元素可以更快地被渲染和显示,因为位图在图形硬件中处理更为高效。合成层可以将需要渲染的内容绘制到离屏(Off-screen)的内存区域中,而不是直接在屏幕上渲染。这样可以避免直接在屏幕上绘制导致的性能问题,并且允许浏览器在后台对离屏内容进行优化处理。通过将图层内容光栅化后,浏览器可以更好地利用图形硬件加速来进行渲染。现代计算机和移动设备中的图形处理单元(GPU)可以高效地处理位图图像,从而提供更流畅的动画效果和更快的渲染速度。

  1. 显示: 等待显示器发出 sync 信号,代表即将显示下一帧。合成层的位图会交给浏览器进程里的biz组件,位图被放进后缓冲区,显示器显示下一帧时,前、后缓冲区会交换,从而显示最新的页面图片。js 里的requestAnimationFrame的回调也是因为 sync 信号才知道即将渲染下一帧才触发的。另外还有游戏里的垂直同步。

渲染时这些步骤就像流水线一样按序执行,如果流水线从某一步开始执行,必然会将下面的步骤都执行到底。

所以,如果页面更新是因为修改了位置/布局相关的样式,会重新触发 2 阶段 layout重新计算布局,这叫做 reflow(重排或回流)。由于要计算大量元素的位置,位置之间还会相互影响,所以可以看出这一步是非常耗时的。同时后面所有步骤也会执行,比如 paint、composite,所以说reflow必然伴随着repaint重绘。

如果页面更新是因为和位置无关的样式修改(比如background-colorcolor),只会从 4 阶段 paint 开始重新触发,因为前面的流程依赖的数据没有变化。这会重新生成绘制命令然后在合成层光栅化、合成。整个过程还是很快的,所以只重绘要比重排快很多。

怎样利用渲染原理提高性能

浏览器自身就带有一些优化手段,比如你可以不用担心color: red; width: 120px因为顺序问题带来重复的 paint,也不用担心多次连续修改样式、多次连续 append 元素会让性能变差。浏览器并不会在你修改后就立刻开始渲染,它只是把更新放进等待队列,然后会在达到一定的修改次数或一定时间后,才会批量更新它们。

我们在写代码更新页面时,原则是要尽量触发比较少的渲染流水线,从 paint 阶段开始会比 layout 阶段快很多。以下是一些常见的注意事项:

  1. 避免间接回流。除了直接修改位置相关的样式之外,有些情况可能会间接修改布局。比如如果你的box-sizing不是border-box,同时也没固定width,那么你添加或修改了 border-width,就会影响盒模型的宽度,影响了布局位置。还有例如<img />没有指定高度,导致图片加载后高度被顶起,造成页面 reflow

  2. 读写分离原则。js 获取元素位置信息可能会触发强制 reflow,比如getBoundingClientRectoffsetTop等。前面已经讲了浏览器是批量更新,会有等待队列,所以你在获取位置信息时,可能等待队列还有更新没有清空,页面不是最新的。浏览器为了保证你拿到的数据是准确的,会去强制清空队列,强制 reflow 页面。而当你第二次获取位置信息,并且期间没有发生更新的话,等待队列是空的,那么就不会再触发 reflow 了。所以如果你要批量修改一批元素尺寸,并且获取它们的尺寸信息,就千万不能这样写:

const elements = document.querySelectorAll(".target");
const count = 1000;
for (let i = 0; i < count; i++) {
  // 将元素width增加20px
  elements[i].style.width = parseInt(elements[i].style.width) + 20 + "px";
  // 获取该元素最新宽度
  console.log(elements[i].getBoundingClientRect().width);
}

经过上面对浏览器批量更新和强制 reflow 的解释,可以看出这样写是很有问题的,页面会 reflow 1000 次!因为每次你修改style.width后,浏览器会把这次更新放进等待队列里。这一步没什么问题。可是紧接着你就开始获取这个元素的宽度,于是浏览器为了知道最新宽度,就会清空等待队列,跳过批量更新,强制去 reflow 页面。然后一直这样循环 1000 次。

而如果你这样写,1000 次尺寸修改就只会 reflow 一次:

const elements = document.querySelectorAll(".target");
const count = 1000;
for (let i = 0; i < count; i++) {
  // 将元素width增加20px
  elements[i].style.width = parseInt(elements[i].style.width) + 20 + "px";
}
for (let i = 0; i < count; i++) {
  // 获取该元素最新宽度
  console.log(elements[i].getBoundingClientRect().width);
}

可以看出,第一个循环里只做更新,第二个循环里只做读取。这样的好处是,第一个循环里的 1000 次更新全部放进等待队列里了,然后在第二个循环里发生第 1 次读取时,页面强制批量更新了一次,发生一次 reflow。从第 2 次读取开始,由于等待队列已经清空了,所以不会再有 reflow。这可以称为样式的读写分离原则。

css 硬件加速

在合成层之前的那些阶段的计算基本都是 CPU 执行的,CPU 计算单元远没有 GPU 多,虽然对复杂任务能力强大,但面对简单重复的任务速度比 GPU 慢很多。如果页面的渲染直接从合成层开始,只由 GPU 计算,速度必然会非常快,这就是硬件加速。哪些方式可以开启呢。

transform 3Dopacity这些 css 样式,由于不涉及回流和重绘,仅仅是图层的变换,所以会跳过前面的 layout、paint 阶段,直接交给合成层,由 GPU 对图层做一些简单变换即可,GPU 处理这些事情非常简单。另外还有一个 css 属性叫will-change,它是专门用来做硬件加速的,它会提前告诉 GPU 哪些属性将来会变化,提前做好准备。

要注意一点的是,用 js 修改样式,哪怕修改的是上述的可以硬件加速的样式,也是会经过 CPU 的。还记得那个渲染流水线吗,js 只能修改 dom 树的内容,必然会触发 dom 变化,所以会从渲染流水线的第一步开始直到最后,并不会直接从合成层开始。

既然 js 修改的样式不能硬件加速,那还能怎么修改呢?可以用 animation 或者 transition 这类非 js 的方式。你可以实验一下,在 js 完全阻塞页面时,看看 animation 是不是依然在工作。


如何记录性能和排查渲染卡顿

判断一个页面卡不卡,并不是自己试了觉得不卡就行,从主观上考量不能给出量化数据,工作中一定要从数据出发才能让别人信服。

1. 开发者指标

如果你想查看本机打开某个页面的性能,可以去 DevTools 里的 Lighthouse

Lighthouse

点击Analyze page load按钮,会生成一份性能报告。

report

里面包含了首次绘制时间、可交互时间等性能指标,可访问性指标,用户体验指标,SEO 指标,PWA(渐进式 web 应用)等。

如果你想排查导致本机渲染卡顿的原因。你可以去 DevTools 里的 performance,它清晰的展示了耗时从低到高的各项任务

tasks

我们可以看到Long Task这个词,它指的是长任务,长任务的定义是阻塞主线程达 50 毫秒或以上的任务。你可以点击一个Long Task任务,详细查看这个任务里做了什么(例子中因为querySelectorAll耗时太久,所以需要优化)

task detail

2. 真实用户监控

以上仅适合开发过程中临时排查,针对的设备只是你的计算机和你的网络情况。不能知道实际上线后的项目在不同网络环境、设备、地理位置的用户那里是怎样的性能表现。所以要想知道真实的性能指标,还需要其他办法。

要判断性能好坏,首先必须要定义一套明确的指标名称。那判断性能需要哪些指标呢?

性能指标中文全称描述
FP首次绘制浏览器首次在屏幕上绘制像素的时间点,即页面开始显示内容的时间。
FCP首次有内容绘制页面首次绘制出任何文本、图像、非空白 canvas 或其他可视元素的时间点,用户可以看到页面有一些可见的内容。
LCP最大内容绘制页面中最大的可见内容元素绘制完成并可见的时间点,通常是页面上最显眼的图像或元素块。
TTI可交互时间页面加载完成且用户可以与页面进行交互的时间点,主线程空闲且页面可以响应用户输入。
TBT总阻塞时间页面加载过程中,主线程被长时间任务(Long Task,通常是 JavaScript 执行)阻塞的总时间。
CLS累计布局偏移页面加载过程中发生的意外布局变化的总量,可能导致用户在交互时误触或出现不良体验。比如 img 没有设置高度,图片加载后高度撑起。
FID首次输入延迟用户首次与页面交互(如点击按钮)时,页面事件响应用户输入所需的时间。

如何得到这些指标呢?现代浏览器里一般会有performanceapi,在里面你可以看到非常多详细的性能数据,虽然以上数据有些并不会直接有,但你可以通过一些基础 api 算出来。

performance

可以看到里面有例如:eventCounts-事件数量,memory-内存占用,navigation-页面打开方式、重定向次数,timing-dns 查询时间、tcp 连接时间、响应时间、dom 解析渲染时间、可交互时间等。

另外performance还有一些很有用的 api,比如performance.getEntries()

performance.getEntries()

它会返回一个数组,里面列出了所有资源、关键时刻耗费的时间。其中就有first-paint-FPfirst-contentful-paint-FCP指标。如果你只想找某些指定的性能报告,可以用performance.getEntriesByName()performance.getEntriesByType()做筛选。

下面讲一下 TTI(Time to Interactive 可交互时间)指标应该如何计算:

  1. 先获取 First Contentful Paint 首次内容绘制 (FCP)时间,可以通过上面的performance.getEntries()拿到。
  2. 沿时间轴正向搜索时长至少为 5 秒的安静窗口,其中,安静窗口的定义为:没有长任务(Long Task,js 阻塞超过 50ms 的任务)且不超过两个正在处理的网络 GET 请求。
  3. 沿时间轴反向搜索安静窗口之前的最后一个长任务,如果没有找到长任务,则在 FCP 步骤停止执行。
  4. TTI 是安静窗口之前最后一个长任务的结束时间(如果没有找到长任务,则与 FCP 值相同)。

可能难点在于大家不知道怎么得到长任务。有一个类叫PerformanceObserver可以用来监听性能数据,在entryTypes里加入longtask即可获取长任务信息,你还可以加入更多类型来获取其他性能指标,具体可以看看这个类的文档。以下给出监听 longtask 的示例:

const observer = new PerformanceObserver(function (list) {
  const perfEntries = list.getEntries();
  for (let i = 0; i < perfEntries.length; i++) {
    // 这里可以处理长任务通知:
    // 比如报告分析和监控
    // ...
  }
});
// register observer for long task notifications
observer.observe({ entryTypes: ["longtask"] });
// 之后如果有长任务执行的话,会把执行数据放入性能检测队列
// 于是就会在observer中得到"longtask" entries.

在你写出统计各项性能指标的代码后,你可以把它埋入用户的页面中,在用户打开页面时上报给性能统计后台。在统计性能时,你应该着重关注靠后的数据(比如末尾95%处的用户),这样你可以知道性能比较差的用户是什么样的情况,观察指标靠前的用户数据其实意义不大,因为很可能是网页缓存。Google对这一百分比的推荐是75%处的用户,但我认为还可以再往后。

除了自己实现,你也可以直接使用开源的性能监控库,比如Googleweb-vitals,它可以统计出LCPFIDCLS等指标。

import {onLCP, onFID, onCLS} from 'web-vitals';

onCLS(console.log);
onFID(console.log);
onLCP(console.log);

同时Google认为一些关键指标的好坏标准是这样的

image.png

图中LCP在2.5秒以内就是优秀,2.5秒至4秒则需要优化,超过4秒就很差。


js 如何优化

js 的优化角度比较多,要分不同场景,所以要从技术栈选型、多线程、v8 这几个角度说。


技术栈选型

一、页面渲染方案选择

1. CSR 浏览器端渲染

现在 react vue 这些 spa 前端框架相当流行,采用状态驱动的 spa 应用可以做到迅速的页面切换。但随之带来的缺点是,所有的逻辑都在浏览器端的 js 里,导致首屏启动流程过长。

2. SSR 服务端渲染

因为 spa(CSR浏览器端渲染) 先天的渲染流程就比服务器渲染(SSR)要长,它会经过 html 请求 -> js 请求 -> js 执行 -> js 渲染内容 -> dom 挂载后请求接口 -> 更新内容这些步骤。而服务端渲染只需要 html 请求 -> 页面内容渲染 -> js 请求 -> js 执行添加事件这几个步骤。页面内容渲染的时机,服务端渲染要比 spa 快很多,非常适合希望用户尽快看到内容的场景。

你可以使用 react 的 nextjs 框架或者 vue 的 nuxtjs 框架,它们可以在前后端同一套代码的情况下做到服务器端同构渲染。本质原理是 nodejs 带来的服务端运行环境、虚拟 dom 这个抽象层带来的多平台渲染能力。在同一套代码的情况下,浏览器和服务器都可以渲染(服务器渲染出来的是 html 文本),并且在页面二次跳转时依然可以采用 spa 的方式,在保证首屏速度的前提下不丢失 spa 的页面切换速度。同时两大框架的 SSR 性能也在不断优化,比如在 React18 的 SSR 中,新的renderToPipeableStream api 可以流式渲染 html 和具有Suspense特性,可以跳过耗时的任务,让用户更快看见主要页面。还可以选择性注水(Selective Hydration),将不需要同步加载的组件选择性地用 lazy 和 Suspense 包起来(和客户端渲染时一样),优化主要页面的可交互时间,间接做到了 ssr 中的代码分割。

3. SSG 静态页面生成

比如 react 的 nextjs 框架还支持生成静态站点,直接在打包时运行你的组件生成最终的 html,你的静态页面可以没有运行时,达到极致的打开速度。

4. App 客户端渲染

如果你的前端页面是放在 App 里的,可以让客户端实现和服务端渲染一样的机制,这时 App 中打开页面就类似服务端渲染。或者更简单的做法是将你的前端 spa 包放在客户端包里,也可以做到秒开。它们的最大提速点其实是用户在安装 App 时也下载了前端资源。

5. Edge Computing 边缘计算和 CDN 部署

你可以在边缘计算服务器中提供SSR渲染函数或者直接将静态前端资源部署到cdnoss中,让更靠近用户侧的服务器帮你做服务端渲染或者直出静态页面。但要注意后端请求还是要去内网服务器的。这种方式可以极大加快用户冷请求速度,同时减少内网服务器的压力。

二、前端框架的取舍

取舍1:你是否一定要用框架

现代前端开发过程中一般会毫不犹豫的选择框架开发。但如果你的项目,现在与未来都不会复杂,并且你极度追求性能,那么你其实可以不使用reactvue这样的状态驱动框架。虽然你可以通过它们享受到只修改状态页面就更新这样的开发便利,但提升DX(开发者体验)的同时也是有性能成本的。首先因为引入了额外运行时,导致 js 变多。其次由于它们最小都是组件级别的渲染,也就是状态变化后,对应的组件会全量重新执行一遍,所以得到的虚拟dom必须要经过 diff 才能达到比较好的浏览器渲染性能。这些多出来的环节导致它肯定没有你直接用jsjquery精准修改 dom 快。所以如果你的项目现在以及未来都不复杂,并且你希望它足够快和轻量,那么你完全可以直接用js或者jquery实现。

取舍2:更新机制的不同

如果你确定要选择使用react或者vue框架开发,它们之间也有一定的性能差异。比如,由于vue是基于监听对象属性的gettersetter触发更新的,内部有组件和状态属性的映射关系,所以可以精准的知道被修改的状态对应的组件是什么。但react由于必须创建一个新的状态,所以并不知道哪些属性更新了,粒度会更粗。同时由于设计时倾向函数式编程的设计,每次状态更新都会从root节点重新render(无stateprops变化的组件会跳过),所以会比vue有更多运行时的流程。虽然reactfiber任务优先级机制的加持,但它只是通过优化任务调度来优化用户体验,并不能加快总的运行时间。

取舍3:diff算法的不同

此外,框架之间还有一些内部细节的不同带来的性能差异,比如diff算法。vue采用的是从节点列表首尾两端开始的双端diff算法。

新旧VNode节点如下图所示:

vue-vdom

第一次循环后,发现尾部旧节点 D 与头部新节点 D 相同,直接复用旧节点 D,同时旧节点指针endIndex移动到旧 C,新节点指针的 startIndex 移动到新 C:

vue-diff-1

第二次循环后,依然可以复用 C,于是 diff 后创建了 C 的真实节点插入到第一次创建的 D 节点后面。旧节点的 endIndex 继续前移一位,新节点的 startIndex 后移一位:

vue-diff-2

第三次循环中,发现 E 节点是旧节点列表里没有的,这时候会直接创建新的真实节点 E,插入到真实 C 节点之后。同时新节点的 startIndex 移动到了 A。旧节点的 endIndex 都保持不动:

vue-diff-3

第四次循环中,发现新节点的 A 和旧节点的 startIndex 相同,于是 diff 后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex 移动到了 B,新节点的startIndex 移动到了 B:

vue-diff-4

第五次循环中,和第四次循环一样,于是旧节点的 startIndex 移动到了 C,新节点的 startIndex 移动到了 F:

vue-diff-5

旧节点的 startIndex 大于 endIndex,说明新节点中剩下的都是不在旧节点中的,需要直接新创建。于是创建 F 节点对应的真实节点放到 B 节点后面:

vue-diff-6

vue3中还对diff额外做了一些优化,它会在编译时会分析出静态节点并打上标签,让它在运行时跳过diff。

vue的双端diff可以带来更高的节点复用率,相比之下,reactdiff只有从左到右遍历这一个方向。reactdiff中有一个lastPlacedIndex变量,lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex

// old
abcd

// new
dabc

lastPlacedIndex初始是 0

/** 第一轮遍历开始 **/

d(新)和 a(旧)
key 或 节点type 变了,react会认为节点不可复用
跳出第一轮遍历
此时 lastPlacedIndex 还是 0

/** 第一轮遍历结束 **/

/** 第二轮遍历开始 **/

现在 new节点列表还是 dabc,没用完,不需要执行删除旧节点
old节点列表还是 abcd,没用完,不需要执行插入新节点

将剩余old节点(abcd)保存为Map结构

继续遍历剩余的new节点列表(dabc)

d节点在旧节点列表里存在
因为旧节点列表为 abcd,所以 旧d 的 index 是 3,即oldIndex是3
然后比较 oldIndex 与 lastPlacedIndex

如果 oldIndex >= lastPlacedIndex,则代表该可复用节点不需要移动,因为该节点旧的位置比新的位置更靠后,所以不需要往右移动。
并将 lastPlacedIndex 设置为 oldIndex;
如果 oldIndex < lastplacedIndex 说明该可复用节点之前的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动

在例子中,oldIndex 3 大于 lastPlacedIndex 0,
则 lastPlacedIndex = 3;
d节点位置不变

此刻实际dom是:d

继续遍历剩余new节点列表

现在old节点列表是 abc
   new节点列表是 abc

a节点在 old 节点列表中存在
oldIndex 等于 0, 小于 lastPlacedIndex 3
lastPlacedIndex不变
a节点需要往右移动

此刻实际dom是:da

继续遍历剩余new节点列表

现在old节点列表是 bc
   new节点列表是 bc

b节点 在 old旧节点列表中存在
oldIndex 是 1,因为旧节点列表是 abcd,所以 旧b 的实际 index === 1
小于lastPlacedIndex 3
所以 b节点需要向右移动

此刻实际dom是:dab

最后,old节点列表是 c
     new节点列表是 c
     
和上面一样,也是需要向右移动

此刻实际dom是:dabc。符合dom操作预期。

由此可以看出,当我们做出将后面的节点移动到前面这样的更新时,reactdiff算法会把除第一个节点以外的其他节点都调整位置,而vuediff调整次数就比较少。因此在框架性能测试网站(JS framework benchmark)中,react的这种更新的测试用例得分就远不如vue好,并不是react不想实现更高效的算法,而是reactfiber链表(由虚拟dom转化而来)是一个单向链表,兄弟节点之间只有往右没有往左的指针,所以实现不了vue那样的双端diff。不过实际项目中这种场景不常见,大家只作为一个技术点记住就好。

JS framework benchmark

三、框架的优化

如果你选用的是 React 框架,一般在开发过程中是需要额外做一些优化的。比如使用useMemo在依赖不变时缓存数据、useCallback在依赖不变时缓存函数、Class ComponentshouldComponentUpdate判断组件是否要更新等。因为 react 内部是通过变量引用地址是否变化来判断是否要更新的(使用的是Object.is()一样的算法),所以就算两个对象或者数组字面量完全一样,也是不同的两个值,这一点要注意。

另外如果可以的话,尽量保持使用最新的版本。一般新版本都会对性能做优化。

比如 React18 新增了任务优先级机制,避免长任务阻塞页面的交互,让用户感知上更快。低优先级的更新会被高优先级更新(比如用户点击、输入)中断,等高优先级更新完成后才继续执行低优先级更新。这样用户在交互时会觉得反应很及时。你可以用useTransitionuseDeferredValue产生低优先级的更新。

另外 React18 还对批量更新做了优化。批量更新指的是,当你连续修改多次状态时,框架并不会真的render组件更多次,而是把这些更新合并起来统一更新一次。react以往的批量更新其实是通过类似锁机制实现的,比如:

// 假设在react的onClick事件中

lock();
// 锁住了,更新只是放进队列并不会真的更新

update();
update();

unlock();
// 解锁,批量更新

这就会限制你只能在固定的一些地方(react预留了锁机制的地方)才能使用到批量更新的特性,比如生命周期hooksreact 事件里,因为 react 以外的地方是不会有锁的。并且,如果你用了 setTimeoutajax 等脱离当前宏任务的 api,里面的更新也会不能批量更新。因为:

// 假设在react的onClick事件中

lock();

fetch("xxx").then(() => {
  update();
  update();
});

unlock();
// updates已经脱离了当前宏任务,一定在unlock之后才执行,这时已经没有锁了,两次update就会让react渲染两次。

React18开始,批量更新是基于优先级设计的,所以不需要一定在 react 规定的地方才能批量更新,你在任何地方更新多次状态都是会批量更新的。

四、框架生态的性能选型

除了框架本身以外,它的生态选型也会对性能有影响。vue 的生态一般比较固定,但 react 的生态非常丰富,为了追求性能,就需要对不同库的特性和原理有所了解。我们这里主要谈谈全局状态管理和样式方案的选型。

框架生态选型之:全局状态管理

在状态管理库中,react-redux在极端条件下就可能有性能问题。注意这里说的是react-redux,而不是reduxredux只是一个通用库,本身很简单,可以用于各种地方,无法直接谈论性能好坏。react-redux是一个用来让 react 能够使用 redux 的库,通过connect高阶函数订阅和更新组件,也可以用hooks订阅更新。因为redux状态每次更新都是一个新的引用,所以react-redux无法知道哪些依赖状态的组件需要更新(无法精确知道哪些属性发生了变化,也就无法做状态与组件之间的映射从而精确更新组件),那就需要用selector比较一下更新前后的状态值是否变化,变化的组件才更新。于是每个依赖了全局状态的组件的selector函数都需要执行一遍,如果selector里面逻辑比较重或者使用了全局状态的组件数量比较多,就会产生性能问题。

这种情况下可以试试mbox,它的基本原理和 vue 类似,是基于监听对象的gettersetter,当组件使用状态时会触发getter从而收集组件和状态的绑定关系,更新某个状态时触发了setter,通过之前收集的绑定关系就可以精确触发对应组件的更新,所以性能上更好一些。

另外zustand使用体验不错也比较推荐,虽然它也是基于redux那一套,但用起来非常方便,还可以在组件外使用,不需要大量模板代码。

最后,也可以不使用全局状态库。如果引入了全局状态管理,那它一定会带来额外的运行时成本和项目学习/开发成本,最好的情况就是不用全局状态。如果你的项目不是很复杂,有一些需要跨组件的状态,那完全可以只用reactcontext搭配原生的useState将状态提升到组件们的公共父组件上,因为context可以透传,所以可以很方便的在不同层级的组件里使用这些状态,但要注意引用地址不该变化时不要变化,否则每次都会带着所有依赖它的组件更新。同时,由于react设计了订阅机制,context是不受上层memo影响的,如果你上层的组件被memo缓存了,下层的组件使用了useContext。当context中的状态更新时,依然可以正常让使用它的组件更新,哪怕组件上层的组件是被memo缓存的。利用这一点,你可以放心无脑缓存上层组件,以防止不必要的render计算:

const ContextA = createContextA('');
const ContextB = createContextB('');

const Parent = () => {
    const [a, setA] = useState('');
    const [b, setB] = useState('');
    
    // 缓存了Child渲染结果
    const child = useMemo(() => <Child />, []);
    
    return child;
}

const Child = () => {
    return <div>
        <A />
        <B />
    </div>
}

const A = () => {
    const a = useContext(ContextA);
    
    return <div>{a}</div>
}

const B = () => {
    const b = useContext(ContextB);
    
    return <div>{b}</div>
}

如果Child不使用useMemo的话,每次ab的更新都带动ChildAB三组件一起执行一遍render,这是没有必要的。加上memo之后,Child组件不会再render了,同时由于context可以无视memo缓存,所以a更新可以只让A组件 renderb更新只让B组件 render

另外,使用全局状态的另一个问题是还可能导致tearing(撕裂)。举个例子比较好懂:因为在react18以后有任务优先级机制,如果在你的组件中有全局状态a1,同时又有一个低优先级状态b1。你在一次更新中同时更新了它们两个,状态分别改为a2b2。在更新过程中突然出现了一个高优先级任务c(比如用户点击事件),那这时会中断低优先级b2的更新,实际页面还是先使用旧值b1,优先去执行高优先级的更新。但外部的全局状态并没有优先级机制,它已经真的被更新了。所以在执行完高优先级更新、执行完低优先级任务,组件里状态情况是:全局状态是最新值a2,低优先级状态是旧值b1,这就会造成同一个画面中部分新旧不一致,也就是撕裂现象。尤其原因是,外部库管理的状态是脱离于react之外的,react不知道它们更新了,也无法用优先级管理它们。

react18为了解决这个问题,提供了useExternalSyncStore 这个 hook,它可以让你订阅一个外部的数据源,不止是全局状态库,浏览器的api,比如localstorage的数据变化,history等等也属于外部数据。外部可以使用它来通知react发生了状态更新,它保证了在并发渲染开启的情况下,组件能够读取到一致的数据快照,避免出现“撕裂”现象。react会强制将机制回退到同步更新来让这次更新成为一个整体。可以看出,这个hook api并不是面向普通开发者的,更多是提供给写状态管理库等开源工具的人。下面贴出官方文档的demo

调用组件顶层的useSyncExternalStore从外部数据存储读取值

import { useSyncExternalStore } from 'react';  
import { todosStore } from './todoStore.js';  

function TodosApp() {  
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);  
  return (
      <>
        <button onClick={() => todosStore.addTodo()}>Add todo</button>
        <hr />
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>{todo.text}</li>
          ))}
        </ul>
      </>
  );
}
// todoStore.js

let nextId = 0;
let todos = [{ id: nextId++, text: 'Todo #1' }];
let listeners = [];

export const todosStore = {
  addTodo() {
    todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }]
    emitChange();
  },
  subscribe(listener) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(l => l !== listener);
    };
  },
  getSnapshot() {
    return todos;
  }
};

function emitChange() {
  for (let listener of listeners) {
    listener();
  }
}

订阅浏览器 api 实现网络在线或离线

import { useSyncExternalStore } from 'react';

export default function ChatIndicator() {
  const isOnline = useSyncExternalStore(subscribe, getSnapshot);
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function getSnapshot() {
  return navigator.onLine;
}

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

框架生态选型之:样式方案

在常见样式方案中,只谈性能方面,只有css-in-js方案是可能有运行时的,比如styled-componentemotion。但不绝对,有些css-in-js库会在你没有根据 props 动态计算样式的代码会自动去掉运行时。如果你的样式是有根据组件 props 计算的,那么运行时必不可少,它会在执行到组件 js 时计算出 css 然后帮你添加 style 样式标签。这会带来两个问题,一是性能成本、二是样式渲染时间被延后到 js 执行阶段。你可以使用除了css-in-js以外的方案来优化这一点,比如csssasslessstylustailwind.css。这里最推荐 tailwind.css,在讲网络层面的优化时已经讲到了,它不仅零运行时,同时由于原子化,可以让你充分复用样式,你的 css 资源会很小。


js 多线程

前面我们知道 js 任务会阻塞页面渲染,但如果某个长任务是业务上必须的怎么办?比如大文件 hash。这时我们可以开启另一个线程,让它去跑这个长任务并把最后的结果告诉主线程。

Web Worker

const myWorker = new Worker("worker.js");

myWorker.postMessage(value);

myWorker.onmessage = (e) => {
  const computeResult = e.data;
};
// worker.js
onmessage = (e) => {
  const receivedData = e.data;
  const result = compute(receivedData);
  postMessage(result);
};

Web Worker 只能被创建它的线程访问,也就是创建它的那个页面窗口。

Shared Worker

Shared Worker 可以被多个不同窗口、iframe、workers 访问。

const myWorker = new SharedWorker("worker.js");

myWorker.port.postMessage(value);

myWorker.port.onmessage = (e) => {
  const computeValue = e.data;
};
// worker.js
onconnect = (e) => {
  const port = e.ports[0];

  port.onmessage = (e) => {
    const receivedData = e.data;
    const result = compute(receivedData);
    port.postMessage(result);
  };
};

关于线程安全

由于 Web Worker 已经小心地控制了与其他线程的通信点,因此实际上很难引起并发问题。它不能访问非线程安全的组件或 DOM。你必须通过序列化对象在线程内外传递特定的数据。所以你必须非常努力才能在你的代码中制造问题。

Content security policy

Workers 有自己的执行上下文,和创建它的 document 的上下文是不同的。因此,Workers 不会被 document 的 Content security policy 管理。比如 document 受这个 http 头控制

Content-Security-Policy: script-src 'self'

这会阻止页面里的所有 script 进制使用eval()。但是如果 script 里面创建了 Worker,在 Worker 线程中还是可以使用eval()的。为了控制 Worker 里的 Content-Security-Policy,你需要在 Worker 脚本的 http response header 里设置它。有个例外是,如果你的 Worker 的 origin 是globally unique identifier(比如 blob://xxx),它会继承 document 的Content-Security-Policy

数据传递

主线程和 Worker 线程之间传递的数据是复制的而不是共享内存地址的。对象会在传递前序列化,然后在接收到时反序列化。大部分浏览器实现 copy 使用structured cloning算法。


适配 V8 引擎内部优化

V8 的编译流水线

  1. 准备环境:V8 会先首先准备代码的运行时环境,这个环境包括堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象、消息循环系统。初始化全局执行上下文和全局作用域。执行上下文中主要包含变量环境、词法环境、this 和作用域链。varfunction声明的变量会被放进变量环境里,这一步是在执行代码前做的,所以可以变量提升。constlet声明的变量会放进词法环境,里面是一个栈结构,每进入和离开{}代码块,都会入栈和出栈,出栈的就无法访问了,所以constlet是有词法作用域的。

  2. 构造事件循环系统:主线程需要不断的从任务队列中读取任务执行,因此需要构造循环事件机制。

  3. 生成字节码:V8 准备好运行时环境后,会先对代码进行词法和语法解析 (Parser),并生成 AST 和作用域信息,之后 AST 和作用域信息被输入到一个称为 Ignition 的解释器中,并将其转化为字节码。字节码是一种和平台无关的中间码。这里使用字节码的好处是,它可以被编译为优化后的机器码,缓存字节码比缓存机器码节省大量内存。在生成字节码时会延迟解析,V8 并不会一次性将全部代码都编译,如果遇到函数声明,它不会立即解析函数内部的代码,只是把顶层函数生成 AST 和字节码。

  4. 执行字节码:V8 中的解释器可以直接执行字节码,在字节码中源代码被编译为了有LdarAdd等类似汇编的指令,可以实现如取指令、解析指令、执行指令、存储数据等。通常有两种类型的解释器:基于和基于寄存器的。基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等,基于寄存器的虚拟机使用寄存器来保存参数、中间计算结果。大多数解释器都是基于栈的,比如 Java 虚拟机,.Net 虚拟机,还有早期的 V8 虚拟机,现在的 V8 虚拟机则采用了基于寄存器的设计。

  5. JIT 即时编译:字节码虽然可以直接执行,但是耗时较长,为了提升代码执行速度,V8 在解释器内增加了一个监控,在执行字节码的过程中,如果发现某一段代码被重复执行多次,那么监控会将这段代码标记为热点代码。

    当某段代码被标记为热点代码后,V8 就会将这段字节码交给优化编译器 TurboFan,优化编译器会在将字节码编译成二进制代码,然后再对编译后的二进制代码进行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。如果后面再执行到这段代码时,V8 会优先选择优化之后的二进制代码,这种设计被称为JIT(即时编译)。

    不过和静态语言不同的是,JavaScript 是一种灵活的动态语言,变量的类型、对象的属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对固定的类型,一旦在执行过程中,变量被动态修改了,那么优化后的机器码就会变成无效的代码,这时候优化编译器就需要执行反优化操作,下次执行时就会回退到解释器解释执行,多了反优化流程反而比常规直接执行字节码更慢。

通过上述编译流水线可以知道,js 多次重复执行一段相同的代码,由于 JIT 的存在,速度是很快的(和 java、c#这些静态强类型语言一个级别)。但前提是你的类型和对象结构不能随意变化。比如下面这段代码。

const count = 10000;
let value = "";
for (let i = 0; i < count; i++) {
  value = i % 2 ? `${i}` : i;
  // do something...
}

for循环里的代码会被执行 10000 次,但里面会不停修改 value 的类型,有时是数字有时是字符串,V8 可能会不停的优化、反优化,还不如直接执行字节码快。所以在写代码时要尽量把类型固定下来,不要随意更改。

V8 引擎存储对象的优化

JS 对象存储在堆中,它更像一个字典,字符串作为键名,任意对象都可以作为键值,通过键名读写键值。然而在 V8 实现对象存储时,并没有完全采用字典的存储方式,这主要是出于性能的考量。因为字典是非线性的数据结构,hash 计算和 hash 冲突导致查询效率会低于顺序存储的数据结构,V8 为了提升存储和查找效率,采用了一套复杂的存储策略。顺序存储结构是一块连续的内存,如线性表和数组,非线性结构一般占用非连续性内存,如链表和树。

对象里分为常规属性排序属性。数字属性会自动按升序排序,被称为排序属性,放在对象所有属性的开头。字符串属性会按创建时的顺序被放在常规属性里。

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性,分别是 elements 属性和 properties 这两个隐藏属性。

当满足这两个条件时:对象创建好后不添加新的属性;对象创建好后不删除属性,V8 会为每个对象创建一个隐藏类,对象中有个 map 属性值指向它。对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:对象中所包含的所有的属性;每个属性值相对于对象起始内存的偏移量。这样读取属性时就不需要一系列过程了,直接拿到偏移量算出内存地址即可。

但 js 是动态语言,对象属性是可以被改变的。给一个对象添加新属性,删除属性,或者改变某个属性的数据类型都会改变这个对象的形状,从而使 V8 重新构建新的隐藏类,降低性能。

所以非必要不推荐使用 delete 关键字删除对象的属性或添加/修改属性,最好在对象声明时就确定。同时声明相同的对象字面量时最好保证完全相同

// 不好,x、y顺序不同
const object1 = { a: 1, b: 2 };
const object2 = { b: 1, a: 2 };

// 好
const object1 = { a: 1, b: 2 };
const object2 = { a: 1, b: 2 };

第一种写法两个对象的形状不同,会生成不同的隐藏类,无法复用。

当多次重复读取同一个对象的属性时,V8 会为它建立内联缓存(Inline Cache)。例如这段代码:

const object = { a: 1, b: 2 };

const read = (object) => object.a;

for (let i = 0; i < 1000; i++) {
  read(object);
}

正常读取对象属性的流程是:查找隐藏类 -> 查找内存偏移量 -> 得到属性值。当读取操作多次执行时,V8 会优化这个流程。

内联缓存简称 IC。在 V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。

内联缓存会为每个函数维护一个 反馈向量 (FeedBack Vector)。反馈向量由很多项组成的,每一项称为一个插槽 (Slot),上面的代码中,V8 会依次将执行 read 函数的中间数据写入到反馈向量的插槽中。

代码中 return object.a 是一个调用点,因为它读取了对象属性,那么 V8 会在 read 函数的反馈向量中为这个调用点分配一个插槽,每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量,当 V8 再次调用 read 函数执行到 return object.a 时,它会在对应的插槽中查找 a 属性的偏移量,之后 V8 就能直接去内存中获取 object.a 的属性值了,相比去隐藏类中查找有更快的执行效率。

const object1 = { a: 1, b: 2 };
const object2 = { a: 3, b: 4 };

const read = (object) => object.a;

for (let i = 0; i < 1000; i++) {
  read(object1);
  read(object2);
}

如果代码变成这样,我们会发现每次循环读取的两次对象形状是不一样的,因此它们的隐藏类也是不一样的。V8 在读取到第二个对象时会发现插槽里的隐藏类和正在读的不一样,于是它会往插槽里添加一个新的隐藏类和属性值内存偏移量。这时插槽里会有两个隐藏类和偏移量。每次读取对象属性时,V8 会一一比较它们。如果正在读取的对象的隐藏类和插槽中的某个隐藏类相同,那么就使用命中的隐藏类的偏移量。如果没有相同的,同样将新的信息添加到那个插槽中。

  • 如果一个插槽中只包含 1 个隐藏类,称这种状态为单态 (monomorphic);

  • 如果一个插槽中包含了 2 ~ 4 个隐藏类,称这种状态为多态 (polymorphic);

  • 如果一个插槽中超过 4 个隐藏类,称这种状态为超态 (magamorphic)。

可以看出单态时的性能是最好的,所以我们在一个多次执行的函数中可以尝试避免修改对象或读取多个对象,来达到更好的性能。

这里可以引出一件事情,之前我看 React17 版本时官方在关于『为什么要用 _jsx 代替 createElement 』的解释时,讲到了 createElement 的一些缺点,里面就提到了 createElement 是『高度复态』的,难以从 V8 层面优化。其实看懂了这篇文章,就能明白那句话什么意思,它其实就是文章里说的超态,于是你也就明白为什么官方说 createElement 难以优化了。createElement函数在页面中会被调用非常多次,但它接受的组件 props 等参数各不相同,于是会产生非常多内联缓存,所以说它是『高度复态(超态)』的。(不过至少这一点 _jsx 好像依然没能解决,但他们能觉察到这一点还是很厉害的)

如果你喜欢我写的文章,可以关注我github上的前端技术分享。