详解浏览器工作原理

3,442 阅读17分钟

前言

本文主要介绍如下内容:

  • 浏览器的高层结构
  • 浏览器的渲染原理
  • 浏览器如何加载Javascript脚本
  • Javascript引擎如何工作

引入

在浏览器中输入URL并回车后都发生了什么?

让我们从大家最熟悉的这个面试问题引入,先不往下看文章,你能脱口而出的说出答案嘛?如果可以恭喜你,你可以跳过这一小节。如果不可以那就还是看一下吧~

  1. URL解析:从URL中抽取出域名字段
  2. DNS域名解析
    • 查找浏览器缓存:浏览器会缓存2-30分钟访问过网站的DNS信息,如未找到
    • 检查系统缓存:检查hosts文件,它保存了一些访问过网站的域名和IP的数据,如未找到
    • 检查路由器缓存:路由器有自己的DNS缓存,如未找到
    • 检查ISP DNS缓存:ISP服务商DNS缓存(本地服务器缓存),如未找到
    • 递归查询:从根域名服务器到顶级域名服务器再到极限域名服务器依次搜索对应目标域名的IP
  3. 浏览器与服务器建立TCP连接(3次握手)
    • 第一次握手:客户端向服务器端发送请求等待服务器确认
    • 第二次握手:服务器收到请求并确认,回复一个指令
    • 第三次握手:客户端收到服务器的回复指令并返回确认
  4. 请求和传输数据:服务器解析客户端请求,并返回相应的数据
  5. 浏览器渲染页面:这里先空着,我们下面讲浏览器原理时展开
  6. 关闭TCP连接:当数据完成请求到返回的过程之后,根据Connection的Keep-Alive属性可以选择是否断开TCP连接,四次挥手释放。

Emmmm,到这里这道题目基本解答完毕,但是却引出了另一个问题,浏览器从服务端拿到数据后做了什么才将网页呈现到我们的显示器上,下面就让我们一起来探索浏览器的秘密吧~~

浏览器的高层结构

首先让我们看一下浏览器的主要组件:

  • 用户界面:包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。
  • 浏览器引擎:在用户界面和呈现引擎之间传送指令。
  • 呈现引擎:负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。
  • 网络:用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。
  • 用户界面后端:用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。
  • Javascript解析器:用于解析和执行 JavaScript 代码。
  • 数据存储:这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。

值得注意的是,和大多数浏览器不同,Chrome浏览器的每个标签页都分别对应一个呈现引擎实例。每个标签页都是一个独立的进程。

浏览器的呈现引擎

在浏览器的主要组件中我们最关注的就是浏览器的呈现引擎,因为呈现引擎,顾名思义,它决定了呈现在浏览器的内容。呈现引擎也叫做浏览器内核,不同浏览器使用的呈现引擎是不一样的。常见浏览器使用的呈现引擎如下:

呈现引擎 浏览器
Trident(MSHTML) IE,MaxThon,TT,The World,360,搜狗浏览器等
Gecko Netscape6 及以上版本,FF,MozillaSuite/SeaMonkey 等
Presto Opera7及以上。 [Opera内核原为:Presto,现为:Blink]
Webkit Safari,Chrome等。[Chrome:Blink(WebKit 的分支)]
EdgeHTML Microsoft Edge。 [此内核其实是从 MSHTML fork 而来,删掉了几乎所有的 IE私有特性]

下面我们将以Webkit为例讲解浏览器呈现引擎工作的主要流程,Gecko的工作流程与Webkit基本是相同的,只是术语略有不同。

Webkit的主要工作流程

上图展示的是webkit的主要工作流程,接下来我们按照图片的流程来逐渐阐述Webkit是如何工作的。但在这之前我们先要明白从HTTP请求回来开始,呈现引擎的整个工作流程不是一步做完再做下一步,而是一条流水线。

