输入 url 到页面渲染的过程

# 输入 url 到页面渲染的过程

这是一道非常适合考察前端 er 基本功的面试题,深入讨论下去总会遇到知识盲区。。

今天我们以应届生水平来看一遍这个问题。

# URL 解析

# 为什么 URL 需要编码

URL 编码的原则就是使用安全的字符(没有特殊用途或者特殊意义的可打印字符)去表示那些不安全的字符,从而形成统一标准。

# 编码方法

escape 字符串编码

escape 适合对字符串进行编码,不适用于 URL

不会编码下列特殊字符

: @\*\_+-./

字符的 16 进制格式值,当该值小于等于 0xFF 时,用一个 2 位转移序列: %xx 表示. 大于的话则使用 4 位序列:%uxxxx 表示。

encodeURIComponent 编码范围更大

encodeURI 方法不会编码下列字符:

ASCII字母,数字,~!@#$&*()=:/,;?+'

encodeURIComponent 方法不会编码下列字符:

ASCII字母,数字, ~!*()'

比如

encodeURI('https://www.baidu.com/some other thing');
// 'https://www.baidu.com/some%20other%20thing'
// 其中,空格被编码成了 %20

encodeURIComponent('https://www.baidu.com/some other thing');
// 'http%3A%2F%2Fwww.baidu.com%2Fsome%20other%20thing'
// 连 "/" 都被编码了,整个URL已经没法用了。

# URL、URN、URI

URNURL都已经是URI的一种。

  • URI 统一资源标志符,在某一规则下能把一个资源独一无二地标识出来,是 URL 和 URN 的超集。

    相当于身份证号码。

  • URL 统一资源定位符,主要由 协议主机端口路径查询参数锚点6 部分组成。

    相当于家庭住址。

  • URN 统一资源名称, URNURI的历史名字,只从URI发布之后,URN的使用已经被URI取代了。

    相当于姓名。

URI示意图

# 是否需要构造请求

输入 URL 后,浏览器会解析出协议、主机、端口、路径等信息,并构造一个 http 请求。

  • 浏览器发送请求前,根据请求头的expirescache-control判断是否命中(包括是否过期)强缓存策略,如果命中,直接从缓存获取资源,并不会发送请求。如果没有命中,则进入下一步。

  • 没有命中强缓存规则,浏览器会发送请求,根据请求头的If-Modified-SinceIf-None-Match判断是否命中协商缓存,如果命中,直接从缓存获取资源。如果没有命中,则进入下一步。

  • 如果前两步都没有命中,则需要发起 http 请求。进入下一步从服务端获取资源

# DNS 域名解析

# 解析过程:

# 递归查询

我们的浏览器、操作系统、路由器都会缓存一些 URL 对应的 IP 地址,统称为 DNS 高速缓存。这是为了加快 DNS 解析速度,使得不必每次都到根域名服务器中去查询。

  • Chrome 搜索自身 DNS 缓存。chrome 输入 chrome://net-internals/#dns 可查看到
  • 搜索操作系统自身 DNS 缓存

# 迭代查询

局部的 DNS 服务器并不会自己向其他服务器进行查询,而是把能够解析该域名的服务器 IP 地址返回给客户端,客户端会不断的向这些服务器进行查询,直到查询到了位置,迭代的话只会帮你找到相关的服务器,然后说我现在比较忙,你自己去找吧。

  • 查找本地 hosts 文件
  • DNS 域名解析器(电脑里配置的)
  • 宽带运营商服务器查询有没有缓存
  • 查看是否设置转发模式,根域名服务器(返回 com 地址)
  • 顶级域名服务器.com/.cn/.edu(返回 baidu.com 地址)
  • ...次级域名服务器...
  • 权威域名服务器.taobao/.baidu(返回www.baidu.com)

# 递归、迭代定义

# 递归:程序重复调用自身,并明确有递归结束条件的编程称为递归。

使用要满足以下两个条件:

  • 在过程或函数内调用自身;
  • 必须有一个明确的递归结束条件;

