一文搞懂浏览器输入 URL 做了什么

什么是 URL?

URL(统一资源定位符)是因特网中唯一资源地址,他是浏览器用于检测已发布资源(例如 HTML 页面、CSS 文档、图像等)的关键机制之一。

理论上说,每个有效的 URL 都指向一个唯一的资源。而在实际中,也有一些例外,最常见的情况就是一个 URL 指向了不存在的或者被移动过的资源。

URL 组成部分

  1. URL 的第一部分是 Scheme,表示浏览器必须使用的协议来请求资源(常见的就是 HTTP 和 HTTPS )
  2. 权威部分包括域跟端口
    • 域指示被请求的 Web 服务器。通常这是一个域名,但也可以使用 IP 地址(但这很少见,因为它不太方便)。
    • 如果 Web 服务器使用 HTTP 协议的标准端口(HTTP 为 80,HTTPS 为 443)来授予对其资源的访问权限,则通常会省略端口。否则,端口是强制的。

当在浏览器输入 URL 页面具体怎么渲染出来的呢?

1. 查询域名对应的 IP

(1) 浏览器缓存

首先浏览器先看有没有缓存域名跟 IP 的映射关系,如果有则直接返回结果。浏览器缓存过去时间跟 TTL (表示一条 DNS 记录最大有效时间是多长,单位 s)无关,和浏览器本身有关,每个浏览器会规定自己的缓存过期时间,比如 Chrome 就是 60s。

(2)操作系统缓存

检查自己本地的 hosts 文件是否存在域名跟 IP 的映射关系,如果有,就调用这个 IP 地址映射,完成域名解析。OS 缓存过期时间会参考但不完全等于 TTL

(3) 查路由器缓存
(4)DNS 服务器查询

DNS 即域名系统,全称是 Domain Name System。当我们在浏览器输入一个 URL 地址时,浏览器要向这个 URL 的主机对应的域名服务器发送请求,就得知道服务器的 IP,对于浏览器来说,DNS 的作用就是将主机名转换成 IP 地址。DNS 服务器有 3 种类型:根 DNS 服务器、顶级域(Top-Level Domain, TLD)DNS 服务器和权威 DNS 服务器

  • 根 DNS 服务器

    首先我们要明确根域名是什么,比如 www.baidu.com,有些同学可能会误以为 com 就是根域名,其实 com 是顶级域名,www.baidu.com 的完整写法是 www.baidu.com.,最后的这个 . 就是根域名。

    根 DNS 服务器的作用是什么呢?就是管理它的下一级,也就是顶级域 DNS 服务器。通过询问根 DNS 服务器,我们可以知道一个主机名对应的顶级域 DNS 服务器的 IP 是多少,从而继续向顶级域 DNS 服务器发起查询请求。

  • 顶级域 DNS 服务器

    除了前面提到的 com 是顶级域名,常见的顶级域名还有 cnorgedu 等。顶级域 DNS 服务器,也就是 TLD,提供了它的下一级,也就是权威 DNS 服务器的 IP 地址。

  • 权威 DNS 服务器

    权威 DNS 服务器可以返回主机 - IP 的最终映射。

  • 本地 DNS 服务器

    之前对 DNS 有过了解的同学可能会发现,上一节的 DNS 层次结构,为什么没有提到本地 DNS 服务器?因为严格来说,本地 DNS 服务器并不属于 DNS 的层次结构,但它对 DNS 层次结构是至关重要的。那什么是本地 DNS 服务器呢?

    每个 ISP 都有一台本地 DNS 服务器,比如一个居民区的 ISP、一个大学的 ISP、一个机构的 ISP,都有一台或多台本地 DNS 服务器。当主机发出 DNS 请求时,该请求被发往本地 DNS 服务器,本地 DNS 服务器起着代理的作用 ,并负责将该请求转发到 DNS 服务器层次结构中。

    接下来就让我们通过一个简单的例子,看看 DNS 的查询过程是怎样的,看看客户端、本地 DNS 服务器、DNS 服务器层次结构之间是如何交互的。

  • 递归查询、迭代查询

    如下图,假设主机 m.n.com 想要获取主机 a.b.com 的 IP 地址,会经过以下几个步骤:

