1.网络部分

1.1请求

1. 构建请求

浏览器会构建请求行:

  1. // 请求方法是GET,路径为根路径,HTTP协议版本为1.1
  2. GET / HTTP/1.1

2. 查找强缓存

先检查强缓存,如果命中直接使用,否则进入下一步。关于强缓存,如果不清楚可以参考上一篇文章。

3. DNS解析

由于我们输入的是域名,而数据包是通过IP地址传给对方的。因此我们需要得到域名对应的IP地址。这个过程需要依赖一个服务系统,这个系统将域名和 IP 一一映射,我们将这个系统就叫做DNS(域名系统)。得到具体 IP 的过程就是DNS解析。
当然,值得注意的是,浏览器提供了DNS数据缓存功能。即如果一个域名已经解析过,那会把解析的结果缓存下来,下次处理直接走缓存,不需要经过 DNS解析
另外,如果不指定端口的话,默认采用对应的 IP 的 80 端口。
过程
1.Chrome浏览器 会首先搜索浏览器自身的DNS缓存(缓存时间比较短,大概只有1分钟,且只能容纳1000条缓存),看自身的缓存中是否有网址对应的条目,而且没有过期,如果有且没有过期则解析到此结束。
2.如果浏览器自身的缓存里面没有找到对应的条目,那么Chrome会搜索操作系统自身的DNS缓存,如果找到且没有过期则停止搜索解析到此结束.
3.如果在Windows系统的DNS缓存也没有找到,那么尝试读取hosts文件(位于C:\Windows\System32\drivers\etc),看看这里面有没有该域名对应的IP地址,如果有则解析成功。
4.如果在hosts文件中也没有找到对应的条目,浏览器就会发起一个DNS的系统调用,就会向本地配置的首选DNS服务器(一般是电信运营商提供的,也可以使用像Google提供的DNS服务器)发起域名解析请求(通过的是UDP协议向DNS的53端口发起请求,这个请求是递归的请求,也就是运营商的DNS服务器必须得提供给我们该域名的IP地址),运营商的DNS服务器首先查找自身的缓存,找到对应的条目,且没有过期,则解析成功。
如果没有找到对应的条目,则有运营商的DNS代我们的浏览器发起迭代DNS解析请求,它首先是会找根域的DNS的IP地址(这个DNS服务器都内置13台根域的DNS的IP地址),找打根域的DNS地址,就会向其发起请求(请问www.linux178.com这个域名的IP地址是多少啊?),根域发现这是一个顶级域com域的一个域名,于是就告诉运营商的DNS我不知道这个域名的IP地址,但是我知道com域的IP地址,你去找它去,于是运营商的DNS就得到了com域的IP地址,又向com域的IP地址发起了请求(请问www.linux178.com这个域名的IP地址是多少?),com域这台服务器告诉运营商的DNS我不知道www.linux178.com这个域名的IP地址,但是我知道linux178.com这个域的DNS地址,你去找它去,于是运营商的DNS又向linux178.com这个域名的DNS地址(这个一般就是由域名注册商提供的,像万网,新网等)发起请求(请问www.linux178.com这个域名的IP地址是多少?),这个时候linux178.com域的DNS服务器一查,诶,果真在我这里,于是就把找到的结果发送给运营商的DNS服务器,这个时候运营商的DNS服务器就拿到了www.linux178.com这个域名对应的IP地址,并返回给Windows系统内核,内核又把结果返回给浏览器,终于浏览器拿到了www.linux178.com对应的IP地址,该进行一步的动作了。
注:一般情况下是不会进行以下步骤的
如果经过以上的4个步骤,还没有解析成功,那么会进行如下步骤:
5.操作系统就会查找NetBIOS name Cache(NetBIOS名称缓存,就存在客户端电脑中的),那这个缓存有什么东西呢?凡是最近一段时间内和我成功通讯的计算机的计算机名和Ip地址,就都会存在这个缓存里面。什么情况下该步能解析成功呢?就是该名称正好是几分钟前和我成功通信过,那么这一步就可以成功解析。
6.如果第5步也没有成功,那会查询WINS 服务器(是NETBIOS名称和IP地址对应的服务器)。
7.如果第6步也没有查询成功,那么客户端就要进行广播查找。
8.如果第7步也没有成功,那么客户端就读取LMHOSTS文件(和HOSTS文件同一个目录下,写法也一样)
如果第八步还没有解析成功,那么就宣告这次解析失败,那就无法跟目标计算机进行通信。只要这八步中有一步可以解析成功,那就可以成功和目标计算机进行通信。

4. 建立 TCP 连接

这里要提醒一点,Chrome 在同一个域名下要求同时最多只能有 6 个 TCP 连接,超过 6 个的话剩下的请求就得等待。
假设现在不需要等待,我们进入了 TCP 连接的建立阶段。首先解释一下什么是 TCP:

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。