从HTTP请求回来,就产生了流式的数据,后续的DOM树构建、CSS计算、渲染、合成、绘制,都是尽可能地流式处理前一步的产出:即不需要等到上一步骤完全结束,就开始处理上一步的输出,这样我们在浏览网页时,才会看到逐步出现的页面。

HTML解析:从HTML到DOM树

Webkit会用HTML解析算法将HTML转换成DOM树。下面让我们看一下HTML到DOM树的转换:

接下来让我们了解一下HTML解析算法,HTML解析算法的流程如下图所示:
它分为标记化树构建两个过程:

  • 标记化:将输入的内容解析成多个标记(HTML标记包括起始标记,结束标记,属性名称,属性值)。标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直至结束。标记化算法是通过状态机实现的。
  • 树构建:标记生成器发送的每个节点都由树构建器进行处理,规范中定义了每个标记所对应的DOM元素,这些元素会在接收到对应的标记时构建,这些元素不仅会被添加到DOM树,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记,其算法也可以用状态机来表示。

HTML解析完成后,浏览器会将文档状态标注为交互状态,并开始解析那些处于deferred模式的脚本,然后文档状态设置为完成,一个加载时间将随之触发。

CSS解析:从CSS到StyleSheet对象

CSS解析器会将CSS文件解析成StyleSheet对象,下面让我们来看一下CSS到StyleSheet的转换:

这里我们不讲CSS构建的过程,感兴趣的小伙伴可以看一下参考资料里的重学前端,我们简单的介绍一下CSS选择器的特点,这是由CSS设计原则所决定的。

  1. 选择器出现的顺序必定跟构建DOM树的顺序一致,即保证选择器在构建到当前节点时,已经可以准确判断该节点所匹配的CSS规则,不需要后续节点信息。
  2. CSS样式匹配时是从右向左匹配的,DOM找到它所有匹配的CSS样式后再做加权计算,确定最终样式,所以也就不难理解chrome操作台内样式表信息为何那样展示了。

构建呈现树:整合DOM树和StyleSheet对象为呈现树

构建呈现树时,需要计算每一个呈现对象的可视化属性。每个DOM节点都有一个"attach"方法,在节点插入DOM树时会调用节点的attach方法,计算该节点的样式属性生成呈现器。下面让我们看一下整合(webkit的术语叫‘附加’)的过程:

排版:将呈现器盒子放到对应的位置

所有的呈现器都有一个“layout”或者“reflow”方法,每一个呈现器都会调用其需要进行布局的子代的layout方法。有很多排版方法:正常流文字排版,绝对定位,浮动元素排版,flex排版等。

渲染:把每一个呈现器对应的盒子变成位图

这里的渲染是借用计算机图形学里面的解释,就是把模型变成位图的过程。

位图就是在内存里建立一张二维表格,把一张图片的每个像素对应的颜色保存进去(位图信息也是DOM树中占据浏览器内存最多的信息,我们在做内存占用优化时,主要就是考虑这一部分)。

合成:合成位图,提升性能

这个过程实际上是一个性能考量,它并非实现浏览器的必要一环。合成的过程就是根据合成策略合并位图。合成策略就是最大限度的减少绘制次数,它是“猜测”可能变化的元素,将它排除到合成之外。

目前,主流浏览器一般根据position、transform等属性来决定合成策略,来“猜测”这些元素未来可能发生变化。但是,这样的猜测准确性有限,所以新的CSS标准中,规定了will-change属性,可以由业务代码来提示浏览器的合成策略,灵活运用这样的特性,可以大大提升合成策略的效果。

绘制:将位图绘制到屏幕上,变成肉眼可见的图像的过程

一般来说,浏览器并不需要用代码来处理这个过程,浏览器只需要把最终要显示的位图交给操作系统即可。

到这里我们已经将Webkit主要的工作流程捋了一遍,现在让我们来总结一下,从HTTP请求回来的数据通过HTML解析器和CSS解析器,分别解析成DOM树和StyleSheet对象,然后整合两者生成呈现树,呈现树调用layout进行排版,然后通过渲染将呈现器盒子变成位图,根据合成策略合成位图提升绘制性能,把位图给操作系统让其绘制到屏幕上。看到这里我们很容易就理解了一个小知识点:CSS不会阻塞DOM的解析,但会阻塞DOM的渲染。