首先,主机 m.n.com 向它的本地 DNS 服务器发送一个 DNS 查询报文,其中包含期待被转换的主机名 a.b.com

  • 本地 DNS 服务器将该报文转发到根 DNS 服务器;

  • 该根 DNS 服务器注意到 com 前缀,便向本地 DNS 服务器返回 com 对应的顶级域 DNS 服务器(TLD)的 IP 地址列表。

    意思就是,我不知道 a.b.com 的 IP,不过这些 TLD 服务器可能知道,你去问他们吧;

  • 本地 DNS 服务器则向其中一台 TLD 服务器发送查询报文;

  • 该 TLD 服务器注意到 b.com 前缀,便向本地 DNS 服务器返回权威 DNS 服务器的 IP 地址。

    意思就是,我不知道 a.b.com 的 IP,不过这些权威服务器可能知道,你去问他们吧;

  • 本地 DNS 服务器又向其中一台权威服务器发送查询报文;

  • 终于,该权威服务器返回了 a.b.com 的 IP 地址;

  • 本地 DNS 服务器将 a.b.com 跟 IP 地址的映射返回给主机 m.n.comm.n.com 就可以用该 IP 向 a.b.com 发送请求啦。

    主机 m.n.com 向本地 DNS 服务器 dns.n.com 发出的查询就是递归查询 ,这个查询是主机 m.n.com 以自己的名义向本地 DNS 服务器请求想要的 IP 映射,并且本地 DNS 服务器直接返回映射结果给到主机。

    而后继的三个查询是迭代查询 ,包括本地 DNS 服务器向根 DNS 服务器发送查询请求、本地 DNS 服务器向 TLD 服务器发送查询请求、本地 DNS 服务器向权威 DNS 服务器发送查询请求,所有的请求都是由本地 DNS 服务器发出,所有的响应都是直接返回给本地 DNS 服务器

    那么问题来了,所有的 DNS 查询都必须遵循这种递归 + 迭代的模式吗?

    当然不是。

    从理论上讲,任何 DNS 查询既可以是递归的,也可以是迭代的

  • DNS 缓存

    为了让我们更快的拿到想要的 IP,DNS 广泛使用了缓存技术。DNS 缓存的原理非常简单,在一个 DNS 查询的过程中,当某一台 DNS 服务器接收到一个 DNS 应答(例如,包含某主机名到 IP 地址的映射)时,它就能够将映射缓存到本地,下次查询就可以直接用缓存里的内容。当然,缓存并不是永久的,每一条映射记录都有一个对应的生存时间,一旦过了生存时间,这条记录就应该从缓存移出。

    事实上,有了缓存,大多数 DNS 查询都绕过了根 DNS 服务器,需要向根 DNS 服务器发起查询的请求很少。

    此图中可看出 DNS 域名服务器上存储的信息:

    • A 记录:表示将域名指向一个 IPv4地址。

    • CNAME 记录 :将域名指向另一个域名,实现与被指向域名相同的访问效果。(CDN 实际就是分配一个 CNAME 记录来实现加速的)

    • NS 记录:域名解析服务器记录,将子域名指定某个域名服务器来解析。

    • AAAA 记录 :主机名或者域名指向一个 IPv6 地址

      每条记录其实都是有缓存时间的(TTL ),TTL 的时间长度单位是 s,一般为 3600s。比如访问 juejin.cn,如果 DNS 服务器上没有缓存记录,就会像某个 NS 服务器发送请求,获得记录后,该记录会在 DNS 服务器上保存 TTL 时间长度,在 TTL 有效期访问,DNS 会直接返回刚才的记录。

2. HTTP 三次握手四次挥手

