【高性能JavaScript】读书笔记 - DOM 编程 - 07


【简介】DOM 操作的优化核心思想在于减少 DOM 操作的次数。

1. 浏览器中的DOM(DOM in the Browser World)

文档对象模型(DOM)是一个独立于语言的,用于操作 XML 和 HTML 文档的程序接口(API)。在浏览器中,主要用于与 HTML 文档打交道。

DOM是个与语言无关的 API,但是它在浏览器中的接口是用 JavaScript 实现的,所以这也间接地决定了我们需要使用 JavaScript 这种脚本语言来操作DOM。

浏览器中通常会把 DOM 和 JavaScript 独立实现。比如 IE 中,JavaScript 的实现名为 JScript,位于jscript.dll 文件中;而 DOM 实现内存在另一个名为 mshtml.dll 的文件中。Safari 的 DOM 使用的是 WebCore 实现,而 JavaScript 部分是由独立的 JavaScriptCore 引擎实现。

这种分离的技术对于性能意味着损耗。简单的理解,两个相互独立的功能通过 API 接口实现连接,就好比两个岛屿通过一个收费的桥梁实现连接一样,每一次过桥,都需要交纳「过桥费」,过桥的次数越多,所需要交纳的「过桥费」就越多。同样,访问 DOM 的次数越多,所带来的性能的损耗就越大。这种性能的损耗是由 DOM 和 JavaScript 的连接机制决定的,我们无法从这个角度来解决提高性能问题,我们所能做的就是尽可能减少过桥的次数,以此来提升页面的交互响应速度。

2. DOM 的访问与修改 (DOM Access and Modification)

DOM 元素的访问是有代价的(每次访问都需要建立DOM 和 JavaScript 的连接)。而 DOM 元素的修改,更是消耗性能。因为修改 DOM 元素会导致重绘(repaint)和重排(reflow)。因此,通用的经验法则是:减少访问 DOM 的次数,把运算尽量留在 ECMAScript 。

// 这种方式会导致每次循环,都要访问和修改 DOM
function innerHTMLLoop() {
    for (var i = 0; i < 1000; i++) {
        document.getElementById('count').innerHTML += 1;
    }
}
// 将运算交给 ECMAScript,与 DOM 交互只做一次
function innerHTMLLoop() {
    var count = 0;
    for (var i = 0; i < 1000; i++) {
        count += 1;
    }
    document.getElementById('count').innerHTML = count;
}

3. HTML 集合 (HTML Collections)

HTML 集合时包含了多个 DOM 节点引用的类数组对象。最常见的三个获取 HTML 集合的方法:

  • document.getElementByName()
  • document.getElementByTagName()
  • document.getElementByClassName()

这些方法返回的是一个类似数组的列表(能像数组一样通过角标调用元素,比如document.getElementByTagName(‘div’)[1]获取到的是第二个 div 元素,也能使用length属性获取元素个数),但不是数组(没有push()、slice()这样的数组操作方法)。

HTML 集合一直保持着与文档的连接,每次的交互,哪怕是获取集合里元素的个数(即访问length属性),都会导致这个 HTML 集合的更新,所以,操作 HTML 集合会带来严重的性能损耗。

// 会导致死循环,因为每次循环都会创建一个 div
// 而循环条件 divs.length 也会跟着更新,永远也跳不出循环
var divs = document.getElementsByTagName('div');
for (var i = 0; i < divs.length; i++) {
    document.body.appendChild(document.createElement('div'));
}

优化上面的 HTML 集合动态更新的方法很简单,把集合的长度缓存到一个局部变量中,对于集合元素的数量比较多的情况,可以把整个集合元素缓存到数组中。理由有两个:第一个是因为每次迭代,读取元素集合的 length 属性会引发集合进行更新;第二个是读取集合的 length 要比读取数组的 length 要慢很多。

// 缓存 length
function loogCacheLengthCollection(argument) {
    var coll = document.getElementsByTagName('div'),
        len = coll.length;
    for (var i = 0; i < len; i++) {
        /* 代码处理 */
    }
}

对于集合中元素比较多时,可以把集合先拷贝到数组中,再对数组进行操作。理由很简单,数组的操作会比集合的操作高效。但要注意,要权衡拷贝到数组这一额外操作带来的消耗和直接遍历集合带来的消耗,再决定是否要拷贝到数组中。

// 把集合拷贝到数组中
function toArray(coll) {
    for (var i = 0, a = [], len = coll.length; i < len; i++) {
        a[i] = coll[i];
    }
    return a;
}

