浏览器渲染整体流程
-
浏览器,作为用户浏览网页最基本的一个入口,我们似乎认为在地址栏输入 URL 后网页自动就出来了;殊不知在用户输入网页地址,敲下回车的那一刻,浏览器背后做了诸多的事情;去除 DNS 查找等这些细枝末节的工作,整个大的部分可以分为两个,那就是 网络 和 渲染;
-
首先,浏览器的网络线程会发送 http 请求,和服务器之间进行通信,之后将拿到的 html 封装成一个渲染任务,并将其传递给渲染主线程的消息队列;在事件循环机制的作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程;
-
先来看一下整体流程,整个渲染流程分为多个阶段,分别是: HTML 解析、样式计算、布局、分层、生成绘制指令、分块、光栅化、绘制:
-
每个阶段都有明确的输入输出,上一个阶段的输出会成为下一个阶段的输入;这样,整个渲染流程就形成了一套组织严密的生产流水线;
解析 HTML
-
首先第一步就是解析 html 生成 DOM 树;当打开一个网页时,浏览器都会去请求对应的 HTML 文件;虽然平时写代码时都会分为 HTML、CSS、JS 文件,也就是字符串,但是计算机硬件是不理解这些字符串的,所以在网络中传输的内容其实都是 0 和 1 这些字节数据;当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,也就是我们写的代码;
-
当数据转换为字符串以后,浏览器会先将这些字符串通过词法分析转换为标记 token,这一过程在词法分析中叫做标记化 tokenization;
-
为什么需要标记化呢?原因很简单,现在浏览器虽然将字节数据转为了字符串,但是此时的字符串就如何一篇标题段落全部写在一行的文章一样,浏览器此时仍然是不能理解的,例如:
<!DOCTYPE html> <html lang="en"> <head> <title>Document</title> </head> <body> <a>123</a> </body> </html>
-
因此现在所做的标记化,本质就是要将这长长的字符串分拆成一块块,并给这些内容打上标记,便于理解这些最小单位的代码是什么意思;
-
将整个字符串进行了标记化之后,就能够在此基础上构建出对应的 DOM 树出来;
-
上面的步骤,就称之为解析 HTML ,整个流程如下图:
-
在解析 HTML 的过程中,可以能会遇到诸如 style、link 这些标签,聪明的你应该已经想到了,这是和我们网页样式相关的内容,此时就会涉及到 CSS 的解析;
- 为了提高解析效率,浏览器在开始解析前,会启动一个预解析的线程,率先下载 HTML 中的外部 CSS 文件和外部的 JS 文件;
- 如果主线程解析到 link 位置,此时外部的 CSS 文件还没有下载解析好,主线程不会等待,继续解析后续的 HTML;这是因为下载和解析 CSS 的工作是在预解析线程中进行的;这就是 CSS 不会阻塞 HTML 解析的根本原因;
- 最终 CSS 的解析在经历了从字节数据、字符串、标记化后,最终也会形成一颗 CSSOM 树;
-
上面也有提到,预解析线程除了下载外部 CSS 文件以外,还会下载外部 JS 文件,那么针对 JS 代码浏览器是如何处理的?
- 如果主线程解析到 script 位置,如果 script 没有 资源提示关键词 则会停止解析 HTML ,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析 HTML;这是因为 JS 代码的执行过程可能会修改当前的 DOM 树,所以 DOM 树的生成必须暂停;这就是 JS 会阻塞 HTML 解析的根本原因;
- 因此,如果想首屏渲染的越快,就越不应该在最前面就加载 JS 文件,这也是都建议将 script 标签放在 body 标签底部的原因;
- 另外,在现代浏览器中,提供了新的方式来避免 JS 代码阻塞渲染的情况,关于这几种方式的区别,在资源提示关键词下具体来看;
- 如果主线程解析到 script 位置,如果 script 没有 资源提示关键词 则会停止解析 HTML ,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析 HTML;这是因为 JS 代码的执行过程可能会修改当前的 DOM 树,所以 DOM 树的生成必须暂停;这就是 JS 会阻塞 HTML 解析的根本原因;
-
最后总结一下此阶段的成果,第一步完成后,会得到 DOM 树和 CSSOM 树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在 CSSOM 树中;得到了两棵树,如下图所示:
样式计算
-
接下来进入第二步:样式计算,拥有了 DOM 树还不足以知道页面的外貌,因为通常会为页面的元素设置一些样式;主线程会遍历 DOM 树并依次为树中的每个节点计算出它最终的样式,称之为 Computed Style;
-
在这一过程中,很多预设值会变成绝对值,比如 red 会变成 rgb(255, 0, 0);相对单位会变成绝对单位,比如 em 会变成 px;
-
浏览器会确定每一个节点的样式到底是什么,并最终生成一颗样式规则树,这棵树上面记录了每一个 DOM 节点的样式;另外需要注意的是,这里所指的浏览器确定每一个节点的样式,是指在样式计算时会对所有的 DOM 节点计算出 所有的 样式属性值;如果开发者在书写样式时,没有写某一项样式,那么大概率会使用其默认值;例如:
-
这一步完成后,就得到一棵带有样式的 DOM 树;也就是说,经过样式计算后,之前的 DOM 树和 CSSOM 树合并成了一颗带有样式的 DOM 树;
布局
-
前面这些步骤完成之后,渲染进程就已经知道页面的具体文档结构以及每个节点拥有的样式信息了,可是这些信息还是不能最终确定页面的样子;举个例子,假如你现在想通过电话告诉你的朋友你身边的一幅画的内容:“画布上有一个红色的大圆圈和一个蓝色的正方形”,单凭这些信息你的朋友是很难知道这幅画具体是什么样子的,因为他不知道大圆圈和正方形具体在页面的什么位置,是正方形在圆圈前面呢还是圆圈在正方形的前面;
-
渲染网页也是同样的道理,只知道网站的文档流以及每个节点的样式是远远不足以渲染出页面内容的,还需要通过布局(layout)来计算出每个节点的几何信息(geometry);生成布局树的具体过程是:主线程会遍历刚刚构建的 DOM 树,根据 DOM 节点的计算样式计算出一个布局树(layout tree);布局树上每个节点会有它在页面上的 x,y 坐标以及盒子大小(bounding box sizes)的具体信息;
-
布局树大部分时候,和 DOM 树并非一一对应,虽然它长得和先前构建的 DOM 树差不多,但是不同的是这颗树只有那些可见的(visible)节点信息;
- 比如 display:none 的节点没有几何信息,因此不会生成到布局树;
- 又比如使用了伪元素选择器,虽然 DOM 树中不存在这些伪元素节点,但它们拥有几何信息,所以会生成到布局树中;
- 还有匿名行盒、匿名块盒等等都会导致 DOM 树和布局树无法一一对应;
- 比如 display:none 的节点没有几何信息,因此不会生成到布局树;
分层
-
在确认了布局树后,接下来就是绘制了么?还不急,这里还会有一个步骤,就是 分层;
-
分层的好处在于,将来某一个层改变后,仅会对该层进行后续处理,从而提升效率;为了确定哪些元素需要放置在哪一层,主线程需要遍历整颗布局树来创建一棵层次树(Layer Tree)
-
滚动条、堆叠上下文、transform、opacity 等样式都会或多或少的影响分层结果,也可以通过使用 will-change 属性来告诉浏览器对其分层;
生成绘制指令
-
分层工作结束后,接下来就是生成绘制指令;主线程会为每个层单独产生绘制指令集,用于描述这一层的内容该如何画出来;
-
这里的绘制指令,类似于 “将画笔移动到 xx 位置,放下画笔,绘制一条 xx 像素长度的线”,在浏览器所看到的各种复杂的页面,实际上都是这样一条指令一条指令的执行所绘制出来的;如果你熟悉 Canvas,那么这样的指令类似于:
context.beginPath(); // 开始路径 context.moveTo(10, 10); // 移动画笔 context.lineTo(100, 100); // 绘画出一条直线 context.closePath(); // 闭合路径 context.stroke(); // 进行勾勒
-
但要注意,这一步只是生成诸如上面代码的这种绘制指令集,还没有开始执行这些指令;另外,还有一个重要的点需要知道,生成绘制指令集后,渲染主线程的工程就暂时告一段落,接下来主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成;
分块
-
合成线程首先对每个图层进行分块,将其划分为更多的小区域;
-
此时,它不再是像主线程那样一个人在战斗,它会从线程池中拿取多个线程来完成分块工作;
光栅化
-
分块完成后,进入 光栅化 阶段,所谓光栅化,就是将每个块变成位图;更简单的理解就是确认每一个像素点的 rgb 信息,如下图所示:
-
光栅化的操作,并不由合成线程来做,而是会由合成线程将块信息交给 GPU 进程,以极高的速度完成光栅化;
-
GPU 进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块;
绘制
-
当所有的图块都被栅格化后,合成线程会拿到每个层、每个块的位图,从而生成一个个 指引信息;指引会标识出每个位图应该画到屏幕的哪个位置,以及会考虑到旋转、缩放等变形;变形发生在合成线程,与渲染主线程无关,这就是 transform 效率高的本质原因;
-
合成线程会通过 IPC 向浏览器进程(browser process)提交(commit)一个渲染帧;这个时候可能有另外一个合成帧被浏览器进程的 UI 线程(UI thread)提交以改变浏览器的 UI;这些合成帧都会被发送给 GPU 完成最终的屏幕成像;如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给 GPU 来更新页面;
-
最后总结一下浏览器从拿到 html 文档到最终渲染出页面的整体流程,如下图:
资源提示关键词
defer 和 async
-
现代浏览器引入了 defer 和 async;
- async 表示加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步);也就是说下载 JS 文件的时候不会阻塞 DOM 树的构建,但是执行该 JS 代码会阻塞 DOM 树的构建;
- defer 表示加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成;也就是说,下载 JS 文件的时候不会阻塞 DOM 树的构建,然后等待 DOM 树构建完毕后再执行此 JS 文件;
-
具体加载瀑布图如下图所示:
preload
-
preload 顾名思义就是一种 预加载 的方式,它通过声明向浏览器声明一个需要提前加载 当前页的资源,当资源真正被使用的时候立即执行,就无需等待网络的消耗;
<link rel="stylesheet" href="style2.css"> <script src="main2.js"></script> <link rel="preload" href="style1.css" as="style"> <link rel="preload" href="main1.js" as="script">
-
在上面的代码中,会先加载 style1.css 和 main1.js 文件(但不会生效),在随后的页面渲染中,一旦需要使用它们,它们就会立即可用;可以使用 as 来指定将要预加载的内容类型 (style、script、image、font、document、…);
-
preload 指令的一些优点如下:
- 允许浏览器设置资源优先级,从而允许 Web 开发人员优化某些资源的交付;
- 使浏览器能够确定资源类型,因此它可以判断将来是否可以重用相同的资源;
- 浏览器可以通过引用 as 属性中定义的内容来确定请求是否符合内容安全策略;
- 浏览器可以根据资源类型发送合适的 Accept 头(例如:image/webp);
prefetch
-
prefetch 是一种利用浏览器的 空闲时间 加载页面将来可能用到的资源的一种机制,通常可以用于加载非首页的 其他页面所需要的资源,以便加快后续页面的首屏速度;
-
prefetch 加载的资源可以获取非当前页面所需要的资源,并且将其放入缓存至少 5 分钟 (无论资源是否可以缓存);并且当页面跳转时,未完成的 prefetch 请求不会被中断;它的用法跟 preload 是一样的:
<link rel="prefetch" href="/path/to/style.css" as="style">
-
DNS prefetching 允许浏览器在用户浏览时在后台对页面执行 DNS 查找;这最大限度地减少了延迟,因为一旦用户单击链接就已经进行了 DNS 查找;通过将 rel=“dns-prefetch” 标记添加到链接属性,可以将 DNS prefetching 添加到特定 URL;建议在诸如 Web 字体、CDN 之类的东西上使用它;
<!-- Prefetch DNS for external assets --> <link rel="dns-prefetch" href="//fonts.googleapis.com"> <link rel="dns-prefetch" href="//www.google-analytics.com"> <link rel="dns-prefetch" href="//cdn.domain.com">
prerender
-
prerender 与 prefetch 非常相似,prerender 同样也是会收集用户接下来可能会用到的资源;
-
不同之处在于 prerender 实际上是在后台渲染整个页面;
<link rel="prerender" href="https://www.keycdn.com">
preconnect
-
preconnect 指令允许浏览器在 HTTP 请求实际发送到服务器之前设置早期连接;浏览器要建立一个连接,一般需要经过 DNS 查找,TCP 三次握手和 TLS 协商(如果是 https 的话),这些过程都是需要相当的耗时的;所以 preconnet,就是一项使浏览器能够预先建立一个连接,等真正需要加载资源的时候就能够直接请求了;
-
以下是为 CDN URL 启用 preconnect 的示例:
<link href="https://cdn.domain.com" rel="preconnect" crossorigin>
-
在上面的代码中,浏览器会进行以下步骤:
- 解释 href 的属性值,判断是否是合法的 URL;如果是合法的 URL ,然后继续判断 URL 的协议是否是 http 或者 https,如果不是合法的 URL 则结束处理;
- 如果当前页面 host 不同于 href 属性中的 host ,那么将不会带上 cookie ,如果希望带上 cookie 等信息,可以加上 crossorign 属性;
常见面试题
什么是 reflow?什么是 repaint?
reflow 的本质就是重新计算 layout 树;
- 当进行了会影响布局树的操作后,需要重新计算布局树,会引发 layout;
- 为了避免连续的多次操作导致布局树反复计算,浏览器会合并这些操作,当 JS 代码全部完成后再进行统一计算;所以改动属性造成的 reflow 是异步完成的;
- 也同样因为如此,当 JS 获取布局属性时,就可能造成无法获取到最新的布局信息;浏览器在反复权衡下,最终决定获取属性立即 reflow;
repaint 的本质就是重新根据分层信息计算了绘制指令;
- 当改动了可见样式后,就需要重新计算,会引发 repaint;
- 由于元素的布局信息也属于可见样式,所以 reflow 一定会引起 repaint;
重绘与重排的区别?如何进行优化?
区别
- 重排: 部分渲染树(或者整个渲染树)需要重新分析并且节点尺寸需要重新计算,表现为重新生成布局,重新排列元素;
- 重绘: 由于节点的几何属性发生改变或者由于样式发生改变,例如改变元素背景色时,屏幕上的部分内容需要更新,表现为某些元素的外观被改变;
- 单单改变元素的外观,肯定不会引起网页重新生成布局,但当浏览器完成重排之后,将会重新绘制受到此次重排影响的部分;
- 重排和重绘代价是高昂的,它们会破坏用户体验,并且让 UI 展示非常迟缓,而相比之下重排的性能影响更大,在两者无法避免的情况下,一般我们宁可选择代价更小的重绘;
- 重绘不一定会出现重排,重排必然会出现重绘;
如何优化
整个页面可以看做是一幅画,这幅画是由浏览器绘制出来的,浏览器绘制这幅画的过程称之为渲染;
渲染是一件复杂的工作,它大致分为以下几个过程:
- 解析 HTML 生成 DOM 树,解析 CSS 生成样式规则树
- 将 DOM 树和样式规则树结合,生成渲染树(Render Tree)
- 根据生成的渲染树,确定元素的布局信息(元素的尺寸、位置),这一步称之为 reflow,译作重排或回流
- 根据渲染树和布局信息,生成元素的像素信息(元素横纵的像素点,左上角的偏移量、每个像素的颜色等),这一步称之为 repaint,译作重绘
- 将像素信息提交到 GPU 完成屏幕绘制
当元素的布局信息发生变化时,会导致重排;
当元素的像素信息发生变化时,会导致重绘;
重排一定会导致重绘,因此布局信息的变化一定会导致像素信息的变化;
在实际开发中,获取和设置元素尺寸、位置均会导致重排和重绘,而仅设置元素的外观(比如背景颜色)则只会导致重绘,不会导致重排;
重排是一项繁琐的工作,会降低效率,因此在开发中,应该尽量避免直接获取和设置元素的尺寸、位置,尽量使用变量来保存元素的布局信息;
如何触发重排和重绘?如何避免重绘或者重排?
如何触发
:任何改变用来构建渲染树的信息都会导致一次重排或重绘:
- 添加、删除、更新 DOM 节点;
- 通过
display: none
隐藏一个 DOM 节点-触发重排和重绘;- 通过
visibility: hidden
隐藏一个 DOM 节点-只触发重绘,因为没有几何变化;- 移动或者给页面中的 DOM 节点添加动画;
- 添加一个样式表,调整样式属性;
- 用户行为,例如调整窗口大小,改变字号,或者滚动;
如何避免
集中改变样式:往往通过改变 class 的方式来集中改变样式
使用 DocumentFragment:通过 createDocumentFragment 创建一个游离于 DOM 树之外的节点,然后在此节点上批量操作,最后插入 DOM树 中,因此只触发一次重排;
提升为合成层:将元素提升为合成层有以下优点:
- 合成层的位图,会交由 GPU 合成,比 CPU 处理要快;
- 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层;
- 对于 transform 和 opacity 效果,不会触发 layout 和 paint;
- 提升合成层的最好方式是使用
will-change: transform
;
为什么 transform 的效率高?
因为 transform 既不会影响布局也不会影响绘制指令,它影响的只是渲染流程的最后一个「draw」阶段;
由于 draw 阶段在合成线程中,所以 transform 的变化几乎不会影响渲染主线程;反之,渲染主线程无论如何忙碌,也不会影响 transform 的变化;
下面是具体示例:
HTMLCSSJavaScript<button id="btn">死循环</button> <div class="ball ball1"></div> <div class="ball ball2"></div>
.ball { width: 100px; height: 100px; background: #f40; border-radius: 50%; margin: 30px; } .ball1 { animation: move1 1s alternate infinite ease-in-out; } .ball2 { position: fixed; left: 0; animation: move2 1s alternate infinite ease-in-out; } @keyframes move1 { to { transform: translate(100px); } } @keyframes move2 { to { left: 100px; } }
function delay(duration) { var start = Date.now(); while (Date.now() - start < duration) {} } btn.onclick = function () { delay(5000); }; // 死循环之后,只有 transform 动画没有被影响,进而说明 transform 不在渲染线程上
计算机网络🛜 HTTP 各版本差异
上一篇