高性能JS-Dom操作

翻译自:高性能Javascript 第三章
Dom操作是昂贵的,它通常是web应用的性能瓶颈。这篇文章讨论Dom操作对应用带来消极影响的几个方面,以及给出如何来提高效率的建议。这一章讨论的三类问题包括:

  • 访问和修改Dom元素
  • 修改dom元素的样式和引起的重绘、重排
  • 通过Dom事件处理用户交互
    但是首先,我们了解下什么是Dom和为什么它执行比较慢?

DOM

Dom是用来与XML和HTML文档交互的、与语言无关的应用程序接口(API)。虽然Dom是语言无关的API,但在浏览器中该接口是使用Javascript实现的。

浏览器中保持Dom和Javascript实现相互独立是常见的。比如,IE,Javascript实现被叫作JScript,存在一个jscript.dll的库文件中;然而Dom实现在另一个名为mshtml.dll的库中。Safari使用WebKit的WebCore来执行Dom操作和渲染,另外有一个分离的JavaScriptCore引擎执行核心js。Google Chrome 使用WebCore渲染页面,但是实现了它自己的JavaScript引擎(V8)

本来就慢

这对性能意味着什么?这两个独立的功能相互交流总是要付出代价的。一个很好的比喻是把Dom比作一块地,Javascript(或ECMAScript)是另一块地,他们之间有一座收费桥。每次你的ECMAScript需要接触DOM时,你必须通过这座桥,并且支付过路费。你与DOM一块工作的越多,你支付得越多。所以一般的建议是交叉那座桥尽可能地少。

DOM访问和修改

简单地访问DOM元素需要代价,修改元素代价会更高昂,因为它通常会引起浏览器重新计算页面的几何变化。
当然,访问或修改元素最糟糕的情况是当你在循环中的时候,尤其是在HTML collections的循环中。
先来了解下Dom操作的问题,考虑下下面的例子:

function innerHTMLLoop(){
  for(var count=0;count<15000;count++){
    document.getElementById('here').innnerHTML+='a';
  }
}

这是一个在循环中更新页面内容的函数,这个代码的问题是每次循环,元素被访问了两次:一次是获取innerHTML属性的值,一次是写入值到innerHTML。

这个函数更有效率的版本是使用本地变量存储更新的内容,然后在循环结束时一次性写入:

function innerHTMLLoop2(){
  var content='';
  for(var count=0;count<15000;count++){
    content+='a';
  }
  document.getElementById('here').innerHTML+=content;
}

碰到Dom操作,通用的原则是:尽可能停留在ECMAScript,减少触碰Dom。

innerHTML与Dom方法比较

很多年来,在web开发者社区对这个问题有很多的讨论:使用非标签的innerHTML更新页面是不是比较好的,比起使用纯Dom方法,比如document.createElement()要来得更好。抛开web标准,从效率上考虑,答案是:它变得越来越不重要,但在处理大规模页面更新时,使用innerHTML在大多数浏览器中会给你带来更快的执行效率

克隆节点

另一种更新页面内容的方法是克隆存在的Dom节点,而不是创建一个新的。换句话说,使用element.cloneNode()代替document.createElement()

克隆节点在大多数浏览器中更加高效。作为例证,下面是使用element.cloneNode()生成表的部分代码清单:

function tableCloneDOM(){
  var i,table,thead,tbody,tr,th,td,a,ul,li,
        oth=document.createElement('th'),
        otd=document.createElement('td'),
        otr=document.createElement('tr'),
        oa=document.createElement('a'),
        oli=document.createElement('li'),
        oul=document.createElement('ul');
  tbody=document.createElement('tbody');
  
  for(i=1;i<=1000;i++){
    tr=otr.cloneNode(false);
    td=otd.cloneNode(false);
    td.appendChild(document.createTextNode((i%2) ? 'yes' : 'no'));
    tr.appendChild(td);
    td=otd.cloneNode(false);
    td.appendChild(document.createTextNode(i));
    tr.appendChild(td);
    td=otd.cloneNode(false);
    td.appendChild(document.createTextNode('my name is #' + i));
    tr.appendChild(td);

    // ... the rest of the loop...
  }
  // ... the rest of the table generation ...
}

HTML Collections

HTML collections 是包含DOM节点的类数组对象,下面的方法会返回HTML collections:

  • document.getElementsByName()
  • document.getElementsByClassName()
  • document.getElementsByTagName()
    下面的属性会返回HTML collections:
  • document.imags
  • document.links
  • document.forms
  • document.forms[0].elements
    这些方法和属性返回HTMLCollection对象,它是一个类数组的列表,但并不是数组(因为它们没有比如push()slice()这样的方法),但是提供了一个像数组一样的 length属性,允许通过索引来访问列表中的元素。比如, document.images[1]返回集合中的第二个元素。