现在我们已经以Webkit为例介绍了呈现引擎的主要工作流程,但是我们似乎还遗漏了些什么。对的,Javascript,我们好像一直没有提及当Webkit解析到JavaScript代码时会怎么处理,接下来就让我们一起来看一看这一部分知识吧~~

浏览器加载JavaScript脚本

正常加载流程

浏览器加载JavaScript脚本,主要通过<script>元素完成。其正常流程如下

  1. 浏览器的呈现引擎持有渲染的控制权,它正常解析HTML页面
  2. 解析遇到<script>标签,呈现引擎移交控制权给Javascript引擎(例如chrome的V8)
  3. 如果<script>标签引用了外部脚本那就先下载再执行,否则直接执行代码
  4. JavaScript引擎执行完毕移交控制权给呈现引擎,呈现引擎继续解析

加载外部脚本时,浏览器会暂停页面渲染,等待脚本下载并执行完成后,再继续渲染。原因是 JavaScript 代码可以修改 DOM,所以必须把控制权让给它,否则会导致复杂的线程竞赛的问题。

defer属性

浏览器解析到包含defer属性的<script>元素时,其运行流程如下

  1. 浏览器的呈现引擎持有渲染的控制权,它正常解析HTML页面
  2. 解析遇到包含defer属性的<script>标签,继续解析HTML,同时并行下载外链脚本
  3. 解析完成,文档处于交互状态时开始解析处于deferred模式的脚本
  4. 脚本解析完毕后,将文档状态设置为完成,DOMContentLoaded事件随之触发

使用defer属性时需要注意的点:

  • defer属性下载的脚本文件在DOMContentLoaded事件触发前执行(即刚刚读取完</html>标签)
  • defer属性可以保证执行顺序就是它们在页面上出现的顺序
  • 对于内置而不是加载外部脚本的script标签,以及动态生成的script标签,defer属性不起作用
  • 使用defer加载的外部脚本不应该使用document.write方法

async属性

浏览器解析到包含async属性的<script>元素时,其运行流程如下

  1. 浏览器的呈现引擎持有渲染的控制权,它正常解析HTML页面
  2. 解析遇到包含async属性的<script>标签,继续解析HTML,让另一进程同时并行下载外链脚本
  3. 脚本下载完成,浏览器暂停解析HTML,开始执行下载的脚本
  4. 脚本执行完毕,浏览器恢复解析HTML

使用async属性时需要注意的点:

  • async属性可以保证脚本下载的同时,浏览器继续渲染
  • async属性无法保证脚本的执行顺序,哪个先下载结束就先执行哪一个
  • 包含async属性的脚本不应该使用document.write方法
  • 如果同时使用async和defer属性,后者不起作用,浏览器行为由async属性决定

脚本的动态加载

<script>元素还可以动态生成,生成后再插入页面,从而实现脚本的动态加载。动态生成的script标签不会阻塞页面渲染,也就不会造成浏览器假死。但是问题在于,这种方法无法保证脚本的执行顺序,哪个脚本文件先下载完成,就先执行哪个。如果想避免这个问题,可以设置async属性为false。还可以监听脚本的onload事件来为脚本指定回调。

CSS阻塞JS加载

因为JS脚本可能会引用DOM的样式做计算,所以为了保证脚本计算的正确性,Firefox浏览器会等到脚本前面的所有样式表,都下载并解析完,再执行脚本;Webkit则是一旦发现脚本引用了样式,就会暂停执行脚本,等到样式表下载并解析完,再恢复执行。

此外,对于来自同一个域名的资源,比如脚本文件、样式表文件、图片文件等,浏览器一般有限制,同时最多下载6~20个资源,即最多同时打开的 TCP 连接有限制,这是为了防止对服务器造成太大压力。如果是来自不同域名的资源,就没有这个限制。所以,通常把静态文件放在不同的域名之下,以加快下载速度。

