《高性能 JavaScript》第 3 章 DOM 编程

第 3 章 DOM 编程

《高性能 JavaScript》—— Nicholas C. Zakas

用 JS 操作 DOM 的代价很昂贵,这通常是 web 应用的性能瓶颈。

本章讨论三类问题:

  • 访问和修改 DOM 元素
  • 修改 DOM 元素的样式会导致重绘(repaint)和重排(reflow)
  • 通过 DOM 事件处理与用户的交互

1. 浏览器中的 DOM

DOM(Document Object Model,文档对象模型)就是 W3 定义的操作 XML/HTML 的接口层,
浏览器对(DOM)接口层进行实现,我们(开发者)对接口层进行调用,
也就是说,我们通过(DOM)接口层可以操作界面。

浏览器实现 DOM 和 ECMAScript 是不同的两个东西,
比如 Chrome, 使用 webkit 的 WebCore 库来渲染界面,使用 V8 来实现 ECMAScript。

可以认为 webkit 和 v8 是两个不同的孤岛,
它们之期通信是有额外的开销的。

2. DOM 访问与修改

访问 DOM 元素是有代价的,
修改元素代价更大,因为通常会导致重新计算页面的重绘和重排。

尤其是对 HTML 元素集合进行循环操作,如下:

// 总共读写 15000 * 2 次
function innerHTMLLoop() {
  for (var count = 0; count < 15000; count++) {
    // 先读一次,再写一次
    document.getElementById('here').innerHTML += 'a';
  }
}

应该尽量减少读写 DOM 的次数,把运算逻辑尽量放到 JS 中去,如下:

// 总共读写 2 次
// 改进后,性能(花费的时间)提升几十倍
function innerHTMLLoop2() {
  var content = '';

  for (var count = 0; count < 15000; count++) {
    content += 'a';
  }

  document.getElementById('here').innerHTML += content;
}

2.1. innerHTML vs DOM方法

当要修改页面区域时,有两种方式:

  • innerHTML
  • document.createElement()

这两种方式的性能相差无几,
在非现代浏览器中,innerHTML 要快一点点,
在现代浏览器中,document.createElement() 要快一点点。

应该根据可读性、稳定性、团队习惯、代码风格来综合考虑使用哪种方式。

2.2. 克隆节点

创建新的页面,除了 document.createElement(tagName)
还可以使用 var dupNode = node.cloneNode(deep); 克隆已有的节点。

虽然克隆节点会更有效率,但不是特别明显,只能带来百分之几的性能提升。

2.3. HTML 集合

HTML 集合是是类数组对象,集合里的元素是 Node 节点的引用,获取集合方式:

  • document.getElementsByName()
  • document.getElementsByClassName()
  • document.getElementsByTagName()
  • document.images All img elements on the page
  • document.links All a elements
  • document.forms All forms
  • document.forms[0].elements All fields in the first form on the page
  • 等等

它们都会返回 HTMLCollection 对象。

HTML集合是实时的,
也就是说,当底层文档对象更新时,它也会自动更新。

HTML集合对象一直与文档保持着连接,
length, 每次获取集合对象的元素个数时,会重新在整个页面查询一次以获取最新的个数。

2.3.1. 昂贵的集合

集合是实时的,如下代码:

var alldivs = document.getElementsByTagName('div');

// 每次执行 alldivs.length 
// 都会重新执行一次 `document.getElementsByTagName('div')` 获取最新的 length
for (var i = 0; i < alldivs.length; i++) {
  // 往页面不断插入新元素
  document.body.appendChild(document.createElement('div'))
}

上面的代码是个死循环。

比较如下操作:

// 慢
// 每次访问 length 都会重新查询所有 div
function loopCollection() {
  var coll = document.getElementsByTagName('div');

  for (var count = 0; count < coll.length; count++) {
    /* do nothing */
  }
}

// 快
// 缓存 length,比上面代码快 几倍 ~ 几十倍
function loopCacheLengthCollection() {
  var coll = document.getElementsByTagName('div'),
      len = coll.length;

  for (var count = 0; count < len; count++) {
    /* do nothing */
  }
}


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

