浏览器渲染原理
浏览器内核
渲染引擎+JS 引擎
浏览器内核分类:
|浏览器|内核|css 前缀|
|–|–|–|
|IE|Trident|-ms-|
|Firefox|Gecko|-moz-|
|Safari/Chrome|webkit|-webkit-|
Webkit 占比最多。
浏览器线程
- GUI 渲染线程
GUI-图形用户界面,那 GUI 渲染线程也就是负责渲染浏览器界面上显示内容,即负责解析 HTML 标签和 CSS 样式。在浏览器的五个渲染过程中,GUI 渲染线程均参与其中。 - JS 引擎线程
负责处理 JavaScript 脚本的线程。单线程,执行栈+任务队列。
事件循环机制 - 定时触发线程
处理setTimeout、setInterval代码的线程。
当定时器被触发后,会将需要执行的任务添加到 JS 的任务队列,当 JS 引擎线程空闲的时候,会按顺序执行任务队列中的任务。 - 事件处理线程
JS 引擎遇到事件处理程序代码块时,会将这个处理程序添加到事件处理线程中。当事件满足条件被触发后,事件处理程序会将该事件添加到前面提到前面说的 JS 执行时维护的任务队列中,之后就交由 JS 引擎线程去处理。 - 异步 http 请求线程
用于处理XMLHttpRequest请求。
需要向服务器发起请求时,就会交给该线程处理。当请求得到响应后,如果需要执行回调函数,会将回调放入 JS 的任务队列,后续再由 JS 引擎线程处理。
页面加载过程
- 浏览器根据 DNS 服务器得到域名的 IP 地址
- 向 IP 发送 HTTP 请求
- 服务器收到、处理并返回 HTTP 请求
- 浏览器得到返回内容
浏览器渲染过程
- 解析 HTML 生成 DOM 树 - 渲染引擎首先解析
HTML文档,生成DOM树; - 解析 CSS 生成 CSSOM 树 - 处理
CSS标记,构成层叠样式表模型CSSOM(CSS Object Model); - 构建 Render 树 - 将
DOM和CSSOM合并为渲染树(rendering tree),代表一系列将被渲染的对象; - 布局 Render 树 - 渲染树的每个元素包含的内容都是计算过的,被称作布局
layout。浏览器使用一种流式处理的方法,只需要一次绘制操作就可以布局所有的元素。 - 绘制 Render 树 - 将渲染树的各个节点绘制到屏幕上,
painting。
以上五个步骤并不一定一次性顺序完成,如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上重新渲染。

