更新于

DOM编程

发布于 / 分类: javascript高性能 / 暂无评论 / 阅读量: 527

dom是浏览器中非常重要的一部分,他其实相对于js是一个独立的语言,我们通过js去操作其实只是利用api沟通,并不是直接操作的,也就是说dom和js是两个部分,他们之间通过api进行沟通,那么这个沟通的过程自然就会产生性能的损耗,那么你沟通的越多,网页的响应速度就越慢。

dom的访问与修改

function innerHtmlLoop() {
  for(var i =0;i<15000;i++) {
    document.getElementById('box').innerHtml += 'a';
  }
}

这里我们对元素box添加内容,添加15000个a字符,这里就进行了15000次api的沟通,这是非常消耗资源的,实际上还有效率更高的写法:

function innerHtmlLoop() {
  var content = '';
  for(var i =0;i<15000;i++) {
     content  += 'a';
  }
  document.getElementById('box').innerHTML += content;
}

我们先获取到完整的需要添加的内容,然后再一次性传入,这样就只产生了一次沟通,性能提升非常大。

innerHTML 对比 dom方法

通过innerHTML 创建元素速度快呢还是createElement速度快呢?

目前以现在的浏览器来说其实速度已经是相差无几了,甚至dom的方法速度会快过innerHTML,但是innerHTML长盛不衰的秘诀自然是他的方便程度了,创建一个p元素我们使用innerHTML可以用极少量的代码完成,而dom则需要重复好几步,更复杂一点来说,如果是大量的内容,innerHTML无疑是完胜dom。

//innerHTML
document.body.innerHTML += '<p>这是一个p元素</p>';
//dom
var p = document.createElement('p');
var text = document.createTextNode('这是一个p元素');
p.appendChild(text);
document.body.appendChild(p);

为了减少dom的重复性,js还提供了html dom的专门属性,比如表格的专门属性,表单的属性,所以使用这两个方法可以根据需求自行判断使用。

节点克隆

如果你只是想添加一个相同元素,只是内容不同,那么使用节点克隆会更有优势,虽然这个优势并不明显。

使用cloneNode(Boolean);

使用布尔值来判断,是否克隆该元素的子元素:

true 克隆该元素内的所有子元素

false 只克隆该元素本身

var p = document.getElementsByTagName('p')[0];
var p2 = p.cloneNode(true);
alert(p == p2)   // true

HTML集合

HTML集合是包含了DOM节点引用的类数组对象,他并没有数组的push()或者slice()方法,但提供了一个length属性,并且还可以通过数字索引的方式访问集合中的元素。

HTML集合事实上会与文档保持连接,当文档内容发生改变时,集合也会发生改变,哪怕你只是获取length属性,这也是HTML集合低效率的源头。

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

这段代码看上去没什么问题,实际上却是一个死循环,因为div.length是即时性的获取,当我们对文档进行添加div时,length也会+1,然后就会进入无限死循环。

然而解决这个问题也很简单,我们只需要将length作为局部的缓存来进行使用即可。

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

length成为了一个固定的值,不会影响到for语句的循环。

事实上,如果我们多次循环引用div集合中的元素,性能损耗也会加重,办法也是一样,将里面的元素变为一个数组保存。

var div = documetn.getElementsByTagName('div');
toArray(div);


function toArray(arr) {
 for(var i = 0,a = [],length = arr.length;i<length;i++){
   a[i] == arr[i];
 } 
 return a;
}

通过调用toArray()函数返回的数组a,运行速度会快过从div元素集合调用,但是事实上如果你只是进行一次轮询,那么就没必要使用数组去保存,因为数组保存本身就进行了一次查询损耗,所以这个使用还是要场景,但是length是可以作为一个局部缓存来提升性能。

访问集合元素时使用局部变量

前面只是一个for循环,如果我们要频繁的对同一个元素进行修改,怎么提升性能?

var div = documetn.getElementsByTagName('div'),
    length = div.length,
    name = '';