# 迭代:迭代是重复反馈过程的活动。每一次对过程的重复称为一次“迭代”,每一次迭代得到的结果会作为下一次迭代的初始值。

# 两者的关系:

  1. 递归中有迭代,但迭代中不一定有递归,大部分可以相互转换;
  2. 相比较而言,能用迭代就不要用递归,递归不断调用函数,浪费空间,也容易引起堆栈溢出;

递归:也就是函数重复调用自身,并明确有递归结束条件的编程。

迭代:也就是按找某种规则执行重复的活动,每一次执行的结果会作为下一次执行的初始值。

循环:满足条件的情况下,重复执行同一段代码。

遍历:按照一定的规则访问树形结构中的每一个节点,且每个节点仅访问一次。

# DNS 负载均衡

DNS 还有负载均衡的作用,现在很多网站都有多个服务器,当一个网站访问量过大的时候,如果所有请求都请求在同一个服务器上,可能服务器就会崩掉,这时候就用到了 DNS 负载均衡技术,当一个网站有多个服务器地址时,在应答 DNS 查询的时候,DNS 服务器会对每个查询返回不同的解析结果,也就是返回不同的 IP 地址,从而把访问引导到不同的服务器上去,来达到负载均衡的目的。例如可以根据每台机器的负载量,或者该机器距离用户的地理位置距离等等条件。

# DNS 预解析

大型网站,有多个不同服务器资源的情况下,都可采取 DNS 预解析,提前解析,减少页面卡顿。

<link rel="dns-prefetch" href="www.taobao.com" />

# CDN 解析

CDN 是一种内容分发网络,部署在应用层,利用智能分配技术,根据用户访问的地点,按照就近访问的原则分配到多个节点,来实现多点负载均衡。 简单来说,用户就近访问,访问速度更快,大公司也无需搞一台超级带宽的存储服务器,只需使用多台正常带宽的 CDN 节点即可。 而 CDN 的常见实现是有一台源站服务器,多个 CDN 节点定时从源站同步。

当我们需要加速网站时

  1. 通过向服务商注册自己加速域名,源站域名
  2. 进入到自己域名的 DNS 配置信息,设置一个CNAME指向 CDN 服务商即可。

开启 CDN 后的 DNS 过程

  1. DNS 系统会最终将域名的解析权交给 CNAME 指向的 CDN 专用 DNS 服务器
  2. CDN 的 DNS 服务器将 CDN 的全局负载均衡设备 IP 地址返回用户
  3. 用户向 CDN 的全局负载均衡设备发起内容访问请求
  4. 全局负载均衡设备根据用户 IP 地址、请求的内容,选择用户所属区域的区域负载均衡设备,告诉用户向这台设备发起请求
  5. 区域负载均衡设备会为用户选择一台合适的缓存服务器提供服务
  6. 全局负载均衡设备把服务器的 IP 地址返回给用户

优势有很多:

  1. 本地 Cache 加速,加快访问速度
  2. 镜像服务,消除运营商之间互联的瓶颈影响,保证不同网络的用户都能得到良好的访问质量
  3. 远程加速,自动选择 cache 服务器
  4. 带宽优化,分担波峰网络流量,减轻主站压力
  5. 集群抗 ddos 攻击
cdn流程图

# TCP/IP 连接

Chrome 在同一个域名下要求同时最多只能有 6 个 TCP 连接,超过 6 个的话剩下的请求就得等待。

osi-2 osi

# 三次握手

为什么需要三次握手,两次不行吗?

其实这是由 TCP 的自身特点可靠传输决定的。客户端和服务端要进行可靠传输,那么就需要确认双方的接收发送能力。第一次握手可以确认客服端的发送能力,第二次握手,服务端SYN=1,Seq=Y就确认了发送能力,ACK=X+1就确认了接收能力,所以第三次握手才可以确认客户端的接收能力。不然容易出现丢包的现象。

# 三次握手过程中可以携带数据吗?

