Skip to content

Latest commit

 

History

History
1926 lines (1503 loc) · 124 KB

http和浏览器.md

File metadata and controls

1926 lines (1503 loc) · 124 KB

TCP

TCP 和 UDP 的区别

基本的区别:

  • TCP是一个面向连接的、可靠的、基于字节流的传输层协议。
  • 而UDP是一个面向无连接的传输层协议。(就这么简单,其它TCP的特性也就没有了)。

具体来分析,和 UDP 相比,TCP 有三大核心特性: 面向连接
所谓的连接,指的是客户端和服务器的连接,在双方互相通信之前,TCP 需要三次握手建立连接,而 UDP 没有相应建立连接的过程。

可靠性
TCP 花了非常多的功夫保证连接的可靠,这个可靠性体现在哪些方面呢?一个是有状态,另一个是可控制。

TCP 会精准记录哪些数据发送了,哪些数据被对方接收了,哪些没有被接收到,而且保证数据包按序到达,不允许半点差错。这是有状态。 当意识到丢包了或者网络环境不佳,TCP 会根据具体情况调整自己的行为,控制自己的发送速度或者重发。这是可控制。 相应的,UDP 就是无状态, 不可控的。

面向字节流。
UDP 的数据传输是基于数据报的,这是因为仅仅只是继承了 IP 层的特性,而 TCP 为了维护状态,将一个个 IP 包变成了字节流。

TCP 三次握手的过程

TCP 的三次握手,主要是建立连接的时候,为了确认双方的两样能力: 发送的能力和接收的能力。

Screen Shot 2021-08-27 at 14.28.31

  • 最开始双方都处于CLOSED状态。
  • 服务端开始监听某个端口,进入了LISTEN状态。
  • 客户端主动发起连接,发送 SYN , 自己变成了SYN-SENT状态。 (第一次握手)
  • 服务端接收到,返回SYN和ACK(对应客户端发来的SYN),自己变成了SYN-REVD。(第二次握手)
  • 之后客户端再发送ACK给服务端,自己变成了ESTABLISHED状态;(第三次握手)
  • 服务端收到ACK之后,也变成了ESTABLISHED状态。

另外需要注意的是,从图中可以看出,SYN 是需要消耗一个序列号的,下次发送对应的 ACK 序列号要加1,为什么呢?只需要记住一个规则: 凡是需要对端确认的,一定消耗TCP报文的序列号。

SYN 需要对端的确认, 而 ACK 并不需要,因此 SYN 消耗一个序列号而 ACK 不需要。

为什么不是两次?

因为无法确认客户端的接收能力。握手的目的是:确实客户端和服务端都有发送和接收的能力。

  • 第一次握手:(确认客户端发送的能力)
  • 第二次握手:(确认服务端接收和发送的能力)
  • 第三次握手:(确认客户端接收的能力)

为什么不是四次?

因为三次已经确认完毕了,之后没有其它东西需要确认,再多握手也是浪费。

携带数据

前两次握手不能携带数据,第三次握手的时候可以携带。

如果前两次握手能够携带数据,那么一旦有人想攻击服务器,那么他只需要在第一次握手中的 SYN 报文中放大量数据,那么服务器势必会消耗更多的时间和内存空间去处理这些数据,增大了服务器被攻击的风险。

第三次握手的时候,客户端已经处于ESTABLISHED状态,并且已经能够确认服务器的接收、发送能力正常,这个时候相对安全了,可以携带数据。

同时打开会怎样

  • 在发送方给接收方发SYN报文的同时,接收方也给发送方发SYN报文
  • 发完SYN,两者的状态都变为SYN-SENT。
  • 在各自收到对方的SYN后,两者状态都变为SYN-REVD。
  • 接着会回复对应的ACK + SYN,这个报文在对方接收之后,两者状态一起变为ESTABLISHED。

Screen Shot 2021-08-27 at 14.34.52

TCP 四次挥手的过程

断开连接需要四次握手,多的一次其实就是第二次握手,服务端要发两个报文回来。

Screen Shot 2021-08-27 at 14.36.23

  • 刚开始双方处于ESTABLISHED状态。
  • 客户端要断开了,向服务器发送 FIN 报文
  • 发送后客户端变成了FIN-WAIT-1状态。注意, 这时候客户端同时也变成了half-close(半关闭)状态,即无法向服务端发送报文,只能接收。
  • 服务端接收后向客户端确认,变成了CLOSED-WAIT状态。
  • 客户端接收到了服务端的确认,变成了FIN-WAIT2状态。
  • 随后,服务端向客户端发送FIN,自己进入LAST-ACK状态,
  • 客户端收到服务端发来的FIN后,自己变成了TIME-WAIT状态,然后发送 ACK 给服务端。注意了,这个时候,客户端需要等待足够长的时间,具体来说,是 2 个 MSL(Maximum Segment Lifetime,报文最大生存时间), 在这段时间内如果客户端没有收到服务端的重发请求,那么表示 ACK 成功到达,挥手结束,否则客户端重发 ACK。

为什么是四次挥手而不是三次

主要是为什么服务端要先返回ACK,然后再返回FIN ACK,而不能一起发。这是因为,服务端要等待所有的报文都发送完毕了,才能发FIN ACK。也就是说,关闭之前要处理完事情,所以关闭是一个过程:

  • ACK就是说“我在准备关闭了”
  • FIN ACK就是说,“我已经准备好了,随时可以关闭”。

如果把ACK延迟到FIN ACK再发送,那客户端如果等久了,可能会重复发送FIN过来。先发一个ACK意思就是告诉客户端,我在做事了,别急。

等待2MSL的意义

就是确保如果有什么意外的话,能收到消息,也就是留出一来一回的时间:

  • 1 个 MSL 确保四次挥手中主动关闭方最后的 ACK 报文最终能达到对端
  • 1 个 MSL 确保对端没有收到 ACK 重传的 FIN 报文可以到达

SYN Flood 攻击

三次握手前,服务端的状态从CLOSED变为LISTEN, 同时在内部创建了两个队列:半连接队列(SYN队列)和全连接队列(ACCEPT队列)。

半连接队列 当客户端发送SYN到服务端,服务端收到以后回复ACK和SYN,状态由LISTEN变为SYN_RCVD,此时这个连接就被推入了SYN队列,也就是半连接队列。

全连接队列 当客户端返回ACK, 服务端接收后,三次握手完成。这个时候连接等待被具体的应用取走,在被取走之前,它会被推入另外一个 TCP 维护的队列,也就是全连接队列(Accept Queue)。

SYN Flood 攻击原理

SYN Flood 属于典型的 DoS/DDoS 攻击。其攻击的原理很简单,就是用客户端在短时间内伪造大量不存在的 IP 地址,并向服务端疯狂发送SYN。对于服务端而言,会产生两个危险的后果:

  • 处理大量的SYN包并返回对应ACK, 势必有大量连接处于SYN_RCVD状态,从而占满整个半连接队列,无法处理正常的请求。
  • 由于是不存在的 IP,服务端长时间收不到客户端的ACK,会导致服务端不断重发数据,直到耗尽服务端的资源。

如何应对 SYN Flood 攻击

  • 增加 SYN 连接,也就是增加半连接队列的容量。
  • 减少 SYN + ACK 重试次数,避免大量的超时重发。
  • 利用 SYN Cookie 技术,在服务端接收到SYN后不立即分配连接资源,而是根据这个SYN计算出一个Cookie,连同第二次握手回复给客户端,在客户端回复ACK的时候带上这个Cookie值,服务端验证 Cookie 合法之后才分配连接资源。

TCP 快速打开的原理

每次都三次握手好麻烦,可以优化成利用SYN Cookie 的TCP 快速打开(TCP Fast Open, 即TFO)。

Screen Shot 2021-08-27 at 15.42.33

流程

首轮三次握手:

  • 首先客户端发送SYN给服务端,服务端接收到。
  • 服务端不是立刻回复 SYN + ACK,而是通过计算得到一个SYN Cookie, 将这个Cookie放到 TCP 报文的 Fast Open选项中,然后才给客户端返回。
  • 客户端拿到这个 Cookie 的值缓存下来。后面正常完成三次握手。

后面的三次握手:

  • 客户端会将之前缓存的 Cookie、SYN 和HTTP请求发送给服务端
  • 服务端验证了 Cookie 的合法性,如果不合法直接丢弃;如果是合法的,那么就正常返回SYN + ACK。

现在服务端能向客户端发 HTTP 响应了!这是最显著的改变,三次握手还没建立,仅仅验证了 Cookie 的合法性,就可以返回 HTTP 响应了。 当然,客户端的ACK还得正常传过来,不然怎么叫三次握手嘛。

注意: 客户端最后握手的 ACK 不一定要等到服务端的 HTTP 响应到达才发送,两个过程没有任何关系。

TFO 的优势

TFO 的优势并不在与首轮三次握手,而在于后面的握手,在拿到客户端的 Cookie 并验证通过以后,可以直接返回 HTTP 响应,充分利用了1 个RTT(Round-Trip Time,往返时延)的时间提前进行数据传输,积累起来还是一个比较大的优势。

超时重传机制

TCP 具有超时重传机制,即间隔一段时间没有等到数据包的回复时,重传这个数据包。

重传间隔也叫做超时重传时间(Retransmission TimeOut, 简称RTO),它的计算跟上一节提到的 RTT 密切相关。这里我们将介绍两种主要的方法,一个是经典方法,一个是标准方法。

经典方法

经典方法引入了一个新的概念——SRTT(Smoothed round trip time,即平滑往返时间),每产生一次新的 RTT. 就根据一定的算法对 SRTT 进行更新.这个算法的局限,就是在 RTT 稳定的地方表现还可以,而在 RTT 变化较大的地方就不行了

标准方法

为了解决经典方法对于 RTT 变化不敏感的问题,后面又引出了标准方法,也叫Jacobson / Karels 算法。这个公式在 SRTT 的基础上加上了最新 RTT 与它的偏移,从而很好的感知了 RTT 的变化,这种算法下,RTO 与 RTT 变化的差值关系更加密切。

流量控制

对于发送端和接收端而言,TCP 需要把发送的数据放到发送缓存区, 将接收的数据放到接收缓存区

而流量控制索要做的事情,就是在通过接收缓存区的大小,控制发送端的发送。如果对方的接收缓存区满了,就不能再继续发送了。

HTTP

报文结构

对于 TCP 而言,在传输的时候分为两个部分:TCP头和数据部分。HTTP类似,是一个类似header + body的结构,具体来讲是:

起始行 + 头部 + 空行 + 实体
  • 请求报文例子
POST /search HTTP/1.1   // 起始行 (如果是get的话这里会很长,包含数据的url都在这)
Accept: ...             // 头部
Accept-Language: ...
Accept-Encoding: ...
Connection: Keep-Alive  
Cookie: ...
                             // 空行
hl=zh-CN&source=hp&q=domety  // (实体 POST中使用,数据不在url里面在这儿)
  • 响应报文例子
HTTP/1.1 200 OK       // 起始行,也叫状态行
Content-Type: ...     // 头部
Content-Length: ...
                      // 空行
<html>               // 实体
<head>
<title>xxx</title>
</head>
<body>
</body>
</html>

请求方法

http/1.1规定了以下请求方法(注意,都是大写):

  • GET: 通常用来获取资源
  • HEAD: 获取资源的元信息
  • POST: 提交数据,即上传数据
  • PUT: 修改数据
  • DELETE: 删除资源(几乎用不到)
  • CONNECT: 建立连接隧道,用于代理服务器
  • OPTIONS: 列出可对资源实行的请求方法,用来跨域请求
  • TRACE: 追踪请求-响应的传输路径

GET 和 POST 有什么区别? 略

URI

URI, 全称为(Uniform Resource Identifier), 也就是统一资源标识符,它的作用很简单,就是区分互联网上不同的资源。

Screen Shot 2021-08-26 at 17.13.12

例子:

https://www.baidu.com/s?wd=HTTP&rsv_spt=1

这个 URI 中,

编码

URI 只能使用ASCII, ASCII 之外的字符是不支持显示的,而且还有一部分符号是界定符,如果不加以处理就会导致解析出错。 因此,URI 引入了编码机制,将所有非 ASCII 码字符和界定符转为十六进制字节值,然后在前面加个%。 如,空格被转义成了%20,三元被转义成了%E4%B8%89%E5%85%83。