认识下网络模型
OSI 七层网络协议 TCP/IP 四层网络协议 对应网络协议 功能
应用层 应用层 DNS、FTP、HTTP 为应用程序各种网络服务
表示层 应用层 SSL 数据转化、加密、压缩,将应用层传来的数据转换成能够在网络上传输的格式,并在接收端将数据恢复成原始格式
会话层 应用层 RPC、SMTP 不同主机之间会话的建立和管理
传输层 传输层 TCP、UDP 主要任务就是建立端到端的连接,实现数据的可靠传输,传输层使用端口号来区分不同的应用程序,并为每个应用程序建立独立的数据传输通道。
网络层 网络层 IP 主要功能是地址跟路由。根据 IP 地址找到目标设备路由,并将数据报发送到目标设备。
数据链路层 数据链路层 局域网、以太网 将网络层传来的数据包封装成帧,并在接收端将帧解封装还原成数据包。
物理层 数据链路层 将二进制数据流转化为光信号和电信号,通过物理媒介发送到目标设备。

TCP 在数据传输之前必须先建立连接,TCP 做了很多工作来提供可靠的数据传输,包括建立、管理、终止连接,确认和重传。同时 TCP 还提供了分段跟重组,流量控制

UDP 是一种简单的传输层协议,他并不能提供可靠的数据传输,UDP 会把应用程序发给他的数据打包成一个 UDP 数据报,然后在把这个数据报传给 IP。这就导致不能想 UDP 写入太多数据,否则会导致 IP 分段后果。

TCP 会把应用程序发来的数据分成大小适合的若干 TCP 段

由于很多程序同时在使用 TCP 和 UDP,他们会把数据交给 TCP/UDP,TCP/UDP 也会接受来自 IP 的、包含指向不同应用程序的数据,所以就需要一种标识来区分应用程序,便是通过端口号(port)来进行多路复用或多路分解。端口号是一个 16 位的二进制整数,取值范围是 0~65535。

多路复用是应用程序把数据交给 TCP 或 UDP 时,TCP 会把这些数据分成若干 TCP 段,UDP 则产生一个数据报,这些 TCP 段 或 数据报中填入源端口号和目标端口号。发送给到目标机器。

多路分解是多路复用的逆过程,当目标机器上的 TCP UDP 接收到 TCP 段和 UDP 数据报时,会检查他们的目标端口号,根据不同的目标端口号把数据发给不同的应用程序。

下面的图是TCP头部的规范定义

TCP端口号

TCP的连接是需要四个要素确定唯一一个连接:
(源IP,源端口号)+ (目地IP,目的端口号)

所以TCP首部预留了两个16位作为端口号的存储,而IP地址由上一层IP协议负责传递

源端口号和目地端口各占16位两个字节,也就是端口的范围是2^16=65535

另外1024以下是系统保留的,从1024-65535是用户使用的端口范围
TCP的序号和确认号
32位序号 seq :Sequence number 缩写seq ,TCP通信过程中某一个传输方向上的字节流的每个字节的序号,通过这个来确认发送的数据有序 ,比如现在序列号为1000,发送了1000,下一个序列号就是2000。
32位确认号 ack:Acknowledge number 缩写ack,TCP对上一次seq序号做出的确认号,用来响应TCP报文段,给收到的TCP报文段的序号seq加1。
TCP的标志位

每个TCP段都有一个目的,这是借助于TCP标志位选项来确定的,允许发送方或接收方指定哪些标志应该被使用,以便段被另一端正确处理。

用的最广泛的标志是 SYNACKFIN,用于建立连接,确认成功的段传输,最后终止连接。

  1. SYN :简写为S,同步标志位,用于建立会话连接,同步序列号;
  2. ACK : 简写为.,确认标志位,对已接收的数据包进行确认;
  3. FIN : 简写为F,完成标志位,表示我已经没有数据要发送了,即将关闭连接;
TCP 三次握手建立连接

所谓三次握手(Three-way Handshake),是指建立一个 TCP 连接时,需要客户端和服务器总共发送3个报文。

