本文是阅读《高性能JavaScript》一书后,从 DOM编程 模块对JavaScript性能优化做了部分总结,记录一下。可能总结的不好,不是很完整,也希望各位大佬能多给出一些建议。万分感谢!


慢的必然性

  • 简单理解,两个相互独立的功能只要通过接口彼此连接,就会产生消耗。

DOM 的修改与访问

访问DOM元素是有代价的,修改元素的代价则更为昂贵,因为它会导致浏览器重新计算页面的集合变化

当然,最坏的情况就是在循环中访问或修改元素,尤其是对 HTML 元素集合循环操作

  • 例一

    1
    2
    3
    4
    5
    function innerHTMLLoop() {
    for(var count = 0; count < 15000; count ++) {
    document.getElementById('here').innerHTML += 'a';
    }
    }
  • 例二

    1
    2
    3
    4
    5
    6
    7
    function innerHTMLLoop2() {
    var content = '';
    for(var count = 0; count < 15000; count ++) {
    content += 'a';
    }
    document.getElementById('here').innerHTML += content;
    }
  • 结果显而易见,例二中的 innerHTMLLoop2 运行得更快

  • 得出结论,访问DOM的次数越多,代码运行得速度越慢,因此,通用的经验法则是:减少访问 DOM 的次数,把运算尽量留在ECMAScript 这一端处理

innerHTML 对比 DOM 方法

  • 在旧版本浏览器中 innerHTML 的优势明显,在新版本的浏览器中两种方式相差无几

节点克隆

  • 使用 element.cloneNode() 替代 document。createElemnt(),运行效率会稍快一点,但是差距不大

