技术分享PPT整理(三):网页渲染流程
时间:2023-02-16 19:30:01
刚开始学习的时候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它是基于页面渲染过程和网站隔离策略。作为对比,Chrome它是基于一个页面、一个渲染过程和网站隔离策略。虽然内存占用率确实很高,但这种多过程架构也有独特的优势:
- 更高的容错性WEB应用中,HTML,JavaScript和CSS越来越复杂,这些代码经常出现在渲染引擎中BUG,而有些BUG它将直接导致渲染引擎的崩溃。多过程架构使每个渲染引擎在各自的过程中运行,不相互影响。也就是说,当其中一个页面崩溃并挂断时,其他页面可以正常运行,不受影响。
- 更高的安全性和沙箱性(sanboxing)。渲染引擎经常在网络上遇到不可信甚至恶意的代码。他们将利用这些漏洞在计算机上安装恶意软件。针对这个问题,浏览器限制不同过程的不同权限,并为其提供沙盒操作环境,使其更加安全可靠
- 更高的响应速度。在单进程的架构中,各个任务相互竞争抢夺CPU资源减慢了浏览器的响应速度,多过程架构正好避免了这一缺点。
网页渲染
一般来说,输入URL后要经过五个步骤网页才会渲染完成:
- DNS 查询
- TCP 连接
- HTTP 请求即响应
- 服务器响应
- 客户端渲染
首先,如果域名被输入,浏览器将首先从hosts找出文件中是否有相应的设置,如果没有,请访问附近的设置DNS服务器进行DNS获得正确的查询IP地址,然后进行TCP连接在三次握手建立后开始处理HTTP服务器端收到返回响应文档的请求,获得响应文档的浏览器开始使用渲染引擎进行页面渲染。
渲染引擎是我们常说的浏览器内容,比如Webkit、Gecko这些。
渲染引擎
浏览器内核为多线程,在内核控制下各线程相互配合,保持同步,浏览器通常由以下常驻线程组成:
- GUI 渲染线程
- 渲染浏览器界面,分析HTML,CSS,构建DOM树和RenderObject树木、布局、绘画等。
- 当界面需要重绘时(Repaint)或由某种操作引起回流(reflow)该线程将执行
- GUI渲染线程与JS当引擎线程相互排斥时,当JS引擎执行时GUI线程会挂起(相当于冻结),GUI更新将保存在队列中等到JS空闲时立即执行发动机。
- JavaScript引擎线程
- 也称为JS负责处理内核Javascript脚本程序V8引擎)
- JS引擎线程负责解析Javascript脚本,操作代码。
- JS引擎一直在等待任务队列中任务的到来,然后一个接一个地处理Tab页(renderer无论何时只有一个过程)JS线程在运行JS程序
- 同样注意,GUI渲染线程与JS引擎线程是相互排斥的,所以如果JS执行时间过长,会导致页面渲染不连贯,导致页面渲染加载堵塞。
- 传说中的setInterval与setTimeout所在线程
- 浏览器定时计数器不是由JavaScript(因为JavaScript发动机为单线程, 在阻塞线程状态下会影响记录时的准确性)
- 因此,通过单独的线程计时和触发定时(计时后,添加到事件队列中等待JS空闲后执行引擎)
- 事件触发线程
- 属于浏览器而不是浏览器JS可以理解,JS引擎本身并不忙,需要浏览器打开另一个线程来协助)
- 当JS引擎执行代码块setTimeOut(也可以来自浏览器内核的其他线程,如鼠标点击,AJAX异步请求等。)将在事件线程中添加相应的任务
- 当相应的事件符合触发条件时,线程将事件添加到待处理队列的尾部,等待JS引擎的处理
- 注意,由于JS单线程关系,所以队列中的这些事件必须排队等待JS引擎处理(当JS只有在发动机有空的时候才会执行)
- 异步http请求线程
- 在XMLHttpRequest连接后,通过浏览器打开新的线程请求
- 当检测到状态变化时,如果设置回调函数,异步线程会产生状态变化事件,然后将回调放入事件队列。JavaScript引擎执行。
这五个线程各司其职,但我们这里还是将目光放到GUI渲染上:
渲染流程
- 处理 HTML 标记并构建 DOM 树。
- 处理 CSS 标记并构建 CSSOM 树
- 将 DOM 与 CSSOM 合并成渲染树。
- 根据渲染树进行布局,计算每个节点的几何信息。
- 在屏幕上绘制每个节点。
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节点不包括:
- 一些不会体现在渲染输出中的节点(
没有 defer 或 async,浏览器会立即加载并执行指定的脚本,也就是说不等待后续载入的文档元素,读到就加载并执行。 -
情况2
(异步下载)
async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在 DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。 -
情况3
(延迟执行)
defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。
- 一些不会体现在渲染输出中的节点(
defer 与相比普通 script,有两点区别:
- 载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。
- 在加载多个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树上,再进行后续操作,避免大量无谓的计算量。