三次握手的目的是连接服务器指定端口,建立 TCP 连接,并同步连接双方的序列号和确认号,交换 TCP 窗口大小信息。在 socket 编程中,客户端执行 connect() 时。将触发三次握手。

三次握手过程的示意图如下:

  • 第一次握手

    客户端将TCP报文标志位SYN置为1 ,随机产生一个序号值seq=J,保存在TCP首部的序列号(Sequence Number)字段里,指明客户端打算连接的服务器的端口,并将该数据包发送给服务器端,发送完毕后,客户端进入SYN_SENT状态,等待服务器端确认。

  • 第二次握手

    服务器端收到数据包后由标志位SYN=1知道客户端请求建立连接,服务器端将TCP报文标志位SYN和ACK都置为1 ,ack=J+1,随机产生一个序号值seq=K,并将该数据包发送给客户端以确认连接请求,服务器端进入SYN_RCVD状态。

  • 第三次握手

    客户端收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给服务器端,服务器端检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,客户端和服务器端进入ESTABLISHED状态,完成三次握手,随后客户端与服务器端之间可以开始传输数据了。

注意:我们上面写的ack和ACK,不是同一个概念:

  • 小写的ack代表的是头部的确认号Acknowledge number, 缩写ack,是对上一个包的序号进行确认的号,ack=seq+1。
  • 大写的ACK,则是我们上面说的TCP首部的标志位,用于标志的TCP包是否对上一个包进行了确认操作,如果确认了,则把ACK标志位设置成1。
为什么需要三次握手?

我们假设client发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达server。

本来这是一个早已失效的报文段。但server收到此失效的连接请求报文段后,就误认为是client再次发出的一个新的连接请求。于是就向client发出确认报文段,同意建立连接。

假设不采用"三次握手",那么只要server发出确认,新的连接就建立了。由于现在client并没有发出建立连接的请求,因此不会理睬server的确认,也不会向server发送数据。但server却以为新的运输连接已经建立,并一直等待client发来数据。这样,server的很多资源就白白浪费掉了。

所以,采用"三次握手"的办法可以防止上述现象发生。例如刚才那种情况,client不会向server的确认发出确认。server由于收不到确认,就知道client并没有要求建立连接。

TCP 四次挥手关闭连接

四次挥手即终止TCP连接,就是指断开一个TCP连接时,需要客户端和服务端总共发送4个包以确认连接的断开。在socket编程中,这一过程由客户端或服务端任一方执行close来触发。

由于TCP连接是全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当一方完成数据发送任务后,发送一个FIN来终止这一方向的连接,收到一个FIN只是意味着这一方向上没有数据流动了,即不会再收到数据了,但是在这个TCP连接上仍然能够发送数据,直到这一方向也发送了FIN。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭。

四次挥手过程的示意图如下:

挥手请求可以是Client端,也可以是Server端发起的,我们假设是Client端发起:

  • 第一次挥手 : Client端发起挥手请求,向Server端发送标志位是FIN报文段,设置序列号seq,此时,Client端进入FIN_WAIT_1状态,这表示Client端没有数据要发送给Server端了。

  • 第二次分手 :Server端收到了Client端发送的FIN报文段,向Client端返回一个标志位是ACK的报文段,ack设为seq加1,Client端进入FIN_WAIT_2状态,Server端告诉Client端,我确认并同意你的关闭请求。

  • 第三次分手 : Server端向Client端发送标志位是FIN的报文段,请求关闭连接,同时Client端进入LAST_ACK状态。

  • 第四次分手 : Client端收到Server端发送的FIN报文段,向Server端发送标志位是ACK的报文段,然后Client端进入TIME_WAIT状态。Server端收到Client端的ACK报文段以后,就关闭连接。此时,Client端等待2MSL的时间后依然没有收到回复,则证明Server端已正常关闭,那好,Client端也可以关闭连接了。

为什么连接的时候是三次握手,关闭的时候却是四次握手?

建立连接时因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。所以建立连接只需要三次握手。