var coll = document.getElementsByTagName('div');
var arr = toArray(coll);
// 处理数组效率会比处理集合效率高许多
for (var i = 0; i < arr.length; i++) {
    /* 代码处理 */
}

另一个需要优化的是,对于任何类型的 DOM 访问,需要多次访问同一个 DOM 属性,或者方法需要访问多次的时候,最好使用一个局部变量缓存次成员。

function collectionGlobal() {
    var coll = document.getElementsByTagName('div'),
        len = coll.length;
    for (var i = 0; i < len; i++) {
        // 千万不要傻傻的再去读取全局 document 效率低
        nodeName = document.getElementsByTagName('div').nodeName;
        nodeType = document.getElementsByTagName('div').nodeType;
        tagName = document.getElementsByTagName('div').tagName;
    }
}
function collectionLocal() {
    var coll = document.getElementsByTagName('div'),
        len = coll.length,
        nodeName = nodeType = tagName = '';
    for (var i = 0; i < len; i++) {
        // 缓存集合的引用,效率会好一些
        nodeName = coll[count].nodeName;
        nodeType = coll[count].nodeType;
        tagName = coll[count].tagName;
    }
}
function collectionNodesLocal() {
    var coll = document.getElementsByTagName('div'),
        len = coll.length,
        nodeName = nodeType = tagName = '',
        el = null;
    for (var i = 0; i < len; i++) {
        // 把集合元素缓存到变量中 推荐用法
        el = coll[count];
        nodeName = el.nodeName;
        nodeType = el.nodeType;
        tagName = el.tagName;
    }
}

4. 重绘与重排 (Repaints and Reflows)

浏览器下载完页面中的所有组件——HTML、JavaScript、CSS、图片——之后会解析并生成两个内部数据结构:

  • DOM数:表示页面结构
  • 渲染树:表示 DOM 节点如何显示

DOM 树中的每一个需要显示的节点在渲染树中至少存在一个对应的节点(隐藏 DOM 元素在渲染树中没有对应的节点)。渲染数中的节点被称为「帧(frames)」或「盒(boxes)」,符合 CSS 模型的定义,理解页面元素为一个具有内边距(padding),外边距(margins),边框(borders)和位置(position)的盒子。一旦 DOM 和渲染树构建完成,浏览器就开始绘制「paint」(显示)页面元素。

当 DOM 的变化影响了元素的几何属性(宽和高)——比如改变边框宽度或者给段落增加文字,导致行数增加——浏览器需要重新计算元素的几何属性,同样其他元素的集合属性和位置也会因此受到影响。浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树。这个过程称为「重排(reflow)」。完成重排后,浏览器会重新绘制受影响的部分到屏幕中,这个过程称为「重绘(repaint)」。

并不是所有的 DOM 变化都会影响几何属性。例如,改变一个元素的背景色并不会影响它的宽和高。在这种情况下,只会发生一次重绘(不需要重排),因为元素的布局并没有改变。

重绘和重排操作都是代价昂贵的操作,它们会导致 Web 应用程序的 UI 反应迟钝。所以,应当尽可能减少这类过程的发生。

4-1. 重排何时发生(When Does a Reflow Happen)

要优化重排,首先我们需要明确一下那些情况会发生重排。

  • 添加或删除可见的 DOM 元素。
  • 元素位置改变。
  • 元素尺寸改变(外边距、内边距、边框、宽度、高度)
  • 内容改变。
  • 页面渲染器初始化。
  • 浏览器窗口尺寸改变。

根据改变的范围和程度,渲染树或大或小的对应的部分发生重排。

4-2. 最小化重绘和重排(Minimizing Repaints and Reflows)

重绘和重排可能代价非常昂贵,因此一个好的提高程序响应速度的策略就是减少此类操作的发生。为了减少发生次数,应该合并对此对 DOM 和样式的修改,然后依次处理掉。

改变样式

对于样式,一个能够达到同样效果且销量更高的方式是:合并所有的改变然后一次处理。这样只会修改 DOM 一次。

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

将三个样式合并一起处理,能确保只修改 DOM 一次,触发重排一次。

批量修改 DOM

减少重排和重绘,一般有以下三种基本操作:

  1. 隐藏元素,应用修改,重新显示。
  2. 使用文档片段在当前 DOM 之外构建一个子树,再把它拷贝回文档。
  3. 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素。

我们以如下的例子作为演示:
这里写图片描述

左边为原始文档中的列表,现在需要更新列表信息,添加新的栏目。原始文档列表代码片段如下:

<ul id="myList">
    <li><a href="http://localhost/column-a">栏目a</a></li>
    <li><a href="http://localhost/column-b">栏目b</a></li>
