译:我为什么使用Map(和WeakMap)处理DOM节点

原文:Why I Like Using Maps (and WeakMaps) for Handling DOM Nodes

在处理大量DOM节点时,Map(和WeakMap)是非常实用的工具,本文分析了具体原因。

我们在JavaScript中使用大量普通的老式对象(POJO),来存储键值对数据,它很好用——清晰、易读:

1
2
3
4
5
const person = {
firstName: 'Alex',
lastName: 'MacArthur',
isACommunist: false
};

但是当开始处理大量频繁读取、修改、添加属性的实体时,人们更常见的做法是采用Map这种数据结构。原因是:在特定的场景下,Map比对象有更多的优势,特别是存在性能敏感的问题,或者插入顺序非常重要时。

但在最近,我开始意识到它们特别适合的场景:处理大量DOM节点

我在读Caleb Porzio最近的一篇博客时冒出了这个想法。在这篇博客中,他设计了一个10000行的表格作为示例,每一行都可以“激活”。为了管理每一行的选中状态,使用了一个对象存储键值对。以下是其中一个迭代的注释版本。我还补充了分号,因为我可不是野蛮人译注:这里作者开玩笑,说不加句尾分号的是野蛮人)。

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
import { ref, watchEffect } from 'vue';

let rowStates = {};
let activeRow;

document.querySelectorAll('tr').forEach((row) => {
// 设置行状态
rowStates[row.id] = ref(false);

row.addEventListener('click', () => {
// 更新行状态
if (activeRow) rowStates[activeRow].value = false;

activeRow = row.id;

rowStates[row.id].value = true;
});

watchEffect(() => {
// 读取行状态
if (rowStates[row.id].value) {
row.classList.add('active');
} else {
row.classList.remove('active');
}
});
});

这些代码运行良好(而且和本文主题没有直接冲突,所以并无冒犯之意)。但是!它使用的是对象,一个大型的类似哈希映射表的结构。用来关联值(value)的键名(key)必须是字符串,因此在每一行必须有一个唯一ID(或其他字符串),在生成和读取这些ID时,必然带来额外的代码成本。

任何对象都可以作为键名

与之不同的是,Map允许使用HTML节点作为键名(key),因此这段代码改成了这样:

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
import { ref, watchEffect } from 'vue';

- let rowStates = {};
+ let rowStates = new Map();
let activeRow;

document.querySelectorAll('tr').forEach((row) => {
- rowStates[row.id] = ref(false);
+ rowStates.set(row, ref(false));

row.addEventListener('click', () => {
- if (activeRow) rowStates[activeRow].value = false;
+ if (activeRow) rowStates.get(activeRow).value = false;

activeRow = row;

- rowStates[row.id].value = true;
+ rowStates.get(activeRow).value = true;
});

watchEffect(() => {
- if (rowStates[row.id].value) {
+ if (rowStates.get(row).value) {
row.classList.add('active');
} else {
row.classList.remove('active');
}
});
});

最明显的好处是,不需要操心每一行的唯一ID了。每一行的HTML节点引用它自身作为键名——自然是唯一的。这样,就不需要设置或读取任何属性了,更简单、更有弹性。

读写操作一般而言是性能良好的

“一般而言”的斜体,是因为在大部分案例中,性能差异是微不足道的。但当你处理更大的数据集,读写操作就会显示出性能优势。这甚至是写在规范中的——Map的构建必须保证数据持续增长时的性能:

Map在实现过程中,使用的哈希表或其他机制,必须保证在数据集的元素数量增长时,平均访问时间呈亚线性变化。

“亚线性”代表性能不会随着Map大小的增长率而降级。所以,即使时很大的Map也会保持相对快的读写速度。

即使没有性能优势,也没有必要搞一个DOM属性,或者用字符串ID来查找。每个键名就是它自己的引用,可以省去一步或两步操作。

我还做了一些基本的性能测试,来验证这些想法。首先,根据Caleb的场景,我在页面上生成了10000个<tr>元素:

1
2
3
4
5
6
7
8
9
10
const table = document.createElement('table');
document.body.append(table);

const count = 10_000;
for (let i = 0; i < count; i++) {
const item = document.createElement('tr');
item.id = i;
item.textContent = 'item';
table.append(item);
}

然后,我写了一个脚本,循环所有行,在对象或Map中存储一些相关状态,测量花了多长时间。我还在for循环中将相同的过程重复很多次,然后确定了平均的读写时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const rows = document.querySelectorAll('tr');
const times = [];
const testMap = new Map();
const testObj = {};

for (let i = 0; i < 1000; i++) {
const start = performance.now();

rows.forEach((row, index) => {
// Test Case #1
// testObj[row.id] = index;
// const result = testObj[row.id];

// Test Case #2
// testMap.set(row, index);
// const result = testMap.get(row);
});

times.push(performance.now() - start);
}

const average = times.reduce((acc, i) => acc + i, 0) / times.length;

console.log(average);

我用不同的行数进行了测试。

100行 10000行 100000行
对象 0.023ms 3.45ms 89.9ms
Map 0.019ms 2.1ms 48.7ms
快(%) 17% 39% 46%

注意,这些结果在不同的环境中可能有很大差异,但总体来看,结果符合我的预期。当处理相对少的数量时,Map和对象的性能是相当的。但随着数量增加,Map开始拉开差距,亚线性的性能变化开始发力。

WeakMap管理内存更高效

另外还有一个Map接口的特别版本,能更好的管理内存——WeakMap。它对键名(key)的引用是“弱引用”,任何键名在其他地方失去引用绑定,就符合垃圾回收的标准。因此,当键名不再被使用时,键名+键值就从WeakMap中被整个砍掉了,能清理更多的内存。这种机制对DOM节点而言同样有效。

为了弄出效果,我们将会使用FinalizationRegistry,在监听的引用被垃圾回收时,会触发回调(我从来没想到还有这么好用的东西,哈哈)。我们先从一个小列表开始:

1
2
3
4
5
<ul>
<li id="item1">first</li>
<li id="item2">second</li>
<li id="item3">third</li>
</ul>

接下来,我们把这些列表项放入WeakMap,注册监听item2。我们将会移除item2,当它被垃圾回收时,回调函数会被触发,然后我们就能看WeakMap有何变化了。

但是…垃圾回收是不可预测的,而且也没有官方的方法手动触发,所以为了触发我们定时生成了大量对象,并将它们驻留在内存中。以下是完整脚本:

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
(async () => {
const listMap = new WeakMap();

// 每个列表项存入Map
document.querySelectorAll('li').forEach((node) => {
listMap.set(node, node.id);
});

const registry = new FinalizationRegistry((heldValue) => {
// 发生垃圾回收了!
console.log('回收后:', heldValue);
});

registry.register(document.getElementById('item2'), listMap);

console.log('回收前:', listMap);

// 移除节点,释放引用!
document.getElementById('item2').remove();

// 定时创建大量对象来触发垃圾回收
const objs = [];
while (true) {
for (let i = 0; i < 100; i++) {
objs.push(...new Array(100));
}

await new Promise((resolve) => setTimeout(resolve, 10));
}
})();

一开始,WeakMap包含3个列表项,符合预期。但在item2被移出DOM,发生垃圾回收时,它变了:

WeakMap remove item2

由于DOM中不再有对该节点的引用,WeakMapitem2的键名和键值被整个移除,释放了一点内存。这个特性我很喜欢,它能帮助环境中的内存保持更干净的状态。

太长不看版本

我喜欢用Map操作DOM节点,因为:

  • 节点自身可以用作键名,不需要在每个节点上搞属性读写的事。
  • 对大量的数据而言,它从设计到实现,都有更好的性能。
  • 节点作为WeakMap的键名,在节点从DOM移除时,可以实现自动的垃圾回收。

其他使用案例?

类似MapSet的“新”特性,用在有趣的现实场景中,让我感觉很有意思。如果你也有类似的想法,和我分享一下吧!