for(var i = 0;i<length;i++) {
  name += documetn.getElementsByTagName('div')[i].nodeName;
  name += documetn.getElementsByTagName('div')[i].nodeType;
  name += documetn.getElementsByTagName('div')[i].tagName;
}
alert(name);

上面这样的写法其实是较慢的方法,因为每次for循环都会去document里获取三次div集合。

那么我们进行优化一下,只进行一次获取!

var div = documetn.getElementsByTagName('div'),
    length = div.length,
    name = '';
for(var i = 0;i<length;i++) {
  name += div[i].nodeName;
  name += div[i].nodeType;
  name += div[i].tagName;
}
alert(name);

这种方法虽然快一点,但是还要进行三次div集合里面的查询。

var div = documetn.getElementsByTagName('div'),
    length = div.length,
    name = '',
    element = null;
for(var i = 0;i<length;i++) {
  element = div[i];
  name += element .nodeName;
  name += element .nodeType;
  name += element .tagName;
}
alert(name);

当我们把重复的元素作为一个局部的缓存来保存使用时,他实际上只会进行一次查询,这样就大大的提高的运行的效率。

获取DOM元素

当我们要对某个元素的内部子元素进性操作的时候,常常是使用childNodes来获取子节点集合,但是childNodes是会将空白节点也作为子元素保存获取,这就造成了不必要的损失,虽然我们可以通过js进行筛选,但是筛选本身就是一种损失。

除了获取集合,我们还会通过判断相邻的元素来进行操作,常用的如nextSibling、firstChild来进行遍历判断。

var body = documetn.body.childNodes,
    length = body.length,
    name = '';
for(var i = 0;i<length;i++) {
   name += body[i].nodeName;
}
alert(name);

通过for语句遍历body的childNodes集合。

var body = documetn.body,
    element = body.firstChild,
    name = '';
do {
  name += element.nodeName;
}wile(element = element.nextSibling);
alert(name);

事实上使用nextSibling进行遍历会比通过childNodes速度更快。

元素节点

dom元素中诸如childNodes、firstChild、nextSibling这些其实并不会区分元素节点和其他类型的节点,比如空白节点,但是大多数情况下我们并不需要空白节点,因此常常需要过滤掉这些,为此我们可以使用一些现代浏览器提供的API来避免这些麻烦。

属性名说明注释
children获取所有子元素节点(不包含空白节点)ie6-ie8支持该属性,但是会返回元素的注释节点
childElementCount子元素节点length
firstElementChild第一个子元素,不包含空白
lastElementChild最后一个子元素,不包含空白
nextElementSibling下一个同级元素,不包含空白
previousElementSibling上一个同级元素,不包含空白

使用children代替childNodes会更快,因为集合项更少,并且支持度还不错。

选择器API

对于获取DOM中的元素,除了常用的getElementById()、getElementsByTagName(),我们还可以使用通过选择器来获取,虽然w3c有同一个getElementsByClassName()方法,但是兼容性非常不好,但是我们有一个原生的DOM方法可以使用。

querySelectorAll(‘class名’);

var elements = document.querySelectorAll('#menu a');

elements包含一个引用列表,指向位置id=‘menu’下的所有a元素,querySelectorAll()方法使用css选择器作为参数并返回一个Nodelist----包含着匹配节点的类数组对象。

注意这个方法返回的不是HTML集合,因此不会实时的链接文档,这也避免了之前HTML集合所带来的性能问题。

如果不使用querySelectorAll(),那么代码会更长一些

var elements = document.getElementById('menu').getElementsByTagName('a');

这种情况下返回的是一个HTML集合,要想达到querySelectorAll()的效果,你还需要使用之前的toArry()方法将里面的元素保存在数组中去。

querySelectorAll()还支持组合查询,比如查找div元素中class为‘warning’和‘notice’的元素。

var elements = document.querySelectorAll('div.warning,div.notice');

如果不使用querySelectorAll()来获取,代码会复杂的多。

var elements = document.getElementsByTagName('div'),
    length = elements.length,
    arr = [],
    className = '';
for(var i =0;i<length;i++) {
  className = elements[i].className;
  if(className == 'warning' || className == 'notice') {
     arr.push(elements[i]);
  }
}