HTML 集合

  • HTML 集合以一种“假定实时态”实时存在,这意味着当底层文档对象更新时,它也会自动更新
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 一个意外的死循环
    var alldivs = document.getElementsByTagName('div');
    for(var i = 0; i < alldivs.length; i ++) {
    document.body.appendChild(document.createElement('div));
    }

    /**
    * 结局方案
    * 将 HTML 集合拷贝到普通数组,再进行操作
    */
    function toArray(coll) {
    for(var i = 0, a = []; i < coll.length; i ++) {
    a[i] = coll[i];
    }
    return a;
    }
    var coll = document.getElementsByTagName('div');
    var ar = toArray(coll);

昂贵的集合

优化方案:

  • 相同的内容和数量下,便利一个数组的速度明显快于便利一个 HTML 集合

  • 循环时缓存数组长度


遍历 DOM

获取 DOM 元素

  • 在不同浏览器中,elemnt.nextSibling 和 parentEle.childNode 运行时间相差无几

    • 但是在 IE6 中,nextSibling 快16倍

    • 在 IE7 中,nextSibling 快 105 倍

  • 因此得出结论,在老版本的 IE 中,推荐使用 nextSibling 来查找DOM 节点,其他情况取决于个人或团队偏好

元素节点

  • 在所有浏览器中,children 都比 childNodes 要快,因为集合项更少

选择器 API

  • 如果需要处理大量组合查询,推荐使用 querySelectorAll()

  • 还有一个新的选择器API,querySelector(),可以获取第一个匹配到的节点


重绘与重排

  • 概念:
    当DOM的变化影响了元素的几何属性(宽和高)——比如改变边框宽度或给段落增加文字,导致行数增加——浏览器需要重新计算元素的几何数形,同样其他元素的几何属性也会因此受到影响。浏览器回事渲染树中受到影响的部分失效,并重新构建渲染树。这个过程称为“重排”。完成重排后,浏览器会重新绘制受影响的部分到屏幕中,该过程称为“重绘”。
  • 并不是所有的 DOM 变化都会影响几何属性,例如,改变一个元素的背景色并不会影响它的宽和高。这种情况下,只会发生一次重绘(不需要重排)

重排何时发生

  • 添加或删除可见的 DOM 元素

  • 元素位置改变

  • 元素尺寸改变(包括:外边距、内边距、边框厚度、宽度、高度等属性改变)

  • 内容改变,例如:文本改变或图片被另一个不同尺寸的图片替代

  • 页面渲染器初始化

  • 浏览器窗口尺寸改变

  • 滚动条出现时

渲染树变化的排队与刷新

由于每次重排都会产生计算消耗,大多数浏览器通过队列化修改并批量执行来优化重拍过程,然而,你可能会(经常不知不觉)强制刷新队列要求计划任务立即执行。获取布局信息的操作会导致队列刷新。例如一下方法:

  • offsetTop, offsetLeft, offsetWidth, offsetHeight

  • scrollTop, scrollLeft, scrollWidth, scrollHeight

  • clientTop, clientLeft, clientWidth, clientHeight

  • getComputedStyle() (currentStyle in IE)

以上属性和方法需要最新的布局信息,因此浏览器不得不执行渲染队列的“待处理变化”并触发重排以返回正确的值。

  • 因此,在修改样式的过程中,最好避免出现上面列出的属性。它们都会刷新渲染队列,即使你是在获取最近未发生改变的或者与最新改变无关的布局信息

最小化重绘与重排

  • 如上所述,通过延迟访问布局信息来避免“重排”

改变样式

  • 合并所有的改变然后依次处理

    1
    2
    3
    4
    5
    6
    7
    8
    var el = document.getElementById('box');
    el.style.borderLeft = '1px';
    el.style.borderRight = '2px';
    el.style.padding = '5px';
    // 上面可能会导致浏览器触发三次重排
    // 改写成下面的方式就会更高效
    var el = document.getElementById('box');
    el.style.cssText = 'border-left: 1px; border-right: 1px; padding: 5px;'
  • 另一个一次性修改样式的方法是修改CSS的class名称,而不是修改内联样式。适用于那些不依赖运行逻辑和计算的情况

    1
    2
    var el = document.getElementById('box');
    el.className = 'active'

批量修改 DOM

可以使用以下步骤进行优化:

  1. 使元素脱离文档流

  2. 对其应用多重改变

  3. 把元素带回文档中

使 DOM 脱离文档的三种基本方法:

  • 前情提要
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    <!-- 如下元素 -->
    <ul id="myList">
    <li><a href="http://www.baidu.com">百度</a></li>
    <li><a href="http://www.google.com">谷歌</a></li>
    <ul>
    <script>
    var data = [
    {
    "name": "Jack",
    "url": "https://www.redux.org.cn"
    },
    {
    "name": "Ross",
    "url": "https://github.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);
    </script>
  1. 隐藏元素,应用修改,重新显示

    1
    2
    3
    4
    5
    // 修改之后的更新方式
    var ul = document.getElementById('myList');
    ul.style.display = 'none';
    appendDataToElement(ul, data);
    ul.style.display = 'block';
  2. 使用文档片段(document fragment)在当前 DOM 之外构建一个子树,再把它拷贝回文档

    1
    2
    3
    4
    5
    // 修改之后的更新方式
    var ul = document.getElementById('myList');
    var fragment = document.creatDocumentFragment();
    appendDataToElement(fragment, data);
    ul.appendChild(fragment);
  3. 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素

    1
    2
    3
    4
    5
    // 修改之后的更新方式
    var ul = document.getElementById('myList');
    var clone = ul.cloneNode(true);
    appendDataToElement(clone, data);
    ul.parentNode.replaceChild(clone, ul);

缓存布局信息

  • 正如开始所说的,尽量减少获取布局信息的获取次数

  • 应用场景:获取偏移量、滚动位置、计算出的样式值

  • 例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 移动元素到一定位置后停止动画
    // 低效的
    myElement.style.left = 1 + myElement.offsetLeft + 'px';
    myElement.style.top = 1 + myElement.offsetTop + 'px';
    if (myElement.offsetLeft >= 500) {
    stopAnimation();
    }

    // 改写之后:
    // 先将 myElement.offsetLeft 缓存给局部变量 current
    var current = myElement.offsetLeft;

    ....
    // 循环体中
    current ++;
    myElement.style.left = current + 'px';
    myElement.style.top = current + 'px';
    if (current >= 500) {
    stopAnimation();
    }

让元素脱离动画流

  • 步骤

    1. 使用绝对位置定位页面上的动画元素,将其脱离文档流

    2. 让元素动起来。当它扩大时,会临时覆盖部分页面。但这只是页面一个小区域的重绘过程,不会产生重排并重绘页面的大部分内容

    3. 当动画结束时恢复定位,从而只会下移一次文档的其他元素

hover 伪类

  • 如果你有大量元素使用了 :hover,那么会降低响应速度。此问题在 IE8 中更为明显

  • 因此在元素很多时应避免使用这种效果,比如很大的表格或很长的列表

事件委托

当页面中存在大量元素,且每一个都要一次或多次绑定事件处理器时,这种情况可能会影响性能。每绑定一个事件处理器都是有代价的,它要么是加重了页面负担(更多的标签或JavaScript代码),要么是增加了运行期的执行时间。

因此,一个简单而优雅的处理 DOM 事件的技术是事件委托。它基于这样一个原理:事件逐层冒泡并能被父级元素捕获

  • 实例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    /**
    * 场景: <div><ul id="menu"><li><a href="http://www.github.com">menu #1</a></li></ul></div>
    * 用户点击链接 “menu #1”,点击事件首先由<a>元素收到,然后向 DOM 树上层冒泡,被<li>元素接受,接着是<ul>,然后是<div>。
    * 以此类推,一直到达document 的顶层乃至 window。这使得你可以添加一个事件处理器到父级元素,由它接受所有子节点的事件消息
    *
    */
    document.getElementById('menu').onclick = function(e) {
    // 浏览器 target
    e = e || window.event;
    var target = e.target || e.srcElement;

    var pageid, hrefparts;

    // 只关心hrefs, 非链接点击则退出
    if (target.nodeName !== 'A') {
    return;
    }

    // 从链接中找出页面 ID
    hrefparts = target.href.split('/');
    pageid = hrefparts[hrefparts.length - 1];
    pageid = pageid.replace('.html', '');

    // 更新页面
    ajaxReauest('xhr.php?page=' + id, updatePageContents);

    // 浏览器阻止默认行为并取消冒泡
    if (typeof e.preventDefault === 'function') {
    e.preventDefault();
    e.stopPropagation();
    } else {
    e.returnValue = false;
    e.cancelBubble = true;
    }
    }
  • 跨浏览器兼容性的部分(可封装到可重用的类库)

    • 访问事件对象,并判断事件源

    • 取消文档树中的冒泡(可选)

    • 阻止默认动作(可选,但本例需要,因为需要捕获并阻止打开链接)


小结

访问和操作 DOM 是现代 Web 应用的重要部分。范每次穿越连接 ECMAScript 和 DOM 两个岛屿之间的前两,都会被收取“过桥费”。为了减少 DOM 编程带来的性能损失,请记住以下几点:

  • 最小化 DOM 访问次数,尽可能在 JavaScript 端处理

  • 如果需要多次访问某个 DOM 节点,请使用局部变量存储它的引用

  • 小心处理 HTML 集合,因为它实时连系着底层文档。把集合的长度缓存到一个变量中,并在迭代中使用。如果需要经常操作集合,建议把它拷贝到一个数组中

  • 如果可能的话,使用速度更快的 API,比如 querySelectorAll()和 firstElementChild

  • 要留意重绘和重排;批量修改样式时,“离线”操作 DOM 树,使用缓存,并减少访问布局信息的次数

  • 动画中使用绝对定位,使用拖放代理

  • 使用事件委托来减少事件处理器的数量