根据标准定义,HTML collections是"认为是活的,意思就是当文档更新时,他们会自动更新"。HTML collections事实上表示一个对文档的查询,并且这些查询在你每次更新信息时,重新执行。

昂贵的collections

为了证明collections是活的,考虑下面代码片段:

var alldivs =document.getElementsByTagName('div');
for(var i=0;i<alldivs.length;i++){
  document.body.appendChild(document.createElement('div'))
}

这个代码看上去会在页面上生成双倍的div元素,它遍历存在的div,然后创建新的div添加到body。但事实上,它会无限循环,因为循环的退出条件alldivs.length在每一次迭代中自动增加1,反应底层文档的当前状态。

像这样循环HTML collections会导致逻辑错误,但事实上它也非常慢,因为在每次迭代中,查询需要执行。

当collection的length属性在每次迭代中都被访问时,引起了collection的更新和所有浏览器上显著的性能开销。优化的方法是简单的存储collection的length到变量中,使用该变量作为循环退出条件:

function loopCacheLengthCollection(){
  var coll =document.getElementsByTagName('div'),
        len=coll.lenght;
  for(var count=0;count<len;count++){
      /* do nothing */
  }
}

对于很多较小的collection遍历,只需要把length存到变量就足够了。但遍历一个数组比遍历一个collection要来得快,所以如果一个collection的元素首先被复制到数组中,访问他们会比较快。但是请注意复制这个额外步骤的开销,所以根据你的情况去衡量是否需要复制到数组是重要的。

当访问collection元素时的局部变量

下面的例子,在循环中访问每个元素的三个属性,最慢的版本是每次访问document,一个优化的版本是保存一个collection的引用,最快的版本是存储collection的当前元素到一个变量:

// slow
function collectionGlobal(){
  var coll=document.getElementsByTagName('div'),
        len=coll.length,
        name='';
  for(var count=0;count<len;count++){
    name=document.getElementsByTagName('div')[count].nodeName;
    name=document.getElementsByTagName('div')[count].nodeType;
    name=document.getElementsByTagName('div')[count].tagName;
  }
  return name;
}

// faster
function collectionLocal(){
  var coll=document.getElementsByTagName('div'),
        len=coll.length,
        name='';
   for(var count=0;count<len;count++){
     name=coll[count].nodeName;
     name=coll[count].nodeTag;
     name=coll[count].tagName; 
   }
   return name;
}

// fastest
function collectionNodesLocal(){
  var coll=document.getElementsByTagName('div'),
        len=coll.length,
        name='',
        el=null;
  for(var count=0;count<len;count++){
    el=coll[count];
    name=el.nodeName;
    name=el.nodeType;
    name=el.tagName;
  }
  return name;
}

遍历DOM

Dom API提供了访问文档中特定部分的多个方法。在一些情况下,你从中选择更有效率的API,对特定的工作来说是有益的。

获取DOM

你经常需要从一个DOM元素开始,然后与它的周围元素一块工作,比如遍历它的所有孩子节点。你可以使用childNodes来获取,或者使用nextSibling获取兄弟节点。
考虑下面两个等价的非递归访问元素孩子节点的方法:

function testNextSibling(){
  var el=document.getElementById('mydiv'),
        ch=el.firstChild,
        name='';
  do{
      name=ch.nodeName;
  }while(ch=ch.nextSibling);
  return name;
}

function testChildNodes(){
  var el=document.getElementById('mydiv'),
        ch=el.childNodes;
        name='';
   for(var count=0;count<len;count++){
     name=ch[count].nodeName;
  } 
  return name;      
}
元素节点

DOM属性,比如childNodes,firstChildnextSibling不能够区分元素节点和其他类型的节点,比如文本节点和注释节点,在很多情况下,只需要元素节点,所以需要在循环中检查节点类型,并且过滤掉非元素节点。但这种检查和过滤是不必要的DOM操作。
很多现代浏览器提供了直接返回元素节点的API,只是最好的选择,因为它们要比你循环过滤节点速度要来得快。这些API如下:

Property                                     Use as a replacement for

children                                      childNodes
childElementCount                    childNodes.length
firstElementChild                       firstChild
lastElementChild                        lastChild
nextElementSibling                    nextSibling
previousElementSibling             previousSibling
选择器API

