什么是DOM
文档对象模型(DOM)是一个独立于语言的,用于操作XML和HTML文档的接口程序(API)。在浏览器中,主要用来与HTML文档打交道,同样也用在web程序中获取XML文档,并使用DOM API用来访问文档中的数据。
浏览器HTML渲染过程
渲染引擎在获得文档内容之后,主要要进行以下的过程:
解析HTML以构建DOM树 -> 构建render树 -> 布局render树 -> 绘制render树
谷歌developer上的版本:
字节 -> 字符 -> TOKEN -> 节点 -> 对象模型
重点说一下TOKEN,所谓TOKEN化,就是浏览器将字符串转成符合W3C HTML5标准的各种标签,例如 < html >、< body >等标签,每个标签都具有特殊含义和规则。
- 浏览器将HTML解析成一个DOM树,DOM树的构建过程是一个深度遍历的过程,也就是说当前节点的所有子节点够构件号之后才会去构建当前节点的下一个兄弟节点。
- 将CSS解析成 CSS 对象模型,DOM 和CSSOM是独立的数据结构。
为页面上的任何对象计算最后一组样式时,浏览器都会从先从适用于该节点的最通用规则开始(如果某个节点是body的子元素,那就应用所有body的样式),然后通过应用更具体的规则(即“向下级联”)以递归的方式适用子节点的样式优化显示。
此外,每个浏览器都会提供一组默认的样式(User Agent样式),我们自定义的样式只是替换这些默认样式
- 根据DOM树和CSSOM来构建 Rendering Tree。
浏览器大致要做下列工作:
- 从DOM树的根节点开始遍历每个可见节点
- 某些节点不可见(脚本标记,元标记)他们不会体现在渲染输出中,会被忽略
- 某些通过CSS隐藏的节点,在渲染书中也会被忽略(display:none)
- 对于每个可见节点,为其找到适配的CSSOM规则并应用。
- Emit可见的节点,连同其内容和计算的样式
- 有了Render Tree,浏览器就知道网页上有哪些节点,各个节点的CSS定义以及他们的从属关系,下一步就是根据当前窗口的大小计算每个节点在屏幕中的位置,称为layout。
- 遍历 Render 树,使用上一步计算出的每个节点的绝对像素绘制每个节点。
CSS和JavaScript的阻塞渲染
CSS和JavaScript文件理论上都会阻塞页面的渲染,也就意味着在CSS文件和JavaScript文件都下载并处理完毕之前,浏览器不会渲染任何内容。
为了避免CSS的阻塞,需要将它尽快的下载到客户端,以便缩短数次渲染的时间,对于只有特定条件下才会使用的CSS样式,可以使用CSS的“媒体类型”和“媒体查询”来解决:
<link href="style.css" rel="stylesheet">//会阻塞
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 40em)">
由于JavaScript可以修改页面的结构和内容,所以在JavaScript处理结束之前,浏览器并不能完全确定页面的形态。为了提高渲染性能,可以让JavaScript异步执行,并去除关键渲染路径中不必要的JavaScript。
我们的脚本在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建
这也就意味着脚本找不到网页中任何在其后面的元素。
默认情况下,所有JavaScript都会组织解析器,如果引入的JavaScript是外部文件,那浏览器必须停下来,等待从磁盘、缓存或远程服务器获取脚本,这就可能给关键渲染路径增加数十值数千毫秒的延迟。
为了避免上述性能缺失,我们可以手动声明脚本不需要在引入位置执行,也就是把脚本标记为异步:
<script src="app.js" async></script>
DOM树与渲染树(Render Tree)
DOM树与渲染树存在区别
DOM 树表示页面结构,渲染树表示DOM节点如何显示
DOM树种每一个需要显示的节点在渲染树中至少勋在一个对应的节点,这不包括哪些隐藏的节点,如:display:none的节点。
网络上广泛流传的一张图片如下所示:
重绘与重排
当 DOM 的变化引起了几何属性(宽和高),浏览器需要重新计算元素的集合属性,同样其他元素的集合属性和位置也会因此受到影响。浏览器会使渲染树中的部分失效,并重新构造渲染树。这个过程称为重排(reflow)。完成重排后,浏览器会重新绘制受影响的部分到屏幕中,该过程称为重绘(repaint)。
并不是所有的 DOM 变化都会影响几何属性,比如背景颜色的改变就不会影响长宽,这种情况下就只会发生一次重绘(并不需要重排),因为元素的布局没有改变。重排和重绘的操作对性能的影响很大,这点会在 DOM 的操作的性能分析中详细讲到。
重排发生的场景如下:
- 添加或删除可见的 DOM 元素
- 元素位置改变
- 元素尺寸改变(包括:外边距,内边距,边框,宽度,高度等几何属性)
- 内容的改变
- 页面渲染器的初始化
- 浏览器窗口尺寸变化也会引起重排
事件委托
页面中的事件通常是在onload(或者DOMContentReady)时绑定到相应的元素上,这对于富交互应用的网页来说,在onload时时间绑定会占用非常多的处理时间,并且浏览器需要跟踪每个事件处理器,就需要占用更多的内存。但是在日常使用中,并不是所有的事件都会被用户触发,所以这其中就有很多不必要的性能开销。
所以就出现了事件委托。原理是:事件在被触发之后,会逐级冒泡并能被父级元素捕捉到。这样就只需要在父级元素上绑定一份处理器,就可以处理所有子元素触发的事件。典型例子就是每个LI中的事件就可以绑定在UL上,这样避免了重复绑定。
DOM 标准里每个事件都要经历的三个阶段:
- 捕获
- 到达目标
- 冒泡
IE 不支持捕获
DOM操作性能分析
天生就慢
由于对 DOM 树的操作天生就慢(具体底层原因我也不太清楚,可能目前浏览器渲染 DOM 相比执行js就是慢很多),所以一般会采用以下几种方法来优化DOM操作的性能
最小化重绘和重排
重绘和重排会涉及到大量的DOM的改变和渲染,所以代价昂贵。所以应该尽量合并对DOM的修改,也就是最后能使用尽可能少的步骤来处理想要的DOM更改。对于批量修改DOM,有以下步骤:
- 使元素脱离文档流
使元素脱离文档流又有如下几个方法:
- 隐藏元素,应用修改,重新显示
先display: none, 然后再display: block- 使文档片段在当前DOM之外构建子树,再把它拷贝回文档
- 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素
- 对其应用多重改变
- 把元素带回文档中
缓存布局信息
为了更新布局信息而去查询布局信息时,比如获取偏移量等,浏览器为了返回最新值,会刷新队列并应用所有变更。而每次查询布局信息都会有代价,所以更好的方法是减少布局信息的获取次数,也就是把它赋值给局部变量,然后操作局部变量。
//低效的
myElement.style.left = 1 + myElement.offsetLeft + 'px';
myElement.style.top = 1 + myElement.offsetTop + 'px';
if (myElement.offsetLeft >= 500) {
stopAnimation();
}
//利用局部变量
current++;
myElement.style.left = current + 'px';
myElement.style.top = current + 'px';
if (myElement.offsetLeft >= 500) {
stopAnimation();
}
让元素脱离动画流
当使用展开、折叠来显示和隐藏部分页面时,通常会将展开区域之外的画面整体推动,这对浏览器来说,就需要重排所有移动的内容,极大影响页面渲染效率。可以使用以下步骤来避免上述情况:
- 使用绝对位置定位页面上的需要动的元素,使其脱离文档流。
- 当元素动起来时,会临时覆盖部分其他的页面,这只导致一小部分页面的重绘,并不会产生大面积重排。
- 当动画结束时恢复定位
在IE中尽量避免使用:hover
IE中大量使用:hover
会极大地影响相应速度。
什么是虚拟DOM
经过上面的叙述,我们了解到渲染页面的开销很大,更令人头痛的是当页面产生变化需要更新时,同样也会产生很大的开销。所以才有了上述基于几种原生的减少页面变化时重排的方法。虚拟DOM 是从另一份角度解决这个问题。
渲染方式的变化
前后端不分离
前后端不分离在前后端还不分离的时代,前端其实不需要关心页面状态的改变。某个按钮的点击或者form的提交都会使整个页面刷新,后端去处理用户的操作,并重新生成一套新的前端来交给浏览器渲染。
There is no change. The universe is immutable.
开始前后端分离
初代的前端框架提供了可以把DOM树和MODEL分离的基础,并可以记录MODEL的变化,但是将MODEL的变化应用到UI显示上还是需要开发者自己来。
I have no idea what I should re-render. You figure it out.
数据绑定
将data model 和 DOM 进行绑定,能够监听data model 的改变,并且知道该如何更新到DOM。
I know exactly what changed and what should be re-rendered because I control your models and views.
AngularJS:Dirty Checking
AngularJs 在渲染数据的过程中为每个数据添加了一个监听,这样Angular 会去检查所有监听器去判断数据是否改变,如果改变就对其进行重新渲染。这也是数据绑定的其中一种形式。
I have no idea what changed, so I’ll just check everything that may need updating.
React: Virtual DOM
当页面渲染之后,React会保留一份虚拟DOM在内存里。
当data model 发生改变之后,React会重新渲染一份新的虚拟DOM与之前保留的虚拟DOM进行比较,并针对改变的部分进行重新渲染。
虚拟DOM的优点是你不用关心变化,因为整个DOM都会重新虚拟渲染一遍,并利用DOM diff算法来最小化地进行高成本的DOM操作。当然,虚拟DOM和数据绑定并不冲突,Vue2.0之后就引入了虚拟DOM,同时它还使用了数据绑定。
I have no idea what changed so I’ll just re-render everything and see what’s different now.
前端开发中对于变化的监听一直是主要问题,目前的这些前端框架也就是为了去解决这些问题。
虚拟DOM Diff算法
传统Diff算法的算法复杂度达到了O(n^3),其中n是树中节点的总数,这个算法复杂度是一个对性能很不友好的复杂度。如果采用传统Diff算法,即使JavaScript的执行速度相比DOM操作很快,那对性能的改善肯定也有限。React所采用的是优化后的Diff算法,把算法复杂度降到了O(n),这样就能满足一般情况下的性能要求。之所以能把复杂度如此多的复杂度,是因为React制定了大胆的Diff策略。
- WEB UI中的DOM节点跨层级的移动操作特别少,所以在比较差异的时候可以只比较统计的节点。
- 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
- 针对同一层级的一组节点,可以通过唯一id进行区分。
React利用上述的三点策略分别对Tree Diff、Component Diff以及Element Diff进行了优化。
Diff算法首先需要对新旧两棵树进行深度优先遍历,对每个节点进行唯一的标记。在深度优先遍历的时候,每遍历到一个节点就把该节点和新的树进行对比,如果有差异的话就记录在一个对象里面。新旧树之间的差异包括:
- 替换掉原来的节点;
- 移动、删除、新增子节点;
- 修改节点属性或绑定的事件;
- 对文本节点内容的修改等。
基于策略一,React的Tree Diff将只会对相同颜色方框内的DOM节点进行比较,即同一父节点下的所有子节点。当发现节点已经不存在时,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。这也就意味着如果出现了DOM节点的跨层级移动,React的Diff算法会重新进行渲染跨层级的节点,这对性能具有一定的影响。
React是基于组件构建应用的,基于策略二React会直接判断新组件和旧组件是否为同一类,如果属于同一类组件则进行Tree Diff;如果判断不是同一类组件,则判定该组件为Dirty 组件,从而替换整个组件的所有子节点。如图6所示,当组件D和组件G被判定为不同类组件之后。会直接删除组件D,重新创建组件G及其子节点。
策略三主要应对节点的移动只是在同级中重新排序,如果也对其进行重新渲染的话,就会有一些不必要的性能损失。这就需要使用上文提到的同一层节点所具有的唯一id,对新的同一层的所有节点与旧的节点应用列表对比算法来比较差异。这个问题可以抽象成字符串的最小编辑距离问题,其时间复杂度为O(m*n),但是通常在应用中不需要真的达到最小操作,一般会使用优化后时间复杂度为O(max(m, n))的算法。
经过上述步骤,我们会记录下所有需要更改的节点,这些节点会变成一个DOM补丁。我们就可以根据不同类型的差异将这些补丁更行到相应的对节点上。
小结
随着前端的开发越来越向富交互方面发展,肯定会不断地推动前端开发中对性能的优化。本文所介绍的虚拟DOM算法在浏览器渲染上的应用使得DOM的渲染效率相比之前有了极大地提升,并且能够让开发人员能够专著与业务需求,而不用考虑该如何去处理DOM的变化。
在看到虚拟DOM的优势的同时,我们也要关注其存在的问题。由于其自身特性,需要时刻保存一份虚拟的DOM树在内存中,对某些大型页面应用来说这将会对性能造成极大的影响。又由于其针对每次的DOM更新都需要重新渲染一遍DOM,虽然相比传统方式已经有了很大的性能提升,但是其还是不足以应对高刷新率的页面应用。