querySelectorAll()方法ie8支持css2选择器,ie9及以上就是css3选择器,现代浏览器全支持,所以总体来说还是一个非常不错的选择。

其次的方法: querySelector()

querySelector()仅仅返回匹配指定选择器的第一个元素,其他和querySelectorAll()一样,所以可以根据不同的需要进行使用。

重绘与重排

DOM中的元素几何属性发生改变,就会触发浏览器重排,比如改变了元素的边框大小,宽高,修改了内容导致行数增加,大小发生变化都会导致浏览器进行重排,重排完成后就会进行重绘将受影响的元素重新渲染至页面中。

重排何时发生

  • 添加或删除可见的DOM元素
  • 元素位置发生改变
  • 元素尺寸发生改变
  • 内容改变,如文本改变或者图片被另一个不同尺寸的图片替换
  • 页面渲染器初始化
  • 浏览器窗口发生变化

每次重排都会产生计算消耗,大多数浏览器通过队列化修改来优化重排过程,然后你可能会经常在不知不觉间强制刷新队列并要求计划任务立刻执行。

比如:

  • offsetTop(元素距离顶部的距离),offsetLeft(元素距离左边的距离),offsetWidth(可见区域宽),offsetHeight(可见区域高)
  • scrollTop(滚动条隐藏的高度),scrollLeft(滚动条隐藏的宽度),scrollWidth(除去滚动条的可视宽度 == clientWidth),scrollHeight(除去滚动条的可视高度 == clientHeight)
  • clientTop(获取元素顶部边框的大小),clientLeft(获取元素左边边框的大小),clientWidth(内容的可视区域的宽度),clientHeight(内容的可视区域高度)
  • getComputedStyle() (或者ie的 currentStyle) 获取计算后的css样式

以上属性和方法需要返回最新的值,因此浏览器不得不进行重排和重绘来返回正确的值。

为此在修改样式的过程中,尽量避免使用以上的属性,因为不管你是获取还是设置都会造成浏览器的重排和重绘。

最小化重排和重绘

修改css值

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

这串代码进行了三次css样式修改,改变了元素的大小,因此触发了重排,虽然现在的浏览器进行了队列优化,但是量大还是会有损失,在旧版浏览器尤为明显,而且这段代码进行了四次访问dom,并且可以优化的。

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

通过cssText属性可以批量的写入css属性,这样就只会修改一次dom。

当然我们还可以通过修改class来达到一样的效果。

批量修改DOM

当你需要对DOM元素进行一系列操作的时候,可以通过以下几个步骤来减少重绘和重排次数

  1. 是元素脱离文档流
  2. 对其进行多重改变
  3. 将元素带回文档中

该过程只会触发两次重排,第一步和第三步的时候,如果你不进行脱离文档流,那么在第二步的时候就会触发多次重排重绘。

脱离文档流的方法

  1. 隐藏元素,改变,重新显示
  2. 使用文档片断documetElementFragment
  3. 创建一个节点的副本,对副本进行操作后再替换掉原来的

例子:

<ul id="mylist">
  <li><a href="http://baidu.com">baidu</a></li>
  <li><a href="http://google.com">google</a></li>
</ul>
var data = [
  {
    "name" : "sougou",
    "url" : "http://sougou.com"
  },{
    "name" : "shenma",
    "url" : "http://shenma.com"
  }
];

function appendData(element,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);
    element.appendChild(li);
  }
}

var url = document.getElmentById('mylist');
appendData(url,data);

以上方法给ul添加两个li,改变的内容和大小,而且是分两次传入,于是造成了两次重排重绘。

减少的办法:

var url = document.getElmentById('mylist');
url.style.display = 'none';
appendData(url,data);
url.style.display = 'block';

使用这种方法容易造成用于体验变差,所以我们推荐使用代码片断的方法

documetElementFragment()会创建一个代码片断,这个片断就是专门完成这类任务------更新和移动节点。该代码的特性是当你将这个代码片断插入至文档时,插入的并不是本体,而是他里面的子元素。

