技术分享PPT整理(三):网页渲染流程

283 阅读15分钟

在我刚开始学习Web开发的时候,一直有个疑问——我写出的代码究竟是在什么时候发生作用的呢?是不是每次我修改代码网页都随之变化了?当然,现在来看这肯定是一个错误的想法,经过一段时间的工作和学习后,代码到页面转换的路径在我的脑海里愈发清晰,虽然“输入URL到网页显示之间发生了什么?”是个老生常谈的问题,但我还是想按自己的理解来说明一遍。

浏览器架构

首先从我们最熟悉的朋友开始说起,Web开发离不开浏览器,我在查资料的时候有开很多选项卡的习惯,每次打开任务管理器都能看到Chrome浏览器在内存占用方面一枝独秀,另外还能看到应用名称后面的括号里有个数字,如下图所示,但我打开的标签页是不到23的,那么剩下的进程是什么呢?

我们来看一张经典的图,它描绘了Chrome浏览器中四种进程的位置和作用:

  • 浏览器进程 (Browser Process):负责浏览器的TAB的前进、后退、地址栏、书签栏的工作和处理浏览器的一些不可见的底层操作,比如网络请求和文件访问。
  • 渲染进程 (Renderer Process):负责一个Tab内的显示相关的工作,也称渲染引擎。
  • 插件进程 (Plugin Process):负责控制网页使用到的插件
  • GPU进程 (GPU Process):负责处理整个应用程序的GPU任务

渲染进程较为特殊,每个选项卡里都需要一个渲染进程,它也是网页渲染的核心,我们在下一节详细说明,关于这些进程,可以在浏览器自带的进程管理器中详细查看:

由于经常要做多浏览器兼容,经常同时打开几个浏览器,即使没有仔细对比还是可以发现Chrome浏览器在内存占用方面算是相对比较高的,而Firefox则相对要低很多,这是因为Firefox的Tab进程和IE的Tab进程都采用了类似的策略:有多个Tab进程,但都不一定是一个页面一个Tab进程,一个Tab进程可能会负责多个页面的渲染。而作为对比,Chrome是以一个页面一个渲染进程,加上站点隔离的策略来进行的。虽然内存占用确实比较高,但是这种多进程架构也有独特的优势:

  1. 更高的容错性。当今WEB应用中,HTML,JavaScript和CSS日益复杂,这些跑在渲染引擎的代码,频繁的出现BUG,而有些BUG会直接导致渲染引擎崩溃,多进程架构使得每一个渲染引擎运行在各自的进程中,相互之间不受影响,也就是说,当其中一个页面崩溃挂掉之后,其他页面还可以正常的运行不收影响。
  2. 更高的安全性和沙盒性(sanboxing)。渲染引擎会经常性的在网络上遇到不可信、甚至是恶意的代码,它们会利用这些漏洞在你的电脑上安装恶意的软件,针对这一问题,浏览器对不同进程限制了不同的权限,并为其提供沙盒运行环境,使其更安全更可靠
  3. 更高的响应速度。在单进程的架构中,各个任务相互竞争抢夺CPU资源,使得浏览器响应速度变慢,而多进程架构正好规避了这一缺点。

网页渲染

大致来说,输入URL后要经过五个步骤网页才会渲染完成:

  1. DNS 查询
  2. TCP 连接
  3. HTTP 请求即响应
  4. 服务器响应
  5. 客户端渲染

首先,如果输入的是域名,浏览器会先从hosts文件中查找有没有对应的设置,如果没有则访问附近的DNS服务器进行DNS查询获取正确的IP地址,之后进行TCP连接,通过三次握手建立连接后开始处理HTTP请求,服务器端收到请求返回响应文档,拿到响应文档的浏览器开始使用渲染引擎进行页面渲染。

这里说到的渲染引擎就是我们经常说到的浏览器内容,例如Webkit、Gecko这些。

渲染引擎

浏览器内核是多线程,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

  1. GUI 渲染线程
  • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。
  • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
  • GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。
  1. JavaScript引擎线程
  • 也称为JS内核,负责处理Javascript脚本程序。(例如V8引擎)
  • JS引擎线程负责解析Javascript脚本,运行代码。
  • JS引擎一直等待着任务队列中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
  • 同样注意,GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
  1. 定时触发器线程
  • 传说中的setInterval与setTimeout所在线程
  • 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
  • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
  1. 事件触发线程
  • 归属于浏览器而不是JS引擎,用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
  • 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
  • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
  • 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)
  1. 异步http请求线程
  • 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
  • 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行。