// 快
// 将集合元素拷贝到数组,比上面代码快一点点,因为访问数组元素比访问类数组元素快
function loopCopiedArray() {
  var coll = document.getElementsByTagName('div');
  var arr = toArray(coll);

  for (var count = 0; count < arr.length; count++) {
    /* do nothing */
  }
}
2.3.2. 使用局部变量缓存集合元素

当遍历 HTML 集合时,优化原则如下:

  • 缓存集合对象: 把集合存储在局部变量中
  • 缓存 length 属性: 把 length 缓存在循环外部
  • 缓存集合元素: 把多次访问的集合元素放到局部变量中

代码如下:

// 慢
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;
};

// 较快
// 比上面代码快 几倍 ~ 几十倍,缓存集合对象,缓存 length 属性
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].nodeType;
    name = coll[count].tagName;
  }

  return name;
};

// 最快
// 比上面代码快一点点,缓存集合对象,缓存 length 属性,缓存多次访问的集合元素
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;
};

2.4. 遍历 DOM

DOM API 提供了很多方法来访问指定部分的文档结构,你可以根据情况选择高效的方式。

2.4.1. 遍历子节点

childNodesnextSibling 都可以用于遍历子节点,代码如下:

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

function testChildNodes() {
  var el = document.getElementById('mydiv'),
      ch = el.childNodes,
      len = ch.length,
      name = '';

  for (var count = 0; count < len; count++) {
    name = ch[count].nodeName;
    console.log(name);
  }
}

上面的代码,执行速度差不多。

2.4.2. Element 节点

divpa 等等这些标签都是 Element 节点

Element 节点继承 Node 节点,
而文本节点、注释节点也继承 Node 节点。

有时,我们需要剔除掉非 Element 节点,DOM 提供了一些方法

返回 Element 节点返回 Node 节点
childrenchildNodes
childElementCountchildNodes.length
firstElementChildfirstChild
lastElementChildlastChild
nextElementSiblingnextSibling
previousElementSiblingpreviousSibling

返回 Element 节点的方法更快,因为少了非 Element 节点

2.4.3. 选择器 API

通过 CSS 选择器查找 DOM 元素比原生 DOM 方法要快,如下:

var errs = [],
    divs = document.getElementsByTagName('div'),
    classname = '';

for (var i = 0, len = divs.length; i < len; i++) {
  classname = divs[i].className;

  if (classname === 'notice' || classname === 'warning') {
    errs.push(divs[i]);
  }
}

// 比上面要快几倍
var errs = document.querySelectorAll('div.warning, div.notice');

querySelectorAll() 返回 NodeList 对象(快照),
不是 HTML 集合,所以不会对应实时的文档结构。

3. 重绘与重排

页面资源(HTML、JS、CSS、图片等)下载完毕后,浏览器会解析并生成两个内部结构:

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

DOM树中需要显示的节点(非隐藏节点)在渲染树中有对应的节点。

渲染树中的节点称为 帧(frames)或盒(boxes),
盒(boxes)跟 CSS 盒模型一样,有 padding、margins、 borders、position,

一旦DOM树和渲染树构建完毕,浏览器就会把页面元素显示(绘制 paint)出来。

当DOM元素的变化影响到其几何形状(宽、高),
浏览器会重新计算 该元素及受其影响的其他元素 的几何形状和位置,
并使渲染树中受影响的部分失效,并重新构造渲染树,这个过程称为 重排(reflow),
重排之后,浏览器会重新绘制受影响的部分到屏幕上,这个过程称为 重绘(repaint)。

如果元素改变的是背景色(并不会影响其宽高),则只需要重绘即可。

重绘和重排是非常昂贵的,会导致 UI 反应迟钝,所以尽量减少此类过程。

3.1. 重排何时发生

当页面布局或元素几何形状发生“变化”时,会发生重排:

  • 增删可见的元素
  • 改变元素位置
  • 改变元素尺寸( margin, padding, border thickness, width, height)
  • 改变元素内容(改变文本,改变图片尺寸)
  • 页面初始化渲染
  • 改变浏览器窗口大小

改变的部分 所对应的渲染树部分 会重新进行计算,
有些改变会导致整个页面重排,如出现滚动条。