var fragment = documetElementFragment()
appendData(urfragment,data);
document.getElmentById('mylist').appendChild(fragment);

这样写的话哪怕你插入多个元素,也只是进行了一次重排和重绘。

第三种方法是拷贝一个副本。

var url = document.getElmentById('mylist');
var url2 = url.cloneNode(true);
appendData(url2,data);
url.parentNode.replaceChild(url2,url);

通过cloneNode()复制一个副本,对副本进行操作,然后在通过url的父节点将url替换成操作完成的url2。

推荐尽可能的使用第二张方法,因为他所产生的DOM遍历和重排次数最少。

缓存布局信息

当我尝试对一个元素慢慢的从1px的位置移动到500px的位置时,常常会这样写

var url = document.getElmentById('mylist');
for(var i = 0;i<500;i++) {
  url.style.left = url.offsetLeft + 1 + 'px';
}

这种方法效率低下,因为每次循环都会触发offsetLeft 进行重排获取最新信息,更好方法是:

var url = document.getElmentById('mylist');
for(var i = 0,cureent = url.offsetLeft;i<500;i++) {
  url.style.left = cureent++ + 'px';
}

将offsetLeft保存为一个缓存的值,然后每次对这个缓存的值进行++,然后再赋值给url即可。

让元素脱离动画流

用展开/折叠的方式来显示和隐藏部分页面是一种常见的交互方式,通常是改变一个元素的宽高来达到效果。

一般来说,重排只影响渲染树中的一小部分,但也可能影响很大的部分,甚至整个渲染树。浏览器所需要重排的次数越少,应用程序的响应速度就越快,因此当页面顶部的一个动画推移页面整个余下的部分时,会导致一次代价昂贵的大规模重排,让用户感到页面一顿一顿的。渲染树中需要重新计算的节点越多,情况就会越糟。

使用以下步骤可以避免页面中大部分的重排:

  1. 使用绝对定位使元素脱离文档流
  2. 对元素进行操作,这时的操作是局部的重绘
  3. 当动画结束后恢复定位,从而只会产生一次下移其他元素

实际测试后发现并没有那么简单,因为你使用了绝对定位会影响到元素的位置变化,当元素动画完毕再恢复,这个时候会有很明显的断片,使交互不够流畅。

事件委托

我们通过事件冒泡来减少多余的事件函数以提高性能,因为绑定事件函数是有代价的,浏览器需要实时监听这个事件,当事件越多,也会加重页面的负担。

我们知道ie只支持事件冒泡,不支持捕获,那么事件冒泡就是当你点击一个子元素时,会从内到外进行冒泡,如果父元素有相同的事件就会一起触发,那么他们实际上是共用了一个event对象,那么我们可以通过这个event对象来达到我们减少事件的需要。

<ul id="menu">
            <li><a href="https://www.baidu.com" target="_blank">百度</a></li>
            <li><a href="https://www.baidu.com" target="_blank">百度</a></li>
            <li><a href="https://www.baidu.com" target="_blank">百度</a></li>
            <li><a href="https://www.baidu.com" target="_blank">百度</a></li>
        </ul>

在这个ul中有a元素,a元素有href就会创建对应的click事件,默认都是冒泡的,如果我们要通过ajax来进行无刷新更新的话,a元素首先就要阻止默认行为,那么一般做法就是for循环a元素,然后依次创建click事件并添加阻止默认行为preventDefault();但是这样其实是没必要的,我们可以通过事件冒泡就可以减少这段重复性的代码。

var ul = document.getElementById('menu');
ul.onclick = function(e) {
    var e = e || window.event;
    e.preventDefault();
    e.stopPropagation();
    var target = e.target || e.srcElement;
    
    
}

通过给父元素增加一个click事件,然后阻止默认行为,就可以达到阻止a元素的默认行为,因为是通过同一个event对象触发的冒泡,自然可以取消啦,然后再阻止冒泡,不触发其他父元素的事件,然后就可以为所欲为了。

如果浏览器不支持ajax或者禁止js代码,我们依旧可以使用a元素的href属性平滑过渡。

暂无评论

设置
配色方案

布局

购买