对于大型的对象,使用Map和WeakMap也许是更好的选择

661 阅读5分钟

传统对象存在的劣势

在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%更快
1000.023ms0.019ms17%
10,0003.45ms2.1ms39%
100,00089.9ms48.7ms46%

请注意,这些结果可能会在稍微不同的环境下有所不同,但总体而言,通常情况下,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节点,原因有以下几点:

  1. 节点本身可以直接作为键,无需生成和读取唯一的属性。
  2. 在处理大量对象时,Map通常(设计为)更高效。
  3. 使用WeakMap与节点作为键,可以自动进行垃圾回收,释放不再需要的内存。

在处理大型DOM节点集合时,Map和WeakMap是非常有用的工具,它们提供了更简单、更高效、更灵活的方式来管理数据,并可以更好地管理内存,从而使得应用程序性能更出色。如果你还没有尝试过使用它们来处理DOM节点,我强烈建议你在适当的场景下试一试。希望本文对你有所帮助,

谢谢阅读!