在我刚开始学习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是以一个页面一个渲染进程,加上站点隔离的策略来进行的。虽然内存占用确实比较高,但是这种多进程架构也有独特的优势:
- 更高的容错性。当今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节点不包括:
- 一些不会体现在渲染输出中的节点(
<html><script><link>
….),会直接被忽略掉。 - 通过CSS隐藏的节点。例如上图中的span节点,因为有一个CSS显式规则在该节点上设置了display:none属性,那么它在生成RenderObject时会被直接忽略掉。
- 一些不会体现在渲染输出中的节点(
- Render 树是衔接浏览器排版引擎和渲染引擎之间的桥梁,它是排版引擎的输出,渲染引擎的输入。
4. 布局
到目前为止,浏览器计算出了哪些节点是可见的以及它的信息和样式,接下来就需要计算这些节点在设备视口内的确切位置和大小,这个过程我们称之为“布局”。
布局最后的输出是一个“盒模型”:将所有相对测量值都转换成屏幕上的绝对像素。
5. 渲染
当Layout布局事件完成后,浏览器会立即发出Paint Setup与Paint事件,开始将渲染树绘制成像素,绘制所需的时间跟CSS样式的复杂度成正比,绘制完成后,用户就可以看到页面的最终呈现效果了。
总结
我们对一个网页发送请求并获得渲染后的页面可能也就经过了1~2秒,但浏览器其实已经做了上述所讲的非常多的工作,总结一下浏览器关键渲染路径的整个过程:
- 处理HTML标记数据并生成DOM树。
- 处理CSS标记数据并生成CSSOM树。
- 将DOM树与CSSOM树合并在一起生成渲染树。
- 遍历渲染树开始布局,计算每个节点的位置信息。
- 将每个节点绘制到屏幕。
相关问题
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,有两点区别:
- 载入 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树上,再进行后续操作,避免大量无谓的计算量。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!