前端開發(fā)必備:Maps與WeakMaps在DOM節(jié)點(diǎn)管理中的妙用
這篇文章討論了使用 Maps 和 WeakMaps 處理DOM節(jié)點(diǎn)的優(yōu)勢。Maps 和 WeakMaps 是非常實(shí)用的工具,尤其在處理大量DOM節(jié)點(diǎn)時(shí),它們發(fā)揮著重要作用。
文章作者認(rèn)為,使用 Maps 和 WeakMaps 處理 DOM 節(jié)點(diǎn)有以下幾個(gè)優(yōu)點(diǎn)。首先,它們可以方便地存儲(chǔ)和檢索數(shù)據(jù)。與其他數(shù)據(jù)結(jié)構(gòu)相比,Maps和 WeakMaps 可以更簡潔地組織和查找相關(guān)數(shù)據(jù)。其次,它們可以幫助開發(fā)者更好地管理內(nèi)存。當(dāng)不再需要某個(gè)DOM節(jié)點(diǎn)時(shí),WeakMaps可以自動(dòng)釋放與該節(jié)點(diǎn)相關(guān)的內(nèi)存,從而提高程序的性能。最后,使用 Maps 和 WeakMaps 可以提高代碼的可讀性和可維護(hù)性。將DOM節(jié)點(diǎn)與相關(guān)數(shù)據(jù)關(guān)聯(lián)起來,有助于使代碼更清晰易懂。
下面是正文:
在JavaScript中, 我們經(jīng)常使用普通的對(duì)象來存儲(chǔ)鍵/值數(shù)據(jù),它們非常擅長這項(xiàng)工作 - 清晰易讀:
const person = {
firstName: 'Alex',
lastName: 'MacArthur',
isACommunist: false
};
但是,當(dāng)我們開始處理經(jīng)常被讀取、更改和添加屬性的較大實(shí)體時(shí),更傾向于使用 Maps。因?yàn)樵谀承┣闆r下,Map 比對(duì)象具有多個(gè)優(yōu)勢,特別是性能問題或插入順序比較重要的情況下。
但最近我特別喜歡使用它們來處理大量的DOM節(jié)點(diǎn)。
在閱讀Caleb Porzio最近的博客文章時(shí),我想到了這個(gè)想法。在這篇文章中,他正在使用由10,000個(gè)表行組成的表格,其中一個(gè)可以是“active”。為了管理選擇不同行時(shí)的狀態(tài),使用對(duì)象作為鍵/值存儲(chǔ)。這是他的一個(gè)迭代版本的注釋版本。
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');
}
});
});
它使用一個(gè)對(duì)象作為大型哈希映射表,因此用于關(guān)聯(lián)值的鍵必須是字符串,因此需要在每個(gè)項(xiàng)目上存在唯一的ID(或其他字符串值)。這帶來了一些額外的編程開銷,需要在需要時(shí)生成和讀取這些值。
任何對(duì)象都可以作為鍵
相反,使用 Map 可以讓我們直接將 HTML 節(jié)點(diǎn)作為鍵。因此,該代碼片段最終看起來像這樣:
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');
}
});
});
這里最明顯的好處是我不需要擔(dān)心每行存在唯一ID。節(jié)點(diǎn)引用本身是唯一的,可以作為鍵。因此,既不需要設(shè)置也不需要讀取任何屬性。這更簡單、更具彈性。
讀/寫操作通常更高效
當(dāng)我們處理更大的數(shù)據(jù)集時(shí),操作的性能顯著提高。甚至在規(guī)范中也有說明 - 必須以保持性能的方式構(gòu)建 Map
,以便隨著項(xiàng)目數(shù)量的增加而增長:
Maps must be implemented using either hash tables or other mechanisms that, on average, provide access times that are sublinear on the number of elements in the collection.
“Sublinear” 的意思是性能不會(huì)隨著 Map 的大小成比例地下降。因此,即使是大型 Map,性能也應(yīng)該保持相當(dāng)迅速。
再次強(qiáng)調(diào),沒有必要干擾DOM屬性或通過類似字符串的ID執(zhí)行查找。每個(gè)鍵本身就是一個(gè)引用,這意味著我們可以跳過一兩個(gè)步驟。
我進(jìn)行了一些基本的性能測試來確認(rèn)所有這些。首先,按照Caleb的場景,我在頁面上生成了10,000個(gè) <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);
}
接下來,我設(shè)置了一個(gè)模板來測量循環(huán)遍歷所有這些行并將一些相關(guān)狀態(tài)存儲(chǔ)在對(duì)象或Map
中需要多長時(shí)間。我還在 for
循環(huán)中運(yùn)行了同樣的過程多次,然后確定編寫和讀取所需的平均時(shí)間。
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);
我用不同的行大小運(yùn)行了這個(gè)測試:
請(qǐng)記住,即使是稍微不同的情況,這些結(jié)果也可能會(huì)有很大的差異,但總體而言,它們通常符合我的預(yù)期。在處理相對(duì)較少的項(xiàng)目時(shí),Map 和對(duì)象之間的性能是可比的。但隨著項(xiàng)目數(shù)量的增加,Map 開始拉開差距。性能的次線性變化開始顯現(xiàn)。
WeakMaps更有效地管理內(nèi)存
有一個(gè)特殊版本的 Map 接口,旨在更好地管理內(nèi)存 - WeakMap 。它通過保持對(duì)其鍵的“弱”引用來實(shí)現(xiàn)這一點(diǎn),因此,如果這些對(duì)象鍵中的任何一個(gè)不再具有其他地方綁定的引用,則它有資格進(jìn)行垃圾回收。因此,當(dāng)不再需要該鍵時(shí),整個(gè)條目將自動(dòng)從 WeakMap 中刪除,從而清除更多內(nèi)存。它也適用于DOM節(jié)點(diǎn)。
為了調(diào)整這個(gè),我們將使用 FinalizationRegistry ,它會(huì)在你正在觀察的引用被垃圾回收時(shí)觸發(fā)回調(diào)。我們將從幾個(gè)列表項(xiàng)開始:
<ul>
<li id="item1">first</li>
<li id="item2">second</li>
<li id="item3">third</li>
</ul>
接下來,我們將把這些項(xiàng)目放入 WeakMap 中,并將 item2 注冊(cè)為注冊(cè)表監(jiān)視的對(duì)象。我們將其刪除,每當(dāng)它被垃圾回收時(shí),回調(diào)將被觸發(fā),我們將能夠看到 WeakMap 如何發(fā)生變化。
但是...垃圾收集是不可預(yù)測的,也沒有官方的方法來觸發(fā)它,因此為了測試,我們將定期生成一堆對(duì)象并將它們保存在內(nèi)存中。以下是整個(gè)腳本:
(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));
}
})();
在發(fā)生任何事情之前,WeakMap 如預(yù)期的那樣包含三個(gè)項(xiàng)。但是在從DOM中刪除第二項(xiàng)并進(jìn)行垃圾收集之后,它看起來有點(diǎn)不同
由于節(jié)點(diǎn)引用在DOM中不再存在,整個(gè)條目已從 WeakMap 中刪除,從而釋放了更多的內(nèi)存。這是一個(gè)很 nice 功能,有助于使環(huán)境的內(nèi)存更加整潔。