其实第三次握手的时候,是可以携带数据的。但是,第一次、第二次握手不可以携带数据。

为什么这样呢?大家可以想一个问题,假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据。因为攻击者根本就不理服务器的接收、发送能力是否正常,然后疯狂着重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。

也就是说,第一次握手不可以放数据,其中一个简单的原因就是会让服务器更加容易受到攻击了。而对于第三次的话,此时客户端已经处于 ESTABLISHED 状态。对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据也没啥毛病。

# 什么是半连接队列?

服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列。

当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。

这里在补充一点关于 SYN-ACK 重传次数的问题: 服务器发送完 SYN-ACK 包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传。如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。

注意,每次重传等待的时间不一定相同,一般会是指数增长,例如间隔时间为 1s,2s,4s,8s...

# ISN 是固定的吗?

当一端为建立连接而发送它的 SYN 时,它为连接选择一个初始序号。ISN 随时间而变化,因此每个连接都将具有不同的 ISN。ISN 可以看作是一个 32 比特的计数器,每 4ms 加 1 。这样选择序号的目的在于防止在网络中被延迟的分组在以后又被传送,而导致某个连接的一方对它做错误的解释。

三次握手的其中一个重要功能是客户端和服务端交换 ISN(Initial Sequence Number),以便让对方知道接下来接收数据的时候如何按序列号组装数据。如果 ISN 是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。

# SYN 攻击

服务器端的资源分配是在二次握手时分配的,而客户端的资源是在完成三次握手时分配的,所以服务器容易受到 SYN 洪泛攻击。SYN 攻击就是 Client 在短时间内伪造大量不存在的 IP 地址,并向 Server 不断地发送 SYN 包,Server 则回复确认包,并等待 Client 确认,由于源地址不存在,因此 Server 需要不断重发直至超时,这些伪造的 SYN 包将长时间占用未连接队列,导致正常的 SYN 请求因为队列满而被丢弃,从而引起网络拥塞甚至系统瘫痪。SYN 攻击是一种典型的 DoS/DDoS 攻击。

检测 SYN 攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源 IP 地址是随机的,基本上可以断定这是一次 SYN 攻击。在 Linux/Unix 上可以使用系统自带的 netstats 命令来检测 SYN 攻击。

netstat -n -p TCP | grep SYN_RECV

常见的防御 SYN 攻击的方法有如下几种:

  • 缩短超时(SYN Timeout)时间
  • 增加最大半连接数
  • 过滤网关防护

# 发送 http 请求

http 又分为 http0.9、http1.0、http1.1、http2、http3

# http/0.9

  • 只有一个命令 GET
  • 响应类型: 仅 超文本
  • 没有 header 等描述数据的信息
  • 服务器发送完毕,就关闭 TCP 连接

# http/http1.0

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

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

# Keep-Alive 模式

http 1.0中出现了Connection: keep-alive,用于建立长连接。

http 协议采用“请求-应答”模式,不开启 KeepAlive 模式时,每个 req/res 客户端和服务端都要新建一个连接,完成之后立即断开连接(http 协议为无连接的协议);

当开启 Keep-Alive 模式(又称持久连接、连接重用)时,Keep-Alive 功能使客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive 功能避免了建立或者重新建立连接。

# 如何理解 http 状态码?

RFC 规定 http 的状态码为三位数,被分为五类:

  • 1xx: 表示目前是协议处理的中间状态,还需要后续操作。
  • 2xx: 表示成功状态。
  • 3xx: 重定向状态,资源位置发生变动,需要重新请求。
  • 4xx: 请求报文有误。
  • 5xx: 服务器端发生错误。

接下来就一一分析这里面具体的状态码。

# 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: 当协商缓存命中时会返回这个状态码。详见浏览器缓存 (opens new window)

# 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 的特点概括如下:

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

# http 缺点

# 无状态

所谓的优点和缺点还是要分场景来看的,对于 http 而言,最具争议的地方在于它的无状态

在需要长连接的场景中,需要保存大量的上下文信息,以免传输大量重复的信息,那么这时候无状态就是 http 的缺点了。