状态码

  • 1xx: 表示目前是协议处理的中间状态,还需要后续操作。

    • 101 Switching Protocols: 在HTTP升级为WebSocket的时候,如果服务器同意变更,就会发送状态码 101。
  • 2xx: 表示成功状态。

    • 200 OK: 是见得最多的成功状态码。通常在响应体中放有数据。
    • 204 No Content: 含义与 200 相同,但响应头后没有 body 数据。
    • 206 Partial Content: 顾名思义,表示部分内容,它的使用场景为 HTTP 分块下载和断点续传,当然也会带上相应的响应头字段Content-Range。
  • 3xx: 重定向状态,资源位置发生变动,需要重新请求。

    • 301 Moved Permanently: 即永久重定向,对应着302 Found,即临时重定向。比如你的网站从 HTTP 升级到了 HTTPS 了,以前的站点再也不用了,应当返回301,这个时候浏览器默认会做缓存优化,在第二次访问的时候自动访问重定向的那个地址。而如果只是暂时不可用,那么直接返回302即可,和301不同的是,浏览器并不会做缓存优化。
    • 304 Not Modified: 当协商缓存命中时会返回这个状态码。详见浏览器缓存。
  • 4xx: 请求报文有误。

    • 400 Bad Request: 开发者经常看到一头雾水,只是笼统地提示了一下错误,并不知道哪里出错了。
    • 403 Forbidden: 这实际上并不是请求报文出错,而是服务器禁止访问,原因有很多,比如法律禁止、信息敏感。
    • 404 Not Found: 资源未找到,表示没在服务器上找到相应的资源。
    • 405 Method Not Allowed: 请求方法不被服务器端允许。
    • 406 Not Acceptable: 资源无法满足客户端的条件。
    • 408 Request Timeout: 服务器等待了太长时间。
    • 409 Conflict: 多个请求发生了冲突。
    • 413 Request Entity Too Large: 请求体的数据过大。
    • 414 Request-URI Too Long: 请求行里的 URI 太大。
    • 429 Too Many Request: 客户端发送的请求过多。
    • 431 Request Header Fields Too Large: 请求头的字段内容太大。
  • 5xx: 服务器端发生错误。

    • 500 Internal Server Error: 仅仅告诉你服务器出错了,出了啥错咱也不知道。
    • 501 Not Implemented: 表示客户端请求的功能还不支持。
    • 502 Bad Gateway: 服务器自身是正常的,但访问的时候出错了,啥错误咱也不知道。
    • 503 Service Unavailable: 表示服务器当前很忙,暂时无法响应服务。

性质和缺点

HTTP 特点

  • 灵活可扩展,主要体现在两个方面。
    • 语义上的自由,只规定了基本格式,比如空格分隔单词,换行分隔字段,其他的各个部分都没有严格的语法限制。
    • 传输形式的多样性,不仅仅可以传输文本,还能传输图片、视频等任意数据,非常方便。
  • 可靠传输。HTTP 基于 TCP/IP,因此把这一特性继承了下来。这属于 TCP 的特性,不具体介绍了。
  • 请求-应答。也就是一发一收、有来有回, 当然这个请求方和应答方不单单指客户端和服务器之间,如果某台服务器作为代理来连接后端的服务端,那么这台服务器也会扮演请求方的角色。
  • 无状态。这里的状态是指通信过程的上下文信息,而每次 http 请求都是独立、无关的,默认不需要保留状态信息。

HTTP 缺点

  • 无状态 所谓的优点和缺点还是要分场景来看的,对于 HTTP 而言,最具争议的地方在于它的无状态。在需要长连接的场景中,需要保存大量的上下文信息,以免传输大量重复的信息,那么这时候无状态就是 http 的缺点了。但与此同时,另外一些应用仅仅只是为了获取一些数据,不需要保存连接上下文信息,无状态反而减少了网络开销,成为了 http 的优点。

  • 明文传输 即协议里的报文(主要指的是头部)不使用二进制数据,而是文本形式。这当然对于调试提供了便利,但同时也让 HTTP 的报文信息暴露给了外界,给攻击者也提供了便利。WIFI陷阱就是利用 HTTP 明文传输的缺点,诱导你连上热点,然后疯狂抓你所有的流量,从而拿到你的敏感信息。

  • 队头阻塞问题 当 http 开启长连接时,共用一个 TCP 连接,同一时刻只能处理一个请求,那么当前请求耗时过长的情况下,其它的请求只能处于阻塞状态,也就是著名的队头阻塞问题。接下来会有一小节讨论这个问题。

Accept系列字段

数据格式 HTTP 从MIME type取了一部分来标记报文 body 部分的数据类型,这些类型体现在Content-Type这个字段,当然这是针对于发送端而言,接收端想要收到特定类型的数据,也可以用Accept字段。

具体取值:

  • text: text/html, text/plain, text/css 等
  • image: image/gif, image/jpeg, image/png 等
  • audio/video: audio/mpeg, video/mp4 等
  • application: application/json, application/javascript, application/pdf, application/octet-stream

压缩方式 采取什么样的压缩方式就体现在了发送方的Content-Encoding字段上, 同样的,接收什么样的压缩方式体现在了接受方的Accept-Encoding字段上。这个字段的取值有下面几种:

  • gzip: 当今最流行的压缩格式
  • deflate: 另外一种著名的压缩格式
  • br: 一种专门为 HTTP 发明的压缩算法
Content-Encoding: gzip  // 发送端
Accept-Encoding: gzip   // 接收端

支持语言 对于发送方而言,还有一个Content-Language字段,在需要实现国际化的方案当中,可以用来指定支持的语言,在接受方对应的字段为Accept-Language。

Content-Language: zh-CN, zh, en   // 发送端
Accept-Language: zh-CN, zh, en    // 接收端

字符集 最后是一个比较特殊的字段, 在接收端对应为Accept-Charset,指定可以接受的字符集,而在发送端并没有对应的Content-Charset, 而是直接放在了Content-Type中,以charset属性指定。

Content-Type: text/html; charset=utf-8  // 发送端
Accept-Charset: charset=utf-8           // 接收端

总结图: 左边是服务端,右边是客户端 Screen Shot 2021-08-26 at 17.25.55

传输

  • 定长包体:发送端(服务端)在传输的时候一般会在头部带上Content-Length: xxx, 来指明包体的长度。如果实际传输的内容大于这个值,多出的部分会自动被截掉。
  • 不定长包体:发送端(服务端)头部设置Transfer-Encoding: chunked,然后分段传输。这样有两个效果:Content-Length 字段会被忽略,会基于长连接keep-alive持续推送动态内容。
  • 大文件的传输:采取了范围请求的解决方案,允许客户端仅仅请求一个资源的一部分。前提是服务器要支持范围请求,要支持这个功能,就必须加上这样一个响应头: Accept-Ranges: none

范围请求的一个返回报文例子:

HTTP/1.1 206 Partial Content
Content-Length: 10
Accept-Ranges: bytes
Content-Range: bytes 0-9/100

data xxxxx

多段数据的情况

HTTP/1.1 206 Partial Content
Content-Type: multipart/byteranges; boundary=00000010101
Content-Length: 189
Connection: keep-alive
Accept-Ranges: bytes

--00000010101
Content-Type: text/plain
Content-Range: bytes 0-9/96

data xxxxx
--00000010101
Content-Type: text/plain
Content-Range: bytes 20-29/96

hahahah wwwww
--00000010101--

处理表单数据的提交

表单提交一般是POST请求.有两种主要的表单提交的方式,体现在两种不同的Content-Type取值:

application/x-www-form-urlencoded

  • 其中的数据会被编码成以&分隔的键值对
  • 字符以URL编码方式编码。

转换过程

{ a: 1, b: 2 }
=> a=1&b=2
=> a%3D1%26b%3D2 (最终形式)

multipart/form-data

  • 请求头中的Content-Type字段会包含boundary,且boundary的值由浏览器默认指定。 比如
Content-Type: multipart/form-data;boundary=----WebkitFormBoundaryRRJKeWfHPGrS4LKe
  • 数据会分为多个部分,每两个部分之间通过分隔符来分隔,每部分表述均有 HTTP 头部描述子包体,如Content-Type,在最后的分隔符会加上--表示结束。

例子

Content-Disposition: form-data;name="data1";
Content-Type: text/plain
data1
----WebkitFormBoundaryRRJKeWfHPGrS4LKe
Content-Disposition: form-data;name="data2";
Content-Type: text/plain
data2
----WebkitFormBoundaryRRJKeWfHPGrS4LKe--

multipart/form-data 格式最大的特点在于:每一个表单元素都是独立的资源表述。另外,你可能在写业务的过程中,并没有注意到其中还有boundary的存在,如果你打开抓包工具,确实可以看到不同的表单元素被拆分开了,之所以在平时感觉不到,是以为浏览器和 HTTP 给你封装了这一系列操作。

在实际的场景中,对于图片等文件的上传,基本采用multipart/form-data而不用application/x-www-form-urlencoded,因为没有必要做 URL 编码,带来巨大耗时的同时也占用了更多的空间。

队头阻塞问题

HTTP 传输是基于请求-应答的模式进行的,报文必须是一发一收,里面的任务被放在一个任务队列中串行执行,一旦队首的请求处理太慢,就会阻塞后面请求的处理。这就是著名的HTTP队头阻塞问题。

解决方案

并发连接 对于一个域名允许分配多个长连接,那么相当于增加了任务队列,不至于一个队伍的任务阻塞其它所有任务。在RFC2616规定过客户端最多并发 2 个连接,不过事实上在现在的浏览器标准中,这个上限要多很多,Chrome 中是 6 个。 但其实,即使是提高了并发连接,还是不能满足人们对性能的需求。

域名分片 一个域名不是可以并发 6 个长连接吗?那我就多分几个域名。 比如 content1.sanyuan.com 、content2.sanyuan.com。 这样一个sanyuan.com域名下可以分出非常多的二级域名,而它们都指向同样的一台服务器,能够并发的长连接数更多了,事实上也更好地解决了队头阻塞的问题。

Cookie

HTTP 是一个无状态的协议,每次 http 请求都是独立、无关的,默认不需要保留状态信息。但有时候需要保存一些状态,怎么办呢?

HTTP 为此引入了 Cookie。Cookie 本质上就是浏览器里面存储的一个很小的文本文件,内部以键值对的方式来存储。向同一个域名下发送请求,都会携带相同的 Cookie,服务器拿到 Cookie 进行解析,便能拿到客户端的状态。

服务端可以通过响应头中的Set-Cookie字段来对客户端写入Cookie。比如:

// 请求头
Cookie: a=xxx;b=xxx

// 响应头
Set-Cookie: a=xxx
set-Cookie: b=xxx

生存周期

Cookie 的有效期可以通过Expires和Max-Age两个属性来设置。

  • Expires即过期时间 M- ax-Age用的是一段时间间隔,单位是秒,从浏览器收到报文开始计算。

若 Cookie 过期,则这个 Cookie 会被删除,并不会发送给服务端。

作用域

关于作用域也有两个属性: Domain和path, 给 Cookie 绑定了域名和路径,在发送请求之前,发现域名或者路径和这两个属性不匹配,那么就不会带上 Cookie。对于路径来说,/表示域名下的任意路径都允许使用 Cookie。

安全相关

  • 如果带上Secure,说明只能通过 HTTPS 传输 cookie。
  • 如果 cookie 字段带上HttpOnly,那么说明只能通过 HTTP 协议传输,不能通过 JS 访问,这也是预防 XSS 攻击的重要手段。
  • 相应的,对于 CSRF 攻击的预防,也有SameSite属性。SameSite可以设置为三个值:
    • 在Strict模式下,浏览器完全禁止第三方请求携带Cookie。比如请求sanyuan.com网站只能在sanyuan.com域名当中请求才能携带 Cookie,在其他网站请求都不能。
    • 在Lax模式,就宽松一点了,但是只能在 get 方法提交表单况或者a 标签发送 get 请求的情况下可以携带 Cookie,其他情况均不能。
    • 在None模式下,也就是默认模式,请求会自动携带上 Cookie。

Cookie的缺点

  • 容量缺陷。Cookie 的体积上限只有4KB,只能用来存储少量的信息。
  • 性能缺陷。Cookie 紧跟域名,不管域名下面的某一个地址需不需要这个 Cookie ,请求都会携带上完整的 Cookie,这样随着请求数的增多,其实会造成巨大的性能浪费的,因为请求携带了很多不必要的内容。但可以通过Domain和Path指定作用域来解决。
  • 安全缺陷。由于 Cookie 以纯文本的形式在浏览器和服务器中传递,很容易被非法用户截获,然后进行一系列的篡改,在 Cookie 的有效期内重新发送给服务器,这是相当危险的。另外,在HttpOnly为 false 的情况下,Cookie 信息能直接通过 JS 脚本来读取。

HTTP 代理

HTTP 是基于请求-响应模型的协议,一般由客户端发请求,服务器来进行响应。

当然,也有特殊情况,就是代理服务器的情况。引入代理之后,作为代理的服务器相当于一个中间人的角色,具有双重身份:

  • 对于客户端而言,表现为服务器进行响应
  • 而对于源服务器,表现为客户端发起请求

代理服务器的功能

负载均衡。 客户端的请求只会先到达代理服务器,后面到底有多少源服务器,IP 都是多少,客户端是不知道的。因此,这个代理服务器可以拿到这个请求之后,可以通过特定的算法分发给不同的源服务器,让各台源服务器的负载尽量平均。当然,这样的算法有很多,包括随机算法、轮询、一致性hash、LRU(最近最少使用)等等。

