先来点概念
文档对象模型(DOM)是一个独立于语言的,使用 XML 和 HTML 文档操作的应用程序接口(API)。
在浏览器中,主要与 HTML 文档打交道,在网页应用中检索 XML 文档也很常见。DOM APIs 主要用于访问这些文档中的数据。
尽管 DOM 是与语言无关的 API,在浏览器中的接口却是以 JavaScript 实现的。客户端大多数脚本程序与文档打交道,DOM 就成为 JavaScript 代码日常行为中重要的组成部分。
浏览器通常要求 DOM 实现和 JavaScript 实现保持相互独立。例如,在 Internet Explorer中,被称为 JScript 的 JavaScript实现位于库文件 jscript.dll中,而 DOM 实现位于另一个库 mshtml.dll(内部代号 Trident)。
这种分离技术允许其他技术和语言,如 VBScript,受益于 Trident 所提供的 DOM 功能和渲染功能。Safari 使用 WebKit的 WebCore处理 DOM 和渲染,具有一个分离的 JavaScriptCore 引擎(最新版本中的绰号是 SquirrelFish)。
Google Chrome 也使用 WebKit的 WebCore库渲染页面,但实现了自己的 JavaScript 引擎 V8。
在 Firefox 中,JavaScript 实现采用 Spider-Monkey(最新版中称作 TraceMonkey),与其 Gecko 渲染 引擎相分离。
天生就慢
这对性能意味着什么呢?简单说来,两个独立的部分以功能接口连接就会带来性能损耗。
一个很形象的比喻是把 DOM 看成一个岛屿,把 JavaScript(ECMAScript)看成另一个岛屿,两者之间以一座收费桥连接(参见 John Hrvatin,微,MIX09,http://videos.visitmix.com/MIX09/T53F)。
每次 ECMAScript 需要访问 DOM 时,你需要过桥,交一次“过桥费”。
你操作 DOM 次数越多,费用就越高。一般的建议是尽量减少过桥次数,努力停留在 ECMAScript 岛上。
本章将对此问题给出详细解答,告诉你应该关注什么地方,以提高用户交互速度。
DOM访问和修改
简单来说,正如前面所讨论的那样,访问一个 DOM 元素的代价就是交一次“过桥费”。修改元素的费用可能更贵,因为它经常导致浏览器重新计算页面的几何变化。
当然,访问或修改元素最坏的情况是使用循环执行此操作,特别是在 HTML 集合中使用循环。
function innerHTMLLoop() { for (var count = 0; count < 15000; count++) { document.getElementById('here').innerHTML += 'a'; } }
此函数在循环中更新页面内容。这段代码的问题是,在每次循环单元中都对 DOM 元素访问两次:一次读取 innerHTML 属性能容,另一次写入它。
一个更有效率的版本将使用局部变量存储更新后的内容,在循环结束时一次性写入
function innerHTMLLoop2() { var content = ''; for (var count = 0; count < 15000; count++) { content += 'a'; } document.getElementById('here').innerHTML += content; }
在所有浏览器中,新版本运行速度都要快得多。
访问 DOM 越多,代码的执行速度就越慢。因此,一般经验法则是:轻轻地触摸 DOM,并尽量保持在 ECMAScript 范围内。
重绘和重排版
当浏览器下载完所有页面 HTML标记,JavaScript,CSS,图片之后,它解析文件并创建两个内部数据结构:
一棵DOM树 :表示页面结构
一棵渲染树 :表示 DOM 节点如何显示
渲染树中为每个需要显示的 DOM 树节点存放至少一个节点(隐藏 DOM 元素在渲染树中没有对应节点)。
渲染树上的节点称为“框”或者“盒”,符合 CSS 模型的定义,将页面元素看作一个具有填充、边距、边框和位置的盒。
一旦 DOM 树和渲染树构造完毕,浏览器就可以显示(绘制)页面上的元素了。
当 DOM 改变影响到元素的几何属性(宽和高)——例如改变了边框宽度或在段落中添加文字,将发生一系列后续动作——浏览器需要重新计算元素的几何属性,而且其他元素的几何属性和位置也会因此改变受到影响。
浏览器使渲染树上受到影响的部分失效,然后重构渲染树。
这个过程被称作重排版。重排版完成时,浏览器在一个重绘进程中重新绘制屏幕上受影响的部分。
不是所有的 DOM 改变都会影响几何属性。
例如,改变一个元素的背景颜色不会影响它的宽度或高度。
在这种情况下,只需要重绘(不需要重排版),因为元素的布局没有改变。
什么时候会发生重排版?
正如前面所提到的,当布局和几何改变时需要重排版。在下述情况中会发生重排版:
- 添加或删除可见的 DOM 元素
- 元素位置改变
- 元素尺寸改变(因为边距,填充,边框宽度,宽度,高度等属性改变)
- 内容改变,例如,文本改变或图片被另一个不同尺寸的所替代
- 最初的页面渲染
- 浏览器窗口改变尺寸
根据改变的性质,渲染树上或大或小的一部分需要重新计算。某些改变可导致重排版整个页面:例如,当一个滚动条出现时。
查询并刷新渲染树改变
因为计算量与每次重排版有关,大多数浏览器通过队列化修改和批量显示优化重排版过程。
然而,你可能(经常不由自主地)强迫队列刷新并要求所有计划改变的部分立刻应用。
获取布局信息的操作将导致刷新队列动作,这意味着使用了下面这些方法:
- offsetTop, offsetLeft, offsetWidth, offsetHeight
- scrollTop, scrollLeft, scrollWidth, scrollHeight
- clientTop, clientLeft, clientWidth, clientHeight
- getComputedStyle() (currentStyle in IE)(在 IE 中此函数称为 currentStyle)
布局信息由这些属性和方法返回最新的数据,所以浏览器不得不运行渲染队列中待改变的项目并重新排版以返回正确的值。
在改变风格的过程中,最好不要使用前面列出的那些属性。
任何一个访问都将刷新渲染队列,即使你正在获取那些最近未发生改变的或者与最新的改变无关的布局信息。
考虑下面这个例子,它改变同一个风格属性三次(这也许不是你在真正的代码中所见到的,不过它孤立地展示出一个重要话题):
var computed, tmp = "", bodystyle = document.body.style; if (document.body.currentStyle) { // IE, Opera computed = document.body.currentStyle; } else { // W3C computed = document.defaultView.getComputedStyle(document.body, ''); } bodystyle.color = 'red'; tmp = computed.backgroundColor; bodystyle.color = 'white'; tmp = computed.backgroundImage; bodystyle.color = 'green'; tmp = computed.backgroundAttachment;
在这个例子中,body元素的前景色被改变了三次,每次改变之后,都导入 computed 的风格。
导入的属性 backgroundColor, backgroundImage, 和 backgroundAttachment 与颜色改变无关。
然而,浏览器需要刷新渲染队列并重排版,因为 computed的风格被查询而引发。
最小化重绘和重排版
重排版和重绘代价昂贵,所以,提高程序响应速度一个好策略是减少此类操作发生的机会。
为减少发生次数,你应该将多个 DOM 和风格改变合并到一个批次中一次性执行。
考虑这个例子:
var el = document.getElementById('mydiv'); el.style.borderLeft = '1px'; el.style.borderRight = '2px'; el.style.padding = '5px';
这里改变了三个风格属性,每次改变都影响到元素的几何属性。
在这个糟糕的例子中,它导致浏览器重排版了三次。
大多数现代浏览器优化了这种情况只进行一次重排版,但是在老式浏览器中,效率将十分低下。
如果其他代码在这段代码运行时查询布局信息,将导致三次重布局发生。
而且,此代码访问 DOM 四次,可以被优化。
var el = document.getElementById('mydiv'); el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px;';
批量修改 DOM
当你需要对 DOM 元素进行多次修改时,你可以通过以下步骤减少重绘和重排版的次数:
- 从文档流中摘除该元素
- 对其应用多重改变
- 将元素带回文档中
此过程引发两次重排版——第一步引发一次,第三步引发一次。
如果你忽略了这两个步骤,那么第二步中每次改变都将引发一次重排版。
有三种基本方法可以将 DOM 从文档中摘除:
- 隐藏元素,进行修改,然后再显示它
- 使用一个文档片断在已存 DOM 之外创建一个子树,然后将它拷贝到文档中
- 将原始元素拷贝到一个脱离文档的节点中,修改副本,然后覆盖原始元素
为演示脱离文档操作,考虑这样一个链接列表,它必须被更多的信息所更新:
<ul id="mylist"> <li><a href="http://phpied.com">Stoyan</a></li> <li><a href="http://julienlecomte.com">Julien</a></li> </ul>
假设附加数据已经存储在一个对象中了,需要插入到这个列表中。这些数据定义如下:
var data = [ { "name": "Nicholas", "url": "http://nczonline.net" }, { "name": "Ross", "url": "http://techfoolery.com" } ];
下面是一个通用的函数,用于将新数据更新到指定节点中:
function appendDataToElement(appendToElement, data) { var a, li; for (var i = 0, max = data.length; i < max; i++) { a = document.createElement('a'); a.href = data[i].url; a.appendChild(document.createTextNode(data[i].name)); li = document.createElement('li'); li.appendChild(a); appendToElement.appendChild(li); } };
将数据更新到列表而不管重排版问题,最明显的方法如下:
var ul = document.getElementById('mylist'); appendDataToElement(ul, data);
使用这个方法,data 队列上的每个新条目追加到 DOM 树都会导致重排版。如前面所讨论过的,减少重排版的一个方法是通过改变 display属性,临时从文档上移除<ul>元素然后再恢复它。
var ul = document.getElementById('mylist'); ul.style.display = 'none'; appendDataToElement(ul, data); ul.style.display = 'block';
另一种减少重排版次数的方法是:在文档之外创建并更新一个文档片断,然后将它附加在原始列表上。
文档片断是一个轻量级的 document 对象,它被设计专用于更新、移动节点之类的任务。
文档片断一个便利的语法特性是当你向节点附加一个片断时,实际添加的是文档片断的子节点群,而不是片断自己。
下面的例子减少一行代码,只引发一次重排版,只触发“更新 DOM”一次。
var fragment = document.createDocumentFragment(); appendDataToElement(fragment, data); document.getElementById('mylist').appendChild(fragment);
第三种解决方法首先创建要更新节点的副本,然后在副本上操作,最后用新节点覆盖老节点:
var old = document.getElementById('mylist'); var clone = old.cloneNode(true); appendDataToElement(clone, data); old.parentNode.replaceChild(clone, old);
推荐尽可能使用文档片断(第二种解决方案)因为它涉及最少数量的 DOM 操作和重排版。
唯一潜在的缺点是,当前文档片断还没有得到充分利用,开发者可能不熟悉此技术。
注:信息来源:javascript高性能编程