由于TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议,TCP是全双工模式

这就意味着,关闭连接时,当Client端发出FIN报文段时,只是表示Client端告诉Server端数据已经发送完毕了。当Server端收到FIN报文并返回ACK报文段,表示它已经知道Client端没有数据发送了,但是Server端还是可以发送数据到Client端的,所以Server很可能并不会立即关闭SOCKET,直到Server端把数据也发送完毕。

当Server端也发送了FIN报文段时,这个时候就表示Server端也没有数据要发送了,就会告诉Client端,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。

为什么要等待2MSL?

MSL :报文段最大生存时间,它是任何报文段被丢弃前在网络内的最长时间。

有以下两个原因:

  • 第一点:保证TCP协议的全双工连接能够可靠关闭

    由于IP协议的不可靠性或者是其它网络原因,导致了Server端没有收到Client端的ACK报文,那么Server端就会在超时之后重新发送FIN,如果此时Client端的连接已经关闭处于CLOESD状态,那么重发的FIN就找不到对应的连接了,从而导致连接错乱,所以,Client端发送完最后的ACK不能直接进入CLOSED状态,而要保持TIME_WAIT,当再次收到FIN的收,能够保证对方收到ACK,最后正确关闭连接。

  • 第二点:保证这次连接的重复数据段从网络中消失

    如果Client端发送最后的ACK直接进入CLOSED状态,然后又再向Server端发起一个新连接,这时不能保证新连接的与刚关闭的连接的端口号是不同的,也就是新连接和老连接的端口号可能一样了,那么就可能出现问题:如果前一次的连接某些数据滞留在网络中,这些延迟数据在建立新连接后到达Client端,由于新老连接的端口号和IP都一样,TCP协议就认为延迟数据是属于新连接的,新连接就会接收到脏数据,这样就会导致数据包混乱。所以TCP连接需要在TIME_WAIT状态等待2倍MSL,才能保证本次连接的所有数据在网络中消失。

3. 浏览器渲染

(1) 构建 DOM 对象模式

摘要

  • 字节 → 字符 → 令牌 → 节点 → 对象模型。
  • HTML 标记会转换为文档对象模型 (DOM);CSS 标记会转换为 CSS 对象模型 (CSSOM)。
  • DOM 和 CSSOM 是独立的数据结构。
  • 借助 Chrome 开发者工具的"性能"面板,我们可以捕获和检查 DOM 和 CSSOM 的构建和处理开销。
xml 复制代码
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
  </body>
</html>
  1. 转换:浏览器从磁盘或网络读取 HTML 的原始字节,并根据文件的指定编码(例如 UTF-8)将其转换为各个字符。
  2. 令牌化 :浏览器会将字符串转换为不同的令牌(如 W3C HTML5 标准中所指定的 <html><body>)以及其他尖括号内的字符串。每个令牌都有特殊含义和自己的一组规则。
  3. 词法分析:发出的令牌会转换为"对象",用于定义其属性和规则。
  4. DOM 构建 :最后,由于 HTML 标记定义了不同标记之间的关系(某些标记包含在其他标记中),因此创建的对象会以树状数据结构的形式关联起来,该结构还会捕获原始标记中定义的父子关系:HTML 对象是 body 对象的父级,bodyparagraph 对象的父级,以此类推,直到构建文档的完整表示形式。

整个过程的最终输出是简单网页的文档对象模型 (DOM),浏览器会使用该模型对网页进行所有后续处理。

当 HTML 解析器发现 <script> 标签时,会暂停 HTML 文档的解析,并必须加载、解析和执行JavaScript代码,即<script> 标签会阻塞 DOM 的解析和渲染。

defer

defer属性告诉浏览器不要等待脚本,浏览器会继续处理 HTML,构建 DOM。该脚本"在后台"加载,然后在 DOM 完全构建完成后再运行。

js 复制代码
<p>...content before script...</p>

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

<!-- 不等待脚本,立即显示 -->
<p>...content after script...</p>