保障安全。 利用心跳机制监控后台的服务器,一旦发现故障机就将其踢出集群。并且对于上下行的数据进行过滤,对非法 IP 限流,这些都是代理服务器的工作。

缓存代理。 将内容缓存到代理服务器,使得客户端可以直接从代理服务器获得而不用到源服务器那里。

相关头部字段

Via 代理服务器需要标明自己的身份,在 HTTP 传输中留下自己的痕迹,就可以通过Via字段来记录。Via中记录的顺序即为在 HTTP 传输中报文传达的顺序。

例子:

客户端 -> 代理1 -> 代理2 -> 源服务器

// 在源服务器收到请求后,会在请求头拿到这个字段:
Via: proxy_server1, proxy_server2

// 而源服务器响应时,最终在客户端会拿到这样的响应头:
Via: proxy_server2, proxy_server1

X-Forwarded-For 字面意思就是为谁转发, 它记录的是请求方的IP地址(注意,和Via区分开,X-Forwarded-For记录的是请求方这一个IP)。这意味着每经过一个不同的代理,这个字段的名字都要变

  • 从客户端到代理1,这个字段是客户端的IP
  • 从代理1到代理2,这个字段就变为了代理1的 IP

但是这会产生两个问题:

  • 意味着代理必须解析 HTTP 请求头,然后修改,比直接转发数据性能下降。
  • 在 HTTPS 通信加密的过程中,原始报文是不允许修改的。

解决方法: 由此产生了代理协议,一般使用明文版本,只需要在 HTTP 请求行上面加上这样格式的文本即可, 不需要直接修改请求头。

// PROXY + TCP4/TCP6 + 请求方地址 + 接收方地址 + 请求端口 + 接收端口
PROXY TCP4 0.0.0.1 0.0.0.2 1111 2222
GET / HTTP/1.1
...

X-Real-IP,X-Forwarded-Host,X-Forwarded-Proto 分别是获取用户真实 IP,域名,协议名 的字段,不管中间经过多少代理,这个字段始终记录最初的客户端的IP。

缓存和缓存代理

大体流程

首先通过 Cache-Control 验证强缓存是否可用

  • 如果强缓存可用,直接使用
  • 否则进入协商缓存,即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since或者If-None-Match这些条件请求字段检查资源是否更新
    • 若资源更新,返回资源和200状态码
    • 否则,返回304,告诉浏览器直接从缓存获取资源

为什么产生代理缓存?

对于源服务器来说,它也是有缓存的,比如Redis, Memcache,但对于 HTTP 缓存来说,如果每次客户端缓存失效都要到源服务器获取,那给源服务器的压力是很大的。 由此引入了缓存代理的机制。让代理服务器接管一部分的服务端HTTP缓存,客户端缓存过期后就近到代理缓存中获取,代理缓存过期了才请求源服务器,这样流量巨大的时候能明显降低源服务器的压力。 那缓存代理究竟是如何做到的呢? 总的来说,缓存代理的控制分为两部分,一部分是源服务器端的控制,一部分是客户端的控制。

源服务器的缓存控制

private 和 public
在源服务器的响应头中,会加上Cache-Control这个字段进行缓存控制字段,那么它的值当中可以加入private或者public表示是否允许代理服务器缓存,前者禁止,后者为允许。 比如对于一些非常私密的数据,如果缓存到代理服务器,别人直接访问代理就可以拿到这些数据,是非常危险的,因此对于这些数据一般是不会允许代理服务器进行缓存的,将响应头部的Cache-Control设为private,而不是public。

proxy-revalidate
must-revalidate的意思是客户端缓存过期就去源服务器获取,而proxy-revalidate则表示代理服务器的缓存过期后到源服务器获取。

s-maxage s是share的意思,限定了缓存在代理服务器中可以存放多久,和限制客户端缓存时间的max-age并不冲突。

小例子: 源服务器在响应头中加入这样一个字段:

Cache-Control: public, max-age=1000, s-maxage=2000

复制代码相当于源服务器说: 我这个响应是允许代理服务器缓存的,客户端缓存过期了到代理中拿,并且在客户端的缓存时间为 1000 秒,在代理服务器中的缓存时间为 2000 s。

客户端的缓存控制

max-stale 和 min-fresh
在客户端的请求头中,可以加入这两个字段,来对代理服务器上的缓存进行宽容和限制操作。比如:max-stale: 5表示客户端到代理服务器上拿缓存的时候,即使代理缓存过期了也不要紧,只要过期时间在5秒之内,还是可以从代理中获取的。又比如:min-fresh: 5表示代理缓存需要一定的新鲜度,不要等到缓存刚好到期再拿,一定要在到期前 5 秒之前的时间拿,否则拿不到。

only-if-cached
这个字段加上后表示客户端只会接受代理缓存,而不会接受源服务器的响应。如果代理缓存无效,则直接返回504(Gateway Timeout)。 以上便是缓存代理的内容,涉及的字段比较多,希望能好好回顾一下,加深理解。

跨域

浏览器遵循同源政策,必须这三个都相同才是同源:

  • scheme(协议)
  • host(主机)
  • port(端口)

非同源站点有这样一些限制:

  • 不能读取和修改对方的 DOM
  • 不读访问对方的 Cookie、IndexDB 和 LocalStorage
  • 限制 XMLHttpRequest 请求。(后面的话题着重围绕这个)

当浏览器向目标 URI 发 Ajax 请求时,只要当前 URL 和目标 URL 不同源,则产生跨域,被称为跨域请求。

跨域请求的响应一般会被浏览器所拦截,注意,是被浏览器拦截,响应其实是成功到达客户端了。

那这个拦截是如何发生呢?以为为什么需要拦截 TODO

CORS

CORS 其实是 W3C 的一个标准,全称是跨域资源共享。它需要浏览器和服务器的共同支持,具体来说,非 IE 和 IE10 以上支持CORS,服务器需要附加特定的响应头。

简单请求

浏览器根据请求方法和请求头的特定字段,将请求做了一下分类,具体来说规则是这样,凡是满足下面条件的属于简单请求:

  • 请求方法为 GET、POST 或者 HEAD
  • 请求头的取值范围: Accept、Accept-Language、Content-Language、Content-Type(只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain)

浏览器画了这样一个圈,在这个圈里面的就是简单请求, 圈外面的就是非简单请求,然后针对这两种不同的请求进行不同的处理。

简单请求发出去之前,浏览器会自动在请求头当中,添加一个Origin字段,用来说明请求来自哪个源。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围中,那么浏览器就会将响应拦截。 因此,Access-Control-Allow-Origin字段是服务器用来决定浏览器是否拦截这个响应,这是必需的字段。与此同时,其它一些可选的功能性的字段,用来描述如果不会拦截,这些字段将会发挥各自的作用。

Access-Control-Allow-Credentials
这个字段是一个布尔值,表示是否允许发送 Cookie,对于跨域请求,浏览器对这个字段默认值设为 false,而如果需要拿到浏览器的 Cookie,需要添加这个响应头并设为true, 并且在前端也需要设置withCredentials属性:

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

Access-Control-Expose-Headers 这个字段是给 XMLHttpRequest 对象赋能,让它不仅可以拿到基本的 6 个响应头字段(包括Cache-Control、Content-Language、Content-Type、Expires、Last-Modified和Pragma), 还能拿到这个字段声明的响应头字段。比如这样设置:

Access-Control-Expose-Headers: xxx

那么在前端可以通过 XMLHttpRequest.getResponseHeader('xxx') 拿到 xxx 这个字段的值。

非简单请求

非简单请求相对而言会有些不同,体现在两个方面: 预检请求响应字段

例子

var url = 'http://xxx.com';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'xxx');
xhr.send();

当这段代码执行后,首先会发送预检请求。方法是OPTIONS,同时会加上Origin源地址和Host目标地址,这很简单。同时也会加上两个关键的字段:

  • Access-Control-Request-Method, 列出 CORS 请求用到哪个HTTP方法
  • Access-Control-Request-Headers,指定 CORS 请求将要加上什么请求头
OPTIONS / HTTP/1.1
Origin: 当前地址
Host: xxx.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header

接下来是响应字段,响应字段也分为两部分,一部分是对于预检请求的响应,一部分是对于 CORS 请求的响应。其中有这样几个关键的响应头字段:

  • Access-Control-Allow-Methods: 允许的请求方法列表。
  • Access-Control-Allow-Credentials: 是否允许发送 Cookie
  • Access-Control-Allow-Headers: 允许发送的请求头字段
  • Access-Control-Max-Age: 预检请求的有效期,在此期间,不用发出另外一条预检请求
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0

在预检请求的响应返回后

  • 如果请求不满足响应头的条件,则触发XMLHttpRequest的onerror方法,当然后面真正的CORS请求也不会发出去了。
  • 如果请求满足响应头的条件,现在就和发送简单请求的情况是一样的。浏览器自动加上Origin字段,服务端响应头返回Access-Control-Allow-Origin。

跨域解决方案

JSONP

虽然XMLHttpRequest对象遵循同源政策,但是script标签不一样,它可以通过 src 填上目标地址从而发出 GET 请求,实现跨域请求并拿到响应。这也就是 JSONP 的原理,接下来我们就来封装一个 JSONP.。

const jsonp = ({ url, params, callbackName }) => {
  const generateURL = () => {
    let dataStr = '';
    for(let key in params) {
      dataStr += `${key}=${params[key]}&`;
    }
    dataStr += `callback=${callbackName}`;
    return `${url}?${dataStr}`;
  };

  return new Promise((resolve, reject) => {
    // 初始化回调函数名称
    callbackName = callbackName || Math.random().toString.replace(',', ''); 

    // 创建 script 元素并加入到当前文档中
    let scriptEle = document.createElement('script');
    scriptEle.src = generateURL();
    document.body.appendChild(scriptEle);
  
    // 绑定到 window 上,为了后面调用
    window[callbackName] = (data) => {
      resolve(data);
      // script 执行完了,成为无用元素,需要清除
      document.body.removeChild(scriptEle);
    }
  });
}