这五个线程各司其职,但我们这里还是将目光放到GUI渲染上:

渲染流程

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,以计算每个节点的几何信息。
  5. 将各个节点绘制到屏幕上。

1. DOMTree的构建(Document Object Model)

第一步(解析):从网络或者磁盘下读取的HTML原始字节码,通过设置的charset编码,转换成字符

第二步(token化):通过词法分析器,将字符串解析成Token,Token中会标注出当前的Token是开始标签,还是结束标签,或者文本标签等。

第三步(生成Nodes并构建DOM树):浏览器会根据Tokens里记录的开始标签,结束标签,将Tokens之间相互串联起来(带有结束标签的Token不会生成Node)。

2. CSSOMTree的构建(CSS Object Model)

当HTML代码遇见标签时,浏览器会发送请求获得该标签中标记的CSS文件(使用内联CSS可以省略请求的步骤提高速度,但没有必要为了这点速度而丢失了模块化与可维护性),style.css中的内容见下图:

浏览器获得外部CSS文件的数据后,就会像构建DOM树一样开始构建CSSOM树,这个过程没有什么特别的差别。

从图中可以看出,最开始body有一个样式规则是font-size:16px,之后,在body这个样式基础上每个子节点还会添加自己单独的样式规则,比如span又添加了一个样式规则color:red。正是因为样式这种类似于继承的特性,浏览器设定了一条规则:**CSSOMTree需要等到完全构建后才可以被使用,因为后面的属性可能会覆盖掉前面的设置。**比如在上面的css代码基础上再添加一行代码p {font-size:12px},那么之前设置的16px将会被覆盖成12px。

看到这里,感觉好像少了什么?我们的页面不会只包含HTML和CSS,JavaScript通常也在页面中占有很大的比重,并且JavaScript也是引发性能问题的重要因素,这里通过解答下面几个问题来说明JavaScript在页面渲染中的情况。

问题:渲染过程中遇到JS文件怎么处理?