但与此同时,另外一些应用仅仅只是为了获取一些数据,不需要保存连接上下文信息,无状态反而减少了网络开销,成为了 http 的优点。

# 明文传输

即协议里的报文(主要指的是头部)不使用二进制数据,而是文本形式。

这当然对于调试提供了便利,但同时也让 http 的报文信息暴露给了外界,给攻击者也提供了便利。WIFI陷阱就是利用 http 明文传输的缺点,诱导你连上热点,然后疯狂抓你所有的流量,从而拿到你的敏感信息。

# 队头阻塞问题

当 http 开启长连接时,共用一个 TCP 连接,同一时刻只能处理一个请求。

那么当前请求耗时过长的情况下,其它的请求只能处于阻塞状态。

也就是著名的队头阻塞问题。

# GET 和 POST 有什么区别?

首先最直观的是语义上的区别。

而后又有这样一些具体的差别:

  • 缓存的角度,GET 请求会被浏览器主动缓存下来,留下历史记录,而 POST 默认不会。
  • 编码的角度,GET 只能进行 URL 编码,只能接收 ASCII 字符,而 POST 没有限制。
  • 参数的角度,GET 一般放在 URL 中,因此不安全,POST 放在请求体中,更适合传输敏感信息。
  • 幂等性的角度,GET幂等的,而POST不是。(幂等表示执行相同的操作,结果也是相同的)
  • TCP的角度,GET 请求会把请求报文一次性发出去,而 POST 会分为两个 TCP 数据包,首先发 header 部分,如果服务器响应 100(continue), 然后发 body 部分。(火狐浏览器除外,它的 POST 请求只发一个 TCP 包)

# http1.1

主要改进:解决了 http 的队头阻塞问题。

# 什么是 http 队头阻塞?

从前面的小节可以知道,http 传输是基于请求-应答的模式进行的,报文必须是一发一收,但值得注意的是,里面的任务被放在一个任务队列中串行执行,一旦队首的请求处理太慢,就会阻塞后面请求的处理。这就是著名的http队头阻塞问题。

# 允许并发连接

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

但其实,即使是提高了并发连接,还是不能满足人们对性能的需求。

# 允许域名分片

一个域名不是可以并发 6 个长连接吗?那我就多分几个域名。

比如 content1.baidu.com 、content2.baidu.com。

这样一个baidu.com域名下可以分出非常多的二级域名,而它们都指向同样的一台服务器。

能够并发的长连接数更多了,事实上也更好地解决了队头阻塞的问题。

# https

# TLS 加密

关于 https (opens new window)

在 http 的基础上再加一层 TLS(传输层安全性协议,TLS1.0 = SSL3.1)或者 SSL(安全套接层),就构成了 https 协议。

https 默认工作在 TCP 协议 443 端口,它的工作流程一般如以下方式:

  1. TCP 三次同步握手

  2. 客户端验证服务器数字证书

  3. TLS 握手

  4. TLS 安全加密隧道协商完成

  5. 网页以加密的方式传输,用协商的对称加密算法和密钥加密,保证数据机密性;

    用协商的 hash 算法进行数据完整性保护,保证数据不被篡改。

# http2

由于 HTTPS 在安全方面已经做的非常好了,HTTP 改进的关注点放在了性能方面。对于 HTTP/2 而言,它对于性能的提升主要在于两点:

  • 头部压缩
  • 多路复用

当然还有一些颠覆性的功能实现:

  • 设置请求优先级
  • 服务器推送

这些重大的提升都是为了解决 HTTP 早期设计上的不足。

# 头部压缩

在 HTTP/1.1 及之前的时代,请求体一般会有响应的压缩编码过程,通过Content-Encoding头部字段来指定。

但你有没有想过头部字段本身的压缩呢?

当请求字段非常复杂的时候,尤其对于 GET 请求,请求报文几乎全是请求头,这个时候还是存在非常大的优化空间的。