// 调用
jsonp({
  url: 'http://localhost:3000',
  params: { 
    a: 1,
    b: 2
  }
}).then(data => {
  console.log(data);    // 数据数据数据


// 服务端
const express = require('express')
const app = express()
app.get('/', function(req, res) {
  const { a, b, callback } = req.query
  const data = '数据数据数据'

  // 注意哦,返回给script标签,浏览器直接把这部分字符串执行
  res.end(`${callback}(${data})`);
})
app.listen(3000)

和CORS相比,JSONP 最大的优势在于兼容性好,IE 低版本不能使用 CORS 但可以使用 JSONP,缺点也很明显,请求方法单一,只支持 GET 请求。

Nginx

Nginx 是一种高性能的反向代理服务器,可以用来轻松解决跨域问题。

  • 正向代理帮助客户端访问客户端自己访问不到的服务器,然后将结果返回给客户端。
  • 反向代理拿到客户端的请求,将请求转发给其他的服务器,主要的场景是维持服务器集群的负载均衡,换句话说,反向代理帮其它的服务器拿到请求,然后选择一个合适的服务器,将请求转交给它。

正向代理服务器是帮客户端做事情,而反向代理服务器是帮其它的服务器做事情。

nginx

Nginx如何来解决跨域的呢? 比如说现在客户端的域名为client.com,服务器的域名为server.com,客户端向服务器发送 Ajax 请求,当然会跨域了,那这个时候让 Nginx 登场了,通过下面这个配置:

server {
  listen  80;
  server_name  client.com;
  location /api {
    proxy_pass server.com;
  }
}

Nginx 相当于起了一个跳板机,这个跳板机的域名也是client.com,让客户端首先访问 client.com/api,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给server.com,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。

HTTPS

什么是HTTPS,TLS,SSL

之前谈到了 HTTP 是明文传输的协议,传输保文对外完全透明,非常不安全,那如何进一步保证安全性呢? 由此产生了 HTTPS,其实它并不是一个新的协议,而是在 HTTP 下面增加了一层 SSL/TLS 协议,简单的讲,HTTPS = HTTP + SSL/TLS。

SSL 即安全套接层(Secure Sockets Layer),在 OSI 七层模型中处于会话层(第 5 层)。之前 SSL 出过三个大版本,当它发展到第三个大版本的时候才被标准化,成为 TLS(传输层安全,Transport Layer Security),并被当做 TLS1.0 的版本,准确地说,TLS1.0 = SSL3.1。

现在主流的版本是 TLS/1.2, 之前的 TLS1.0、TLS1.1 都被认为是不安全的,在不久的将来会被完全淘汰。因此我们接下来主要讨论的是 TLS1.2, 当然在 2018 年推出了更加优秀的 TLS1.3,大大优化了 TLS 握手过程。

本质:TLS/SSL 其实就是通过非对称加密,生成对称加密的 session key 的过程

RSA的过程: RSA

TLS1.3 在 TLS1.2 的基础上废除了大量的算法,提升了安全性。同时利用会话复用节省了重新生成密钥的时间,利用 PSK (Pre-Shared Key) 做到了0-RTT连接。

HTTP2 有哪些改进?

由于 HTTPS 在安全方面已经做的非常好了,HTTP 改进的关注点放在了性能方面。

头部压缩

首先是在服务器和客户端之间建立哈希表,将用到的字段存放在这张表中,那么在传输的时候对于之前出现过的值,只需要把索引(比如0,1,2,...)传给对方即可,对方拿到索引查表就行了。这种传索引的方式,可以说让请求头字段得到极大程度的精简和复用。

http2的索引

其次是对于整数和字符串进行哈夫曼编码,哈夫曼编码的原理就是先将所有出现的字符建立一张索引表,然后让出现次数多的字符对应的索引尽可能短,传输的时候也是传输这样的索引序列,可以达到非常高的压缩率。

多路复用

HTTP 队头阻塞的问题的根本原因在于HTTP 基于请求-响应的模型,在同一个 TCP 长连接中,前面的请求没有得到响应,后面的请求就会被阻塞。 后面我们又讨论到用并发连接和域名分片的方式来解决这个问题,但这并没有真正从 HTTP 本身的层面解决问题,只是增加了 TCP 连接,分摊风险而已。而且这么做也有弊端,多条 TCP 连接会竞争有限的带宽,让真正优先级高的请求不能优先处理。

首先,HTTP/2 认为明文传输对机器而言太麻烦了,不方便计算机的解析,因为对于文本而言会有多义性的字符,比如回车换行到底是内容还是分隔符,在内部需要用到状态机去识别,效率比较低。于是 HTTP/2 干脆把报文全部换成二进制格式,全部传输01串,方便了机器的解析。

原来Headers + Body的报文格式如今被拆分成了一个个二进制的帧,用Headers帧存放头部字段,Data帧存放请求体数据。分帧之后,服务器看到的不再是一个个完整的 HTTP 请求报文,而是一堆乱序的二进制帧。这些二进制帧不存在先后关系,因此也就不会排队等待,也就没有了 HTTP 的队头阻塞问题。

通信双方都可以给对方发送二进制帧,这种二进制帧的双向传输的序列,也叫做流(Stream)。HTTP/2 用流来在一个 TCP 连接上来进行多个数据帧的通信,这就是多路复用的概念。

所谓的乱序,指的是不同 ID 的 Stream 是乱序的,但同一个 Stream ID 的帧一定是按顺序传输的。所以还是可以通过ID来还原数据,二进制帧到达后对方会将 Stream ID 相同的二进制帧组装成完整的请求报文和响应报文。在二进制帧当中还有其他的一些字段,实现了优先级和流量控制等功能。

服务器推送

另外值得一说的是 HTTP/2 的服务器推送(Server Push)。在 HTTP/2 当中,服务器已经不再是完全被动地接收请求,响应请求,它也能新建 stream 来给客户端发送消息,当 TCP 连接建立之后,比如浏览器请求一个 HTML 文件,服务器就可以在返回 HTML 的基础上,将 HTML 中引用到的其他资源文件一起返回给客户端,减少客户端的等待。

websocker

# 客户端请求
GET /chat HTTP/1.1     
Host: server.example.com     
Upgrade: websocket     
Connection: Upgrade     
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==     
Sec-WebSocket-Protocol: chat, superchat     
Sec-WebSocket-Version: 13     
Origin: http://example.com

# 服务端响应
HTTP/1.1 101 
Switching Protocols     
Upgrade: websocket     
Connection: Upgrade     
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=     
Sec-WebSocket-Protocol: chat

优势

Websocket协议解决了服务器与客户端全双工通信的问题。(能双向传输数据)

WebSocket与HTTP的关系

相同点

  • 都是一样基于TCP的,都是可靠性传输协议。
  • 都是应用层协议。

不同点

  • WebSocket是双向通信协议,模拟Socket协议,可以双向发送或接受信息。HTTP是单向的。
  • WebSocket是需要握手进行建立连接的。

Socket.IO

Socket.IO 由两部分组成:

  • 一个服务端用于集成 (或挂载) 到 Node.JS HTTP 服务器: socket.io
  • 一个加载到浏览器中的客户端: socket.io-client

浏览器

V8的优化

parse的执行流程

lexer也叫scanner Screen Shot 2021-07-30 at 15.51.07

JIT混合引擎

JS源代码经过了词法分析和语法分析这两个步骤,转成了字节码,其实就是经过任何一门程序语言必经的步骤:编译。但是不同于C++的编译执行(会先对bytecode进行优化),JS编译结束之后,并不会生成存放在内存或者硬盘之中的目标代码或可执行文件。生成的指令字节码Bytecode,会被立即被JSCore这台虚拟机进行逐行解释执行。运行指令字节码(ByteCode)是JS引擎中很核心的部分,各家JS引擎的优化也主要集中于此。

在 V8 出现之前,所有的 JavaScript 虚拟机所采用的都是解释执行的方式,这是 JavaScript 执行速度过慢的一个主要原因(没有编译执行做的优化)。而 V8 率先引入了 即时编译(JIT) 双轮驱动的设计。这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。

思路类似于用同构渲染优化SPA,就是在需要的时候做A,不需要的时候做B,而不是极端的A(SSR)或者极端的B(SPA)。就是在需要的时候优化代码,做编译执行,在不需要的时候,直接解释执行

Screen Shot 2021-07-30 at 15.56.16

JIT具体流程

Parser 将 JS 源码转换为 AST,然后 Ignition 将 AST 转换为 Bytecode,最后 TurboFan 将 Bytecode 转换为经过优化的 Machine Code(实际上是汇编代码)。

  • 如果函数没有被调用,则 V8 不会去编译它。
  • 如果函数只被调用 1 次,则 Ignition 将其编译 Bytecode 就直接解释执行了。TurboFan 不会进行优化编译,因为它需要 Ignition 收集函数执行时的类型信息。这就要求函数至少需要执行 1 次,TurboFan 才有可能进行优化编译。
  • 如果函数被调用多次,则它有可能会被识别为热点函数,且 Ignition 收集的类型信息证明可以进行优化编译的话,这时 TurboFan 则会将 Bytecode 编译为 Optimized Machine Code(优化的机器码),以提高代码的执行性能。

图片中的红色虚线是逆向的,也就是说 Optimized Machine Code 会被还原为 Bytecode,这个过程叫做 「Deoptimization」。这是因为 Ignition 收集的信息可能是错误的,比如 add 函数的参数之前是整数,后来又变成了字符串。生成的 Optimized Machine Code 已经假定 add 函数的参数是整数,那当然是错误的,于是需要进行 Deoptimization。

比如:

function add(x, y) {
  return x + y;
}

add(1, 2);
add('1', '2');

Screen Shot 2021-07-30 at 15.56.24

从输入url到渲染中,发生了什么,每一步如何优化

进程与线程

  • 进程就是一个程序运行的实例,操作系统会为进程创建独立的内存,用来存放运行所需的代码和数据
  • 而线程是进程的组成部分,每个进程至少有一个主线程及可能的若干子线程,这些线程由所属的进程进行启动和管理。由于多个线程可以共享操作系统为其所属的同一个进程所分配的资源,所以多线程的并行处理能有效提高程序的运行效率。

进程和线程之间的区别:

  • 只要某个线程执行出错,将会导致整个进程崩溃。
  • 进程与进程之间相互隔离。这保证了当一个进程挂起或崩溃的情况发生时,并不会影响其他进程的正常运行,虽然每个进程只能访问系统分配给自己的资源,但可以通过 IPC 机制进行进程间通信。
  • 进程所占用的资源会在其关闭后由操作系统回收。即使进程中存在某个线程产生的内存泄漏,当进程退出时,相关的内存资源也会被回收。
  • 线程之间可以共享所属进程的数据。

多进程浏览器

在 2008 年谷歌发布 Chrome 多进程浏览器之前,市面上几乎所有浏览器都是单进程的,它们将所有功能模块都运行在同一个进程中。单进程浏览器在以下方面有着较为明显的隐患:

  • 流畅性:首先是页面内存泄漏,浏览器内核通常非常复杂,单进程浏览器打开再关闭一个页面的操作,通常会有一些内存不能完全回收,这样随着使用时间延长,占用的内存会越来越多,从而引起浏览器运行变慢;其次由于很多模块运行在同一个线程中,如JS引擎、页面渲染及插件等,那么执行某个循环任务的模块就会阻塞其他模块的任务执行,这样难免会有卡顿的现象发生。
  • 安全性:由于插件的存在,不免其中有些恶意脚本会利用浏览器漏洞来获取系统权限,进行引发安全问题的行为。
  • 稳定性:由于所有模块都运行在同一个进程中,对于稍复杂的JS代码,如果页面渲染引擎崩溃,就会导致整个浏览器崩溃。同样,各种不稳定的第三方插件,也是导致浏览器崩溃的隐患。

出于对单进程浏览器存在问题的优化,Chrome 推出了多进程浏览器架构,浏览器把原先单进程内功能相对独立的模块抽离为单个进程处理对应的任务,主要分为以下几种进程。

  • 浏览器主进程:一个浏览器只有一个主进程,负责如菜单栏、标题栏等界面显示,文件访问,前进后退,以及子进程管理等。
  • GPU 进程:GPU(图形处理单元)最初是为了实现 3D 的 CSS 效果而引入的,后来随着网页及浏览器在界面中的使用需求越来越普遍,Chrome 便在架构中加入了 GPU 进程。
  • 插件进程:主进程会为每个加入浏览器的插件开辟独立的子进程,由于进程间所分配的运行资源相对独立,所以即便某个插件进程意外崩溃,也不至于对浏览器和页面造成影响。另外,出于对安全因素的考虑,这里采用了沙箱模式(即上图中虚线所标出的进程),在沙箱中运行的程序受到一些限制:不能读取敏感位置的数据,也不能在硬盘上写入数据。这样即使插件运行了恶意脚本,也无法获取系统权限。
  • 网络进程:负责页面的网络资源加载,之前属于浏览器主进程中的一个模块,最近才独立出来。
  • 渲染进程:也称为浏览器内核,其默认会为每个标签窗口页开辟一个独立的渲染进程,负责将 HTML、CSS 和 JavaScript 等资源转为可交互的页面,其中包含多个子线程,即 JS 引擎线程、GUI 渲染线程、事件触发线程、定时触发器线程、异步 HTTP 请求线程等。当打开一个标签页输入 URL 后,所发起的网络请求就是从这个进程开始的。另外,出于对安全性的考虑,渲染进程也被放入沙箱中。

第一步:DNS解析

当浏览器从(第三方)服务器请求资源时,必须先将该跨域域名解析为 IP 地址,然后浏览器才能发出请求。此过程称为 DNS 解析。

首先查询浏览器自身的 DNS 缓存,如果查到 IP 地址就结束解析,由于缓存时间限制比较大,一般只有1分钟,同时缓存容量也有限制,所以在浏览器缓存中没找到 IP 地址时,就会搜索系统自身的 DNS 缓存;如果还未找到,接着就会尝试从系统的 hosts 文件中查找。

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

DNS 解析是个很耗时的过程,若解析的域名过多,势必会延缓首屏的加载时间。

DNS解析

优化DNS解析

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

  • 减少 DNS 的请求次数,进行缓存

缓存 DNS 查找以提高性能。这种缓存可以在由用户的 ISP 或局域网维护的特殊缓存服务器上进行,但是在个别用户的计算机上也会发生缓存。DNS 信息保留在操作系统的 DNS 缓存中(Microsoft Windows上的“ DNS客户端服务”)。大多数浏览器都有自己的缓存,与操作系统的缓存分开。只要浏览器将 DNS 记录保留在其自己的缓存中,它就不会对操作系统发出记录请求进行打扰。

当客户端的 DNS 缓存为空(对于浏览器和操作系统)时,DNS 查找的次数等于网页中唯一主机名的数目。这包括在页面的 URL,图像,脚本文件,样式表,Flash 对象等中使用的主机名。减少唯一主机名的数量将减少 DNS 查找的数量。

减少域名的数量有可能减少页面中并行下载的数量。避免 DNS 查找会减少响应时间,但是减少并行下载可能会增加响应时间。我的指导原则是将这些资源划分为至少两个但不超过四个域名。这将在减少 DNS 查找和允许高度并行下载之间取得良好的折衷。

  • 进行 DNS 预获取:DNS Prefetch

DNS-prefetch (DNS 预获取) 是尝试在请求资源之前解析域名。这可能是后面要加载的文件,也可能是用户尝试打开的链接目标。

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

还可以通过使用 HTTP Link 字段将 dns-prefetch(以及其他资源提示)指定为 HTTP 标头:

Link: <https://fonts.gstatic.com/>; rel=dns-prefetch

注意几点:

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

第二步:建立TCP 连接

三次握手 四次握手

第三步:http通信

当 TCP 连接建立好之后,便可通过 HTTP 等协议进行前后端的通信,但在实际的网络访问中,并非浏览器与确定 IP 地址的服务器之间直接通信,往往会在中间加入反向代理服务器。

HTTP通信的优化

HTTP1.1与长连接

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

为了解决这个问题,有些浏览器在请求时,用了一个非标准的 Connection 字段。这个字段要求服务器不要关闭 TCP 连接,以便其他请求复用。服务器同样回应这个字段。

Connection: keep-alive

一个可以复用的 TCP 连接就建立了,直到客户端或服务器主动关闭连接。但是,这不是标准字段,不同实现的行为可能不一致,因此不是根本的解决办法。

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

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

客户端和服务器发现对方一段时间没有活动,就可以主动关闭连接。不过,规范的做法是,客户端在最后一个请求时,发送 Connection: close,明确要求服务器关闭 TCP 连接。

目前,对于同一个域名,大多数浏览器允许同时建立6个持久连接。

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

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

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

一个 TCP 连接现在可以传送多个回应,势必就要有一种机制,区分数据包是属于哪一个回应的。这就是 Content-length 字段的作用,声明本次回应的数据长度。在1.0版中,Content-Length 字段不是必需的,因为浏览器发现服务器关闭了 TCP 连接,就表明收到的数据包已经全了。

使用 Content-Length 字段的前提条件是,服务器发送回应之前,必须知道回应的数据长度。对于一些很耗时的动态操作来说,这意味着,服务器要等到所有操作完成,才能发送数据,显然这样的效率不高。更好的处理方法是,产生一块数据,就发送一块,采用“流模式”(stream)取代“缓存模式”(buffer)。

因此,1.1版规定可以不使用 Content-Length 字段,而使用"分块传输编码"(chunked transfer encoding)。只要请求或回应的头信息有 Transfer-Encoding 字段,就表明回应将由数量未定的数据块组成

Transfer-Encoding: chunked

每个非空的数据块之前,会有一个16进制的数值,表示这个块的长度。最后是一个大小为0的块,就表示本次回应的数据发送完了。下面是一个例子。

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

25
This is the data in the first chunk

1C
and this is the second one

3
con

8
sequence

0

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

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

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

HTTP2

2015年,HTTP/2 发布。它不叫 HTTP/2.0,是因为标准委员会不打算再发布子版本了,下一个新版本将是 HTTP/3。

二进制协议 HTTP/1.1 版的头信息肯定是文本(ASCII 编码),数据体可以是文本,也可以是二进制。HTTP/2 则是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为"帧"(frame):头信息帧和数据帧。

二进制协议的一个好处是,可以定义额外的帧。HTTP/2 定义了近十种帧,为将来的高级应用打好了基础。如果使用文本实现这种功能,解析数据将会变得非常麻烦,二进制解析则方便得多。

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

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

这样双向的、实时的通信,就叫做多工(Multiplexing)。可以简单理解为,可以支持更多的并行。

这是一个对比 HTTP1 和 HTTP2 资源加载的在线示例:https://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 源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能会再请求静态资源,所以就主动把这些静态资源随着网页一起发给客户端了。

压缩

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

使用 Gzip 压缩文本 浏览器和服务器之间会使用主动协商机制。浏览器发送 Accept-Encoding 首部,其中包含有它所支持的压缩算法,以及各自的优先级,服务器则从中选择一种,使用该算法对响应的消息主体进行压缩,并且发送 Content-Encoding 首部来告知浏览器它选择了哪一种算法。

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

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

请求体数据压缩 前面我们介绍了 HTTP 协议中的 Accept-Encoding/Content-Encoding 机制。这套机制可以很好地用于文本类响应正文的压缩,可以大幅减少网络传输,从而一直被广泛使用。但 HTTP 请求的发起方(例如浏览器),无法事先知晓要访问的服务端是否支持解压,所以现阶段的浏览器没有压缩请求正文。

有一些通讯协议基于 HTTP 做了扩展,他们的客户端和服务端是专用的,可以放心大胆地压缩请求正文。例如 WebDAV 客户端就是这样。实际的 Web 项目中,会存在请求正文非常大的场景,例如发表长篇博客,上报用于调试的网络数据等等。这些数据如果能在本地压缩后再提交,就可以节省网络流量、减少传输时间。

主要的的三种数据压缩格式:

  • DEFLATE,是一种使用 Lempel-Ziv 压缩算法(LZ77)和哈夫曼编码的压缩格式。详见 RFC 1951;
  • ZLIB,是一种使用 DEFLATE 的压缩格式,对应 HTTP 中的 Content-Encoding: deflate。详见 RFC 1950;
  • GZIP,也是一种使用 DEFLATE 的压缩格式,对应 HTTP 中的 Content-Encoding: gzip。详见 RFC 1952;

反向代理服务器

对需要提供复杂功能的网站来说,通常单一的服务器资源是很难满足期望的。一般采用的方式是将多个应用服务器组成的集群由反向代理服务器提供给客户端用户使用,这些功能服务器可能具有不同类型,比如文件服务器、邮件服务器及 Web 应用服务器,同时也可能是相同的Web服务部署到多个服务器上,以实现负载均衡的效果,反向代理服务器的作用如图所示。

反向代理

反向代理服务器根据客户的请求,从后端服务器上获取资源后提供给客户端。反向代理服务器通常的作用如下:

  • 负载均衡。
  • 安全防火墙。
  • 加密及SSL加速。
  • 数据压缩。
  • 解决跨域。
  • 对静态资源缓存。

常用作反向代理服务器的有 Nginx、IIS、Apache。

经反向代理收到请求后,具体的服务器后台处理流程大致如下。

  • 首先会有一层统一的验证环节,如跨域验证、安全校验拦截等。如果发现是不符合规则的请求,则直接返回相应的拒绝报文。
  • 通过验证后才会进入具体的后台程序代码执行阶段,如具体的计算、数据库查询等。
  • 完成计算后,后台会以一个HTTP响应数据包的形式发送回请求的前端,结束本次请求。

缓存

缓存的原理是在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新向服务器发起资源请求。

缓存的技术种类有很多,比如代理缓存、浏览器缓存、网关缓存、负载均衡器及内容分发网络等,它们大致可以分为两类:共享缓存和私有缓存。共享缓存指的是缓存内容可被多个用户使用,如公司内部架设的Web代理;私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存。

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

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

  • 强缓存就是当浏览器判断出本地缓存未过期时,直接读取本地缓存,无须发起 HTTP 请求,此时状态为:200 from cache。在HTTP 1.1版本后通过头部的 cache-control 字段的 max-age 属性值规定的过期时长来判断缓存是否过期失效,这比之前使用 expires 标识的服务器过期时间更准确而且安全。

  • 协商缓存则需要浏览器向服务器发起 HTTP 请求,来判断浏览器本地缓存的文件是否仍未修改,若未修改则从缓存中读取,此时的状态码为:304。具体过程是判断浏览器头部 if-none-match 与服务器短的 e-tag 是否匹配,来判断所访问的数据是否发生更改。这相比 HTTP 1.0 版本通过 last-modified 判断上次文件修改时间来说也更加准确。具体的浏览器缓存触发逻辑如图所示。

强缓存优于协商缓存,若强缓存生效则直接使用强缓存,若不生效则再进行协商缓存的请求,由服务器来判断是否使用缓存,如果都失效则重新向服务器发起请求获取资源。

缓存

强缓存

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

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

expires的缺点 对本地时间戳过分依赖,如果客户端本地的时间与服务器端的时间不同步,或者对客户端时间进行主动修改,那么对于缓存过期的判断可能就无法和预期相符。

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

Cache-Control:public, max-age=31536000

no-cache 和 no-store

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

private 和 public private 和 public 也是 cache-control 的一组互斥属性值,它们用以明确响应资源是否可被代理服务器进行缓存。

若资源响应头中的 cache-control 字段设置了 public 属性值,则表示响应资源既可以被浏览器缓存,又可以被代理服务器缓存。private 则限制了响应资源只能被浏览器缓存,若未显式指定则默认值为 private。

max-age 和 s-maxage max-age 属性值会比 s-maxage 更常用,它表示服务器端告知客户端浏览器响应资源的过期时长。在一般项目的使用场景中基本够用,对于大型架构的项目通常会涉及使用各种代理服务器的情况,这就需要考虑缓存在代理服务器上的有效性问题。这便是 s-maxage 存在的意义,它表示缓存在代理服务器中的过期时长,且仅当设置了 public 属性值时才有效。

由此可见 cache-control 能作为 expires 的完全替代方案,并且拥有其所不具备的一些缓存控制特性,在项目实践中使用它就足够了,目前 expires 还存在的唯一理由是考虑可用性方面的向下兼容。

协商缓存

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

服务端返回结果的时候,会有

last-modified: xxx

再去请求的时候,就会带上

ifmodified-since: xxx

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

# 协商缓存有效的话(注意强缓存命中的话是返回200)
Status Code: 304 Not Modified

# 协商缓存无效的话
...
last-modified: yyy
...

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

  • 它只是根据资源最后的修改时间戳进行判断的,假设请求的文件资源进行了编辑,但内容并没有发生任何变化,时间戳也会更新,从而导致协商缓存时关于有效性的判断验证为失效,需要重新进行完整的资源请求。
  • 其次标识文件资源修改的时间戳单位是秒,如果文件修改的速度非常快,假设在几百毫秒内完成,那就无法识别出该次文件资源的更新。

ETag 为了弥补通过时间戳判断的不足,从 HTTP 1.1 规范开始新增了一个 ETag 的头信息,即实体标签(Entity Tag)。其内容主要是服务器为不同资源进行哈希运算所生成的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的 ETag 标签值就会不同,因此可以使用 ETag 对文件资源进行更精准的变化感知。

# 返回结果
Content-Type: image/jpeg
ETag: xxx     # ETag的优先级比Last-Modified高,所以Last-Modified就没用了
Last-Modified: ttt
Content-Length: 9887

# 新的请求
If-Modified-Since: ttt
If-None-Match: xxx

# 新的返回
304表示缓存有效,否则返回全新的结果

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

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

缓存决策树

怎样选择缓存

根据具体的资源使用场景选择恰当的缓存校验方式。可以将一个网站所需要的资源按照不同类型去拆解,为不同类型的资源制定相应的缓存策略。

  • HTML 属于包含其他文件的主文件,为保证当其内容发生修改时能及时更新,应当将其设置为协商缓存
  • 网站对图片的修改基本都是更换修改,同时考虑到图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可采用强制缓存且过期时间不宜过长
  • 样式表文件CSS:由于其属于文本文件,可能存在内容的不定期修改,又想使用强制缓存来提高重用效率,故可以考虑在样式表文件的命名中增加文件指纹或版本号(比如添加文件指纹后的样式表文件名变为了 style.51ad84f7.css),这样当发生文件修改后,不同的文件便会有不同的文件指纹,即需要请求的文件 URL 不同了,因此必然会发生对资源的重新请求。同时考虑到网络中浏览器与 CDN 等中间代理的缓存,其过期时间可适当延长到一年
  • JS文件:其可类似于样式表文件的设置,采取文件指纹和较长的过期时间,如果 JavaScript 中包含了用户的私人信息而不想让中间代理缓存,则可为 cache-control 添加 private 属性值。

一些补充的优化:

  • 拆分源码,分包加载

对大型的前端应用迭代开发来说,其代码量通常很大,如果发生修改的部分集中在几个重要模块中,那么进行全量的代码更新显然会比较冗余,因此我们可以考虑在代码构建过程中,按照模块拆分将其打包成多个单独的文件。这样在每次修改后的更新提取时,仅需拉取发生修改的模块代码包,从而大大降低了需要下载的内容大小。

  • 预估资源的缓存时效

根据不同资源的不同需求特点,规划相应的缓存更新时效,为强制缓存指定合适的 max-age 取值,为协商缓存提供验证更新的 ETag 实体标签。

  • 控制中间代理的缓存

凡是会涉及用户隐私信息的尽量避免中间代理的缓存,如果对所有用户响应相同的资源,则可以考虑让中间代理也进行缓存。

  • 避免网址的冗余

缓存是根据请求资源的 URL 进行的,不同的资源会有不同的 URL,所以尽量不要将相同的资源设置为不同的 URL。

5、规划缓存的层次结构

参考缓存决策中介绍的示例,不仅是请求的资源类型,文件资源的层次结构也会对制定缓存策略有一定影响,我们应当综合考虑。

CDN缓存

CDN 全称 Content Delivery Network,即内容分发网络,它是构建在现有网络基础上的虚拟智能网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、调度及内容分发等功能模块,使用户在请求所需访问的内容时能够就近获取,以此来降低网络拥塞,提高资源对用户的响应速度。

没有CDN的情况

  • 当用户在浏览器中输入所要访问的域名时,若本机无法完成域名解析工作,则会转向 DNS 服务器请求对该域名的解析。
  • DNS 服务器解析完成返回给浏览器该域名所对应的 IP 地址。
  • 浏览器向该 IP 地址指向的服务器发起资源请求。
  • 最后服务器响应用户请求将资源返回给浏览器

有CDN的情况

  • 由于 DNS 服务器将对 CDN 的域名解析权交给了 CNAME 指向的专用 DNS 服务器,所以对用户输入域名的解析最终是在 CDN 专用的 DNS 服务器上完成的。
  • 解析出的结果 IP 地址并非确定的 CDN 缓存服务器地址,而是 CDN 的负载均衡器的地址。
  • 浏览器会重新向该负载均衡器发起请求,经过对用户 IP 地址的距离、所请求资源内容的位置及各个服务器复杂状况的综合计算,返回给用户确定的缓存服务器 IP 地址。
  • 对目标缓存服务器请求所需资源的过程。

这个过程也可能会发生所需资源未找到的情况,那么此时便会依次向其上一级缓存服务器继续请求查询,直至追溯到网站的根服务器并将资源拉取到本地,如图所示。

虽然这个过程看起来稍微复杂了一些,但对用户体验来说是无感知的,并且能带来比较明显的资源加载速度的提升,因此对目前所有一线互联网产品来说,使用 CDN 已经不是一条建议,而是一个规定。

针对静态资源 CDN 网络能够缓存网站资源来提升首次请求的响应速度,但并非能适用于网站所有资源类型,它往往仅被用来存放网站的静态资源文件。所谓静态资源,就是指不需要网站业务服务器参与计算即可得到的资源,包括第三方库的 JavaScript 脚本文件、样式表文件及图片等,这些文件的特点是访问频率高、承载流量大,但更新修改频次低,且不与业务有太多耦合。

如果是动态资源文件,比如依赖服务器端渲染得到的 HTML 页面,它需要借助服务器端的数据进行计算才能得到,所以它就不适合放在 CDN 缓存服务器上。

核心功能 CDN 网络的核心功能包括两点:缓存与回源

  • 缓存指的是将所需的静态资源文件复制一份到 CDN 缓存服务器上
  • 回源指的是如果未在 CDN 缓存服务器上查找到目标资源,或 CDN 缓存服务器上的缓存资源已经过期,则重新追溯到网站根服务器获取相关资源的过程。

cdn优化 例子:淘宝主站请求的域名为 www.taobao.com,而静态资源请求 CDN 服务器的域名有 g.alicdn.com 和 img.alicdn.com 两种,它们是有意设计成与主站域名不同的,这样做的原因主要有两点:

  • 第一点是避免对静态资源的请求携带不必要的 Cookie 信息

首先对第一点来说,Cookie 的访问遵循同源策略,并且同一域名下的所有请求都会携带全部 Cookie 信息。虽然 Cookie 的存储空间就算存满也并不是很大,但如果将所有资源请求都放在主站域名下,那么所产生的效果对于任何一个图片、JavaScript 脚本及样式表等静态资源文件的请求,都会携带完整的 Cookie 信息,若这些完全没有必要的开销积少成多,那么它们所产生的流量浪费就会很大

  • 第二点是考虑浏览器对同一域名下并发请求的限制。

因为浏览器对于同域名下的并发请求存在限制,通常 Chrome 的并发限制数是 6,其他浏览器可能多少会有所差异。这种限制也同时为我们提供了一种解决方案:通过增加类似域名的方式来提高并发请求数,比如对多个图片文件进行并发请求的场景,可以通过扩展如下类似域名的方式来规避限制:

https://img1.alicdn.com/examp1.jpg
https://img2.alicdn.com/examp1.jpg

虽然这种方式对于多并发限制是有效的,但是缓存命中是要根据整个 URL 进行匹配的,如果并发请求了相同的资源却又使用了不同的域名,那么图片之前的缓存就无法重用,也降低了缓存的命中,对于这种情况我们应该考虑进行恰当的域名合并优化。

service worker

概览

Service Worker 是伴随着 Google 推出的 PWA(即 Progressive Web App 渐进式 Web 应用)一同出现的技术,它能够实现诸如消息推送、后台加载、离线应用及移动端添加到主屏等堪比原生应用的功能,同时还具备小程序“无须安装、用完即走”的体验特点。虽然 Service Worker 已被列入 W3C 标准,但在各端上的兼容性并不理想,目前来讲应用比较多的还是在基于 Chrome 的 PC 端浏览器上。

技术由来

JavaScript 的执行是单线程的,如果一个任务的执行占用并消耗了许多计算资源,则势必会导致阻塞执行其他任务,这正是单线程的弊端。为此浏览器引入了 Web Worker,它是一个独立于浏览器主线程之外的工作线程,可以将较复杂的运算交给它来处理,而无须担心这是否会对页面渲染产生负面影响。Service Worker 正是在此基础上增加了对离线缓存的管理能力,它的表现弥补了之前 HTML 5 上采用 AppCache 实现离线缓存的诸多缺陷。

Service Worker 定义了由事件驱动的生命周期,这使得页面上任何网络请求事件都可以被其拦截并加以处理,同时还能访问缓存和 IndexedDB,这就可以让开发者制定自定义度更高的缓存管理策略,从而提高离线弱网环境下的 Web 运行体验。

基本特征

  • 独立于浏览器主线程,无法直接操作 DOM
  • 在开发过程中可以通过 localhost 使用,但要部署到线上环境则需要 HTTPS 的支持
  • 能够监听并拦截全站的网络请求,从而进行自定义请求响应控制
  • 在不使用的时候会被中止,在需要的时候进行重启,所以我们不能依赖在其 onmessage 与 onfetch 的事件监听处理程序中的全局状态,如果有此需要可以通过访问 IndexedDB API 将全局状态进行存储
  • 广泛使用 Promise 来处理异步
  • 消息推送
  • 后台同步

生命周期

通常每一个 Service Worker 都会依赖于各自独立的执行脚本,在页面需要使用时通过请求相应的执行脚本进行 Service Worker 的注册;当注册流程启动后,便可在执行脚本中监听 install 事件来判断安装是否成功,若安装成功则可进行有关离线缓存的处理操作。

但此时的安装成功并不意味着 Service Worker 已经能够取得页面的控制权,只有进行激活后,Service Worker 才能监听页面发出的 fetch 和 push 等事件;如果 Service Worker 的执行脚本发生修改需要进行更新,则更新的流程也会涉及完整的生命周期。

service worker

注册

// 仅当浏览器支持 Service Worker 的场景下进行相应的注册
if ('serviceWorker' in navigator) {
  // 在load以后再执行这个同步注册脚本,以防阻塞
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js')
  })
}