3.2. 重排队列与刷新

由于每次重排都会消耗计算能力,浏览器把多个(导致重排的)“变化”放到队列里,然后批量执行。
也就是说,创建缓冲区,缓存“变化”,缓冲区满后再执行计算,最后重排一次即可。

但当你查询布局信息时会导致刷新缓冲区(让缓冲区中的“变化”立马重排):

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

以上属性和方法需要返回的最新的布局信息。

所以尽量把这些操作放到最后执行,即延迟访问布局信息。

3.3. 最小化重绘和重排

合并所有修改,然后一次处理:

3.3.1. 改变样式
var el = document.getElementById('mydiv');

// 单独处理影响性能
el.style.borderLeft = '1px';
el.style.borderRight = '2px';
el.style.padding = '5px';

// 合并处理
el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px;';

// 或者设置样式类
el.className = 'active';
3.3.2. 批量处理DOM的变化

当你要对一个DOM元素进行多次修改时,你可以通过下面的步骤来减少重排和重绘:

  1. 让元素脱离文档流
  2. 对运行进行修改
  3. 将元素返回文档中

这个过程之后只有第1步、第2步会会硬气重排,
如果不怎么做,第2步中的每个修改都可能会引起重排。

有三种基本的方法:

  1. 隐藏元素,然后修改元素,最后显示出来
  2. 使用文档片段
  3. 克隆元素,然后修改克隆元素,最后用克隆的元素替换掉原始元素

示例:

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 data = [
  {
    "name": "Nicholas",
    "url": "http://nczonline.net"
  },
  {
    "name": "Ross",
    "url": "http://techfoolery.com"
  }
];

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

// 方式 2
var fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
document.getElementById('mylist').appendChild(fragment); // 只会添加片段的所有子节点

// 方式 3
var old = document.getElementById('mylist');
var clone = old.cloneNode(true);
appendDataToElement(clone, data);
old.parentNode.replaceChild(clone, old);

3.4. 缓存布局信息

获取布局信息(如 offsets, scroll values, computed style values)
会是重排缓冲区刷新(计算队列里所有“变化”并重排)。

如果多次获取布局信息,可以缓存到局部变量中:

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

// 高效
var current = myElement.offsetLeft;
current++;
myElement.style.left = current + 'px';
myElement.style.top = current + 'px';
if (current >= 500) {
 stopAnimation();
}

3.5. 使动画元素脱离文档流

在页面通常使用 展开/折叠 的(动画)方式来 显示/隐藏 元素,
如果处理不好会导致很大的区域(甚至整个页面)的重排。

用以下的方式,可以避免页面大块区域的重排:

  1. 通过绝对定位让元素脱离文档流
  2. 在元素上应用动画效果
  3. 恢复元素的定位属性

3.6. 事件委托

避免在“同类型”的元素上挨个绑定事件处理器。

每个事件有三个阶段:

  1. 捕获 (根元素 -> 目标元素)
  2. 在目标元素上触发
  3. 冒泡 (目标元素 -> 根元素)

事件发生时,该事件都会传播祖先元素上。

在祖先元素上绑定事件处理器,在处理器中判断目标元素是否是指定元素:

// 阻止所有链接直接打开页面
document.onclick = function(e) {
  var target = e.target;

  if (target.nodeName !== 'A') {
    return;
  }

  e.preventDefault();

  handleUnsafeLinks(target.href);
};

4. 总结

在现代web应用中,操作DOM是很重要的一部分,每次通过 JS 与 DOM 通信都会有开销,
为了降低DOM编程的性能开销,请记住以下几点:

  • 最小化 DOM 操作,尽量把业务逻辑放在 JS 这一块做完,然后再操作 DOM
  • 用局部变量存储经常访问的 DOM 的引用
  • HTML集合是实时的,缓存HTML集合的 length,或者将其拷贝到数组,进而对数组进行操作
  • 尽量使用更高效的 API,如 querySelectorAll()firstElementChild
  • 注意重排和重绘;批量处理样式更新,“离线”操作DOM树,缓存并最小化访问布局信息
  • 使动画元素绝对定位,使用拖拽代理
  • 使用事件委托来最小化事件处理函数的绑定
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值