HTTP/2 针对头部字段,也采用了对应的压缩算法——HPACK,对请求头进行压缩。

HPACK 算法是专门为 HTTP/2 服务的,它主要的亮点有两个:

  • 首先是在服务器和客户端之间建立哈希表,将用到的字段存放在这张表中。

    那么在传输的时候对于之前出现过的值,只需要把索引(比如 0,1,2,...)传给对方即可。

    对方拿到索引查表就行了。这种传索引的方式,让请求头字段得到极大程度的精简和复用。

  • 其次是对于整数和字符串进行哈夫曼编码

    哈夫曼编码的原理就是先将所有出现的字符建立一张索引表,然后让出现次数多的字符对应的索引尽可能短。

    传输的时候也是传输这样的索引序列,可以达到非常高的压缩率。

    HTTP/2 当中废除了起始行的概念。

    将起始行中的请求方法、URI、状态码转换成了头字段,不过这些字段都有一个":"前缀,用来和其它请求头区分开。

# 多路复用

为了解决队头阻塞问题,通过二进制分帧来实现多路复用

# HTTP 队头阻塞

我们之前已经讨论了 HTTP 队头阻塞的问题,其根本原因在于 HTTP 基于请求-响应的模型,在同一个 TCP 长连接中,前面的请求没有得到响应,后面的请求就会被阻塞。

后面我们又讨论到用并发连接域名分片的方式来解决这个问题,但这并没有真正从 HTTP 本身的层面解决问题,只是增加了 TCP 连接,分摊风险而已。而且这么做也有弊端,多条 TCP 连接会竞争有限的带宽,让真正优先级高的请求不能优先处理。

而 HTTP/2 便从 HTTP 协议本身解决了队头阻塞问题

注意,这里并不是指的TCP队头阻塞,而是HTTP队头阻塞,两者并不是一回事。

TCP 的队头阻塞是在数据包层面,单位是数据包,前一个报文没有收到便不会将后面收到的报文上传给 HTTP。

HTTP 的队头阻塞是在 HTTP 请求-响应层面,前一个请求没处理完,后面的请求就要阻塞住。两者所在的层次不一样。

那么 HTTP/2 如何来解决所谓的队头阻塞呢?—— 二进制分帧。

# 二进制分帧

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

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

通信双方都可以给对方发送二进制帧,这种二进制帧的双向传输的序列,也叫做(Stream)。

HTTP/2 用来在一个 TCP 连接上来进行多个数据帧的通信,这就是多路复用的概念。

可能你会有一个疑问,既然是乱序首发,那最后如何来处理这些乱序的数据帧呢?

首先要声明的是,所谓的乱序,指的是不同 ID 的 Stream 是乱序的,但同一个 Stream ID 的帧一定是按顺序传输的。二进制帧到达后对方会将 Stream ID 相同的二进制帧组装成完整的请求报文响应报文

# 流的状态变化

最开始两者都是空闲状态,当客户端发送Headers帧后,开始分配Stream ID, 此时客户端的打开, 服务端接收之后服务端的也打开,两端的都打开之后,就可以互相传递数据帧和控制帧了。

当客户端要关闭时,向服务端发送END_STREAM帧,进入半关闭状态, 这个时候客户端只能接收数据,而不能发送数据。

服务端收到这个END_STREAM帧后也进入半关闭状态,不过此时服务端的情况是只能发送数据,而不能接收数据。随后服务端也向客户端发送END_STREAM帧,表示数据发送完毕,双方进入关闭状态

如果下次要开启新的,流 ID 需要自增,直到上限为止,到达上限后开一个新的 TCP 连接重头开始计数。由于流 ID 字段长度为 4 个字节,最高位又被保留,因此范围是 0 ~ 2 的 31 次方,大约 21 亿个。

在 HTTP/2 中,所谓的,其实就是二进制帧的双向传输的序列。那么在 HTTP/2 请求和响应的过程中,流的状态是如何变化的呢?

HTTP/2 其实也是借鉴了 TCP 状态变化的思想,根据帧的标志位来实现具体的状态改变。