当在DOM中选择元素时,开发者经常需要比getElementById()getElementsByTagName()更细粒度的控制。有时候为了获取你需要的元素列表,你需要组合使用这些接口并且遍历返回的节点,但是这样子做会变得很低效。

另一方面,使用css选择器是一个识别节点方便的方法,因为开发者已经熟悉css。很多JS库提供了这样的APIs,比如querySelectorAll(),当然这种方法要使用js和Dom迭代并缩小元素快速。
考虑下面代码:
var elements=document.querySelectorAll('#menu a');

elements变量会包含在带有id="menu"的元素内的所有a元素,querySelectorAll()使用css选择器字符串作为参数并返回一个NodeList——一个包含匹配节点的类数组对象,这个方法不返回HTML collection,所以返回的节点不代表文档活的结构,这避免了之前HTML collection遇到的性能问题。

为了达到使用querySelectorAll()同样的目标,你需要更啰嗦的代码:
var elements=document.getElementById('menu').getElementsByTagName('a');
这种情况下,elements会是一个HTML collection,所以你另外需要拷贝它到一个数组中,如果你想要得到使用querySelectorAll()返回相同的类型。

重绘和重排

一旦浏览器下载了所有的页面组件——HTML标签,Javascript,CSS,图片 —— 它解析这些文件,并创建两个内部的数据接口:

  • 一个DOM树
    代表页面结构
  • 一个渲染树
    代表DOM节点将会如何被显示

对于需要显示的每一个Dom节点,渲染树至少有一个节点。在渲染树中的节点叫作framesboxes,对应CSS盒子模型(包括padding、margins、borders、position)。一旦DOM和渲染树构造完成,浏览器可以在页面上绘制(paint)元素

当元素的几何被改变(宽度和高度) —比如border变得更粗或者在段落中添加更多的文本,浏览器需要重新计算元素的几何值和被改变影响到的其他元素的几何或位置。浏览器会使呈现树的部分失效并重新构建渲染树,这个过程叫作reflow。一旦reflow完成,浏览器重新绘制影响的部分,称为repaint

不是所有的DOM改变都会影响几何。比如,改变元素的背景色不会改变它的宽和高,这种情况下,只会有repaint(不会发生reflow),因为元素的布局没有被改变。

重绘和重排是昂贵的操作,可以使web应用的UI减少响应。因此,尽可能减少他们的发生是很重要的。

什么时候会发生重排?

就像上面提到的,无论什么时候布局和几何改变,重排都是需要的。这些发生在以下情况:

  • 可视化DOM元素被添加或删除
  • 元素改变位置
  • 元素改变尺寸(因为margin、padding、border厚度、宽度、高度等)
  • 内容改变,比如,文本改变或图片被替换成一个不同的尺寸
  • 页面初始化渲染
  • 浏览器窗口大小改变

取决于变化的性质,渲染树种一个小的或大的部分需要被重新计算,一些改变会导致整个页面的重排:比如,当滚动条出现

存队列和刷新渲染树的变化

因为每次重排都有计算资源消耗,大多数浏览器通过把改变放入队列,并批量执行他们来优化重排。然而你可能会经常无意识地强制队列去刷新,请求所有的计划改变立刻被执行。当你想要检索布局信息时,队列会被强制刷新,这些检索布局信息的接口如下:

  • offsetTop,offsetLeft,offsetWidth,offsetHeight
  • scrollTop,scrollLeft,scrollWidth,scrollHeight
  • clientTop,clientLeft,clientWidth,clientHeight
  • getComputedStyle()

被这些属性和方法返回的布局信息需要被更新,所以浏览器必须去执行渲染队列中改变和reflow,为了返回正确的值。

在改变样式的过程中,最好不要使用以上任何属性。因为即便你获取到的布局信息最近没有改变或者与最后的改变没关系,仍然会刷新渲染队列。

考虑下面的代码,改变同一个样式属性三次:

var computed,
      tmp='',
      bodystyle=document.body.style;
if(document.body.currentStyle){
  computed=document.body.currentStyle;
} else{
  computed=document.defaultView.getComputedStyle(document.body,'');
}
// inefficient way of modiying the same property
// and retrieving style information right after
bodystyle.color='red';
tmp=computed.backgroundColor;
bodystyle.color='white';
tmp=computed.backgroundImage;
bodystyle.color='green';
tmp=computed.backgroundAttachment;

这个例子中,body元素的前景色被改变了三次,并且每次改变后,都有一个样式计算属性被访问,这些检索的属性(backgroundColor,backgroundImage和backgroundAttachment)是与color改变不相干的。但是浏览器需要刷新渲染队列并重排,因为计算样式属性本就有这个要求的事实。