安装 在该事件中 Service Worker 其实还并未获得页面的控制权,即还不能监听页面上所发出的请求事件,但此时却可以执行一些与页面没有直接操作关系的其他任务,比如缓存一些页面稍后会用到的资源内容

const cacheName = 'v1'

// 监听 SW 安装的 install 事件并进行缓存操作
this.addEventListener('install', event => {
  // 设置安装步骤的处理内容
  
  // waitUntil意思说是说这里面的代码执行完毕之后,才会安装完成。
  event.waitUntil(
    caches.open(cacheName).then(cache => {
      // 参数是一个由一组相对于 origin 的 URL 组成的数组,这些 URL 就是你想缓存的资源的列表。
      return cache.addAll([
        '/dist/index.js',
        '/dist/css/index.css'
      ])
    })
  )
})

激活 若想要 Service Worker 获得页面的控制权,跳过安装完成后的等待期,十分简单的方式就是直接刷新浏览器,此时新的 Service Worker 便会被激活。当然也可以通过调用 self.skipWaiting() 方法来逐出当前旧的 Service Worker,并让新的 Service Worker 进入 activated 激活态以获得对页面的控制权。代码示例如下:

self.addEventListener('install', event => {
  // 让 SW 进入激活态
  self.skipWaiting()
})

响应缓存 当 Service Worker 安装成功并进入激活态后,便可以接收页面所发出的 fetch 事件来进行缓存响应的相关操作

