概述
在面试题中有一道经典面试题就是浏览器输入url之后发送了什么,看了下网上的大多数文章都感觉不太全,所以这里梳理了比较全的流程,如果有误,欢迎评论指正。本文大致是从以下内容概览入手,有需要的可以跳转到感兴趣的部分进行浏览。
- 导航
- DNS查询
- 建立连接
- 编译
- 渲染
- 关闭页面
导航
当我们在地址栏输入内容之后之后,浏览器会根据输入内容来进行判断,先判断输入是否合法
,不合法则会进行提示,然后判断是否是一个可访问地址然后进行对应的处理。当输入www.baidu.com
时,浏览器会先进行解析获取IP地址,然后去访问。如果输入的是要搜索的字符串文本,浏览器会根据自己的规则将文本进行拼接。当输入百度
时会拼接https://www.google.com.hk/search?q=%E7%99%BE%E5%BA%A6&rlz=1C5CHFA_enHK1033HK1033&oq=%E7%99%BE%E5%BA%A6...
因为浏览器是采用UTF-8编码的,所以这个
百度
被编码成了UTF-8格式。
DNS查询
通过上面对输入进行判断,然后就需要和服务器建立连接进行数据传输。但是我们现在只知道域名
地址,如何找到对应的服务器呢? 我们知道域名只是对人类优好方便记忆的一种方式,实际计算机并不认识,它只能通过IP来查找到要连接到那里的服务器然后建立连接进行通信。所以这里就要通过DNS来获取服务器的IP地址-DNS解析,解析过程将尽10个步骤。为了方便理解可以查看下图:
DNS就是充当了翻译,主要保存了域名和IP的映射
Linux: /etc/hosts
Windows:C:\Windows\System32\drivers\etc\hosts
由图所知,当浏览器缓存和host没有这条DNS记录的话就会找本地DNS服务器请求目的服务器的IP地址,然后再用本地DNS服务器通过跟DNS服务器找到顶级DNS服务器然后获取到Name Server服务器IP,找到Name Server目的服务器IP获取域名对映的IP这条信息以及TTL。然后本地DNS服务器会将这条记录以及TTL缓存起来(TTL来设置缓存时间),并返回给本地电脑,然后本地电脑也会将这条记录和TTL保存在host文件中。通过上面的流程,电脑本机就知道了需要建立连接服务器的IP地址了。
TTL(Time-To-Live,生存时间),简单地说它表示一条域名DNS解析记录在DNS服务器上缓存时间。
Name Server服务器就是我要访问的网站域名提供商的服务器,其实该域名的解析任务就是由域名提供商的服务器来完成。 比如我要访问www.baidu.com,而这个域名是从A公司注册获得的,那么A公司上的服务器就会有www.baidu.com的相关信息。
建立连接
有了目的服务器IP之后就可以建立连接,连接过程中是依赖OSI网络协议建立的,本文主要是简单介绍一下,每层具体的作用以及信息等可以查看其他文章。
上图是7层OSI协议,常说的4/5层是因为把其他层进行了合并:
常见的协议所属层级如下:
在与目的服务器建立连接的过程中,有我们熟悉的TCP三次握手、四次挥手的过程(挥手是通信结束,断开连接时进行,最后会提到)
三次握手
当两台服务器第一次建立连接之后,都是处于关闭状态,我们希望通信的连接建立是稳定可靠的,所以需要三次握手来进行保证。三次握手可以理解说是 SYN、SYN-ACK、ACK——因为通过 TCP 首先发送了三个消息进行协商,然后在两台电脑之间开始一个 TCP 会话,这意味着当请求尚未发出的时候,终端与每台服务器之间还要来回多发送三条消息。
准确说是至少三次,三次是保证通信双方都正常可以发送/接受的最小次数
具体的握手流程可以查看下图:
URG:紧急指针(urgent pointer)有效。
ACK:确认序号有效,确认应答。(为了与确认号ack区分开,我们用大写表示)
PSH:接收方应该尽快将这个报文交给应用层。
RST:重置连接。
SYN:发起一个新连接。
FIN:释放一个连接。
seq: 序列号
ack: 确认号码
由上图我们能看出来,经过三次之后,客户端和服务端都知道通信双方的发送/接收能力正常可以进行通信,然后成功建立连接然后进行数据传输。
当连接建立之后,客户端就会发送一个get请求来获取当前页面所需要当document文档。
我们都知道TCP/IP是一种面向连接的、可靠的、基于字节流的传输层通信协议,它会保证数据不丢包、不乱序
。但是在传输过程中由于网络原因可能会导致丢包或者数据包堵塞的情况,所以有了`确认应答、超时重传、拥塞控制 / TCP 慢启动等方式来保存数据等稳定可靠。
数据去重
在传输中会将数据进行分片然后以字节的形式传输,并且有时候数据发送是重复的(超时重传等重传机制),那如何确定接收数据是正确有序的呢? 这就需要Seq序列号来完成了。TCP会对每个字节的数据都进行编号,数据的编号就是数据的序列号,每个字节都有自己独一无二的编号,故序列号具有唯一性。当接收端收到很多重复的数据,那么TCP协议就需要通过序列号把重复的丢弃掉,来实现去重。
ACK确认应答
在TCP连接成功后,发送的每一条数据都可能会丢失,因此需要确认应答,以保证数据的完整性。当收到一条报文后,接收端向发送端发送一条确认ACK,此ACK的作用就是告诉发送端:接收端已经成功的收到了消息,并且希望收到下一条报文的序列号是什么
超时重传
TCP每发送一个报文段,就会对这个报文段设置一次计时器(RTT),只要计时器设置的重传时间到,但发送端还没有收到接收端发来的确认,此时就会重传此报文,主要是以下两种场景出现:
- 数据包丢失:对方根本没收到数据包,更不可能发送 ACK。
- 确认应答丢失:对方发送的 ACK 丢失。
往返时延(Round-Trip Time):数据发送时刻到接收到确认时刻的差值。
滑动窗口
确认应答,超时重传,都是为TCP的可靠性提供了保证,引入可靠性,其实传输效率是降低了的,TCP也想尽可能的提高传输效率,大家注意这里的提高传输效率,实际上是尽量的降低效率的亏损,无论再怎么提高都不可能比UDP这种不考虑可靠性的效率高,我们要做的是尽量提升TCP效率。
发送数据之后当接收到返回的ACK才会继续发送,但是这样会就导致数据传输效率很低(传输时间+等待时间),这明显是不行的,所以通过滑动窗口的形式来批量一次发送多个数据,然后等待一次ACK即可,减少了等待时间。
ack丢失
如果在传输过程中,ack丢失的话,需要怎么做呢?
实际上,如果是ack丢了,我们不做任何处理也没事,因为ack是确认序号,代表该序号之前的所有数据都确认到达了,后续ack的信息中包含了前面的数据,所以只要最后的ack接收到了,中途缺失的ack可以不操作。。
比如我们这里的1001的ack丢失了,但是我们的2001ack顺利到达了,2001到达的含义就是:2001之前的所有数据都确认到达了(1-1000 和 1001-2000都已到达),2001这个ack已经覆盖了上一个丢失的1001ack的信息了。
数据丢失
如果传输过程中,数据丢失,则后续所有的数据都不会接收,会发送ACK为丢失数据的信息,告诉发送端继续发送。
拥塞控制 / TCP 慢启动
在传输过程中,TCP 包被分割成段。如果服务器在发送每个分段之后都等待 ACK,那么客户端将频繁地发送 ACK,并且可能会增加传输时间,即使在网络负载较低的情况下也是如此。所以使用了滑动窗口
来批量一次发送多段数据。服务器在发送一定数量的分段后,必须从客户端接收一个 ACK 包的确认。
另一方面,一次发送过多的分段会导致在繁忙的网络中客户端无法接收分段并且长时间地只会持续发送 ACK,服务器必须不断重新发送分段的问题。
为了平衡传输分段的数量,TCP 慢启动算法用于逐渐增加传输数据量,直到确定最大网络带宽,并在网络负载较高时减少传输数据量。
传输段的数量由拥塞窗口(CWND: 控制滑动窗口大小)
的值控制,该值可初始化为 1、2、4 或 10 MSS(以太网协议中的 MSS 为 1500 字节)。该值是发送的字节数,客户端收到后必须发送 ACK。
如果收到 ACK,那么 CWND 值将加倍,这样服务器下次就能发送更多的数据分段。相反,如果没有收到 ACK,那么 CWND 值将减半。因此,这种机制在发送过多分段和过少分段之间取得了平衡。
编译
数据通过TCP/IP以字节流的形式传输,当浏览器接收到document文档之后,浏览器首先会下载HTML文档。一旦开始接收文档内容,浏览器就会开始解析HTML,将其转换成一个DOM树。这个过程中,如果遇到标签会并行加载css文件,遇到<script>
标签,浏览器会暂停HTML的解析,会根据标签的属性(如async、defer
)来决定何时执行JavaScript代码。然后将控制权交给V8引擎来执行JavaScript代码。在执行JavaScript代码的过程中,等待JavaScript代码执行完成后再继续。
- defer 和 async 在下载时是一样的,都是异步的(相较 HTML 解析)。
- defer 会在 HTML 解析完成后执行的,async 则是下载完成后执行。
- defer 是按照加载顺序执行的,async 是哪个文件先加载完,哪个先执行。
- async 在使用的时候,可以用于完全无依赖的脚本,比如百度分析或者 Google Analytics。
v8引擎就是javascript引擎对实现,用以解析和执行Js代码
由于整个解析/执行HTML、CSS是在浏览器的渲染引擎执行的,而Js是在v8引擎执行的,所以下面分为浏览器渲染原理、V8引擎原理来进行介绍。
V8引擎原理
V8主要是执行Js的,在了解 V8 的工作原理之前,先解释一些概念和原理,比如接下来我们要详细讲解的编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)。
编译器和解释器
之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。按语言的执行流程,可以把语言划分为编译型语言和解释型语言。
编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。
而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。
那编译器和解释器是如何“翻译”代码的呢?具体流程你可以参考下图:
从图中你可以看出这二者的执行流程,大致可阐述为如下:
- 在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功
- 在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。
V8是如何执行Js的
看查看下图来全览一下Js执行的整个流程:
从图中可以清楚地看到,V8 在执行过程中既有解释器 Ignition
,又有编译器 TurboFan
,那么它们是如何配合去执行一段 JavaScript 代码的呢? 下面我们就按照上图来一一分解其执行流程。
第一步:1. 生成抽象语法树(AST)
什么是AST?
AST(抽象语法树):可以理解为编译器和解释器能理解的一种数据结构,类似我们理解的Js语言一样。可以类比为:渲染引擎将 HTML 格式文件转换为计算机可以理解的 DOM 树的情况类似。
高级语言是开发者可以理解的语言,但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说,它们可以理解的就是 AST 了。所以无论你使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST。
接下来举个例子来看看AST结构是怎样的?
Js代码如下:
var myName = " 极客时间 "
function foo(){
return 23;
}
myName = "geektime"
foo()
转换为ASN结构就是这样:
从图中可以看出,AST 的结构和代码的结构非常相似,其实你也可以把 AST 看成代码的结构化的表示,编译器或者解释器后续的工作都需要依赖于 AST,而不是源代码。
AST 是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是 Babel。Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转为 ES5 代码,这意味着你可以现在就用 ES6 编写程序,而不用担心现有环境是否支持 ES6。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码
。
除了 Babel 外,还有 ESLint 也使用 AST。ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。
现在你知道了什么是 AST 以及它的一些应用,那接下来我们再来看下 AST 是如何生成的。通常,生成 AST 需要经过两个阶段。
词法分析
将一行行的源码拆解成一个个 token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。你可以参考下图来更好地理解什么 token。
从图中可以看出,通过var myName = “极客时间”简单地定义了一个变量,其中关键字“var”、标识符“myName” 、赋值运算符“=”、字符串“极客时间”四个都是 token,而且它们代表的属性还不一样。
语法分析
将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。
这就是 AST 的生成过程,经过词法、语法分析之后生成AST。
有了 AST 后,那接下来 V8 就会根据AST生成字节码。
第二步:生成字节码
有了 AST 和执行上下文后,那接下来的第二步,解释器 Ignition 就登场了,它会根据 AST 生成字节码,并解释执行字节码。
其实一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。
那什么是字节码呢?为什么引入字节码就能解决内存占用问题呢?
字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。
理解了什么是字节码,我们再来对比下高级代码、字节码和机器码,你可以参考下图
从图中可以看出,机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。
第三步:执行代码
由上面知道,Js是解释型语言,当生成AST之后,需要有Ignition解释器将其转换成字节码然后进行解释为机器码来执行。在执行代码的过程中,当发现代码多次执行(即为热代码
),这时候编译器TurboFan就出场来将热代码编译成机器码来提高执行效率(减少了字节码翻译为机器码的过程),这种在执行过程中将热代码通过编译器编译成机器码的过程就叫JIT(即使编译)
浏览器渲染
我们都知道浏览器会解析html、css来构建对应的DOM树以及CSSOM树,然后根据CSSOM树计算DOM样式生成渲染树(Renderer Tree),最后在页面进行布局、绘制、合成形成用户可交互的页面。如下图:
有图能看出来最重要的就是DOM树和CSSOM树的构建。
构建DOM树
首先通过两个问题还了解DOM树:
-
Q:渲染引擎为什么要将HTML字节流转换为DOM结构?
A: 渲染引擎之所以需要使用HTML解析器(调用XML解析器)解析HTML文档来生成DOM节点进而构建DOM树,是因为浏览器是无法直接理解和使用HTML的,所以需要将HTML转化为浏览器能够理解的结构——DOM树。 -
Q: 什么是DOM树?
A:在页面中,每个HTML标签都会被浏览器解析成文档对象。HTML本质上就是一个嵌套结构,在解析时会把每个文档对象用一个树形结构组织起来,所有的文档对象都会挂在document上,这种组织方式就是HTML最基础的结构——文档对象模型(DOM),这棵树的每个文档对象就叫做DOM节点。 -
Q:在渲染引擎中DOM有什么用?
A:1、从页面的视角来看,DOM 是生成页面的基础数据结构;
2、从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对 DOM 结构进行访问,从而改变文档的结构、样式和内容;
3、从安全视角来看,DOM 是一道安全防护线,一些不安全的内容在 DOM 解析阶段会被拒之门外。
那么基于上面的理解,前面我们说的网络上数据说通过0/1二进制数据来传输的字节流,那在浏览器中是如何将字节流转换成DOM的呢?大致流程可以查看下图:
从上图能看出来当浏览器的网络进程获取到字节流之后就会判断数据的 content-type
,当值为text/html
时,表示当前数据是html类型需要渲染进程进行渲染,然后网络进程会向浏览器主进程发送申请创建一个渲染进程,然后通过进程管道将字节流传入给渲染进程,让渲染进程通过调用HTML解析器来将字节流数据转换为DOM信息。
在渲染引擎内部,HTML 解析器负责将 HTML 字节流转换为 DOM 结构,其转化过程如下:
由图能看出主要经过了几个步骤:
- 使用分词器生成Token
- 通过栈来创建DOM并构建DOM树
HTML解析字节流生成Toekn
下面以这些代码为例来讲解:
<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>
第一步:通过分词器将字节流转换为Token
分词器先将字节流转换为一个个 Token,分为 Tag Token 和文本 Token。上述 HTML 代码通过词法分析生成的 Token 如下所示:
由图可以看出,Tag Token 又分 StartTag 和 EndTag,分别与开始/结束标签对应,分别对于图中的蓝色和红色块,文本 Token 对应的绿色块。
通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成,然后就进行DOM树的构建。
第二步:根据Token生成DOM树
HTML 解析器维护了一个Token 栈结构
,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:
- 如果压入到栈中的是StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
- 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
- 如果分词器解析出来的是EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。
这里需要补充说明下,HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底。然后经过分词器解析出来的第一个 StartTag html Token 会被压入到栈中,并创建一个 html 的 DOM 节点,添加到 document 上
当分词器进行解析时,第一次解析出来时startTag(html)
:
然后按照同样的流程解析出来 StartTag body 和 StartTag div,其 Token 栈和 DOM 的状态如下图所示:
接下来解析出来的是第一个 div 的文本 Token,渲染引擎会为该 Token 创建一个文本节点,并将该 Token 添加到 DOM 中,它的父节点就是当前 Token 栈顶元素对应的节点,如下图所示:
再接下来,分词器解析出来第一个 EndTag div,这时候 HTML 解析器会去判断当前栈顶的元素是否是 StartTag div,如果是则从栈顶弹出 StartTag div,如下图所示
按照同样的规则,一路解析,最终结果如下图所示:
上面的入/出栈总结一下就是:判断分词器解析出来的是什么,如果是startTag Token就放入栈中并生成DOM构建DOM树,如果是文本节点就生成DOM,构建DOM树(不需要放入栈中), 如果是endTag Token就匹配栈中第一个元素是否为startTag Token,如果是则将startTag弹出。如下图:
至此,DOM树就成功的构建出来了。
HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。
构建CSSOM树
上面已经基本了解了DOM的构建过程,但是这个DOM结构只包含节点,并不包含任何的样式信息。下面就来看看,浏览器是如何把CSS样式应用到DOM节点上的。
同样,浏览器也是无法直接理解CSS代码的,需要将其浏览器可以理解的CSSOM树。实际上。浏览器在构建 DOM 树的同时,如果样式也加载完成了,那么 CSSOM 树也会同步构建。CSSOM 树和 DOM 树类似,它主要有两个作用:
- 提供给 JavaScript 操作样式的能力;
- 为渲染树的合成提供基础的样式信息。
不过,CSSOM 树和 DOM 树是独立的两个数据结构,它们并没有一一对应关系。DOM 树描述的是 HTML 标签的层级关系
,CSSOM 树描述的是选择器之间的层级关系
。可以在浏览器的控制台,通过document.styleSheets命令来查看CSSOM树:
CSS样式的来源主要有三种:
- 通过 link / import 引用的外部 CSS 样式文件。
<style>
标签内的CSS样式。- 元素的style属性内嵌的CSS。
link和@import的区别可以查看这篇文章:link和@import的区别
在将CSS转化为树形对象之前,还需要将样式表中的属性值进行标准化处理,比如,当遇到以下CSS样式:
body { font-size: 2em }
p {color:blue;}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
可以看到上面CSS中有很多属性值,比如2em、blue、red、bold等,这些数值并不能被浏览器直接理解。所以,需要将所有值转化为浏览器渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。经过标准化的过程,上面的代码会变成这样:
body { font-size: 32px }
p {color: rgb(0, 0, 255);}
div {font-weight: 700}
div p {color: (0, 128, 0);}
div {color: (255, 0, 0); }
可以看到,2em被解析成了32px,blue被解析成了rgb(255, 0, 0),bold被解析成700。现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,这就涉及到 CSS 的继承规则和层叠规则。
CSS继承
在 CSS 中存在样式的继承机制,CSS 继承就是每个 DOM 节点都包含有父节点的样式。比如在 HTML 上设置“font-size:20px;”,那么页面里基本所有的标签都可以继承到这个属性了。CSS继承
CSS样式层叠
样式计算过程中的第二个规则是样式层叠。层叠是 CSS 的一个基本特征,它是一个定义了 如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。这里不再多说。CSS层叠
总之,样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程 中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样 式,并被保存在 ComputedStyle 的结构内。CSSOM树类似这样:
Js影响 DOM和CSS
上面我们主要将的是DOM和CSS的构建,但是当我们执行到Js代码时可能也会操作更改树的结构,比如:
//foo.js
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'js change node'
// index.html
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>
这里通过引入 JavaScript 脚本来修改dom内容。当执行到 JavaScript 标签时,暂停整个 DOM 的解析,先下载这段 JavaScript 代码,然后执行,因为JavaScript 文件的下载过程会阻塞 DOM 解析。Chrome针对这个进行了优化预解析
预解析操作:当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
知道引入 JavaScript 线程会阻塞 DOM,我们也可以做一些相关的策略来规避,比如使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积。另外,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer
来标记代码,两者区别可以查看上方介绍。
而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。
总之就是Js会阻塞DOM的生成,而CSS文件会阻塞Js的执行,所以一般是将样式文件放在head中,script脚本放在最下方。
构建渲染树
到这里,我们已经知道了DOM树和CSSOM树了,最后就进入到Render树的构建阶段。渲染树就是 DOM 树和 CSSOM 树的结合,会得到一个可以知道每个节点会应用什么样式的数据结构。这个结合的过程就是遍历整个 DOM 树,然后在 CSSOM 树里查询到匹配的样式。
在不同浏览器里,构建渲染树的过程不太一样:(本文主要介绍Chrome中的处理)
- 在 Chrome 里会在每个节点上使用 attach() 方法,把 CSSOM 树的节点挂在 DOM 树上作为渲染树。
- 在 Firefox 里会单独构造一个新的结构, 用来连接 DOM 树和 CSSOM 树的映射关系。
Q:为什么要构建渲染树呢?
A:因为DOM树可能包含一些不可见的元素,比如head标签,使用display:none;属性的元素等。所以在显示页面之前,还要额外地构建一棵只包含可见元素的渲染树
。
可以从下图来看看构建渲染树的过程:
渲染树本质就是所以可见dom的结合,所以会把head
以及dispaly:none
等不可见的dom过滤
dispaly:none是元素不可见,会被过滤。而visibility: hidden;只是视图上不可见,也需要占位,所以需要在渲染树中。
遍历dom树的过程中,出于效率的考虑,会从 CSSOM 树的叶子节点开始查找,对应在 CSS 选择器上也就是从选择器的最右侧向左查找。除此之外,同一个 DOM 节点可能会匹配到多个 CSSOM 节点,而最终的效果由哪个 CSS 规则来确定,就是样式优先级的问题了。当一个 DOM 元素受到多条样式控制时,样式的优先级顺序如下:!important ->内联样式 > ID选择器 > 类选择器 > 标签选择器 > 通用选择器 > 继承样式 > 浏览器默认样式
样式权重如下:
页面布局
经过上面的步骤,就生成了一棵渲染树,这棵树就是展示页面的关键。到现在为止,已经有了需要渲染的所有节点之间的结构关系及其样式信息。下面就需要进行页面的布局。
通过计算渲染树上每个节点的样式,就能得出来每个元素所占空间的大小和位置。当有了所有元素的大小和位置后,就可以在浏览器的页面区域里去绘制元素的边框了。这个过程就是布局。这个过程中,浏览器对渲染树进行遍历,将元素间嵌套关系以盒模型
的形式写入文档流:
盒模型在布局过程中会计算出元素确切的大小和定位。计算完毕后,相应的信息被写回渲染树上,就形成了布局渲染树。同时,每一个元素盒子也都携带着自身的样式信息,作为后续绘制的依据。
所有元素在页面上都是由盒子来包裹着,然后在页面上进行显示的,具体的可以查看盒模型
页面绘制
经过布局,每个元素的位置和大小就有了,那下面是不是就该开始绘制页面了?答案是否定的,因为页面上可能有很多复杂的场景,比如3D变化、页面滚动、使用z-index进行z轴的排序等。所以,为了实现这些效果,渲染引擎还需要为特定的节点生成专用的「图层」,并生成一棵对应的图层树。
构建图层
那什么是图层呢?相信用过Photoshop的小伙伴对图层并不陌生。我们也可以在Chrome浏览器的开发者工具中,选择Layers标签(如果没有,可以在更多工具中查找),就可以看到页面的分层情况,以百度首页为例,其分层情况如下:
可以看到,渲染引擎给页面分了很多图层,这些图层会按照一定顺序叠加在一起,就形成了最终的页面。这里,将页面分解成多个图层的操作就成为分层
, 最后将这些图层合并到一层的操作就成为合成
,分层和合成通常是一起使用的。Chrome 引入了分层和合成的机制就是为了提升每帧的渲染效率。
通常情况下,并不是渲染树上的每个节点都包含一个图层,如果一个节点没有对应的图层,那这个节点就会属于其父节点的图层。那什么样的节点才能让浏览器引擎为其创建一个新的图层呢?需要满足以下其中一个条件:
- 1、拥有层叠上下文属性的元素
背景和边框:建立当前层叠上下文元素的背景和边框。
负的z-index:当前层叠上下文中,z-index属性值为负的元素。
块级盒:文档流内非行内级非定位后代元素。
浮动盒:非定位浮动元素。
行内盒:文档流内行内级非定位后代元素。
z-index:0:层叠级数为0的定位元素。
正z-index:z-index属性值为正的定位元素。
「注意:」 当定位元素z-index:auto,生成盒在当前层叠上下文中的层级为 0,不会建立新的层叠上下文,除非是根元素。
- 2、需要裁剪的元素
假如有一个固定宽高的div盒子,而里面的文字较多超过了盒子的高度,这时就会产生裁剪,浏览器渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域。当出现裁剪时,浏览器的渲染引擎就会为文字部分单独创建一个图层,如果出现滚动条,那么滚动条也会被提升为单独的图层。
绘制图层
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,渲染引擎在绘制图层时,会把一个图层的绘制分成很多绘制指令,然后把这些指令按照顺序组成一个待绘制的列表:
可以看到,绘制列表中的指令就是一系列的绘制操作。通常情况下,绘制一个元素需要执行多条绘制指令,因为每个元素的背景、边框等属性都需要单独的指令进行绘制。所以在图层绘制阶段,输出的内容就是绘制列表。
在Chrome浏览器的开发者工具中,通过Layer标签可以看到图层的绘制列表和绘制过程:
绘制列表只是用来记录绘制顺序和绘制指令的列表,而「绘制操作是由渲染引擎中的合成线程来完成的」。当图层绘制列表准备好之后,主线程会把该绘制列表提交给合成线程。
注意:合成操作是在合成线程上完成的,所以,在执行合成操作时并不会影响到主线程的执行。
当所有的图块都被光栅化之后,合成线程就会生成一个绘制图块的命令,浏览器相关进程收到这个指令之后,就会将其页面内容绘制在内存中,最后将内存显示在屏幕上,这样就完成了页面的绘制。
所谓的光栅化就是按照绘制列表中的指令生成图片
至此浏览器的渲染原理介绍完了,总结流程就是以下几点:
- 1、将HTML内容构建成DOM树;
- 2、将CSS内容构建成CSSOM树;
- 3、将DOM 树和 CSSOM 树合成渲染树;
- 4、根据渲染树进行页面元素的布局;
- 5、对渲染树进行分层操作,并生成分层树;
- 6、为每个图层生成绘制列表,并提交到合成线程;
- 7、合成线程将图层分成不同的图块,并通过栅格化将图块转化为位图;
- 8、合成线程给浏览器进程发送绘制图块指令;
- 9、浏览器进程会生成页面,并显示在屏幕上。
重绘和重排
我们知道,渲染树是基于DOM树和CSSOM树动态构建的,所以,DOM节点和CSS节点的改动都可能会造成渲染树的重新构建。渲染树的改动就会造成页面的重排或者重绘。下面就来看看这两个概念,以及它们触发的条件和减少触发的操作。
重排
当我们的操作引发了 DOM 树中几何尺寸的变化(改变元素的大小、位置、布局方式等),这时渲染树里有改动的节点和它影响的节点都要重新计算。这个过程就叫做重排,也称为回流。在改动发生时,要重新经历页面渲染的整个流程,所以开销是很大的。
以下操作都会导致页面重排:
- 页面首次渲染;
- 浏览器窗口大小发生变化;
- 元素的内容发生变化;
- 元素的尺寸或者位置发生变化;
- 元素的字体大小发生变化;
- 激活CSS伪类;
- 查询某些属性或者调用某些方法;
- 添加或者删除可见的DOM元素。
在触发重排时,由于浏览器渲染页面是基于流式布局的,所以当触发重排时,会导致周围的DOM元素重新排列,它的影响范围有两种:
- 全局范围:从根节点开始,对整个渲染树进行重新布局;
- 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局。
重绘
当对 DOM 的修改导致了样式的变化、但未影响其几何属性(比如修改颜色、背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(会跳过重排环节),这个过程叫做重绘。简单来说,重绘是由对元素绘制属性的修改引发的。
当我们修改元素绘制属性时,页面布局阶段不会执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
下面这些属性会导致回流:
- color、background 相关属性:background-color、background-image 等;
- outline 相关属性:outline-color、outline-width 、text-decoration;
border-radius、visibility、box-shadow。
注意:当触发重排时,一定会触发重绘,但是重绘不一定会引发重排。
相对来说,重排操作的消耗会比较大,所以在操作中尽量少的造成页面的重排。为了减少重排,可以通过以下方式进行优化:
- 在条件允许的情况下尽量使用 CSS3 动画,它可以调用 GPU 执行渲染。
- 操作DOM时,尽量在低层级的DOM节点进行操作
- 不要使用table布局, 一个小的改动可能会使整个table进行重新布局
使用CSS的表达式 - 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
- 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
- 避免频繁操作DOM,可以创建一个文档片段documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中
- 将元素先设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
- 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于
浏览器的渲染队列机制
。 - 浏览器针对页面的回流与重绘,进行了自身的优化——
渲染队列
, 浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。
关闭页面
当我们网页浏览完成之后,需要关闭网页,这时候就需要释放资源、缓存等。并且也会给服务端发送Close消息,来关闭消息通道,放在服务端持续推动,浪费资源。
四次挥手
四次挥手示意图:
由图能看出四次挥手
主要过程为:
1、第一次挥手:客户端发送一个FIN包(FIN=1,随机产生一个值给seg,seg=u)给服务器端,用来关闭客户端到服务器端的数据传输,客户端进入FIN_WAIT_1状态。
2、第二次挥手:服务器端收到FIN包后,发送一个ACK包(ACK=1,ack=u+1,在随机产生一个值给seg,seg=v)给客户端,客户端进入CLOSE_WAIT状态。
3、第三次挥手:服务器端发送一个FIN包(FIN=1,ACK=1,ack=u+1,在随机产生一个值给seg,seg=w)给客户端,用来关闭服务器端到客户端的数据传输,服务器端进入LAST_ACK状态。
4、第四次挥手:客户端接收FIN包,然后进入TIME_WAIT状态,接着发送一个ACK包(ACK=1,seq=u+1,ack=w+1)给服务器端,服务器端确认序号,进入CLOSED状态,完成四次挥手。
Q: 经过4次挥手,客户端和服务端都关闭了通信。那为什么客户端在TIME_WAIT状态还需要等2MSL后才能返回到CLOSED状态?
A:这是因为虽然双方都同意关闭连接了,但是我们必须要假想网络是不可靠
的,你无法保证你最后发送的ACK报文会一定被对方收到,如果服务端没有收到ACK,而重发FIN报文,所以这个TIME_WAIT状态的作用就是用来重发可能丢失的ACK报文。