2

如何使用Map处理Dom节点

 1 year ago
source link: https://www.fly63.com/article/detial/12489
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

更新日期: 2023-05-25阅读: 4标签: dom分享

扫一扫分享

本文浅析一下为什么Map(和WeakMap)在处理大量dom节点时特别有用。

我们在JavaScript中使用了很多普通的、古老的对象来存储键/值数据,它们处理的非常出色:

const person = {
    firstName: 'Alex', 
    lastName: 'MacArthur', 
    isACommunist: false
};

但是,当你开始处理较大的实体,其属性经常被读取、更改和添加时,人们越来越多地使用Map来代替。这是有原因的:在某些情况下,Map跟对象相比有多种优势,特别是那些有敏感的性能问题或插入的顺序非常重要的情况。

但最近,我意识到我特别喜欢用它们来处理大量的DOM节点集合。

这个想法是在阅读Caleb Porzio最近的一篇博文时产生的。在这篇文章中,他正在处理一个假设的例子,即一个由10,000行组成的表,其中一条可以是"active"。为了管理不同行被选中的状态,一个对象被用于键/值存储。下面是他的一个迭代的注释版本。

import { ref, watchEffect } from 'vue';

let rowStates = {};
let activeRow;

document.querySelectorAll('tr').forEach((row) => {
    // Set row state.
    rowStates[row.id] = ref(false);

    row.addEventListener('click', () => {
        // Update row state.
        if (activeRow) rowStates[activeRow].value = false;

        activeRow = row.id;

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

    watchEffect(() => {
        // Read row state.
        if (rowStates[row.id].value) {
            row.classList.add('active');
        } else {
            row.classList.remove('active');
        }
    });
});

这能很好地完成工作。但是,它使用一个对象作为一个大型的类散列表,所以用于关联值的键必须是一个字符串,从而要求每个项目有一个唯一的ID(或其他字符串值)。这带来了一些额外的程序性开销,以便在需要时生成和读取这些值。

对象即key

与之对应的是,Map允许我们使用html节点作为自身的键。上面的代码片段最终会是这样:

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。具有唯一性的节点本身就可以作为键。正因为如此,设置或读取任何属性都是不必要的。它更简单,也更有弹性。

读写性能更佳

在大多数情况下,这种差别是可以忽略不计的。但是,当你处理更大的数据集时,操作的性能就会明显提高。这甚至体现在规范中--Map的构建方式必须能够在项目数量不断增加时保持性能:

Map必须使用哈希表或其他机制来实现,平均来说,这些机制提供的访问时间是集合中元素数量的亚线性。

"亚线性"只是意味着性能不会以与Map大小成比例的速度下降。因此,即使是大的Map也应该保持相当快的速度。

但即使在此基础上,也不需要搞乱DOM属性或通过一个类似字符串的ID进行查找。每个键本身就是一个引用,这意味着我们可以跳过一两个步骤。

我做了一些基本的性能测试来确认这一切。首先,按照Caleb的方案,我在一个页面上生成了10,000个<tr>元素:

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循环中多次运行同一过程,然后确定写入和读取的平均时间。

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行
Object0.023ms3.45ms89.9ms
Map0.019ms2.1ms48.7ms
17%39%46%

请记住,这些结果在稍有不同的情况下可能会有相当大的差异,但总的来说,它们总体上符合我的期望。当处理相对较少的项目时,Map和对象之间的性能是相当的。但随着项目数量的增加,Map开始拉开距离。这种性能上的亚线性变化开始显现出来。

WeakMaps更有效地管理内存

有一个特殊版本的Map接口被设计用来更好地管理内存--WeakMap。它通过持有对其键的"弱"引用来做到这一点,所以如果这些对象键中的任何一个不再有其他地方的引用与之绑定,它就有资格进行垃圾回收。因此,当不再需要该键时,整个条目就会自动从WeakMap中删除,从而清除更多的内存。这也适用于DOM节点。

为了解决这个问题,我们将使用FinalizationRegistry,每当你所监听的引用被垃圾回收时,它就会触发一个回调(我从未想到会发现这样的好东西)。我们将从几个列表项开始:

<ul>
  <li id="item1">first</li>
  <li id="item2">second</li>
  <li id="item3">third</li>
</ul>

接下来,我们将把这些项放在WeakMap中并注册item2,使其受到注册的监听。我们将删除它,只要它被垃圾回收,回调就会被触发,我们就能看到WeakMap的变化。

但是......垃圾收集是不可预测的,而且没有正式的方法来使它发生,所以为了让垃圾回收产生,我们将定期生成一堆对象并将它们持久化在内存中。下面是整个脚本代码:

(async () => {
    const listMap = new WeakMap();

    // Stick each item in a WeakMap.
    document.querySelectorAll('li').forEach((node) => {
    listMap.set(node, node.id);
    });

    const registry = new FinalizationRegistry((heldValue) => {
    // Garbage collection has happened!
    console.log('After collection:', heldValue);
    });

    registry.register(document.getElementById('item2'), listMap);
    
    console.log('Before collection:', listMap);

    // Remove node, freeing up reference!
    document.getElementById('item2').remove();

     // Periodically create a bunch o' objects to trigger collection.
     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中被移除并发生垃圾回收后,它看起来有点不同:

646efe935a901.jpg

由于节点引用不再存在于DOM中,整个条目都被从WeakMap中删除,释放了一点内存。这是一个我很欣赏的功能,有助于保持环境的内存更加整洁。

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

  • 节点本身可以作为键。我不需要先在每个节点上设置或读取独特的属性。
  • 和具有大量成员的对象相比,Map(被设计成)更具有性能。
  • 使用以节点为键的WeakMap意味着如果一个节点从DOM中被移除,条目将被自动垃圾回收。
本文译自:https://www.macarthur.me/posts/maps-for-dom-nodes

链接: https://www.fly63.com/article/detial/12489


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK