JavaScript性能优化:(三)优化DOM操作

导致大多数网站和应用程序出现性能问题的一个最大因素是低效率的 DOM 操作。浏览器的 JavaScript 引擎独立于其渲染引擎,通过浏览器获取对页面 DOM 元素的引用要涉及从一个引擎跳转到另一个引擎,浏览器则充当了二者之间的媒介。为了提高性能,我们需要减少这种跳转出现的次数。本文记述了一些常用的技巧,以帮助提升 DOM 操作性能。

使用新的选择器API

大多数最新版本的现代浏览器都实现了 Selectors API 标准,该标准的核心是两个方法:querySelector()querySelectorAll()

二者均接受一个 String 类型的 CSS 选择器,不同的是,前者返回与之匹配的第一个元素,而后者则返回一个包含了所有匹配节点的 NodeList 实例。

1
2
const elem1 = document.querySelector('#idName');
const elem2 = document.querySelector('.className');

减少对 DOM 的访问

使用局部变量缓存 DOM 元素

下面是一段示范代码:

1
2
const header = document.querySelector('header');
const nav = document.querySelector('nav');

引用共同的祖先元素

如果需要访问的一些元素均位于同一父元素之下,那么我们可以只获取对该父元素的引用,并从该引用中获取它的各项子元素的引用。

需要注意的是,我们不能贪心,不要通过获取某一公共祖先元素的引用从而实现对多个 DOM 元素的访问,因为祖先元素与这些实际要访问的元素间的层级越多,那么所需的内存占用也越多,而且也会增加 JavaScript 深入 DOM 树查找实际需要访问元素的时间,这势必会对应用程序产生负面的性能影响。所以最佳实践,是综合考量需要处理的 DOM 元素数目以及最近的共同祖先元素离它们自身之间有多少层。

下面是一个简单示范:

1
2
3
const header = document.querySelector('header');
const h1 = header.querySelector('h1');
const div = header.querySelector('div');

如果只是通过共同父元素来获取对各个子元素的引用,我们还可以使用 DOM 元素的 children 属性。当然了,这种方法只适用于需要处理的 DOM 元素有一个共同的父元素的情况。

1
2
3
4
const header = document.querySelector('header').children;
const h1 = header[0];
const div = header[1];

利用相邻元素

如果需要访问的一些元素正好是同一元素的相邻元素,那么我们可以只获取对该元素的引用,然后使用 previousElementSiblingnextElementSibling 来获取其相邻元素。

1
2
3
4
5
const div = document.querySelector('div');
// 我们没有使用 Node.previousSibling,是因为我们只需要 HTML 元素节点,而不需要文本节点和注释节点
// 使用 Node.previousSibling 还需要我们手动过滤文本节点和注释节点,没有此种方式效率高
const previousElem = div.previousElementSibling;
const nextElem = div.nextElementSibling;

克隆已有节点

使用 createElement() 方法创建 DOM 元素会带来性能上的损失。为了提高性能,在创建 DOM 元素时,我们应该首先考虑尽可能地利用已有元素来创建新元素。

DOM 节点具有一个名为 cloneNode() 的方法,该方法具有一个可选参数,其接受一个布尔类型的值,用于决定是深克隆还是浅克隆。如果省略,默认值为 false,即浅克隆,仅仅克隆当前节点及其相关对象属性。如果值为 true,即深克隆,也就是当前节点及其相关对象属性和其所有后代节点均会被克隆。尽管参数可以省略,最佳实践是永远传入一个布尔值。

!!!注意: 如果被克隆的 DOM 元素设置了 id 属性,那么克隆的副本也会带有相同的 id 属性值,所以克隆具有 id 属性的元素后应该重写副本的 id 属性值。

1
2
3
4
5
6
7
const div = document.querySelector('#test');
const copyDiv = div.cloneNode(false);
copyDiv.id = 'copyDiv';
const list1 = document.createElement('ul');
const list2 = list1.cloneNode(false);

innerHTML VS 原生 DOM 方法

  • 基于 WebKit 内核的新浏览器中,原生 DOM 方法性能更好
  • 旧版本浏览器中,innerHTML 优势更明显

如果在一个对性能有着苛刻要求的操作中更新一大段 HTML,推荐使用 innerHTML,因为他在绝大部分浏览器中都运行得更快。但对于大多数日常操作而言,二者并没有太大区别。

减少重绘和回流

页面的重绘与回流使得实时页面性能开销巨大,当需要对 DOM 元素进行一系列操作时,我们可以通过以下方法来减少重绘和回流的次数:

通过改变元素的类名修改其样式

我们应该尽量避免使用 DOM 元素的 style 属性来修改元素的样式,因为那样会额外增加重绘和回流的次数,降低性能。最佳实践是永远通过元素的类来制其获取的 CSS 样式,进而达到改变元素样式的目的。

HTML5 新增了一种操纵元素 class 特性值的方式,现在所有的元素都具有了 classList 这一属性。并配套提供了 add()remove()toggle()contains() 方法。

1
2
3
const header = document.querySelector('header');
header.classList.toggle('selected');

隐藏元素->应用修改->重新显示

1
2
3
4
5
6
7
8
9
const p = document.createElement('p');
const div = document.querySelector('div');
div.style.display = 'none';
p.text = 'I am a paragraph';
div.appendChild(p);
div.style.display = 'block';

使用文档片段 DocumnetFragment

(Document Fragment)文档片段是一种轻量级的 DOM 文档,表示没有父节点的最小文档对象。它独立于实时渲染的页面文档而存在,所以它不像实时页面的 DOM 那样会占用额外资源,也称(offline DOM)离线文档。

Document Fragment 继承了 Node 类型的所有属性和方法,我们可以像使用普通文档树中的 DOM 元素那样使用文档片段中的 DOM 元素。

创建一个 DOM Fragment 的方式如下:

1
2
3
4
5
// 方法一:
const fragment = document.createDocumentFragment();
// 方法二:此 API 比较新,请查阅浏览器兼容性,不建议在生产环境使用
const fragment2 = new DocumentFragment();

创建了 Document Fragment 之后,我们就可以像操纵实时页面的 DOM 结构树那样操作它了。

1
2
3
4
5
const header = document.createElement('header');
const nav = document.createElement('nav');
fragment.appendChild(header);
fragment.appendChild(nav);

由于文档片段的离线特性,如果我们将实时页面文档中的节点添加到了文档片段中,那么就会从页面文档树中删除这个节点。同样地,添加到文档片断中的节点与页面文档也没什么关系。

通过将文档片段做参数传给 appendChild()insertBefore() 方法,我们可以将文档片段中的所有内容添加到页面文档树中。注意,添加到实时页面上的是整个离线文档片段中的所有子节点,文档片段本身永远也不会成为文档树中的一部分。

1
document.appendChild(fragment);

如果需要同时修改许多 DOM 元素,这是最推荐的方法。

操作副本元素

第三种解决方案是为需要修改的节点创建一个备份,然后对其副本进行 DOM 操作。一旦 DOM 操作完成,就可以用新的节点替代旧的节点。

1
2
3
4
5
6
const old = document.querySelector('div');
const clone = oldNode.cloneNode(true);
// do something
old.parentNode.replaceChild(clone, old);

提升 DOM 事件性能

委托事件至祖先元素

DOM 事件会从其首次被触发的元素开始冒泡,一直到文档结构的最顶端。对于那些由用户操作的多个同类子元素而言,使用事件委托技术可以帮助我们显著提升 DOM 性能。

下面是一个简单的实现事件委托技术的函数,如果要把事件委托给某一元素处理,就将这个函数注册为该元素相应事件的监听器。其中,第二个参数为真正触发事件的元素的标签名,第三个参数为触发事件之后要执行的回调操作函数。

1
2
3
4
5
6
7
function delegatesEvent(event, aimTagName, callback) {
const aimElement = event.target;
if (aimElement !== undefined && aimElement.tagName === aimTagName.toUpperCase()) {
callback();
}
}

使用事件框架化处理频密发出的事件

某些事件可能会在很短的时间内被触发多次,例如,mousemovetouchmove 等事件。如果有很多事件在很短时间内接连不断地被触发,那么采用 addEventListener() 方式一一注册势必会陷入性能泥淖。

对于这种类型的频密发出的事件,我们可以对代码进行调整,是事件处理函数只负责把当前事件相关的值保存至变量中。将计算密集型的代码迁移至单独的处理函数中,然后使用计时器按一定间隔执行该函数。这一原则被称为事件框架化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const body = document.body;
const header = document.querySelector('header');
let scrollTopPosition = 0;
let scrollLeftPosition = 0;
document.addEventListener('scroll', onScroll, false);
requestAnimationFrame(writeScrollPosition);
function onScroll() {
scrollTopPosition = body.scrollTop;
scrollLeftPosition = body.scrollLeft;
}
function writeScrollPosition() {
header.innerHTML = `${scrollTopPosition}px, ${scrollLeftPosition}px`;
requestAnimationFrame(writeScrollPosition);
}

无论如何,都要避免把计算密集型的事件处理函数直接绑定到会在连续状态改下快速频密触发的事件,我们可以使用事件框架化技术作为替代从而提升事件处理的性能。


参考

  • 《高性能JavaSript》,[美]Nicbolas C.Zakas 著,丁琛 译,电子工业出版社 2015
  • 《精通JavaSript开发》,[英]Den Odell 著,邝健威 厉海洋 译,人民邮电出版社 2015