浏览器渲染原理
最近看了很多大佬写的浏览器渲染原理的文章,自己也学到了很多,这里做一个自我总结来巩固自己的知识。
首先我认为浏览器的渲染原理主要分为两个部分:浏览器渲染部分和网络请求部分。这里只讲浏览器渲染部分。
浏览器渲染部分
浏览器进程
首先,浏览器是一个多进程多线程的一个应用程序,我们来看看它有哪些进程:
-
浏览器进程:浏览器进程是浏览器的主进程,只有一个,作用有:
- 负责浏览器界面的显示,与用户交互,例如前进后退等。
- 负责各个页面的管理,创建和销毁其他进程,例如创建和销毁渲染进程。
-
第三方插件进程:当我们使用插件的时候一般会创建。
-
GPU进程:GPU进程用于页面的绘制。
-
浏览器渲染进程:浏览器渲染进程也就是我们常说的浏览器内核。默认当我们每打开一个
tab页的时候就会创建一个渲染进程,主要是用来解析渲染页面。
当我们打开自己的浏览器的时候,本质上是执行浏览器这个应用程序,这个应用程序会唤醒Browser进程/GPU进程/render进程等,等待任务的分配。
多进程的优点:
- 避免单个页面崩溃影响整个浏览器。
- 多进程可以充分利用多核优势。
重点是浏览器内核(渲染进程)
重点来了,我们可以看到上面提到了很多进程,那么对于页面的渲染,最重要的是什么?答案是渲染进程。我们可以这样理解页面的渲染,js的执行,事件循环都在这个进程的内部。接下来重点分析这个进程。请牢记,浏览器的渲染进程是多线程的。
那么render进程又包含了哪些线程呢?
GUI渲染线程
- 负责渲染浏览器界面,解析
HTML/CSS构建DOM树和渲染树,布局和绘制等。 - 当页面需要重绘或者某种操作引发重拍的时候,该线程就会被执行。
- 注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎线程执行的时候GUI线程会被挂起,GUI更新会被保存在一个队列中等待JS执行空闲的时候立即执行。
JS引擎线程
- 负责解析执行
JS脚本(例如v8引擎)。 JS引擎一直等待任务队列中的任务到来(这就涉及到了EventLoop的知识了,后续会有讲到),一个tab页中只有一个JS引擎在运行。- 同样,
JS引擎线程和GUI渲染线程也是互斥的,如果js执行的时间过程,那么就会影响页面的渲染。
事件触发线程
-
归属于浏览器而不是JS引擎,用来控制事件循环(可以理解js引擎自己都忙不过来,需要浏览器另开线程协助)。例如当我们执行下面代码的时候:
let div = document.getElementsByTagName('div')[0] div,addEventListener('click',()=>{ console.log('点击了') })其实此时当js执行到
addEventListener这个函数的时候,此时就会将这个事件的注册监听交给了事件触发线程,该线程会监听用户的动作,看是否点击了,当点击之后会将对应的回调放到消息队列中等待执行。 -
当对应的事件符合触发线程触发条件的时候,该线程会把事件添加到待处理的队列尾部,等待执行。
定时触发线程
- 传说中的
setTnterval与setTimeout等定时器所在的线程。 - 浏览器定时计数器并不是由JS引擎负责定时计数的,如果代码中出现了例如
setTimeout这种定时任务的时候,本质上是JS引擎将这个任务交给了定时触发线程去做。当计时完成之后会将对应的回调函数放到队列当中等待执行。 - 根据
W3C标准规范规定,当setTimeout中的延迟时间低于4ms时统一将时间算为4ms。
异步http请求线程
- 在
XMLHttpRequest在连接后市通过浏览器新开一个线程去请求的。 - 当检测到状态变更的时候,如果设置有回调函数,此时会将回调函数放到对应的队列当中等待执行。
浏览器进程(线程)示意图
其实浏览器的进程很多,这里我们只展示了几个。在这里要特别声明一下,每一个tab页都是一个渲染进程,所以浏览器可以有很多个render进程。
两个概念
这里我想说说两个概念:第一是同步异步。第二是setTimeout。首先,什么是同步,什么是异步。其实网上对于同步和异步的解释有很多,这里我以render进程的角度来去说说我对同步和异步的看法。其实所谓的同步/异步指的是同步任务和异步任务。我认为所谓的同步指的是js引擎线程执行的任务为同步任务,其他任务执行为异步任务。我们举个例子:
for(let i = 0; i < 10;i++){
console.log(i)
}
setTimeout(()=>{
console.log(123)
},100)
这是一段js代码,当js引擎线程执行该代码的时候,首先执行for循环,然后打印出0-9。这是js引擎线程执行的。所以为同步代码。当执行到setTimeout函数的时候,会将该任务交给定时器线程去执行计时任务。所以是异步代码。
现在我们来说说setTimeout函数,其实上面对于setTimeout的讲述是很粗糙的。setTimeout是全局函数,它内部是由c++代码实现的。而对于底层原理,后续我会讲解。
浏览器页面
浏览器主进程
UI线程
当我们在浏览器地址栏中输入了URL时,UI线程会捕捉到我们输入的内容并对值进行分析,如果是关键字,则会使用搜索引擎进行搜索。如果是URL则会让网络线程去请求资源。
网络线程
网络线程也会根据UI线程给的地址请求资源。当请求到资源之后会触发safe Broser,它的作用是对站点进行安全检测。当数据的来源是浏览器标记的非法站点的时候,浏览器会提示是否继续访问,如果继续则网络线程会将字节流数据交给UI线程,UI线程通过IPC交给render-process。
渲染进程的渲染原理
- 当我们在浏览器地址栏中输入了
URL时,UI线程会捕捉到我们输入的内容并对值进行分析,如果是关键字,则会使用搜索引擎进行搜索。如果是URL则会让网络线程去请求资源。 - 当收到地址之后,网络线程会根据
UI线程给的地址请求资源。当请求到资源之后会触发safe Broser,它的作用是对站点进行安全检测。当数据的来源是浏览器标记的非法站点的时候,浏览器会提示是否继续访问,如果继续则网络线程会将字节流数据交给UI线程。 - 当
UI线程获取到数据之后,此时会通过IPC将字节流数据交给 渲染进程。
文档对象模型 (DOM)的构建
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="style.css" rel="stylesheet">
<title>Critical Path</title>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg"></div>
</body>
</html>
当渲染进程获取到对应文档的字节流数据之后,会将该字节流数据交给对应的GUI线程进行DOM的构建,对于DOM的构建可以分为一下几个步骤:
-
转换:浏览器从磁盘或网络读取 HTML 的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成各个字符。这里是通过网络读取的原始字节。
-
令牌化:
GUI线程将字符串转换成 W3C HTML5 标准]规定的各种令牌,例如,<html>、<body>,以及其他尖括号内的字符串。每个令牌都具有特殊含义的一组规则。其实就是让GUI线程知道那一个是html哪一个是body标签。即让GUI线程认值标签。因为在GUI线程看来,现在它得到的只是一串字符串,并不知道有哪些标签参与了,所以通过解析对应的字符串来获取标签。 -
词法分析:其实词法分析的主要目的是创建节点,GUI线程会为对应的标签创建对应的节点,将创建的节点发在内存当中。
-
DOM构建:当所有的对象节点创建完毕之后,
GUI线程会根据父子关系将创建的节点联系起来。例如:浏览器会以html为DOM的根节点,然后将创建的节点添加到以html为根节点的DOM树上。其实在创建完对象节点之后,每一个节点都是独立存储在内存当中的,GUI线程会通过其父子关系来创建对应的DOM。
总体的过程如下图所示:
整个流程的最终输出是我们这个简单页面的文档对象模型 (DOM),浏览器对页面进行的所有进一步处理都会用到它。
浏览器每次处理 HTML 标记时,都会完成以上所有步骤:将字节转换成字符,确定令牌,将令牌转换成节点,然后构建 DOM 树。
这整个流程可能需要一些时间才能完成,有大量 HTML 需要处理时更是如此。
如果您打开 Chrome DevTools 并在页面加载时记录时间线,就可以看到执行该步骤实际花费的时间。在上例中,将一堆 HTML 字节转换成 DOM 树大约需要 5 毫秒。对于较大的页面,这一过程需要的时间可能会显著增加。创建流畅动画时,如果浏览器需要处理大量 HTML,这很容易成为瓶颈。
DOM 树捕获文档标记的属性和关系,但并未告诉我们元素在渲染后呈现的外观。那是 CSSOM 的责任。
CSS 对象模型 (CSSOM)
当GUI线程构建完DOM之后就回去构建对应的CSSOM,GUI线程在构建对应的DOM时,如果遇到了文档的 head 部分的一个 link 标记,该标记引用一个外部 CSS 样式表:style.css。由于预见到需要利用该资源来渲染页面,它会立即发出对该资源的请求,并返回以下内容:
body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }
我们本可以直接在 HTML 标记内声明样式(内联),但让 CSS 独立于 HTML 有利于我们将内容和设计作为独立关注点进行处理:设计人员负责处理 CSS,开发者侧重于 HTML,等等。
与处理 HTML 时一样,我们需要将收到的 CSS 规则转换成某种浏览器能够理解和处理的东西。因此,我们会重复 HTML 过程,不过是为 CSS 而不是 HTML:
其实我们的DOM树的解析速度是比较快的,一般情况下当css文件返回的时候DOM其实已经构建好了。然后开始解析css文件。但是可能也会有另一种情况。那就是我们内置有样式。这种情况下需要再一次讨论了。
CSS 字节转换成字符,接着转换成令牌和节点,最后链接到一个称为“CSS 对象模型”(CSSOM) 的树结构内:
上面的这个树并不是在DOM基础上进行构建,而是通过的选择器,例如:body等选择器进行的一种构建,如果我们定义了class或其他的表示名称,那么它应该会基于这个来创建一个CSSOM。而不是直接在DOM上挂载这些样式。它会根据样式中的父子关系创建一个树型结构,这个树型结构可能和我们的DOM树型结构不一样,但是后期会进行处理的。
为页面上的任何对象计算最后一组样式时,浏览器都会先从适用于该节点的最通用规则开始(例如,如果该节点是 body 元素的子项,则应用所有 body 样式),然后通过应用更具体的规则(即规则“向下级联”)以递归方式优化计算的样式。
以上面的 CSSOM 树为例进行更具体的阐述。span 标记内包含的任何置于 body 元素内的文本都将具有 16 像素字号,并且颜色为红色 — font-size 指令从 body 向下级联至 span。不过,如果某个 span 标记是某个段落 (p) 标记的子项,则其内容将不会显示。
还请注意,以上树并非完整的 CSSOM 树,它只显示了我们决定在样式表中替换的样式。每个浏览器都提供一组默认样式(也称为“User Agent 样式”),即我们不提供任何自定义样式时所看到的样式,我们的样式只是替换这些默认样式。
要了解 CSS 处理所需的时间,您可以在 DevTools 中记录时间线并寻找“Recalculate Style”事件:与 DOM 解析不同,该时间线不显示单独的“Parse CSS”条目,而是在这一个事件下一同捕获解析和 CSSOM 树构建,以及计算的样式的递归计算。
合并DOM/CSSOM创建render-Tree
我们根据 HTML 和 CSS 输入构建了 DOM 树和 CSSOM 树。 不过,它们都是独立的对象,分别网罗文档不同方面的信息:一个描述内容,另一个则是描述需要对文档应用的样式规则。
首先浏览器将 DOM 和 CSSOM 合并成一个“渲染树”,网罗网页上所有可见的 DOM 内容,以及每个节点的所有 CSSOM 样式信息。
为构建渲染树,浏览器大体上完成了下列工作:
-
从 DOM 树的根节点开始遍历每个可见节点。
- 某些节点不可见(例如脚本标记、元标记等),因为它们不会体现在渲染输出中,所以会被忽略。
- 某些节点通过
CSS隐藏,因此在渲染树中也会被忽略,例如,上例中的span节点---不会出现在渲染树中,---因为有一个显式规则在该节点上设置了display: none属性。
-
对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。
-
这里要注意一点,在渲染树构建的过程中,是以DOM树为主,CSSOM树为副来进行构建的。我们以上面为例。首先浏览器从
html这个节点开始遍历,因为html不是可见元素,继续遍历,直到遍历到body这个元素节点,因此body则会作为render-tree的根节点。然后会在CSSOM中找到body对应的规则,然后让该节点适配这个规则。然后继续遍历DOM树,然后找到p元素节点,然后在CSSOM中找到对应的规则进行适配,然后将其挂载到render-tree中。以此类推遍历可见元素。当遍历到span元素节点时,此时会在CSSOM树种找到对应的样式,但是因为样式为display:none时,此时浏览器就会认为这个节点是不可见的,因此会忽略它。这里还有一点就是可见点中断问题,假如上面DOM中
p节点被display:none之后,则该节点的子节点都会被忽略掉,不会参加render-tree的遍历。 -
发射可见节点,连同其内容和计算的样式。
Note: 简单提一句,请注意 visibility: hidden 与 display: none 是不一样的。前者隐藏元素,但元素仍占据着布局空间(即将其渲染成一个空框),而后者 (display: none) 将元素从渲染树中完全移除,元素既不可见,也不是布局的组成部分。
最终输出的渲染同时包含了屏幕上的所有可见内容及其样式信息。有了渲染树,我们就可以进入“布局”阶段。
阻塞渲染的 CSS
默认情况下,CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕。请务必精简您的 CSS,尽快提供它,并利用媒体类型和查询来解除对渲染的阻塞。
css加载是否会阻塞dom树渲染?
这里说的是头部引入css的情况
首先,我们都知道:css是由单独的下载线程异步下载的。
然后再说下几个现象:
- css加载不会阻塞DOM树解析(异步加载时DOM照常构建)
- 但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息)
这可能也是浏览器的一种优化机制。
因为你加载css的时候,可能会修改下面DOM节点的样式, 如果css加载不阻塞render树渲染的话,那么当css加载完之后, render树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。 所以干脆就先把DOM树的结构先解析完,把可以做的工作做完,然后等你css加载完之后, 在根据最终的样式来渲染render树,这种做法性能方面确实会比较好一点。
计算在窗口中的位置(layout)
到目前为止,我们计算了哪些节点应该是可见的以及它们的计算样式,但我们尚未计算它们在设备视口内的确切位置和大小---这就是“布局”阶段,也称为“自动重排”。
为弄清每个对象在网页上的确切大小和位置,浏览器从渲染树的根节点开始进行遍历。让我们考虑下面这样一个简单的实例:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style="width: 50%">
<div style="width: 50%">Hello world!</div>
</div>
</body>
</html>
以上网页的正文包含两个嵌套 div:第一个(父)div 将节点的显示尺寸设置为视口宽度的 50%,---父 div 包含的第二个 div---将其宽度设置为其父项的 50%;即视口宽度的 25%。
布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸:所有相对测量值都转换为屏幕上的绝对像素。
创建图层和图层树
当布局计算完毕之后,此时GUI线程会从DOM树的根节点开始遍历。创建图层并生成图层树。图层分为两种:渲染层和合成层。处于相同坐标空间(Z轴)的对象节点都会归并到同一个渲染层当中,因此根据层叠上下文,不同坐标空间的渲染对象将形成多个渲染层以体现它们的层叠关系。当渲染对象满足条件时,浏览器就会为其创建一个图层。
我们假设div1和div2都使用了position:absolute属性,那么此时的layer-tree如上图所使,body本身就会创建一个图层。因为span没有独立创建图层的条件,所以这个节点和其父节点div1处于同一个渲染层当中。因为div3与a节点没有独立创建图层的条件,因此和其父节点div2处于同一个图层当中。
生成绘制表
当layer-tree构建完毕之后,接下来GUI渲染引擎会将图层的绘制拆分成一个个绘制指令。例如:先话背景再画边框,然后将这些指令按顺序组合成一个特殊的绘制表。在这个绘制表中记录了每一层该怎么绘制并且每一层的层叠关系。
生成图块和位图
在渲染进程中,有一个专门用来绘制的线程,这个线程叫做合成线程。绘制表准备好之后,渲染进程的GUI线程会给合成线程发送一个commit消息,把绘制列表提交给合成线程。
首先,由于考虑到合成线程绘制的是整个页面,有可能页面的大小远远大于视口的大小,所以如果一次性绘制出页面,不仅仅浪费资源,同时还会增加页面白屏的时间。所以合成器线程对其进行分块操作。首先合成器线程会将图层(渲染层)进行分块操作,这里每一个渲染层都会分块而不会将图层层叠后再进行分块。这些块不是很大,一般是256*256||512*512。
栅格化
当合成器线程将每一个渲染层进行分块之后,将分块的数据交给栅格化线程池。栅格化线程池中的线程会对每一个渲染你层的分块进行处理,将其栅格化为位图,然后将位图信息传到GPU中,存储到GPU的内存当中,然后合成器线程将收集每一个位图在GPU内存中的位置信息和在页面中哪个位置渲染图块的信息。
当合成器线程收集到这些信息之后,该线程会根据这些信息合成一个合成器帧,然后通过IPC管道将该帧传给浏览器主进程,然后浏览器主进程会将该帧发送给GPU线程。
渲染页面
当GPU收到该帧后,会根据该帧的信息,从内存中读取相应的图块位图并渲染到页面当中。当我们滑动页面时,合成器线程会重新计算生成一个合成器帧,然后重新发送给GPU,GPU根据新帧重新渲染。