// 每次任何被 service worker 控制的资源被请求到时,都会触发 fetch 事件
this.addEventListener('fetch', event => {
  event.respondWith((async () => {
    try {
      // 获取缓存内容
      let response = await caches.match(event.request)

      // 缓存无效,发起网络请求获取资源
      if (!response) {
        response = await fetch(event.request.clone())

        // 将新获取的资源纳入缓存中
        const cache = await caches.open(cacheName)
        cache.put(event.request, response.clone())
      }
      return response
    } catch (err) {
      return caches.match('/sw-test2/gallery/myLittleVader.jpg')
    }
  })())
})

高性能加载 为网站应用添加 Service Worker 的能力,就相当于在浏览器后台为该应用增加了一条对资源的处理线程,它独立于主线程,虽然不能直接操作页面 DOM,但可以进行许多离线计算与缓存管理的工作,这将会带来显著的性能提升。下面介绍一点优化资源加载时间的合理做法及注意事项,为确保 Service Worker 能发挥最佳性能提供参考。

当浏览器发起对一组静态 HTML 文档资源的请求时,高性能的加载做法应当是:通过 Service Worker 拦截对资源的请求,使用缓存中已有的 HTML 资源进行响应,同时保证缓存中资源及时更新。

worker2

http请求和响应优化的总结

核心思路:

  • 更好的连接传输效率
  • 更少的请求数量
  • 更小的资源大小
  • 合适的缓存策略

最佳实践:

  • 减少 DNS 查找:每次主机名的解析都需要一次网络往返,从而增加了请求的延迟时间,同时还会阻塞后续的请求。
  • 重用 TCP 连接:尽可能的使用持久连接,以消除因 TCP 握手和慢启动导致的延迟。
  • 减少 HTTP 重定向:HTTP 冲定向需要额外的 DNS 查询、TCP 握手等非常耗时,最佳的重定向次数为0。
  • 压缩传输的资源:比如 Gzip、图片压缩。
  • 使用缓存:比如 HTTP 缓存、CDN 缓存、Service Worker 缓存。
  • 使用 CDN(内容分发网络):把数据放在离用户地理位置更近的地方,可以明显减少每次 TCP 连接的网络延迟,增大吞吐量。 删除没有必要请求的资源。
  • 在客户端缓存资源:缓存必要的应用资源,避免每次都重复请求相同的内容,例如多图片下载可以考虑使用缓存。
  • 内容在传输前先压缩:传输数据之前应该先压缩应用资源,把要传输的字节减少到最小,在压缩的时候确保对每种不同的资源采用最好的压缩手段。
  • 消除不必要的请求开销:减少请求的 HTTP 首部数据(比如 HTTP COokie)
  • 并行处理请求和响应:请求和响应的排队都会导致延迟,可以尝试并行的处理请求和响应(利用多个 HTTP1.1 连接实现并行下载,在可能的情况下使用 HTTP 管道计数)。
  • 针对协议版本采取优化措施。升级到 HTTP2.0。
  • 根据需要采用服务端渲染方式。这种方式可以解决 SPA 应用首屏渲染慢的问题。
  • 采用预渲染的方式快速加载静态页面。页面渲染的极致性能,比较适合静态页面。

第四步:渲染绘制

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

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

浏览器接收读取到的 HTML 文件,其实是文件根据指定编码(UTF-8)的原始字节,形如 3C 62 6F 79 3E 65 6C 6F 2C 20 73 70…。首先需要将字节转换成字符,即原本的代码字符串,接着再将字符串转化为 W3C 标准规定的令牌结构,所谓令牌就是 HTML 中不同标签代表不同含义的一组规则结构。然后经过词法分析将令牌转化成定义了属性和规则值的对象,最后将这些标签节点根据 HTML 表示的父子关系,连接成树形结构,

与将 HTML 文件解析为文档对象模型的过程类似,CSS 文件也会首先经历从字节到字符串,然后令牌化及词法分析后构建为层叠样式表对象模型。

注意,构建 DOM 树和构建 CSSOM 树是可以并行的。

接下来就需要将两个对象模型合并为渲染树,渲染树中只包含渲染可见的节点

  • 从所生成 DOM 树的根节点开始向下遍历每个子节点,忽略所有不可见的节点(脚本标记不可见、CSS 隐藏不可见),因为不可见的节点不会出现在渲染树中。
  • 在 CSSOM 中为每个可见的子节点找到对应的规则并应用。

渲染树

再根据渲染树来布局,以计算每个节点的几何信息。将各个节点绘制到屏幕上。

  • 布局阶段,根据所得到的渲染树,计算它们在设备视图中的具体位置和大小,这一步输出的是一个“盒模型”。
  • 绘制阶段,将每个节点的具体绘制方式转化为屏幕上的实际像素

渲染绘制的优化

优化 DOM

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

总结起来有三种方式可以优化 HTML:

  • 缩小文件的尺寸(Minify)(删除注释、空格与换行等无用的文本)
  • 使用gzip压缩(Compress)
  • 使用缓存(HTTP Cache)。

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

优化 CSSOM

