最近的项目重构了,框架选取的是vue 回顾之前的项目中使用的jquery,里面涉及的dom操作真的不能太多。
项目重构接近尾声的时候,惊叹于vue的高性能,同时也在思考,对于前端来说,整天接触的浏览器,是如何成功的渲染一个个精美绝伦的html页面的。并且之前是用jquery的时候总是少不了操dom,特别是在涉及很多dom的动态加载的时候,会明显的感觉到页面的卡顿,我们平时都说DOM很慢,尽量减少DOM操作,于是就想趁热打铁。研究一下浏览器的渲染原理,和对dom为什么慢一探究竟。
关键路径渲染
提到页面的渲染,有几个相关度非常高的概念,最重要的是关键渲染路径,其他几个概念可以逐层展开。
关键渲染路径(Critical Rendering Path)是指与当前用户操作有关的内容。例如用户刚刚打开一个页面。首屏显示就是当前用户操作的相关的内容,具体就是浏览器收到HTML、css和js 等资源并对其进行处理从而渲染出web页面。
了解浏览器渲染的过程与原理,很大程度上是为了优化关键渲染路径,但优化应该是针对具体问题的解决方案,所以优化没有一定之规。例如为了保障首屏内容的最快速显示,通常会提到渐进式页面渲染,但是为了渐进式页面渲染,就需要做资源的拆分,那么以什么粒度拆分、要不要拆分,不同页面、不同场景策略不同。具体方案的确定既要考虑体验问题,也要考虑工程问题。
首先看看DOM的定义:
DOM(文档对象模型)是针对HTML和XML文档的一个api, dom描绘了一个层次化的节点数,允许开发人员添加、移除、修改页面的某一个部分。
浏览器渲染页面的过程:
从耗时的角度,浏览器请求、加载、渲染一个页面,时间花在下面五件事情上:
1.DNS 查询
2.TCP 连接
3.HTTP 请求即响应
4.服务器响应
5.客户端渲染
这里重点讨论第五个部分,即浏览器对内容的渲染,这一部分(渲染树构建,布局和绘制),又可以分为下面的五个部分。
1.处理 HTML 标记并构建 DOM 树。
2.处理 CSS 标记并构建 CSSOM 树。
3.将 DOM 与 CSSOM 合并成一个渲染树。
4.根据渲染树来布局,以计算每个节点的几何信息。
5.将各个节点绘制到屏幕上。
或者按照下面的这四点进行理解:
解析HTML并生成一颗DOM tree
解析各种各样并结合DOM tree 生成一颗 render tree
对Render tree 的各个节点计算布局信息,比如box的位置和尺寸。
根据Render tree 并利用浏览器的ui层进行绘制。
上图是webkit的渲染机制,geoko和这个流程图会有些许的不同。
有一点需要注意 实际上DOMtree 和render tree 上的节点并非一一对应比如一个display:none 的节点就在会存在DOM tree上,而不会出现在 Render tree 上因为这个节点不会被绘制。
需要明白,这五个步骤并不一定一次性顺序完成。如果 DOM 或 CSSOM 被修改,以上过程需要重复执行,这样才能计算出哪些像素需要在屏幕上进行重新渲染。实际页面中,CSS JavaScript 往往会多次修改 DOM 和 CSSOM,下面就来看看它们的影响方式。
严格来说,并不是js操作dom的api速度慢,而是操作了这个dom对象后,会触发一些浏览器的行为,比如布局(layout)和绘制(paint)下面首先介绍一下这些浏览器的行为,阐述一个页面是怎么最终被呈现出来的。
阻塞渲染 CSS和javascript
谈论资源的阻塞时,我们要清楚,现代浏览器总是并行加载资源。例如,当 HTML 解析器(HTML Parser)被脚本阻塞时,解析器虽然会停止构建 DOM,但仍会识别该脚本后面的资源,并进行预加载。
同时,由于下面两点:
1.默认情况下,CSS 被视为阻塞渲染的资源,这意味着浏览器将不会渲染任何已处理 的内容,直至 CSSOM 构建完毕。
2.JavaScript 不仅可以读取和修改 DOM 属性,还可以读取和修改 CSSOM 属性。
存在CSS资源时候,浏览器会延迟加载javascript的执行和DOM的构建。另外:
1.当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
2.JavaScript 可以查询和修改 DOM 与 CSSOM。
3.CSSOM 构建时,JavaScript 执行将暂停,直至 CSSOM 就绪。
所以script标签的位置很重要,实际使用的时候,可以遵循以下的两个原则。
1.CSS 优先:引入顺序上,CSS 资源先于 JavaScript 资源。
2.JavaScript 应尽量少影响 DOM 的构建。
浏览器的发展日益加快(目前的 Chrome 官方稳定版是 67),具体的渲染策略会不断进化,但了解这些原理后,就能想通它进化的逻辑。下面来看看 CSS 与 JavaScript 具体会怎样阻塞资源。
css
<style> p { color: red; }</style>
<link rel="stylesheet" href="index.css">
这样的 link 标签(无论是否 inline)会被视为阻塞渲染的资源,浏览器会优先处理这些 CSS 资源,直至 CSSOM 构建完毕。
渲染树(Render-Tree)的关键渲染路径中,要求同时具有 DOM 和 CSSOM,之后才会构建渲染树。即,HTML 和 CSS 都是阻塞渲染的资源。HTML 显然是必需的,因为包括我们希望显示的文本在内的内容,都在 DOM 中存放,那么可以从 CSS 上想办法。
最容易想到的当然是精简 CSS 并尽快提供它。除此之外,还可以用媒体类型(media type)和媒体查询(media query)来解除对渲染的阻塞。
<link href="index.css" rel="stylesheet">
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">
第一个资源会加载并阻塞。
第二个资源设置了媒体类型,会加载但不会阻塞,print 声明只在打印网页时使用。
第三个资源提供了媒体查询,会在符合条件时阻塞渲染。
影响页面呈现的因素很多,比如link的位置会影响首屏的呈现等,但这里主要集中讨论与layout相关的内容。paint是一个耗时的过程,layout是一个更加耗时的过程,我们无法确定layout一定是自上而下或者是自下而上进行的,甚至一次layout牵涉到整个文档布局的重新计算。
javascript
JavaScript 的情况比 CSS 要更复杂一些。观察下面的代码:
<p>Do not go gentle into that good night,</p>
<script>console.log("inline")</script>
<p>Old age should burn and rave at close of day;</p>
<script src="app.js"></script>
<p>Rage, rage against the dying of the light.</p>
<p>Do not go gentle into that good night,</p>
<script src="app.js"></script>
<p>Old age should burn and rave at close of day;</p>
<script>console.log("inline")</script>
<p>Rage, rage against the dying of the light.</p>
这样的 script 标签会阻塞 HTML 解析,无论是不是 inline-script。上面的 P 标签会从上到下解析,这个过程会被两段 JavaScript 分别打算一次(加载、执行)。所以实际工程中,我们常常将资源放到文档底部。
改变阻塞模式:defer 与 async
为什么要将 script 加载的 defer 与 async 方式放到后面呢?因为这两种方式是的出现,全是由于前面讲的那些阻塞条件的存在。换句话说,defer 与 async 方式可以改变之前的那些阻塞情形。
首先,注意 async 与 defer 属性对于 inline-script 都是无效的,所以下面这个示例中三个 script 标签的代码会从上到下依次执行。
<!-- 按照从上到下的顺序输出 1 2 3 -->
<script async>
console.log("1");
</script>
<script defer>
console.log("2");
</script>
<script>
console.log("3");
</script>
defer
<script src="app1.js" defer></script>
<script src="app2.js" defer></script>
<script src="app3.js" defer></script>
defer 属性表示延迟执行引入的 JavaScript,即这段 JavaScript 加载时 HTML 并未停止解析,这两个过程是并行的。整个 document 解析完毕且 defer-script 也加载完成之后(这两件事情的顺序无关),会执行所有由 defer-script 加载的 JavaScript 代码,然后触发 DOMContentLoaded 事件。
defer 不会改变 script 中代码的执行顺序,示例代码会按照 1、2、3 的顺序执行。所以,defer与相比普通 script,有两点区别:载入 JavaScript 文件时不阻塞 HTML 的解析,执行阶段被放到 HTML 标签解析完成之后。
async
<script src="app.js" async></script>
<script src="ad.js" async></script>
<script src="statistics.js" async></script>
async 属性表示异步执行引入的 JavaScript,与 defer 的区别在于,如果已经加载好,就会开始执行——无论此刻是 HTML 解析阶段还是 DOMContentLoaded 触发之后。需要注意的是,这种方式加载的 JavaScript 依然会阻塞 load 事件。换句话说,async-script 可能在DOMContentLoaded 触发之前或之后执行,但一定在 load 触发之前执行。
从上一段也能推出,多个 async-script 的执行顺序是不确定的。值得注意的是,向 document 动态添加 script 标签时,async 属性默认是 true,下一节会继续这个话题。
说明了浏览器的渲染和一些优化的步骤,具体说一下DOM的渲染的机制。layout肯定是没有办法避免的。所以我们主要是最小化layout的次数。
什么情况下浏览器会进行layout
layout一般称之为布局,这个操作是用来计算文档中元素的位置和大小是渲染前端额重要一步,在HTML第一次被加载的时候,会有一次layout之外js脚本的执行样式的改变同样的会导致浏览器执行layout这也是本文主要讨论的内容。
一般情况下,浏览器layout是lazy的,也就是说,在js脚本执行之前,是不会更新dom的,任何对于dom的修改都会被暂存在一个队列中,在当前js执行的上下文执行完毕后,会根据这个队列中的修改,进行一次layout。
然而有时候希望在js代码中立刻获取最新的dom节点信息,浏览器就不得不提前执行layout,这是导致dom性能问题的主要原因。
如下的操作会打破常规,并触发执行layput:
通过js获取需要计算的DOM属性
添加或删除DOM元素
resize浏览器窗口大小
改变字体
css伪类的激活,比如:hover
通过js修改DOM元素样式且该样式涉及到尺寸的改变
我们看一个例子:
// Read var h1 = element1.clientHeight; // Write (invalidates layout) element1.style.height = (h1 * 2) + 'px'; // Read (triggers layout) var h2 = element2.clientHeight; // Write (invalidates layout) element2.style.height = (h2 * 2) + 'px'; // Read (triggers layout) var h3 = element3.clientHeight; // Write (invalidates layout) element3.style.height = (h3 * 2) + 'px';
这里面涉及一个属性clientHeight 这个属性是需要计算得到的,于是就会触发浏览器的一次layout。用chrome的调试器看一下就会显示下图(很早的图片仅为了说明问题)
上面的例子中,代码首先修改了一个元素的样式,接下来读取另一个元素的clientHeight属性,由于之前的修改导致当前的DOM被标记为脏,为了保证能准确的获取这个属性,浏览器会进行一次layout我们发现chrome的开发者工具良心的提示了我们这个性能问题。
优化这段代码很简单,预先读取所需要的属性,在一起修改即可。
```
// Read
var h1 = element1.clientHeight;
var h2 = element2.clientHeight;
var h3 = element3.clientHeight;
// Write (invalidates layout)
element1.style.height = (h1 * 2) + 'px';
element2.style.height = (h2 * 2) + 'px';
element3.style.height = (h3 * 2) + 'px';
```
面对一系列DOM操作。
针对一系列的DOM操作,可以有如下的解决方案:
documentFragment
display:none
cloneNode
比如:
var fragment = document.createDocumentFragment();
for (var i=0; i < items.length; i++){
var item = document.createElement("li");
item.appendChild(document.createTextNode("Option " + i);
fragment.appendChild(item);
}
list.appendChild(fragment);
这类优化方案的核心思想都是一样的,就是先对一个不在rendertree上的节点
进行一系列的操作,再把这个节点添加回rendertree 这样无论多么复杂的dom
操作,最终只会触发一次layout。
其他注意的点:
除了由于触发了layout而导致性能的问题外,这边再列出其他的一些细节缓存选择器的结果,减少DOM查询,这里要特别提一下HTMLCollectionHTMLCollection 是通过document.getElementByTagName得到的对象类型,和数组很类似但是每次获取这个对象的一个属性,都相当于一次dom查询。
var divs = document.getElementsByTagName("div");
for (var i = 0; i < divs.length; i++){ //infinite loop
document.body.appendChild(document.createElement("div"));
}
比如上面的这段代码会导致无线循环,所以处理HTMLCollection对象
的时候要做一些缓存。
另外减少DOM元素的嵌套深度并优化css 去除无用的样式对减少layout是有一定的帮助的。
在DOM查询时候,querySelector和querySelectorAll 应该是最后的选择,他们功能强大,但是执行的效率很低,如果可以的话尽量使用其他的方法代替
参考资料
https://zhuanlan.zhihu.com/p/29418126
https://segmentfault.com/a/1190000004114594