# 流的特性

总结一下传输的特性:

  • 并发性。一个 HTTP/2 连接上可以同时发多个帧,这一点和 HTTP/1 不同。这也是实现多路复用的基础。
  • 自增性。流 ID 是不可重用的,而是会按顺序递增,达到上限之后又新开 TCP 连接从头开始。
  • 双向性。客户端和服务端都可以创建流,互不干扰,双方都可以作为发送方或者接收方
  • 可设置优先级。可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验。

# 服务端推送

在 HTTP/2 当中,服务器已经不再是完全被动地接收请求,响应请求,它也能新建 stream 来给客户端发送消息。

当 TCP 连接建立之后,比如浏览器请求一个 HTML 文件,服务器就可以在返回 HTML 的基础上,将 HTML 中引用到的其他资源文件一起返回给客户端,减少客户端的等待。

# http3

  • QUIC「快速 UDP 互联网连接」(Quick UDP Internet Connections)

http3 的主要改进在传输层上。

现在,一切都会走 UDP。

传输层不会再有我前面提到的那些繁重的 TCP 连接了。

# 各协议与 http 协议关系

  • DNS 服务:解析域名至对应的 IP 地址
  • http 协议:生成针对目标 Web 服务器的 http 请求报文
  • TCP 协议:将请求报文按序号分割成多个报文段
  • IP 协议:搜索对方的地址,一边中转一边传送
  • TCP 协议:按序号以原来的顺序重组请求报文请求的处理结果也同样利用 TCP/IP 协议向用户进行回传
  • TCP 是底层通讯协议,定义的是数据传输和连接方式的规范;
  • http 是应用层协议,定义的是传输数据的内容的规范;
  • http 协议中的数据是利用 TCP 协议传输的,所以支持 http 也就一定支持 TCP。

# 服务器端:处理请求并返回 http 报文

每台服务器上都会安装处理请求的应用——Web Server。常见的 Web Server 产品有 apachenginxIISLighttpd 等。

http 请求一般可以分为两类,静态资源 和 动态资源。

请求访问静态资源,这个就直接根据 url 地址去服务器里找就好了。

请求动态资源的话,就需要 web server 把不同请求,委托给服务器上处理相应请求的程序进行处理(例如 CGI 脚本,JSP 脚本,servlets,ASP 脚本,服务器端 JavaScript,或者一些其它的服务器端技术等),然后返回后台程序处理产生的结果作为响应,发送到客户端。

服务器在处理请求的时候主要有三种方式:

  • 第一种:是用一个线程来处理所有的请求,并且同时只能处理一个请求,但是这样的话性能是非常的低的。
  • 第二种:是每一个请求都给他分配一个线程但是当链接和请求比较多的时候就会导致服务器的 cpu 不堪重负。
  • 第三种:就是采用复用 I/O 的方式来处理例如通过 epoll 方式监视所有链接当链接状态发生改变的时候才去分配空间进行处理。

# 响应完成之后

怎么办?TCP 连接就断开了吗?

不一定。这时候要判断Connection字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。

否则断开TCP连接, 请求-响应流程结束。

# 浏览器解析阶段

完成了网络请求和响应,如果响应头中Content-Type的值是text/html,那么接下来就是浏览器的解析渲染工作了。

首先来介绍解析部分,主要分为以下几个步骤:

  • 构建 DOM树、CSS 对象模型 (CSSOM)
  • 样式计算
  • 生成布局树(Layout Tree)

# 构建 DOM

由于浏览器无法直接理解HTML字符串,因此将这一系列的字节流转换为一种有意义并且方便操作的数据结构,这种数据结构就是DOM树DOM树本质上是一个以document为根节点的多叉树。

解析 HTML 的过程涉及到编译原理中的一些概念,在此不做展开。抛出一些结论:

  • HTML 的文法并不是上下文无关文法
  • HTML5 规范 (opens new window)详细地介绍了解析算法。这个算法分为两个阶段:
    1. 标记化。
    2. 建树。