另外,defer脚本总是在 DOM 准备好时执行(但在DOMContentLoaded事件之前)

js 复制代码
<p>...content before scripts...</p>

<script>
  document.addEventListener('DOMContentLoaded', () => alert("DOM fully loaded and parsed after defer!"));
</script>

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

<p>...content after scripts...</p>
  1. 页面内容立即显示。
  2. DOMContentLoaded事件处理程序等待defer脚本执行完之后执行

补充:(MDN)

当纯 HTML 被完全加载以及解析时,DOMContentLoaded 事件会被触发,而不必等待样式表,图片或者子框架完成加载

defer脚本保持相对顺序来执行 ,就像常规脚本一样 例如:我们有两个延迟脚本:long.jssmall.js

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

这两个脚本会并行下载,small.js 可能会比long.js先下载完成,但是执行的时候依然会先执行 long.js

所以defer可用于对脚本执行顺序有严格要求的情况

async

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

  • 浏览器不会阻止async脚本

  • 其他脚本也不会等待async脚本,async脚本也不会等待其他脚本

  • DOMContentLoaded和async脚本不会互相等待

    • DOMContentLoaded可能在async脚本执行之前触发(如果async脚本在页面解析完成后完成加载)
    • 或在async脚本执行之后触发(如果async脚本很快加载完成或在 HTTP 缓存中)

简单来说就是 async 脚本在后台加载完就立即运行

js 复制代码
<p>...content before scripts...</p>

<script>
  document.addEventListener('DOMContentLoaded', () => alert("DOM 完全加载以及解析"));
</script>

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

<p>...content after scripts...</p>
  • 页面内容立即显示:async不阻塞
  • DOMContentLoaded可能发生在async之前或之后
  • small.js先加载完就会在long.js之前执行,但如果long.js在之前有缓存,那么long.js先执行。

应用场景:将独立的第三方脚本集成到页面中时,比如计数器,广告等。

注意:async和defer属性都仅适用于外部脚本,如果script标签没有src属性,尽管写了async、defer属性也会被忽略

Dynamic scripts

还有一种方式可以将脚本添加到页面:Dynamic scripts 动态脚本

可以创建一个脚本并使用JavaScript将其动态添加到文档中

js 复制代码
let script = document.createElement('script');
script.src = "/article/script-async-defer/long.js";
document.body.append(script); // (*)

当脚本被添加到文档后立即开始加载

默认情况下,动态脚本表现为"async"

当然也可以设置 script.async=false,这样脚本会表现为 defer

js 复制代码
function loadScript(src) {
  let script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.body.append(script);
}

// long.js runs first because of async=false
loadScript("/article/script-async-defer/long.js");
loadScript("/article/script-async-defer/small.js");

所以 long.js 会先执行

script 是会阻碍 HTML 解析的,只有下载好并执行完脚本才会继续解析 HTML

defer 和 async有一个共同点:下载此类脚本都不会阻止页面呈现(异步加载),区别在于:

  1. async 执行与文档顺序无关,先加载哪个就先执行哪个;defer会按照文档中的顺序执行
  2. async 脚本加载完成后立即执行,可以在DOM尚未完全下载完成就加载和执行;而defer脚本需要等到文档所有元素解析完成之后才执行
  • <link>标签不会阻塞 DOM 的解析,但是会阻塞 DOM 的渲染,同时还会阻塞之后的 <script> 标签的执行。

  • <link rel="preload"> preload 属性 会预加载资源。

  • <link rel="dns-prefetch"> dns-prefetch 属性 (DNS 预获取) 是尝试在请求资源之前解析域名,仅对跨域域上的 DNS 查找有效。

CSSOM 对象模型也是具有相同的解析操作

(2)构建 CSSOM
把 CSS 转换为 styleSheets

CSS 样式主要来源:

  • <link> 标签
  • <style> 标签
  • 元素的 Style 属性