CSS 会阻塞关键渲染路径。有几种常见的优化:

  • 将任何非必需的 CSS 都标记为非关键资源(例如打印和其他媒体查询),并应确保尽可能减少关键 CSS 的数量,以及尽可能缩短传送时间。

举个例子:一些响应式 CSS 只在屏幕宽度符合条件时才会生效,还有一些 CSS 只在打印页面时才生效。这些 CSS 在不符合条件时,是不会生效的。下面就是一些非关键css的声明,只有在特殊情况下才会读取(media里面声明的就是条件判断)

注意:浏览器仍会下载 CSS,只不过不阻塞渲染的资源优先级较低罢了。

<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">
  • 将关键 CSS 直接内联到 HTML 文档内,以减少关键路径中的往返次数。
  • 避免在 CSS 中使用 @import,因为@import会增加额外的关键路径长度。import的时候多一次请求。(打包完的代码应该没这个问题,会自动合在一起把)

优化JS

几个快速优化方案

  • 所有文本资源都应该让文件尽可能的小,JavaScript 也不例外,它也需要:
    • 删除未使用的代码、缩小文件的尺寸(Minify)
    • 使用 gzip 压缩(Compress)
    • 使用缓存(HTTP Cache)
  • 避免运行时间长的 JavaScript,就是代码写好一点,别乱来
  • 延迟解析 JavaScript (defer)
    • 会先下载,等到 DOM 解析完毕,但在 DOMContentLoaded 事件之前执行。
    • 脚本保持其相对顺序,就像常规脚本一样。
  • 异步加载 JavaScript (async)
    • 会先下载,但DOMContentLoaded 和异步脚本不会彼此等待,谁先准备好就谁先执行。
    • 脚本不会保持其相对顺序,谁先准备好就谁先执行。
  • 通过事件节流和事件防抖来优化

requestAnimationFrame优化 我们应该用requestAnimationFrame代替定时器来执行动画。

使用定时器实现的动画会在一些低端机器上出现抖动或者卡顿的现象,这主要是因为浏览器无法确定定时器的回调函数的执行时机,这是一个宏任务,其创建后回调任务会被放入异步队列,只有当主线程上的任务执行完成后,浏览器才会去检查队列中是否有等待需要执行的任务,所以不是特别精确。

其次屏幕分辨率和尺寸也会影响刷新频率,不同设备的屏幕绘制频率可能会有所不同,而 setInterval 只能设置某个固定的时间间隔,这个间隔时间不一定与所有屏幕的刷新时间同步,那么导致动画出现随机丢帧也在所难免。

requestAnimationFrame最大的优势是将回调函数的执行时机交由系统来决定,即如果屏幕刷新频率是 60Hz,则它的回调函数大约会每 16.7ms 执行一次,如果屏幕的刷新频率是 75Hz,则它回调函数大约会每 13.3ms 执行一次,就是说 requestAnimationFrame 方法的执行时机会与系统的刷新频率同步。

元素移动动画的例子

<body>
  <div class="box"></div>
  <script>
    const element = document.querySelector('.box')
    let start

    function step(timestamp) {
      if (!start) {
        start = timestamp
      }

      const progress = timestamp - start

      // 在这里使用 Math.min() 确保元素刚好停在 200px 的位置
      element.style.left = `${Math.min(progress / 10, 200)}px`

      // 在两秒后停止动画
      if (progress < 2000) {
        window.requestAnimationFrame(step)
      }
    }

    window.requestAnimationFrame(step)
  </script>
</body>

同样的,也可以把大人物拆成小任务,在每一帧的间隙执行。

const taskList = splitTask(BigTask)

// 微任务处理逻辑,入参为每次任务起始时间戳
function processTaskList (taskStartTime) {
  let taskFinishTime
  do {
    // 从任务栈中推出要处理的下一个任务
    const nextTask = taskList.pop()
    // 处理下一个任务
    processTask(nextTask)
    // 执行任务完成的时间,如果时间够 3 毫秒就继续执行
    taskFinishTime = window.performance.now()
  } while (taskFinishTime - taskStartTime < 3)

  // 如果任务堆栈不为空则继续
  if (taskList.length > 0) {
    requestAnimationFrame(processTaskList)
  }
}

requestAnimationFrame(processTaskList)

使用Web Worker优化 JS本身是单线程的,如果有耗时的任务,很容易造成阻塞。所以可将一些纯计算的工作迁移到 Web Worker 上处理,它为 JavaScript 的执行提供了多线程环境,主线程通过创建出 Worker 子线程,可以分担一部分自己的任务执行压力。有点类似于更强大的web api,以前web api是固定了,有了web worker,可以把自定义的任务交给多线程去做。

在 Worker 子线程上执行的任务不会干扰主线程,待其上的任务执行完成后,会把结果返回给主线程,这样的好处是让主线程可以更专注地处理 UI 交互,保证页面的使用体验流程。需要注意的几点:

  • Worker 子线程一旦创建成功就会始终执行,不会被主线程上的事件所打断,这就意味着 Worker 会比较耗费资源,所以不应当过度使用,一旦任务执行完毕就应及时关闭。除此之外,在使用中还有以下几点应当注意。
  • DOM限制:Worker 无法读取主线程所处理网页的 DOM 对象,也就无法使用 document、window 和 parent 等对象,只能访问 navigator 和 location 对象。navigator对象包含了一些关于当前正在跑的这个应用的信息,比如navigator.userAgent。
  • 文件读取限制:Worker 子线程无法访问本地文件系统,这就要求所加载的脚本来自网络。
  • 通信限制:主线程和 Worker 子线程不在同一个上下文内,所以它们无法直接进行通信,只能通过消息来完成。
  • 脚本执行限制:虽然 Worker 可以通过 XMLHTTPRequest 对象发起 ajax 请求,但不能使用 alert() 方法和 confirm() 方法在页面弹出提示。
  • 同源限制:Worker 子线程执行的代码文件需要与主线程的代码文件同源。

Web Worker 的使用方法非常简单,在主线程中通过 new Worker() 方法来创建一个 Worker 子线程,构造函数的入参是子线程执行的脚本路径,由于代码文件必须来自网络,所以如果代码文件没能下载成功,Worker 就会失败。

// html script
const worker = new Worker('worker.js')

const num1 = document.querySelector('#num1')
const num2 = document.querySelector('#num2')
const result = document.querySelector('#result')
const btn = document.querySelector('#btn')

btn.addEventListener('click', () => {
  worker.postMessage({
    type: 'add',
    data: {
      num1: num1.value - 0,
      num2: num2.value - 0
    }
  })
})

worker.addEventListener('message', e => {
  const { type, data } = e.data
  if (type === 'add') {
    result.textContent = data
  }
})

// worker.js
onmessage = function (e) {
  const { type, data } = e.data
  if (type === 'add') {
    const ret = data.num1 + data.num2
    postMessage({
      type: 'add',
      data: ret
    })
  }
}

在子线程处理完相关任务后,需要及时关闭 Worker 子线程以节省系统资源,关闭的方式有两种:

  • 在主线程中通过调用 worker.terminate() 方法来关闭;
  • 在子线程中通过调用自身全局对象中的 self.close() 方法来关闭。

计算样式优化

在 JavaScript 运行过后,若发生了添加和删除元素,对样式属性和类进行了修改,就都会导致浏览器重新计算所涉及元素的样式,某些修改还可能会引起页面布局的更改和浏览器的重新绘制。

减少要计算样式的元素数量 首先我们需要知道与计算样式相关的一条重要机制:CSS 引擎在查找样式表时,对每条规则的匹配顺序是从右向左的,这与我们通常从左向右的书写习惯相反。举个例子,如下 CSS 规则:

.product-list li {}

首先遍历页面上的所有 li 标签元素,然后确认每个 li 标签有包含类名为 product-list 的父元素才是目标元素,所以为了提高页面的渲染性能,计算样式阶段应当尽量减少参与样式计算的元素数量。有几个优化方案:

  • 使用类选择器替代标签选择器,对于上面 li 标签的错误示范,如果想对类名为 product-list 下的 li 标签添加样式规则,可直接为相应的 li 标签定义名为 product-list_li 的类选择器规则。
  • 避免使用通配符做选择器,比如* { margin: 0 }
  • 降低选择器的复杂性,比如把.container:nth-last-child(-n+1) .content改成final-container-content
  • 有id选择器的时候,不要写多余的class,因为id已经可以找到了。
  • 使用 BEM 规范。type-block__element_modifier
// 常规写法
.mylist {}

.mylist .item {}

// BEM 写法
.mylist {}
.mylist__item {}

/* 自定义列表下子元素大、中、小三种尺寸的类选择器 */
.mylist__item_big {}

.mylist__item_normal {}

.mylist__item_small {}

/* 带自定义尺寸修饰符的类选择器 */
.mylist__item_size-10 {}

页面布局与重绘优化

页面布局也叫作重排回流,指的是浏览器对页面元素的几何属性进行计算并将最终结果绘制出来的过程。凡是元素的宽高尺寸、在页面中的位置及隐藏或显示等信息发生改变时,都会触发页面的重新布局。

通常页面布局的作用范围会涉及整个文档,所以这个环节会带来大量的性能开销,我们在开发过程中,应当从代码层面出发,尽量避免页面布局或最小化其处理次数。如果仅修改了 DOM 元素的样式,而未影响其几何属性时,则浏览器会跳过页面布局的计算环节,直接进入重绘阶段。虽然重绘的性能开销不及页面布局高,但为了更高的性能体验,也应当降低重绘发生的频率和复杂度。

能够触发浏览器的页面布局与重绘的操作:

  • 对 DOM 元素几何属性的修改,这些属性包括 width、height、padding、margin、left、top 等,某元素的这些属性发生变化时,便会波及与它相关的所有节点元素进行几何属性的重新计算,这会带来巨大的计算量。
  • 更改 DOM 树的结构,浏览器进行页面布局时的计算顺序,可类比树的前序遍历,即从上向下、从左向右。这里对 DOM 树节点的增、删、移动等操作,只会影响当前节点后的所有节点元素,而不会再次影响前面已经遍历过的元素。
  • 获取某些特定的属性值操作,比如页面可见区域宽高 offsetWidth、offsetHeight,页面视窗中元素与视窗边界的距离 offsetTop、offsetLeft,类似的属性值还有 scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientWidth、clientHeight及调用 window.getComputedStyle 方法。这些属性和方法有一个共性,就是需要通过即时计算得到,所以浏览器就需要重新进行页面布局计算。

避免对样式的频繁改动 在通常情况下,页面的一帧内容被渲染到屏幕上会按照如下顺序依次进行,首先执行JavaScript代码,然后依次是样式计算、页面布局、绘制与合成。如果在JavaScript运行阶段涉及上述三类操作,浏览器就会强制提前页面布局的执行,为了尽量降低页面布局计算带来的性能损耗,我们应当避免使用JavaScript对样式进行频繁的修改。如果一定要修改样式,则可通过以下几种方式来降低触发重排或回流的频次。

不要对样式逐条修改

/* ----- NO ----- */
div.style.height = '100px'
div.style.width = '100px'
div.style.border = '1px solid blue'

/* ----- YES ----- */
.my-div {
  height: 100px;
  width: 100px;
  border: 1px solid blue;
}

div.classList.add('mydiv')

缓存对敏感属性值的计算

const list = document.getElementById('list')

/* --------------- before --------------- */
for (let i = 0; i < 10; i++) {
  list.style.top = `${list.offsetTop + 10}px`   // 每次计算都会触发页面布局的重新计算
  list.style.left = `${list.offsetLeft + 10}px`
}

/* --------------- after --------------- */
let { offsetTop, offsetLeft } = list

for (let i = 0; i < 10; i++) {
  offsetTop += 10
  offsetLeft += 10
}

// 计算完成后统一赋值触发重排
list.style.left = offsetLeft
list.style.top = offsetTop

使用 requestAnimationFrame 方法控制渲染帧 requestAnimationFrame 方法可以控制回调在两个渲染帧之间仅触发一次,如果在其回调函数中一开始就取值到即时敏感属性,其实获取的是上一帧旧布局的值,并不会触发页面布局的重新计算。

requestAnimationFrame(queryDivHeight)

function queryDivHeight () {
  const div = document.getElementById('div')
  console.log(div.offsetHeight)

  // 样式的写操作应该放在读操作后进行,如果先写再读,浏览器就无法用上一帧的值,而必须要重新计算。
  div.classList.add('my-div')

}

对绘制区域的控制 注意对绘制区域的控制,对不需要重新绘制的区域应尽量避免重绘。例如,页面的顶部有一个固定区域的 header 标头,若它与页面其他位置的某个区域位于同一图层,当后者发生重绘时,就有可能触发包括固定标头区域在内的整个页面的重绘。对于固定不变不期望发生重绘的区域,建议可将其提升为独立的绘图层,避免被其他区域的重绘连带着触发重绘。

虽然创建新的图层能够在一定程度上减少绘制区域,但也应当注意不能创建太多的图层,因为每个图层都需要浏览器为其分配内存及管理开销。