</ul>

需要额外添加的信息已经存储在一个对象中:

var data = [
    {
        "name": "栏目c",
        "url": "http://localhost/column-c"
    },
    {
        "name": "栏目d",
        "url": "http://localhost/column-d"
    }
];

定义一个通用函数用于更新指定节点数据:

// appendToElement 插入的位置(父节点对象)
// data 插入的数据
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 fragment):是一个轻量级的 document 对象,它的设计初衷就是为了完成这类任务——更新和移动节点。使用createDocumentFragment() 方法创建一个文档片段。

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 遍历和重排次数最少。

5. 事件委托 (Event Delegation)

事件冒泡:子级元素的某个事件被触发,它的上级元素的该事件也被执行。
比如说我们有这样一个文档片段:

<ul id="menu">
    <li>menu #1</li>
    <li>menu #2</li>
    <li>menu #3</li>
</ul>

分别给 ul 和 li 元素添加 onclick 事件:

window.onload = function(){
    // 获取节点
    var oUl = document.getElementById("menu");
    var aLi = oUl.getElementsByTagName('li');

    // 分别给 ul 和 li 添加点击事件
    oUl.onclick = function () {
        alert("父元素ul被点击"); 
    }
    for(var i=0;i<aLi.length;i++){
        aLi[i].onclick = function(){
            alert("子元素li被点击"); 
        }
    }
}

此时,我们点击某一个 li 元素,首先执行的是 li 的点击事件, alert 出「子元素li被点击」,然后会继续 alert 出「父元素ul被点击」。这样一个先从子元素事件开始执行,再到父元素事件开始执行的过程,成为事件冒泡。

事件委托:只给父元素绑定事件,通过 Event 对象提供的 target 属性,返回事件的目标节点(事件源),就可以模拟出操作当前元素的效果。

window.onload = function(){
  var oUl = document.getElementById("menu");
  oUl.onclick = function(ev){
        // 兼容性处理,标准浏览器用ev.target,IE浏览器用event.srcElement
    var ev = ev || window.event;
    var target = ev.target || ev.srcElement;

    if(target.nodeName == 'LI'){
         alert(target.innerHTML);
    }
  }
}

我们再来看一个例子,也写得很好:

<div id="box">
    <input type="button" id="add" value="添加" />
    <input type="button" id="remove" value="删除" />
    <input type="button" id="move" value="移动" />
    <input type="button" id="select" value="选择" />
</div>

对于这样一个页面片段,我们给每个元素添加事件:

window.onload = function(){
    var Add = document.getElementById("add");
    var Remove = document.getElementById("remove");
    var Move = document.getElementById("move");
    var Select = document.getElementById("select");

    Add.onclick = function(){
        alert('添加');
    };
    Remove.onclick = function(){
        alert('删除');
    };
    Move.onclick = function(){
        alert('移动');
    };
    Select.onclick = function(){
        alert('选择');
    }   
}

我们也可以只添加一次事件:

window.onload = function(){
    var oBox = document.getElementById("box");
    oBox.onclick = function (ev) {
        var ev = ev || window.event;
        var target = ev.target || ev.srcElement;
        if(target.nodeName.toLocaleLowerCase() == 'input'){
            switch(target.id){
                case 'add' :
                    alert('添加');
                    break;
                case 'remove' :
                    alert('删除');
                    break;
                case 'move' :
                    alert('移动');
                    break;
                case 'select' :
                    alert('选择');
                    break;
            }
        }
    }
}

这样原本四次 DOM 操作,变成了一次的 DOM 操作,效率更高。

我们回过头来看,当页面中存在大量元素,而且每一个都要一次或多次绑定事件处理器(比如 click)时,这种情况可能会影响性能。每绑定一个事件处理器都是有代价的,它要么加重了页面负担(更多的标签或 JavaScript 代码),要么是增加了运行期的执行时间。需要访问和修改的 DOM 元素越多,应用程序也就越慢,特别是事件绑定通常发生在 onload 时,此时对每一个富交互应用的网页来说都是一个拥堵的时刻。事件绑定占用了处理时间,而且,浏览器需要跟踪每个事件处理器,这也会占用更多的内存。

所以,一个简单而优雅的处理 DOM 事件的技术是事件委托。通过事件委托的机制,能够减少事件和操作 DOM 的数量,提高性能。

[1] js中的事件委托或是事件代理详解(重点推荐)
[2] js事件冒泡和事件委托
附: 欢迎大家关注我的新浪微博 - 一点编程,了解最新动态 。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值