传统对象存在的劣势
在JavaScript中,我们经常使用普通的对象来存储键/值数据,主打一个——易读易懂:
javascriptCopy code
const person = {
firstName: 'Alex',
lastName: 'MacArthur',
isACommunist: false
};
然而,当处理大型实体,其属性频繁被读取、更改和添加时,人们越来越倾向于使用Map。有很多好处,特别是在有性能的瓶颈情况下,或者在插入顺序非常重要的情况下。
最近,我意识到我特别喜欢在处理大量DOM节点时使用Map(以及WeakMap)。这个想法是在阅读Caleb Porzio的一篇博客文章时产生的。在这篇文章中,他介绍了一个由1万个表格行组成的虚构表格,其中一个行可以是“active(激活)”的状态。为了管理选中的不同行的状态,他使用了一个对象作为键/值存储。
javascriptCopy code
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');
}
});
});
这段代码完美地完成了任务。但是,它使用一个对象作为大型哈希表,所以关联值的键必须是一个字符串,这就需要在每个项目上存在一个唯一的ID(或其他字符串值),这增加了程序上的额外开销,无论是在生成还是读取这些值时都是如此。
相对于传统Object的优势
我们可以考虑下,其实任何对象都可以成为Map的键。因此,我们可以直接使用HTML节点本身作为键。所以,将代码改为使用Map后,如下所示:
javascriptCopy code
import { ref, watchEffect } from 'vue';
let rowStates = new Map();
let activeRow;
document.querySelectorAll('tr').forEach((row) => {
rowStates.set(row, ref(false));
row.addEventListener('click', () => {
if (activeRow) rowStates.get(activeRow).value = false;
activeRow = row;
rowStates.get(activeRow).value = true;
});
watchEffect(() => {
if (rowStates.get(row).value) {
row.classList.add('active');
} else {
row.classList.remove('active');
}
});
});
最明显的好处是我无需担心每行上是否存在唯一的ID
。节点引用本身就是唯一的,所以它们可以直接作为键。因为这样,不需要设置或读取任何属性。代码更加简单和健壮。
对于不同数量级map和Object的性能测试
另外,读写操作通常更高效。我在原文中进行了一些简单的性能测试。首先,我创建了一个包含10,000个元素的表格:
javascriptCopy code
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循环中多次运行该过程,然后计算写入和读取的平均时间。
javascriptCopy code
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);
我使用不同行数运行了这个测试。结果如下:
行数 | 对象 | Map | %更快 |
---|---|---|---|
100 | 0.023ms | 0.019ms | 17% |
10,000 | 3.45ms | 2.1ms | 39% |
100,000 | 89.9ms | 48.7ms | 46% |
请注意,这些结果可能会在稍微不同的环境下有所不同,但总体而言,通常情况下,Map相对于对象性能的提升是显著的。Map在大型数据集上的表现非常出色。
WeakMap的作用
此外,WeakMap在更有效地管理内存方面表现出色。WeakMap是Map接口的一个特殊版本,旨在更好地管理内存。它通过对键持有“弱引用”,这意味着如果这些键不再在其他地方有引用,它们将被垃圾回收。对于DOM节点,这一点尤其有用。这样,当节点不再需要时,整个条目会自动从WeakMap中删除,释放更多内存。
为了演示这一点,我们使用FinalizationRegistry,该对象会在你观察的引用被垃圾回收时触发回调。我们从几个列表项开始:
htmlCopy code
<ul>
<li id="item1">first</li>
<li id="item2">second</li>
<li id="item3">third</li>
</ul>
然后,我们将这些项放入WeakMap,并将item2
注册到FinalizationRegistry
以进行监视。接下来,我们移除它,当它被垃圾回收时,回调函数将被触发,我们可以看到WeakMap的变化。
javascriptCopy code
(async () => {
const listMap = new WeakMap();
// 将每个项放入WeakMap。
document.querySelectorAll('li').forEach((node) => {
listMap.set(node, node.id);
});
const registry = new FinalizationRegistry((heldValue) => {
// 垃圾回收发生了!
console.log('After collection:', heldValue);
});
registry.register(document.getElementById('item2'), listMap);
console.log('Before collection:', 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中有三个项,这是预期的。但是在从DOM中移除第二个项并进行垃圾回收后,它的状态变为:
bashCopy code
Before collection: WeakMap {<li id="item1"> => "item1", <li id="item2"> => "item2", <li id="item3"> => "item3"}
After collection: WeakMap {<li id="item1"> => "item1", <li id="item3"> => "item3"}
由于节点引用不再存在于DOM中,整个条目从WeakMap中删除,释放了一些内存。这是我喜欢的功能,有助于保持环境的内存更加整洁。
综上所述
用Map处理DOM节点,原因有以下几点:
- 节点本身可以直接作为键,无需生成和读取唯一的属性。
- 在处理大量对象时,Map通常(设计为)更高效。
- 使用WeakMap与节点作为键,可以自动进行垃圾回收,释放不再需要的内存。
在处理大型DOM节点集合时,Map和WeakMap是非常有用的工具,它们提供了更简单、更高效、更灵活的方式来管理数据,并可以更好地管理内存,从而使得应用程序性能更出色。如果你还没有尝试过使用它们来处理DOM节点,我强烈建议你在适当的场景下试一试。希望本文对你有所帮助,
谢谢阅读!