最好的方法是在改变的时候,不要请求访问布局信息。比如计算样式属性访问移到最后,代码如下:

bodystyle.color='red';
bodystyle.color='white';
bodystyle.color='green';
tmp=computed.backgroundColor;
tmp=computed.backgroundImage;
tmp=computed.backgroundAttachment;

这个例子在所有浏览器上都是快速的。

最小化重绘和重排

重绘和重排是昂贵的,所以最好的策略是减少他们的数量。为了减少数量,你应该合并多个DOM和样式改变到一个批次然后一次性应用他们。

样式改变

思考下面的例子:

var el=document.getElementById('mydiv');
el.style.borderLeft='1px';
el.style.borderRight='2px';
el.style.padding='5px';

这里有三个样式属性被改变,他们中的每一个都会影响元素的几何。最坏的情况,会导致浏览器重排三次。大多数现代浏览器做了优化,只会重排一次,但在老浏览器,它依然是低效的。如果有其他代码在代码运行期间请求布局信息,它可能导致三次重排。

最有效的方式是合并所有的改变并一次性应用他们,仅用一次就修改DOM。这个能够使用cssText属性来做到:

var el=document.getElementById('mydiv');
el.style.cssText='border-left:1px;border-right:2px;padding:5px;';

上面修改cssText的属性会覆盖掉已存在的样式,为了保持存在的样式,你可以添加在原cssText字符串后追加:

el.style.cssText+=';border-left:1px;';

或者改变Css 类名

var le=document.getElementById('mydiv');
el.className='active';
批量改变DOM

当你做多个DOM元素的改变时,你可以通过以下步骤来减少重绘和重排数量:

1.把元素脱离文档流
2.对元素做多个修改
3.把元素返回文档流

这个过程会导致两次重排——一次在步骤1,另一次在步骤3。如果你省略这些步骤,在步骤2中的每次改变都会导致重排

有三种基本的方法可以在脱离文档流的情况下修改DOM:

  • 隐藏元素,应用修改,然后再显示元素
  • 使用文档片段documentFragment构建子树,然后再拷贝到文档中
  • 拷贝初始元素到一个脱离文档的节点,然后再拷贝上修改,最后替换初始元素

为了说明脱离文档的操作,考虑下面的链接列表必须被更多信息更新:

<ul id="mylist">
  <li><a href="http://phpied.com"></a></li>
  <li><a href="http://julienlecomte.com"></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);
  }
}

使用这个方法,每次新的实体被追加到DOM文档中时,都会导致重排。
就像上面讨论的,一种减少重排的方法是通过使用display属性,零时把<ul>元素从文档流中删除,然后再返回:

var ul=document.getElementById('mylist');
ul.style.display='none';
appendDataToElement(ul,data);
ul.style.display='block';

另外一种最小化重排的方法是创建和更新一个文档片段:

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);

缓存布局信息

之前已经提到的,浏览器使用队列尝试最小化重排数量,然后批量执行他们。但当你请求布局信息比如offsets,滚动值,或计算的样式信息,浏览器刷新队列并应用所有的改变,为了返回更新后的值。最小化布局信息的请求数量是最好的方式,当你请求它的时候,分配它到局部变量中,然后访问局部变量。

考虑一个移动myElement元素的例子

// inefficient
myElement.style.left=1+myElement.offsetLeft+'px';
myElement.style.top=1+myElement.offsetTop+'px';
if(myElement.offsetLeft>=500){
  stopAnimation();
}

这个是没有效率的,因为每次元素移动时,代码请求offset值,导致浏览器刷新渲染队列,这个对于性能来说是没有益处的。一个最好的方式是访问位置信息一次,然后存在它到变量中,比如var current=myElement.offsetLeft;,然后在动画循环中,在current变量上工作:

current++;
myElement.style.left=current+'px';
myElement.style.top=current+'px';
if(current>=500){
  stopAnimation();
}

事件代理

当在一个页面上有很多元素,并且他们中的每一个都一个或多个事件绑定(比如onclick),这个可能影响性能。更多的DOM节点,你需要去访问并修改,会使你的应用程序变慢。另外附加事件需要处理时间,而且浏览器需要跟踪每一个事件,这个会消耗内存。

一个简单的方法是使用事件代理技术来处理。这个基于一个事实:事件冒泡并且能被父元素处理。使用事件代理,你可以只在包裹元素上绑定一个事件,处理所有孩子节点触发的事件。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容