// 创建新图层
.nav-layer {
  will-change: transform;
  transform: translate(0);    // 如果不支持will-change属性
}

降低绘制复杂度 比如少点把阴影交给photoshop处理过的图片,而不是直接用css处理。

资源加载优化

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

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

注意:当延迟加载的媒体资源未渲染出来之前,应当在页面中使用相同尺寸的占位图像。如果不使用占位符,图像延迟显示出来后,尺寸更改可能会使页面布局出现移位。这种现象不仅会对用户体验带来困惑,更严重的还会触发浏览器成本高昂的回流机制,进而增加系统资源开销造成卡顿。而用来占位的图像解决方案也有多种,十分简单的方式是使用一个与目标媒体资源长宽相同的纯色占位符,或者像之前使用的Base64图片,当然也可以采用LQIP或SQIP等方法。

  • 传统方式
  • Intersection Observer 方式
  • CSS 类名方式
  • 原生的延迟加载支持

资源优先级 浏览器向网络请求到的所有数据,并非每个字节都具有相同的优先级或重要性。所以浏览器通常都会采取启发式算法,对所要加载的内容先进行推测,将相对重要的信息优先呈现给用户,比如浏览器一般会先加载CSS文件,然后再去加载JavaScript脚本和图像文件。

但即便如此,也无法保证启发式算法在任何情况下都是准确有效的,可能会因为获取的信息不完备,而做出错误的判断。

浏览器基于自身的启发式算法,会对资源的重要性进行判断来划分优先级,通常从低到高分为:Lowest、Low、High、Highest等。

比如,在 <head> 标签中,CSS 文件通常具有最高的优先级 Highest,其次是 <script> 标签所请求的脚本文件,但当 <script> 标签带有 defer 或 async 的异步属性时,其优先级又会降为 Low。

当发现资源默认被分配的优先级不是我们想要的情况时,可以用preload,prefetch,preconnect,dns-prefetch。

  • preload和prefetch的本质都是预加载,即先加载、后执行,加载与执行解耦,都不会阻塞页面的onload。
  • preload用来声明当前页面的关键资源,强制浏览器尽快加载,优先级很高;而prefetch用来声明将来可能用到的资源,在浏览器空闲时进行加载,优先级很低。
  • preload可以用来预加载css或者字体,可以用as申明类型,不然不会执行。加载字体资源必须设置crossorigin属性,否则会导致重复加载。
  • preconnect和dns-prefetch都顾名思义,提前建立连接或者提前找ip。假如页面引入了许多第三方域下的资源,而如果它们都通过 preconnect 来预建立连接,其实这样的优化效果反而不好,甚至可能变差,所以这个时候就有另外一个方案,那就是对于最关键的连接使用 preconnect,而其他的则可以用 dns-prefetch。

web性能指标

RAIL 性能模型

RAIL是一种由 Google Chrome 团队于 2015 年提出的性能模型, 可以看做提高用户体验的一些原则:

  • 响应(Response):应该尽可能快速的响应用户, 应该在 100ms 以内响应用户输入。
  • 动画(Animation):在展示动画的时候,每一帧应该以 16ms 进行渲染,这样可以保持动画效果的一致性,并且避免卡顿。
  • 空闲(Idle):当使用 Javascript 主线程的时候,应该把任务划分到执行时间小于 50ms 的片段中去,这样可以释放线程以进行用户交互。
  • 加载(Load):应该在小于 1s 的时间内加载完成你的网站,并可以进行用户交互。

基于用户体验的性能指标

First Contentful Paint(FCP) FCP(First Contentful Paint)首次内容绘制,浏览器首次绘制来自 DOM 的内容的时间,内容必须是文本、图片(包含背景图)、非白色的 canvas 或 SVG,也包括带有正在加载中的 Web 字体的文本。这是用户第一次开始看到页面内容,但仅仅有内容,并不意味着它是有用的内容(例如 Header、导航栏等),也不意味着有用户要消费的内容。

Largest Contentful Paint(LCP) LCP(Largest Contentful Paint)最大内容绘制,可视区域中最大的内容元素呈现到屏幕上的时间,用以估算页面的主要内容对用户可见时间。

First Input Delay(FID) FID(First Input Delay)首次输入延迟,从用户第一次与页面交互(例如单击链接、点击按钮等)到浏览器实际能够响应该交互的时间。

输入延迟是因为浏览器的主线程正忙于做其他事情,所以不能响应用户。发生这种情况的一个常见原因是浏览器正忙于解析和执行应用程序加载的大量计算的 JavaScript。

第一次输入延迟通常发生在第一次内容绘制(FCP)和可持续交互时间(TTI)之间,因为页面已经呈现了一些内容,但还不能可靠地交互。

Time to Interactive(TTI) 表示网页第一次 完全达到可交互状态 的时间点,浏览器已经可以持续性的响应用户的输入。完全达到可交互状态的时间点是在最后一个长任务(Long Task)完成的时间, 并且在随后的 5 秒内网络和主线程是空闲的。

Total Block Time(TBT) Total Block Time(TBT)总阻塞时间,度量了 FCP 和 TTI 之间的总时间,在该时间范围内,主线程被阻塞足够长的时间以防止输入响应。

只要存在长任务,该主线程就会被视为“阻塞”,该任务在主线程上运行超过50毫秒(ms)。我们说主线程“被阻止”是因为浏览器无法中断正在进行的任务。因此,如果用户确实在较长的任务中间与页面进行交互,则浏览器必须等待任务完成才能响应。

如果任务足够长(例如,超过50毫秒的任何时间),则用户很可能会注意到延迟并感觉页面缓慢或过时。

给定的长任务的阻止时间是其持续时间超过50毫秒。页面的总阻塞时间是FCP和TTI之间发生的每个长任务的阻塞时间的总和。

Cumulative Layout Shift(CLS) Cumulative Layout Shift(CLS)累计布局偏移,CLS 会测量在页面整个生命周期中发生的每个意外的布局移位的所有单独布局移位分数的总和,它是一种保证页面的视觉稳定性从而提升用户体验的指标方案。

Time to First Byte(TTFB) 浏览器从请求页面开始到接收第一字节的时间,这个时间段内包括 DNS 查找、TCP 连接和 SSL 连接。

其它一些指标的计算

  • 重定向次数:performance.navigation.redirectCount
  • 重定向耗时: redirectEnd - redirectStart
  • DNS 解析耗时: domainLookupEnd - domainLookupStart
  • TCP 连接耗时: connectEnd - connectStart
  • SSL 安全连接耗时: connectEnd - secureConnectionStart
  • 网络请求耗时 (TTFB): responseStart - requestStart
  • 数据传输耗时: responseEnd - responseStart
  • DOM 解析耗时: domInteractive - responseEnd
  • 资源加载耗时: loadEventStart - domContentLoadedEventEnd
  • 首包时间: responseStart - domainLookupStart
  • 白屏时间: responseEnd - fetchStart
  • 首次可交互时间: domInteractive - fetchStart
  • DOM Ready 时间: domContentLoadEventEnd - fetchStart
  • 页面完全加载时间: loadEventStart - fetchStart
  • http 头部大小: transferSize - encodedBodySize

performance

web vitals

Google 在 2020 年 5 月 5 日提出了新的用户体验量化方式 Web Vitals 来衡量网站的用户体验,并将这些衡量结果用作其排名算法的一部分。

其实就是用主要的三个指标来衡量:

  • 加载性能(LCP) — 显示最大内容元素所需时间
  • 交互性(FID) — 首次输入延迟时间
  • 视觉稳定性(CLS) — 累积布局配置偏移

总结

DNS解析,把找到url对应的ip地址,然后浏览器才能发出请求

  • 缓存:本地主机进行查询, 然后DNS 服务器一层一层网上找,找到最近的缓存
  • 设置rel="dns-prefetch"

建立tcp连接
三次握手,四次挥手

http通信

  • HTTP 1.1:
    • 引入了持久连接(HTTP Persistent Connections),即 TCP 连接默认不关闭,可以被多个请求复用,不用声明 Connection: keep-alive。
    • (pipelining),即在同一个 TCP 连接里面,客户端可以同时发送多个请求。
  • HTTP 2:
    • 头信息压缩和建立索引表,以后重复的字段可以直接发id
    • 服务器推送:服务端收到请求一个网页,可以顺便主动发些相关的资源回去
    • 多工:支持更多的并行处理,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应。
    • 二进制协议:HTTP/1.1 版的头信息是ASCII 编码的文本,HTTP 2完全用了二进制,统称为统称为"帧"(frame)。好处:
      • 可以定义额外的帧。HTTP/2 定义了近十种帧,为将来的高级应用打好了基础。
      • 还可以不按顺序发送,用数据流的形式,解决了队头阻塞的问题。虽然是乱序的,之后可以根据id还原信息。
  • 使用反向代理服务器,好处是:
    • 负载均衡(公司就是用nginx把流量发到不同的shard)
    • 安全防火墙
    • 加密及SSL加速
    • 数据压缩
    • 解决跨域
    • 对静态资源缓存
  • 缓存:
    • cache-control:
      • no-cache: 强制使用协商缓存,no-store就是完全不要任何缓存
      • private/public: 能不能被代理缓存
      • max-age/s-maxage: 客户端/代理服务器中的缓存有效时长
    • 强缓存:就是当浏览器判断出本地缓存未过期时,直接读取本地缓存,无须发起 HTTP 请求,此时状态为:200 from cache
      • expires的缺点:对本地时间戳过分依赖
      • 用max-age更好,用相对时间来解决了对本地时间戳的依赖
    • 协商缓存: 需要浏览器向服务器发起 HTTP 请求,来判断浏览器本地缓存的文件是否仍未修改,若未修改则从缓存中读取,此时的状态码为:304,修改的话就重新一个请求200。设置
      • 服务端返回 Last-Modified: xxx => 下个个请求带上 If-Modified-Since: xxx。有两个问题
        • 假设请求的文件资源进行了编辑,但内容并没有发生任何变化,时间戳也会更新
        • 单位是秒,无法满足高更新率。
      • 服务端返回 ETag: xxx => 下个个请求带上 If-None-Match: xxx。问题:
        • 对于生成文件资源的 ETag 需要付出额外的计算开销
        • 分为强验证和弱验证
    • 怎样选择缓存
      • HTML:协商缓存
      • 图片:强缓存且过期时间不宜过长,因为一般都是直接改变
      • CSS:强缓存 + 名字里带上hash,提高重用率,改变的时候请求路径会改变。
      • JS:强缓存 + 名字里带上hash,如果js里面带了私人信息可以设的private
    • 用CDN
      • DNS解析的时候向CDN服务器拿到最近的资源,主要用来存储一些静态资源。
      • 例子www.taobao.com请求的资源路径是g.alicdn.com,好处:
        • 避免对静态资源的请求携带不必要的 Cookie 信息
        • 提高并发。可以同时请求n个g-n.alicdn.com
    • 用service worker:由事件驱动的生命周期,这使得页面上任何网络请求事件都可以被其拦截并加以处理,同时还能访问缓存和 IndexedDB
      • 可以用来当多线程计算
      • 可以用来拿缓存,定制自定义度更高的缓存管理策略
    • 用SSR

渲染绘制

  • 浏览器解析HTML和CSS文件,来构建DOM和CSSOM。(可并行)
    • 优化HTML:打包、压缩、缓存
    • 优化CSS:
      • 打包、压缩、缓存
      • 将任何非必需的 CSS 都标记为非关键资源,比如设置media=xxx让它在特殊条件下才生效。
      • 减少关键路径:将关键 CSS 直接内联到 HTML 文档内,减少使用import。
      • 计算样式优化:避免使用通配符做选择器、降低选择器的复杂性、有id就别用class、使用 BEM 规范。
    • 优化JS:
      • 打包、压缩、缓存
      • 用defer和async,或者放到最后面
    • 控制资源优先级:preload,prefetch,preconnect,dns-prefetch
  • 从所生成 DOM 树的根节点开始向下遍历每个子节点,在 CSSOM 中为每个可见的子节点找到对应的规则并应用,生成render tree。
  • 布局阶段:根据所得到的渲染树,计算它们在设备视图中的具体位置和大小,这一步输出的是一个“盒模型”。
  • 绘制阶段:将每个节点的具体绘制方式转化为屏幕上的实际像素。
    • requestAnimationFrame优化:用requestAnimationFrame代替定时器来执行动画,因为执行时机会与系统的刷新频率同步。也可以把大人物拆成小任务,在每一帧的间隙执行,其实类似于一个处理动画的requestIdleCallback。
    • 避免对样式的频繁改动,或者把样式改动放到一起只执行一次,不要逐条修改。
    • 缓存对敏感属性值的计算,就是那些会触发布局与重绘的值:offsetXXX、scrollXXX、clientXXX。
    • 对绘制区域的控制,创建新的图层。transform: translate(0),will-change: transform
    • 降低绘制复杂度,把阴影交给ps
    • 图片延迟加载

references