浏览器预解析

WebKit和Firefox都进行了这项优化。在执行脚本时,其他线程会解析文档的其余部分,找出并加载需要通过网络加载的其他资源。通过这种方式,资源可以在并行连接上加载,从而提高总体速度。请注意,预解析器不会修改DOM树,而是将这项工作交由主解析器处理;预解析器只会解析外部资源(例如外部脚本、样式表和图片)的引用。

Emmmm,到这里我们就了解了浏览器的呈现引擎只负责解析HTML和CSS,遇到JS时它会把控制权交给JS的引擎来解析和执行。因为JS引擎拿走了渲染的控制权,所以JS显而易见会阻塞DOM的解析,为了让JS不阻塞DOM的解析浏览器进行了异步加载以及预解析等优化。嗯,我们已经了解了呈现引擎,接下来让我们了解一下它的小伙伴Javascript引擎的工作流程吧~~

Javascript引擎的工作原理

首先,让我们了解几个概念,这会帮助我们更好的理解js代码是如何执行的。

  • Javascript引擎:从头到尾负责整个 JavaScript 程序的编译及执行过程。
  • 编译器:负责语法分析及代码生成等脏活累活。具体工作如下图所示:

了解基础的概念后,现在让我们一起来捋一遍Javascript引擎是如何工作的吧~~

  1. 一个Javascript引擎常驻于内存中,它等待着宿主(例如浏览器)把Javascript代码传递给它执行。
  2. 当宿主向它传递Javascript代码时,它将Javascript源代码丢给编译器编译成可执行代码。
  3. 然后引擎开始执行可执行代码,执行过程如下:

将宿主(例如浏览器)发起的宏观任务添加到宏观任务队列,如果JS引擎主线程的任务栈是空的,它会自动从宏观任务队列拉取任务并执行,在执行过程中遇到setTimeout等异步代码会先放到计时器模块,计时器模块计时结束后加入将其加入宏观任务队列;在执行过程中遇到Promise等代码会将其作为一个微观任务加入到当前宏观任务末尾的微观任务队列中。当前宏观任务正常任务执行完毕后会执行当前宏观任务末尾的微观任务队列里面的任务,微观任务队列内任务执行完毕后该宏观任务执行完毕,主线程任务栈会拉取下一个宏观任务。在此期间宿主环境随时可能在宏观任务队列添加任务,JS引擎也随时可能在当前宏观任务队列末尾的微观任务队列添加微观任务。如图所示,形成了一个事件循环。

Emmmm,如果小伙伴们想进一步的了解JS引擎的工作细节,我推荐以下文章和视频来chrome的V8引擎是如何工作的。

感恩大家还能看到这里,文章可能还有一些地方不是特别完善,我会慢慢迭代完善的~~

结尾

最后谈一点点自己的感想,我个人一直觉得学习原理很重要,最近学习了圈外解决问题的课程就更加坚定了我的信念。因为解决问题的第一步就是澄清问题,而学习原理可以帮助我们更快的定位问题所在从而解决问题。爱因斯坦曾说,如果给我一个小时解答一道决定我生死的问题,我会花55分钟来弄清楚这道题目到底是在问什么。一旦清楚它到底在问什么,剩下的5分钟足够回答这个问题。在实际的工作中也确实如此,一旦程序出了问题,我们往往花大量的时间在调试上,而一旦找到了问题解决起来就很快了。

因为前端工程师打交道最多的就是浏览器,了解浏览器的工作原理不管是对写代码还是对项目的性能优化都会有所帮助,所以我断断续续看了许多关于浏览器工作原理的文章及书籍小册,终于觉得是时候整理输出一些东西,希望可以加深自己的理解,更希望可以对小伙伴们有所帮助。如果文章中有什么表述不对的地方,欢迎大家在评论区指正。最后感谢阅读这篇文章的小伙伴们。

参考资料