浏览器无法直接理解纯文本的CSS样式,当渲染引擎接收到 CSS 文本时,会将 CSS 文本转换为浏览器可以理解的 styleSheets 结构。 浏览器无法直接理解纯文本的CSS样式,当渲染引擎接收到 CSS 文本时,会将 CSS 文本转换为浏览器可以理解的 styleSheets 结构。

在浏览器开发者工具 console 中 输入 document.styleSheets 即可看到所有解析后 CSS 样式。

如果不提供任何 CSS,也会有默认样式。 如 <h1> 标签显示大于 <h2> 标签,这是因为浏览器具有默认样式表。如果你想知道 Chrome 的默认 CSS 是什么样的,你可以在这里看到源代码

标准化样式表中的属性值

1. body { font-size: 2em }

2. p { color: blue }

3. span { display: none }

4. div { font-weight: bold }

5. div p { color: green }

6. div { color: red }

可以看到上面的 CSS 文本中有很多属性值,如 2em、blue、bold,这些属性值不被渲染引擎理解,需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

如 2em 被解析成 32px,red 被解析成了 rgb(255, 0, 0),bold 被解析为 700。

(3)构建渲染树

首先,浏览器将 DOM 和 CSSOM 合并成一个"渲染树",网罗网页上所有可见的 DOM 内容,以及每个节点的所有 CSSOM 样式信息。

为构建渲染树,浏览器大致执行以下操作:

  1. 从 DOM 树的根节点开始遍历每个可见节点。

    • 某些节点不可见(例如脚本标记、元标记等),因为它们不会体现在渲染输出中,所以会被忽略。
    • 某些节点使用 CSS 隐藏,因此在渲染树中也会被忽略,例如,上例中的 span 节点在渲染树中缺失,因为有一个显式规则在该节点上设置了"display: none"属性。
  2. 对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。

  3. 发射可见节点,连同其内容和计算的样式。

注意 :简单提一句,请注意 visibility: hiddendisplay: none 不同。前者隐藏元素,但元素仍占据着布局空间(即将其渲染成一个空框),而后者 (display: none) 将元素从渲染树中完全移除,元素既不可见,也不是布局的组成部分。

最终输出的渲染树同时包含了屏幕上的所有可见内容及其样式信息。有了渲染树,就可以进入"布局"阶段。有时也称为"自动重排"。

布局流程的输出是一个"盒模型",它会精确地捕获每个元素在视口内的确切位置和尺寸:所有相对测量值都转换为屏幕上的绝对像素。

(4)分层

主线程遍历布局树,根据策略对布局树进行分层,并生成一棵对应的图层树。Chrome 引入了分层和合成的机制就是为了提升每帧的渲染效率。

在浏览器开发者工具 (Layers) 可以查看可视化页面的分层情况,如下图所示:

什么样的节点会创建独立的图层?
  • display 属性值为 fixed 或者 sticky
  • opacity 属性值小于 1
  • z-index 属性值不为 auto
  • transform、filter、perspective、clip-path 属性值不为 none
  • 在 will-change 中指定了任意CSS属性
(5)绘制(Paint)

有了图层树后,主线程会为每个图层单独绘制指令集,用于描述这一层的内容该如何画出来。如把画笔移到某个位置,先画什么再画什么,把一个图层的绘制拆分成很多小的 绘制指令 ,然后再把这些指令按照顺序组成一个 待绘制列表。

主线程将每个图层的绘制信息提交给合成线程(compositor thread) ,剩余工作将由合成线程完成。

(6)分块(Tiling)

浏览器渲染分块用于将页面分割为多个独立的块,然后分别渲染这些块。每个块都是独立的渲染区域,并且可以单独更新和绘制,以提高渲染性能和响应速度。

  • 页面分割: 浏览器将页面划分为多个独立的块。通常,这些块的大小是固定的,例如 256x256 像素。每个块都有自己的位置和尺寸。

  • 多线程处理: 浏览器可以使用多个线程来并行处理分块。这使得浏览器能够更高效地利用硬件资源。