建立 TCP连接经历了下面三个阶段:

  1. 通过三次握手(即总共发送3个数据包确认已经建立连接)建立客户端和服务器之间的连接。
  2. 进行数据传输。这里有一个重要的机制,就是接收方接收到数据包后必须要向发送方确认, 如果发送方没有接到这个确认的消息,就判定为数据包丢失,并重新发送该数据包。当然,发送的过程中还有一个优化策略,就是把大的数据包拆成一个个小包,依次传输到接收方,接收方按照这个小包的顺序把它们组装成完整数据包。
  3. 断开连接的阶段。数据传输完成,现在要断开连接了,通过四次挥手来断开连接。

读到这里,你应该明白 TCP 连接通过什么手段来保证数据传输的可靠性,一是三次握手确认连接,二是数据包校验保证数据到达接收方,三是通过四次挥手断开连接。
当然,如果再深入地问,比如为什么要三次握手,两次不行吗?第三次握手失败了怎么办?为什么要四次挥手等等这一系列的问题,涉及计算机网络的基础知识,比较底层,但是也是非常重要的细节,希望你能好好研究一下,另外这里有一篇不错的文章,点击进入相应的推荐文章,相信这篇文章能给你启发。

5. 发送 HTTP 请求

现在TCP连接建立完毕,浏览器可以和服务器开始通信,即开始发送 HTTP 请求。浏览器发 HTTP 请求要携带三样东西:请求行请求头请求体
首先,浏览器会向服务器发送请求行,关于请求行, 我们在这一部分的第一步就构建完了,贴一下内容:

  1. // 请求方法是GET,路径为根路径,HTTP协议版本为1.1
  2. GET / HTTP/1.1

结构很简单,由请求方法请求URIHTTP版本协议组成。
同时也要带上请求头,比如我们之前说的Cache-ControlIf-Modified-SinceIf-None-Match都由可能被放入请求头中作为缓存的标识信息。当然了还有一些其他的属性,列举如下:

  1. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
  2. Accept-Encoding: gzip, deflate, br
  3. Accept-Language: zh-CN,zh;q=0.9
  4. Cache-Control: no-cache
  5. Connection: keep-alive
  6. Cookie: /* 省略cookie信息 */
  7. Host: www.baidu.com
  8. Pragma: no-cache
  9. Upgrade-Insecure-Requests: 1
  10. User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1

最后是请求体,请求体只有在POST方法下存在,常见的场景是表单提交

1.2 网络响应

HTTP 请求到达服务器,服务器进行对应的处理。最后要把数据传给浏览器,也就是返回网络响应。
跟请求部分类似,网络响应具有三个部分:响应行响应头响应体
响应行类似下面这样:

  1. HTTP/1.1 200 OK

HTTP协议版本状态码状态描述组成。
响应头包含了服务器及其返回数据的一些信息, 服务器生成数据的时间、返回的数据类型以及对即将写入的Cookie信息。
举例如下:

  1. Cache-Control: no-cache
  2. Connection: keep-alive
  3. Content-Encoding: gzip
  4. Content-Type: text/html;charset=utf-8
  5. Date: Wed, 04 Dec 2019 12:29:13 GMT
  6. Server: apache
  7. Set-Cookie: rsv_i=f9a0SIItKqzv7kqgAAgphbGyRts3RwTg%2FLyU3Y5Eh5LwyfOOrAsvdezbay0QqkDqFZ0DfQXby4wXKT8Au8O7ZT9UuMsBq2k; path=/; domain=.baidu.com

响应完成之后怎么办?TCP 连接就断开了吗?
不一定。这时候要判断Connection字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。
否则断开TCP连接, 请求-响应流程结束。

2.解析部分

完成了网络请求和响应,如果响应头中Content-Type的值是text/html,那么接下来就是浏览器的解析渲染工作了。
首先来介绍解析部分,主要分为以下几个步骤:

  • 构建 DOM
  • 样式计算
  • 生成布局树(Layout Tree)

    2.1 构建 DOM 树

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

    HTML文法的本质

    首先,我们应该清楚把握一点: HTML 的文法并不是上下文无关文法
    这里,有必要讨论一下什么是上下文无关文法
    在计算机科学的编译原理学科中,有非常明确的定义:

    若一个形式文法G = (N, Σ, P, S) 的产生式规则都取如下的形式:V->w,则叫上下文无关语法。其中 V∈N ,w∈(N∪Σ)* 。

其中把 G = (N, Σ, P, S) 中各个参量的意义解释一下:

  1. N 是非终结符(顾名思义,就是说最后一个符号不是它, 下面同理)集合。
  2. Σ 是终结符集合。
  3. P 是开始符,它必须属于 N ,也就是非终结符。
  4. S 就是不同的产生式的集合。如 S -> aSb 等等。

通俗一点讲,上下文无关的文法就是说这个文法中所有产生式的左边都是一个非终结符。
看到这里,如果还有一点懵圈,我举个例子你就明白了。
比如:

  1. A -> B

这个文法中,每个产生式左边都会有一个非终结符,这就是上下文无关的文法。在这种情况下,xBy一定是可以规约出xAy的。
我们下面看看看一个反例:

  1. aA -> B
  2. Aa -> B

这种情况就是不是上下文无关的文法,当遇到B的时候,我们不知道到底能不能规约出A,取决于左边或者右边是否有a存在,也就是说和上下文有关。
关于它为什么是非上下文无关文法,首先需要让大家注意的是,规范的 HTML 语法,是符合上下文无关文法的,能够体现它非上下文无关的是不标准的语法。在此我仅举一个反例即可证明。
比如解析器扫描到form标签的时候,上下文无关文法的处理方式是直接创建对应 form 的 DOM 对象,而真实的 HTML5 场景中却不是这样,解析器会查看 form 的上下文,如果这个 form 标签的父标签也是 form, 那么直接跳过当前的 form 标签,否则才创建 DOM 对象。
常规的编程语言都是上下文无关的,而HTML却相反,也正是它非上下文无关的特性,决定了HTML Parser并不能使用常规编程语言的解析器来完成,需要另辟蹊径。

解析算法

HTML5 规范详细地介绍了解析算法。这个算法分为两个阶段:

  1. 标记化。
  2. 建树。

对应的两个过程就是词法分析语法分析

标记化算法

这个算法输入为HTML文本,输出为HTML标记,也成为标记生成器。其中运用有限自动状态机来完成。即在当当前状态下,接收一个或多个字符,就会更新到下一个状态。

  1. <html>
  2. <body>
  3. Hello sanyuan
  4. </body>
  5. </html>

通过一个简单的例子来演示一下标记化的过程。
遇到<, 状态为标记打开
接收[a-z]的字符,会进入标记名称状态
这个状态一直保持,直到遇到>,表示标记名称记录完成,这时候变为数据状态
接下来遇到body标签做同样的处理。
这个时候htmlbody的标记都记录好了。
现在来到中的>,进入数据状态,之后保持这样状态接收后面的字符hello sanyuan
接着接收 中的<,回到标记打开, 接收下一个/后,这时候会创建一个end tag的token。
随后进入标记名称状态, 遇到>回到数据状态
接着以同样的样式处理 。

建树算法

之前提到过,DOM 树是一个以document为根节点的多叉树。因此解析器首先会创建一个document对象。标记生成器会把每个标记的信息发送给建树器建树器接收到相应的标记时,会创建对应的 DOM 对象。创建这个DOM对象后会做两件事情:

  1. DOM对象加入 DOM 树中。
  2. 将对应标记压入存放开放(与闭合标签意思对应)元素的栈中。

还是拿下面这个例子说:

  1. <html>
  2. <body>
  3. Hello sanyuan
  4. </body>
  5. </html>

首先,状态为初始化状态
接收到标记生成器传来的html标签,这时候状态变为before html状态。同时创建一个HTMLHtmlElement的 DOM 元素, 将其加到document根对象上,并进行压栈操作。
接着状态自动变为before head, 此时从标记生成器那边传来body,表示并没有head, 这时候建树器会自动创建一个HTMLHeadElement并将其加入到DOM树中。
现在进入到in head状态, 然后直接跳到after head
现在标记生成器传来了body标记,创建HTMLBodyElement, 插入到DOM树中,同时压入开放标记栈。
接着状态变为in body,然后来接收后面一系列的字符: Hello sanyuan。接收到第一个字符的时候,会创建一个Text节点并把字符插入其中,然后把Text节点插入到 DOM 树中body元素的下面。随着不断接收后面的字符,这些字符会附在Text节点上。
现在,标记生成器传过来一个body的结束标记,进入到after body状态。
标记生成器最后传过来一个html的结束标记, 进入到after after body的状态,表示解析过程到此结束。

容错机制

讲到HTML5规范,就不得不说它强大的宽容策略, 容错能力非常强,虽然大家褒贬不一,不过我想作为一名资深的前端工程师,有必要知道HTML Parser在容错方面做了哪些事情。
接下来是 WebKit 中一些经典的容错示例,发现有其他的也欢迎来补充。

  1. 使用
    而不是

    1. if (t->isCloseTag(brTag) && m_document->inCompatMode()) {
    2. reportError(MalformedBRError);
    3. t->beginTag = true;
    4. }

    全部换为
    的形式。

  2. 表格离散

    1. <table>
    2. <table>
    3. <tr><td>inner table</td></tr>
    4. </table>
    5. <tr><td>outer table</td></tr>
    6. </table>

    WebKit会自动转换为:

    1. <table>
    2. <tr><td>outer table</td></tr>
    3. </table>
    4. <table>
    5. <tr><td>inner table</td></tr>
    6. </table>
  3. 表单元素嵌套

这时候直接忽略里面的form

2.2 样式计算

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

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

    格式化样式表

    首先,浏览器是无法直接识别 CSS 样式文本的,因此渲染引擎接收到 CSS 文本之后第一件事情就是将其转化为一个结构化的对象,即styleSheets。
    这个格式化的过程过于复杂,而且对于不同的浏览器会有不同的优化策略,这里就不展开了。
    在浏览器控制台能够通过document.styleSheets来查看这个最终的结构。当然,这个结构包含了以上三种CSS来源,为后面的样式操作提供了基础。

    标准化样式属性

    有一些 CSS 样式的数值并不容易被渲染引擎所理解,因此需要在计算样式之前将它们标准化,如em->px,red->#ff0000,bold->700等等。

    计算每个节点的具体样式

    样式已经被格式化标准化,接下来就可以计算每个节点的具体样式信息了。
    其实计算的方式也并不复杂,主要就是两个规则: 继承层叠
    每个子节点都会默认继承父节点的样式属性,如果父节点中没有找到,就会采用浏览器默认样式,也叫UserAgent样式。这就是继承规则,非常容易理解。
    然后是层叠规则,CSS 最大的特点在于它的层叠性,也就是最终的样式取决于各个属性共同作用的效果,甚至有很多诡异的层叠现象,看过《CSS世界》的同学应该对此深有体会,具体的层叠规则属于深入 CSS 语言的范畴,这里就不过多介绍了。
    不过值得注意的是,在计算完样式之后,所有的样式值会被挂在到window.getComputedStyle当中,也就是可以通过JS来获取计算后的样式,非常方便。

    2.3 生成布局树

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

  4. 遍历生成的 DOM 树节点,并把他们添加到布局树中

  5. 计算布局树节点的坐标位置。

值得注意的是,这棵布局树值包含可见元素,对于 head标签和设置了display: none的元素,将不会被放入其中。
有人说首先会生成Render Tree,也就是渲染树,其实这还是 16 年之前的事情,现在 Chrome 团队已经做了大量的重构,已经没有生成Render Tree的过程了。而布局树的信息已经非常完善,完全拥有Render Tree的功能。
之所以不讲布局的细节,是因为它过于复杂,一一介绍会显得文章过于臃肿,不过大部分情况下我们只需要知道它所做的工作是什么即可,如果想深入其中的原理,知道它是如何来做的,我强烈推荐你去读一读人人FED团队的文章从Chrome源码看浏览器如何layout布局

3.渲染部分

上一节介绍了浏览器解析的过程,其中包含构建DOM样式计算构建布局树
接下来就来拆解下一个过程——渲染。分为以下几个步骤:

  • 建立图层树(Layer Tree)
  • 生成绘制列表
  • 生成图块栅格化
  • 显示器显示内容

    3.1 建图层树

    如果你觉得现在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本身,而不会影响到其他的层。

3.2 生成绘制列表

接下来渲染引擎会将图层的绘制拆分成一个个绘制指令,比如先画背景、再描绘边框……然后将这些指令按顺序组合成一个待绘制列表,相当于给后面的绘制操作做了一波计划。
这里我以百度首页为例,大家可以在 Chrome 开发者工具中在设置栏中展开 more tools, 然后选择Layers面板,就能看到下面的绘制列表:
从浏览器输入URL到页面呈现发生了什么 - 图1

3.3 生成图块和生成位图

现在开始绘制操作,实际上在渲染进程中绘制操作是由专门的线程来完成的,这个线程叫合成线程
绘制列表准备好了之后,渲染进程的主线程会给合成线程发送commit消息,把绘制列表提交给合成线程。接下来就是合成线程一展宏图的时候啦。
首先,考虑到视口就这么大,当页面非常大的时候,要滑很长时间才能滑到底,如果要一口气全部绘制出来是相当浪费性能的。因此,合成线程要做的第一件事情就是将图层分块。这些块的大小一般不会特别大,通常是 256 256 或者 512 512 这个规格。这样可以大大加速页面的首屏展示。
因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。
顺便提醒一点,渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据
然后合成线程会选择视口附近的图块,把它交给栅格化线程池生成位图。
生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程

3.4 显示器显示内容

栅格化操作完成后,合成线程会生成一个绘制命令,即”DrawQuad”,并发送给浏览器进程。
浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。为什么发给显卡呢?我想有必要先聊一聊显示器显示图像的原理。
无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区后缓冲区对换位置,如此循环更新。
看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。

4.总结

到这里,我们算是把整个过程给走通了,现在重新来梳理一下页面渲染的流程。
从浏览器输入URL到页面呈现发生了什么 - 图2