本文淺析一下為什麼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(或其他字串值)。這帶來了一些額外的程式性開銷,以便在需要時生成和讀取這些值。
與之對應的是,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行 | |
---|---|---|---|
Object | 0.023ms | 3.45ms | 89.9ms |
Map | 0.019ms | 2.1ms | 48.7ms |
17% | 39% | 46% |
請記住,這些結果在稍有不同的情況下可能會有相當大的差異,但總的來說,它們總體上符合我的期望。當處理相對較少的專案時,Map
和物件之間的效能是相當的。但隨著專案數量的增加,Map
開始拉開距離。這種效能上的亞線性變化開始顯現出來。
有一個特殊版本的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中被移除並行生垃圾回收後,它看起來有點不同:
由於節點參照不再存在於DOM中,整個條目都被從WeakMap
中刪除,釋放了一點記憶體。這是一個我很欣賞的功能,有助於保持環境的記憶體更加整潔。
我喜歡為DOM節點使用Map
,因為:
Map
(被設計成)更具有效能。WeakMap
意味著如果一個節點從DOM中被移除,條目將被自動垃圾回收。以上就是本文的全部內容,如果對你有所幫助,歡迎點贊、收藏、轉發~