浏览器渲染具体流程
构建 DOM 树
当浏览器接收到服务器返回来的 HTML 文档后,会遍历文档节点,生成 DOM 树。
重点:
DOM树在构建过程中可能会被CSS和JS的加载而阻塞;display:none的元素也会在DOM树中;- 注释也会在
DOM树中; script标签也会在DOM树中
无论是DOM还是CSSOM,都是要经过Bytes=>Characters=>Tokens=>Nodes=>DOM这个解析步骤。
- 转换,将接收到的 HTML 原始字节根据指定编码(一般是 UTF-8)转换成字符(Character)。
- 令牌化, 通过状态机将字符串转换为
Token,每个Token都具有特殊含义和一组规则。 - 词法分析,
Token会被转换成定义了属性等规则的‘对象’。 - DOM 构建, 利用栈构建 DOM 树。
Token 的拆分
Token(词)出自编译原理,表示最小的有意义的单元。
词(token)是如何被拆分的
1 | <p class="a">text text text</p> |
这段代码依次被拆分成token:
<p- ’开始标签‘的开始class='a'- 属性>- ’开始标签‘的结束text text text- 文本</p>- 结束标签
常见的 token
|token|解释|
|–|–|
|<xxx |’开始标签‘的开始|
|a='xxx'|属性|
|>|’开始标签‘的结束|
|text text text|文本|
|</xxx>|结束标签|
|<!xxxx>|注释|
|<![CDATA[hello world!]]>|CDATA 数据节点|
在 Chrome 里,总共定义了 7 中 Token 类型:
1 | enum TokenType { |
代码从字符流中读取字符,在接收第一个字符之前,无法判断这个是哪个词(token),随着我们接收的字符越来越多,拼出其他内容的可能性越来越小。比如,我们接收第一字符<,我们就能确定这个不是文本节点,接着读到第二个字符x,那么我们一下子就知道这不是注释和 CDATA 了,接下来我们就一直读,直到遇到“>”或者空格,这样就得到了一个完整的词(token)了。
这样我们每读入一个字符,其实都要做一次决策,而且这些决策是跟’当前状态‘有关。
状态机
将字符流解析成 Token 的最常用方案。
简单的状态机:
真正完整的 HTML 词法状态机,比上图描述的要复杂的多,更详细的内容可以参考HTML 官方文档,HTML 官方文档了 80 个状态。
状态机的具体过程:
初始状态,从 data 开始,首先区分
<和非<- 如果是
非<那么可以判断这个是一个文本节点。 - 如果是
<,那么进入一个标签状态
- 如果是
标签状态
- 如果下一个字符是”!”,那么可能进入注释节点或 CDATA 节点
- 如果下一个字符是”/“,那么可以确定进入一个结束标签
- 如果下一个字符是字母,那么可以确定进入一个开始标签,
tag open状态 - 如果要完整处理各种 HTML 标准中定义的东西,还要考虑”?”,”%”等内容
-
tag open状态- 字母
letter - “!”
- “/“
就这样根据字符去判断下一步的状态。其本质就是将每个词的“特征字符”逐个拆分成独立的状态,然后再把所有词的特征字符合并起来,形成一个连通图。
- 字母
构建 DOM 树
事实上,构建 DOM 的过程中,不是等所有 Token 都转换完成后再去生成节点对象,而是一边生成 Token 一边消耗 Token 来生成节点对象。
也就是,每个 Token 被生成后,会立刻消耗这个 Token 创建出节点对象。
注意:带有结束标签标识的 Token 不会创建节点对象。
1 | <html> |
把这段代码拆成 Token 之后,就是下面这样,我这里省略了里面的换行跟空格。
|TagName|type|attr|text|
|–|–|–|–|–|
|html|StartTag||’’|
|head|StartTag||’’|
|meta|StartTag|charset=’utf-8’|’’|
|head|EndTag||’’|
|body|StartTag||’’|
|div|StartTag||’’|
|p|StartTag|class=’a’|’’|
||Character||’text text text’|
|div|EndTag||’’|
|body|EndTag||’’|
|html|EndTag||’’|
||EndOfFile||’’|
第一步,在 Tokens 中,StartTag和EndTag通常需要成对匹配。对于<br/>这种自闭合标签,可以视为入栈后立马出栈。
在解析开始的时候,会默认创建一个根为document的空 DOM 结构。同时会将一个 startTag document 的 Token 放入栈底。
第二步,经过词法分析后一个个 StartTag 被放入栈中,同时会创建响应的 DOM 节点,当遇到tagName相同的EndTag 时,对应的StartTag(正常情况下在栈顶,否则出错)弹出栈。
第三步,不断的出栈入栈,最后生成 DOM 树
上面是最简单的情况,在实际构建过程还是需要考虑如下情况:
- 遇到
StartTag就入栈一个节点,当前节点就是这个节点的父节点 - 遇到
EndTag就出栈一个节点(还可以检查是否匹配) - 栈顶元素就是当前节点
- 遇到属性,就添加到当前节点
- 遇到文本节点,如果当前节点是文本节点,则跟文本节点合并,否则入栈成为当前节点的子节点
- 遇到注释节点,作为当前节点的子节点
HTML 具有很强的容错能力,奥妙在于当EndTag跟栈顶的StartTag 不匹配的时候如何处理。
构建 CSSOM 规则树
在浏览器构建 DOM 树时,在 HTML 文档的 head 部分遇到了一个 link 标记,该标记引用一个外部 CSS 样式表:style.css。由于预见到需要利用该 CSS 资源来渲染页面,它会立即发出对该资源的请求,并返回以下内容:
1 | body { font-size: 16px } |
与处理 HTML 时一样,我们需要将收到的 CSS 规则转换成某种浏览器能够理解和处理的东西。因此,我们会重复 HTML 过程,不过是为 CSS 而不是 HTML:Bytes=>Characters=>Tokens=>Nodes=>DOM
.CSS 文件,又名层叠样式表。当CSSOM树生成节点时,每一个节点首先会继承其父节点的所有样式,层叠覆盖,然后再以”向下级联”的规则,为该节点应用更具体的样式,递归生成CSSOM树。譬如,上右图中第二层的p节点,有父节点body,因此该p将继承 body 节点的样式:“font-size: 16px;”。然后再应用该p节点自身的样式:“font-weight: bold;”。所以最终该p节点的样式为:“font-size: 16px;font-weight: bold;”`。
还请注意,以上树并非完整的 CSSOM 树,它只显示了我们决定在样式表中替换的样式。每个浏览器都提供一组默认样式(也称为“User Agent 样式”),即我们不提供任何自定义样式时所看到的样式,我们的样式只是替换这些默认样式。
注意:CSS 匹配 HTML 元素是一个相当复杂和有性能问题的事情。所以,DOM 树要小,CSS 尽量用 id 和 class,千万不要过渡层叠下去
构建渲染树 (Render Tree)
通过 DOM 树和 CSS 规则树,浏览器就可以通过它两构建渲染树了。
- 浏览器会先从 DOM 树的根节点开始遍历每个可见节点
- 某些节点完全不可见(例如 script 标签、meta 标签),因为它们不会在渲染结果中反映,所以会被忽略
- 某些节点通过 CSS 隐藏,也会在渲染树中忽略。比如设置或继承
display:none 给每个可见节点找到相应匹配的 CSSOM 规则,并应有这些规则。

布局与绘制
当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”。
布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。
布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。
与之相关的几个问题
为什么操作 DOM 慢
DOM 是属于渲染引擎相关,而 JS 又与 JS 引擎相关,于是在用 JS 操作 DOM 的时候,其实涉及到浏览器两大线程之间的通信,必然会有性能损耗。还有操作 DOM 可能会导致重绘回流的情况。
经典面试题:插入几万个 DOM,如何实现页面不卡顿?
requestAnimationFrame循环插入 DOM- 虚拟滚动(virtualized scroll)
只渲染可视区域的内容,非可视区域的完全不渲染,当用户滚动的时候实时渲染
浏览器如果渲染过程中遇到 JS 文件怎么处理
渲染过程中如果遇到<script>就停止渲染,执行 JS 代码。因为浏览器的渲染引擎和 JS 引擎是互斥的。JS 代码的加载、解析与执行都会阻塞 DOM 的构建,
如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,这也是建议将 script 标签放在 body 标签底部的原因。当然在当下,并不是说 script 标签必须放在底部,因为你可以给 script 标签添加 defer 或者 async 属性(下文会介绍)。
JS 文件不只是阻塞 DOM 的构建,它会导致 CSSOM 也阻塞 DOM 的构建。
原本 DOM 和 CSSOM 的构建是互不影响,但是一旦引入了 JavaScript,CSSOM 也开始阻塞 DOM 的构建,只有 CSSOM 构建完毕后,DOM 再恢复 DOM 构建。
什么情况下阻塞渲染
渲染的前提是生成渲染树,所以
HTML和CSS肯定会阻塞渲染。所以,渲染的文件越小,并且扁平层级,优化选择器,就渲染的越快。
当解析HTML的时候,会把新来的元素插入DOM树里面,同时去查找CSS,然后把对应的样式规则应用到元素上,查找样式表是按照从右到左的顺序去匹配的。
例如:div p {font-size: 16px},会先寻找所有 p 标签并判断它的父标签是否为 div 之后才会决定要不要采用这个样式进行渲染)。
所以,我们平时写 CSS 时,尽量用 id 和 class,千万不要过渡层叠。当浏览器在解析到
script标签时,会暂停构建DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载JS文件,这也是都建议将script标签放在body标签底部的原因。
现在可以给script标签添加defer或者async属性,异步加载JS文件:defer:表示延迟执行引入的JavaScript,但是会放到HTML解析完成后,并且JS文件加载完成后,顺序执行,这时,可以把 script 标签放在任意位置。async: 表示异步执行引入的JavaScript,是在自己加载完成后立即执行,如果是多个,执行顺序和加载顺序无关。
需要注意的是,这种方式加载的JavaScript依然会阻塞load事件。换句话说,async-script可能在DOMContentLoaded触发之前或之后执行,但一定在load触发之前执行。
回流(reflow)和重绘(repaint)
重绘
当一个元素的外观发生变化,但没有改变布局,重新把元素的外观绘制出来的过程
常见的引起重绘的属性
|color|border-style|visibility|background|
|–|–|–|–|
|text-decoration|background-image|background-position|background-repeat|
|outline-color|outline|outline-style|border-radius|
|outline-with|box-shadow|background-size||
回流、重构、重排
当 DOM 变化影响可元素的几何信息(DOM 对象的位置、尺寸大小、显隐),浏览器需要重新计算元素的几何属性,将其安放在界面的正确位置。
每个页面至少需要一次回流,就是页面第一次加载的时候。
回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变父节点里的子节点很可能会导致父节点的一系列回流。
触发条件
- 添加或删除可见的
DOM元素 - 元素位置改变
- 元素尺寸改变 – 边距
padding/margin、边框border、宽度width、高度height - 元素内容改变 – 输入框输入文字
- 页面渲染初始化 – 无法避免
- 浏览器窗口尺寸改变 –
resize事件发生 - 隐藏可见元素 –
display:none - 激活
CSS伪类 - 读取某些元素属性 –
offsetLeft/Top/Height/Width,clientTop/Left/Width/Height,scrollTop/Left/Width/Height,width/height,getComputedStyle(),currentStyle(IE)
浏览器的渲染队列
1 | div.style.left = '10px'; |
这段代码理论上会触发 4 次重排+重绘,因为每一次都改变了元素的几何属性,实际上最后只触发了一次重排,这都得益于浏览器的渲染队列机制:
当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。
- 强制刷新队列:
1 | div.style.left = '10px'; |
这段代码会触发 4 次重排+重绘,因为在console中你请求的这几个样式信息,无论何时浏览器都会立即执行渲染队列的任务,即使该值与你操作中修改的值没关联。
因为队列中,可能会有影响到这些值的操作,为了给我们最精确的值,浏览器会立即重排+重绘。
强制刷新队列的 style 样式请求:
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop,scrollLeft,scrollWidth,scrollHeightclientTop,clientLeft,clientWidth,clientHeightgetComputedStyle(), 或者 IE 的currentStyle
我们在开发中,应该谨慎的使用这些 style 请求,注意上下文关系,避免一行代码一个重排,这对性能是个巨大的消耗
优化:减少重绘和回流
分离读写操作
1
2
3
4
5
6
7
8div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
console.log(div.offsetLeft);
console.log(div.offsetTop);
console.log(div.offsetWidth);
console.log(div.offsetHeight);上面触发 4 次重排+重绘的代码,这次只触发了一次重排。
在第一个console的时候,浏览器把之前上面四个写操作的渲染队列都给清空了。剩下的console,因为渲染队列本来就是空的,所以并没有触发重排,仅仅拿值而已。样式集中改变
1
2
3
4div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';虽然现在大部分浏览器有渲染队列优化,不排除有些浏览器以及老版本的浏览器效率仍然低下:建议通过改变 class 或者 csstext 属性集中改变样式。
1
2
3
4
5
6
7
8
9// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top = top + "px";
// good
el.className += " theclassname";
// good
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";缓存布局信息
1
2
3
4
5
6
7
8
9// bad 强制刷新 触发两次重排
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';
// good 缓存布局信息 相当于读写分离
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';离线改变 dom
隐藏要操作的
dom
在要操作 dom 之前,通过 display 隐藏 dom,当操作完成之后,才将元素的 display 属性为可见,因为不可见的元素不会触发重排和重绘。1
2
3dom.display = 'none'
// 修改dom样式
dom.display = 'block'通过使用
DocumentFragment创建一个 dom 碎片,在它上面批量操作 dom,操作完成之后,再添加到文档中,这样只会触发一次重排。- 复制节点,在副本上工作,然后替换。
position 属性为 absolute 或 fixed
将需要多次重排的元素,position属性设为absolute或fixed的元素,重排开销比较小,不用考虑它对其他元素的影响。优化动画
动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用requestAnimationFrame- 不要使用
table布局,可能很小的一个小改动会造成整个table的重新布局
去·