# 样式计算生成 CSSOM

关于 CSS 样式,它的来源一般是三种:

  1. link 标签引用
  2. style 标签中的样式
  3. 元素的内嵌 style 属性

# 格式化样式表

首先,浏览器是无法直接识别 CSS 样式文本的,因此渲染引擎接收到 CSS 文本之后第一件事情就是将其转化为一个结构化的对象,即 styleSheets。

这个格式化的过程过于复杂,而且对于不同的浏览器会有不同的优化策略,这里就不展开了。

在浏览器控制台能够通过document.styleSheets来查看这个最终的结构。当然,这个结构包含了以上三种 CSS 来源,为后面的样式操作提供了基础。

# 计算每个节点的具体样式

样式已经被格式化标准化,接下来就可以计算每个节点的具体样式信息了。

其实计算的方式也并不复杂,主要就是两个规则: 继承层叠

每个子节点都会默认继承父节点的样式属性,如果父节点中没有找到,就会采用浏览器默认样式,也叫UserAgent样式。这就是继承规则,非常容易理解。

然后是层叠规则,CSS 最大的特点在于它的层叠性,也就是最终的样式取决于各个属性共同作用的效果,许多人也是苦恼于他的非正交性。

怎么学好这种不正交的语言? 有一个办法:试。 你试的组合情况越多,就越能了解各种奇怪的现象。 其实不用那么悲观,常用的组合也就几十种吧,都试出来并记下来就行了。

或者:

学习 CSS3 中完备、正交的 Flex,缺点是低版本浏览器不兼容

在计算完样式之后,所有的样式值会被挂在到window.getComputedStyle当中,也就是可以通过 JS 来获取计算后的样式,非常方便。

# 布局树Layout Tree

现在已经生成了DOM树CSSOM,接下来要做的就是通过浏览器的布局系统确定元素的位置,也就是要生成一棵布局树(Layout Tree)。

  1. DOM 树与 CSSOM 树合并后形成渲染树。
  2. 渲染树只包含渲染网页所需的节点。
  3. 布局计算每个对象的精确位置和大小。
  4. 最后一步是绘制,使用最终渲染树将像素渲染到屏幕上。

实际上生成Render Tree渲染树这种说法已经是 16 年之前的事情,现在 Chrome 团队已经做了大量的重构,已经没有生成Render Tree的过程了。而布局树的信息已经非常完善,完全拥有Render Tree的功能。

之所以不讲布局的细节,是因为它过于复杂,一一介绍会显得文章过于臃肿,

不过大部分情况下我们只需要知道它所做的工作是什么即可,如果想深入其中的原理,知道它是如何来做的

我强烈推荐你去读一读人人 FED 团队的文章——从 Chrome 源码看浏览器如何 layout 布局 (opens new window)

布局树生成的大致工作如下:

  1. 合并 CSSOM 到 DOM 树节点
  2. 遍历生成的 DOM 树节点,并把他们添加到布局树中
  3. 计算布局树节点的坐标位置。

# 浏览器渲染阶段

# 渲染流程:

  1. 建立图层树(Layer Tree)
  2. 获取 DOM 后分割为多个图层
  3. 对每个图层的节点计算样式结果 (Recalculate style--样式重计算)
  4. 为每个节点生成图形和位置 (reLayout--重排(回流))
  5. 将每个节点绘制填充到图层位图中 (rePaint--重绘)
  6. 图层作为纹理上传至 GPU
  7. 组合多个图层到页面上生成最终屏幕图像 (Composite Layers--图层重组)

# 建立图层树

如果你觉得现在DOM节点也有了,样式和位置信息也都有了,可以开始绘制页面了,那你就错了。

因为你忽略掉了另外一些复杂的场景,比如 3D 动画如何呈现出变换效果,当元素含有层叠上下文时如何控制显示和隐藏等等。

为了解决如上所述的问题,浏览器在构建完布局树之后,还会对特定的节点进行分层,构建一棵图层树(Layer Tree)。