由于JavaScript是可操纵DOM的,如果在修改这些元素属性同时渲染界面(即JS线程和UI线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。

因此为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥的关系,当JS引擎执行时GUI线程会被挂起, GUI更新则会被保存在一个队列中等到JS引擎 线程空闲时立即被执行。

也就是说,在构建DOM时,HTML解析器若遇到了JavaScript,那么它会暂停构建DOM,将控制权移交给JavaScript引擎,等JavaScript引擎运行完毕,浏览器再从中断的地方恢复DOM构建。

问题:为什么有时在js中访问DOM时浏览器会报错?

因为在解析的过程中,如果碰到了script或者link标签,就会根据src对应的地址去加载资源,在script标签没有设置async/defer属性时,这个加载过程是下载并执行完全部的代码,此时,DOM树还没有完全创建完毕,这个时候如果js企图访问script标签后面的DOM元素,浏览器就会抛出找不到该DOM元素的错误。

问题:平时谈及页面性能优化,经常会强调css文件应该放在html文档中的前面引入,js文件应该放在后面引入,这么做的原因是什么呢?

本来,DOM构建和CSSOM构建是两个过程,井水不犯河水。假设DOM构建完成需要1s,CSSOM构建也需要1s,在DOM构建了0.2s时发现了一个link标签,此时完成这个操作需要的时间大概是1.2s,如下图所示:

但JS也可以修改CSS样式,影响CSSOMTree最终的结果,而我们前面提到,不完整的CSSOMTree是不可以被使用的。

问题:如果JS试图在浏览器还未完成CSSOMTree的下载和构建时去操作CSS样式,会发生什么?

我们在HTML文档的中间插中入了一段JS代码,在DOM构建中间的过程中发现了这个script标签,假设这段JS代码只需要执行0.0001s,那么完成这个操作需要的时间就会变成:

那如果我们把css放到前面,js放到最后引入时,构建时间会变成:

由此可见,虽然只是插入了小小的一段只运行0.0001s的js代码,不同的引入时机也会严重影响DOMTree的构建速度。

简而言之,如果在DOM,CSSOM和JavaScript执行之间引入大量的依赖关系,可能会导致浏览器在处理渲染资源时出现大幅度延迟:

  • 当浏览器遇到一个script标签时,DOMTree的构建将被暂停,直至脚本执行完毕
  • JavaScript可以查询和修改DOMTree与CSSOMTree
  • 直至CSSOM构建完毕,JavaScript才会执行
  • 脚本在文档中的位置很重要

3. 渲染树的构建

当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为渲染树。

  • Render 树上的每一个节点被称为:RenderObject。
  • RenderObject跟 DOM 节点几乎是一一对应的,当一个可见的 DOM 节点被添加到 DOM 树上时,内核就会为它生成对应的 RenderOject 添加到 Render 树上。
  • 其中,可见的DOM节点不包括:
    1. 一些不会体现在渲染输出中的节点(<html><script><link>….),会直接被忽略掉。
    2. 通过CSS隐藏的节点。例如上图中的span节点,因为有一个CSS显式规则在该节点上设置了display:none属性,那么它在生成RenderObject时会被直接忽略掉。
  • Render 树是衔接浏览器排版引擎和渲染引擎之间的桥梁,它是排版引擎的输出,渲染引擎的输入

4. 布局

到目前为止,浏览器计算出了哪些节点是可见的以及它的信息和样式,接下来就需要计算这些节点在设备视口内的确切位置和大小,这个过程我们称之为“布局”。

布局最后的输出是一个“盒模型”:将所有相对测量值都转换成屏幕上的绝对像素。

5. 渲染

当Layout布局事件完成后,浏览器会立即发出Paint Setup与Paint事件,开始将渲染树绘制成像素,绘制所需的时间跟CSS样式的复杂度成正比,绘制完成后,用户就可以看到页面的最终呈现效果了。

总结

我们对一个网页发送请求并获得渲染后的页面可能也就经过了1~2秒,但浏览器其实已经做了上述所讲的非常多的工作,总结一下浏览器关键渲染路径的整个过程:

  1. 处理HTML标记数据并生成DOM树。
  2. 处理CSS标记数据并生成CSSOM树。
  3. 将DOM树与CSSOM树合并在一起生成渲染树。
  4. 遍历渲染树开始布局,计算每个节点的位置信息。
  5. 将每个节点绘制到屏幕。

相关问题

defer 和 async

上面我们还提到一个小知识点:在script标签没有设置async/defer属性时,这个加载过程是下载并执行完全部的代码。如果有设置这两个属性会有什么不同呢?

其中蓝色线代表JavaScript加载;红色线代表JavaScript执行;绿色线代表 HTML 解析。

  • 情况1 <script src="script.js"></script> 没有 defer 或 async,浏览器会立即加载并执行指定的脚本,也就是说不等待后续载入的文档元素,读到就加载并执行。

  • 情况2 <script async src="script.js"></script> (异步下载) async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。

  • 情况3 <script defer src="script.js"></script> (延迟执行) defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。

defer 与相比普通 script,有两点区别:

  1. 载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。
  2. 在加载多个JS脚本的时候,async是无顺序的加载,而defer是有顺序的加载。

回流(reflow)和重绘(repaint)

我们知道,当网页生成的时候,至少会渲染一次。在用户访问的过程中,还会不断重新渲染。重新渲染会重复上图中的第四步(回流)+第五步(重绘)或者只有第五个步(重绘)。

  • 重绘:当render tree中的一些元素需要更新属性,而这些属性只是影响元素的外观、风格,而不会影响布局的,比如background-color。
  • 回流:当render tree中的一部分(或全部)因为元素的规模尺寸、布局、隐藏等改变而需要重新构建

**回流必定会发生重绘,重绘不一定会引发回流。**重绘和回流会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。

常见引起回流属性和方法

任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发回流,添加或者删除可见的DOM元素:

  • 元素尺寸改变——边距、填充、边框、宽度和高度
  • 内容变化,比如用户在input框中输入文字
  • 浏览器窗口尺寸改变——resize事件发生时
  • 计算 offsetWidth 和 offsetHeight 属性
  • 设置 style 属性的值

如何减少回流、重绘

  • 使用 transform 替代 top
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • CSS 选择符从右往左匹配查找,避免节点层级过多
  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点。比如对于 video 标签来说,浏览器会自动将该节点变为图层。

为什么操作 DOM 慢

因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。

这也就是为什么我们在使用Vue.js框架时会感觉流畅程度明显高于传统的页面,因为Vue.js使用的是虚拟DOM,若一次操作中有10次更新DOM的动作,虚拟DOM不会立即操作DOM,而是将这10次更新的diff内容保存到本地一个JS对象中,最终将这个JS对象一次性attch到DOM树上,再进行后续操作,避免大量无谓的计算量。