(7)光栅化(Raster)

光栅化是将页面上的图形、文本和其他可见元素转换为像素的过程。在浏览器中,页面的可视内容通常以矢量形式表示,但在显示器上呈现时需要将其转换为光栅图像(由像素组成的位图)。

合成线程将分块信息交给 GPU 进程,GPU 进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块,将 生成位图

(8)画(Draw)

合成线程拿到每个图层、块的位图后,生产一个个 指引(quad) 信息,指引会标识出每个位图应该画在屏幕的哪个位置,以及会考虑旋转,缩放等变形。

变形发生在合成线程,与渲染主线程无关,所以这就是 transform 效率高的本质原因。

重排(reflow)

reflow 的本质是重新计算 layout 树,以下的操作都会导致页面 reflow :

  • 页面首次渲染。
  • 浏览器窗口大小发生变化。
  • 元素的内容发生变化。
  • 元素的尺寸或者位置发生变化。
  • 元素的字体大小发生变化。
  • 激活 CSS 伪类。
  • 查询某些属性或者调用某些方法。
  • 添加或者删除可见的 DOM 元素。

为了避免连续的多次操作导致布局树反复计算,浏览器会合并这些操作,当 JS 代码全部完成后再进行计算,所以改动属性造成的 reflow 是异常完成,也因此 当 JS 获取布局属性时,可能无法获取到最新的布局信息。

浏览器在反复权衡下,最终决定获取属性立即 reflow。 如调用 dom.clientWidth。

重绘(repaint)

repaint 的本质就是重新根据分层信息计算绘制指令,以下的操作会导致 repaint:

  • 改变 color、background 相关属性:background-color、background-image。
  • 改变 outline 相关属性:outline-color、outline-width 、text-decoration。
  • 改变 border-radius、visibility、box-shadow。

当改动了可见样式后,就需要重新计算,会引发 repaint。

元素的布局信息也属于可见样式,所以 reflow 一定会引起 repaint。

为什么 transform 效率高

因为 transform 既不会影响布局也不会影响绘制指令,它只会影响渲染流程的最后一步 draw 阶段,由于 draw 在合成线程中,所以 transform 的变化几乎不会影响到渲染主线程。

总结

把看到的总结起来,方便以后查阅。

涉及文档:

DNS

字节面试被虐后,是时候搞懂 DNS 了

DNS 查询原理详解

DNS域名解析中A、AAAA、CNAME、MX、NS、TXT、SRV、SOA、PTR各项记录的作用

什么是DNS的递归查询和迭代查询?

一文彻底搞懂 TCP三次握手、四次挥手过程及原理

网络分层TCP/IP 与HTTP

网络七层协议详解

一文读懂OSI七层模型与TCP/IP四层的区别/联系

浏览器渲染原理

渲染树构建、布局和绘制

构建对象模型

script 标签中 defer 和 async 的区别

相关推荐
LaoZhangAI6 分钟前
【2025最新】Claude免费API完全指南:无需信用卡,中国用户也能用
前端
hepherd24 分钟前
Flask学习笔记 - 模板渲染
前端·flask
LaoZhangAI24 分钟前
【2025最新】Manus邀请码免费获取完全指南:5种稳定渠道+3个隐藏方法
前端
经常见26 分钟前
浅拷贝与深拷贝
前端
前端飞天猪31 分钟前
学习笔记:三行命令,免费申请https加密证书📃
前端
关二哥拉二胡32 分钟前
前端的 AI 应用开发系列二:手把手揭秘 RAG
前端·面试
斯~内克34 分钟前
前端图片加载性能优化全攻略:并发限制、预加载、懒加载与错误恢复策略
前端·性能优化
奇怪的知识又增长了43 分钟前
Command SwiftCompile failed with a nonzero exit code Command SwiftGeneratePch em
前端
Maofu43 分钟前
从React项目 迁移到 Solid项目的踩坑记录
前端
薄荷味43 分钟前
ubuntu 服务器安装 docker
前端