那这棵图层树是根据什么来构建的呢?

一般情况下,节点的图层会默认属于父亲节点的图层(这些图层也称为合成层)。那什么时候会提升为一个单独的合成层呢?

有两种情况需要分别讨论,一种是显式合成,一种是隐式合成

# 显式合成

下面是显式合成的情况:

一、 拥有层叠上下文的节点。

层叠上下文也基本上是有一些特定的 CSS 属性创建的,一般有以下情况:

  1. HTML 根元素本身就具有层叠上下文。
  2. 普通元素设置position 不为 static并且设置了 z-index 属性,会产生层叠上下文。
  3. 元素的 opacity 值不是 1
  4. 元素的 transform 值不是 none
  5. 元素的 filter 值不是 none
  6. 元素的 isolation 值是 isolate
  7. will-change指定的属性值为上面任意一个。(will-change 的作用后面会详细介绍)

二、需要剪裁的地方。

比如一个 div,你只给他设置 100 * 100 像素的大小,而你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条会被单独提升为一个图层。

# 隐式合成

接下来是隐式合成,简单来说就是层叠等级低的节点被提升为单独的图层之后,那么所有层叠等级比它高的节点都会成为一个单独的图层。

# 层爆炸

这个隐式合成其实隐藏着巨大的风险,如果在一个大型应用中,当一个z-index比较低的元素被提升为单独图层之后,层叠在它上面的的元素统统都会被提升为单独的图层,可能会增加上千个图层,大大增加内存的压力,甚至直接让页面崩溃。这就是层爆炸的原理。

值得注意的是,当需要repaint时,只需要repaint本身,而不会影响到其他的层。

# 生成绘制列表

接下来渲染引擎会将图层的绘制拆分成一个个绘制指令,比如先画背景、再描绘边框......然后将这些指令按顺序组合成一个待绘制列表,相当于给后面的绘制操作做了一个计划。

# 生成图块和生成位图

现在开始绘制操作,实际上在渲染进程中绘制操作是由专门的线程来完成的,这个线程叫合成线程

绘制列表准备好了之后,渲染进程的主线程会给合成线程发送commit消息,把绘制列表提交给合成线程。接下来就是合成线程一展宏图的时候啦。

首先,考虑到视口就这么大,当页面非常大的时候,要滑很长时间才能滑到底,如果要一口气全部绘制出来是相当浪费性能的。因此,合成线程要做的第一件事情就是将图层分块。这些块的大小一般不会特别大,通常是 256 _ 256 或者 512 _ 512 这个规格。这样可以大大加速页面的首屏展示。

因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。

顺便提醒一点,渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据

然后合成线程会选择视口附近的图块,把它交给栅格化线程池生成位图。

生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程

# 光栅化

通过解析、布局和绘制过程,浏览器获得了文档的结构、每个元素的样式、绘制顺序等信息。 将这些信息转换为屏幕上的像素,这个过程被称为光栅化。 光栅化可以被 GPU 加速,光栅化后的位图会被存储在 GPU 内存中。根据前面介绍的渲染流程,当页面布局变更了会触发重排和重绘,还需要重新进行光栅化。此时如果页面中有动画,则主线程中过多的计算任务很可能会影响动画的性能。 因此,现代的浏览器通常使用合成的方式,将页面的各个部分分成若干层,分别对其进行栅格化(将它们分割成了不同的瓦片),并通过合成器线程进行页面的合成。

# 显示器显示内容

光栅化操作完成后,合成线程会生成一个绘制命令,即"DrawQuad",并发送给浏览器进程。

浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。为什么发给显卡呢?我想有必要先聊一聊显示器显示图像的原理。

无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区后缓冲区对换位置,如此循环更新。

看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。


# 感谢巨人

  1. https://juejin.cn/post/6844904021308735502
  2. https://zhuanlan.zhihu.com/p/86426969
  3. https://www.rrfed.com/2017/02/26/chrome-layout/
  4. https://juejin.cn/post/6935232082482298911