Set, Map, WeakSet та WeakMap у JavaScript
Set, Map, WeakSet та WeakMap - це чотири вбудовані типи колекцій у JavaScript, кожен з яких вирішує свою задачу зберігання даних.
Теорія
TL;DR
- Set - список без дублікатів. Map - сховище пар ключ-значення, де ключами можуть бути об'єкти, а не лише рядки.
- WeakSet і WeakMap тримають слабкі посилання (weak references) на об'єкти: якщо більше нічого не вказує на об'єкт, збирач сміття видалить запис автоматично.
- Головний поділ: Set/Map зберігають сильні посилання і підтримують ітерацію. WeakSet/WeakMap обмінюють ітерацію на автоматичне звільнення пам'яті.
- Set для дедуплікації, Map коли потрібні нерядкові ключі, WeakMap для метаданих DOM-вузлів або кешів.
Швидкий приклад
const set = new Set([1, 2, 2, 3]); // Set {1, 2, 3} - дублікати відкинуті
const map = new Map();
map.set('name', 'Alice');
map.set({id: 1}, "object as key"); // будь-який тип підходить як ключ
let node = document.querySelector('#btn');
const weakMap = new WeakMap();
weakMap.set(node, { clickCount: 0 }); // прив'язуємо дані до DOM-вузла
node = null; // запис буде прибрано GC автоматичноSet прибирає дублікат 2 під час створення. Map приймає об'єкт як ключ - звичайні об'єкти так не вміють. WeakMap прив'язує дані до DOM-вузла і відпускає їх, коли вузол зникає.
Головна різниця
Set і Map тримають сильні посилання. Об'єкт, що зберігається в Map як ключ, залишається в пам'яті, поки існує сам Map, навіть якщо більше жоден код на нього не посилається. WeakSet і WeakMap використовують слабкі посилання: якщо єдиним вказівником на об'єкт залишається ключ у WeakMap, збирач сміття вважає цей об'єкт недосяжним і видаляє запис. Жодного ручного очищення.
Коли використовувати
- Прибрати дублікати або швидко перевірити наявність елемента → Set (пошук O(1) проти O(n) у масиву)
- Зберігати значення за нерядковими ключами, наприклад об'єктами або Symbol → Map
- Відстежувати стан для DOM-вузлів без витоків пам'яті → WeakMap
- Позначати вже оброблені об'єкти в алгоритмі, наприклад для визначення циклів у графах → WeakSet
- Потрібна ітерація або
.sizeдля слабкої колекції → не вийде, використовуй Set/Map
Таблиця порівняння
| Ознака | Set | Map | WeakSet | WeakMap |
|---|---|---|---|---|
| Що зберігає | Лише значення | Пари ключ-значення | Лише об'єкти | Об'єкти як ключі, будь-які значення |
| Типи ключів | Будь-яке значення | Будь-яке значення | Лише об'єкти | Лише об'єкти |
| Ітерація | Так | Так | Ні | Ні |
Властивість .size | Так | Так | Ні | Ні |
| Збирання сміття | Ні (сильні посилання) | Ні (сильні посилання) | Так (слабкі посилання) | Так (слабкі посилання) |
| Типовий сценарій | Унікальні списки | Мапи з нерядковими ключами | Мітки відвіданих об'єктів | Кеші, метадані вузлів |
Як це влаштовано всередині
V8 реалізує Set і Map як хеш-таблиці з упорядкованим зв'язним списком поверх них - саме тому порядок вставки зберігається при ітерації. Рівність перевіряється алгоритмом SameValueZero, а не ===. Відмінність: NaN === NaN у JavaScript дає false, але SameValueZero вважає два значення NaN однаковими. Тому new Set([NaN, NaN]) дасть Set {NaN} з одним елементом.
WeakSet і WeakMap використовують структуру під назвою ефемерна таблиця (ephemeron table). Ефемерон - це пара ключ-значення, де значення зберігається лише доти, поки ключ досяжний ззовні таблиці. Під час циклу збирача сміття, якщо рушій виявляє, що ключ WeakMap не має зовнішніх посилань, запис видаляється повністю. Ітерацію свідомо не підтримують: вона вимагала б від рушія тимчасово тримати сильні посилання на всі ключі, що зводить нанівець весь сенс слабких посилань.
Типові помилки
Передавати об'єктні літерали як ключі Map і очікувати збігу:
const map = new Map();
map.set({ id: 1 }, 'дані');
console.log(map.has({ id: 1 })); // false - інше посилання на об'єктMap порівнює ключі за посиланням (SameValueZero), а не за значенням. Два об'єкти {id: 1} - це різні об'єкти в пам'яті. Зберігай посилання в змінну, якщо потрібно потім отримати значення.
Очікувати .size або ітерацію у WeakMap чи WeakSet:
const wm = new WeakMap();
wm.set({}, 1);
console.log(wm.size); // undefined
for (const [k, v] of wm) {} // TypeError: wm is not iterable.size не існує, перелічити записи теж не можна. Якщо потрібно рахувати елементи - відстежуй це окремо.
Вважати, що WeakMap автоматично запобігає всім витокам пам'яті:
let obj = { data: 'великий payload' };
const cache = new WeakMap();
cache.set(obj, 'результат обчислення');
// obj досі посилається вище - жодного GC ще не відбудеться
// Лише коли ВСІ сильні посилання на obj зникнуть, запис у WeakMap приберетьсяСлабкі посилання працюють лише якщо більше нічого не тримає об'єкт. Замикання, масив або звичайна змінна, що вказує на obj - і запис у WeakMap нікуди не дінеться.
Брати звичайний об'єкт замість Map для нерядкових ключів:
Звичайні об'єкти приводять всі ключі до рядків. obj[{id:1}] стає obj["[object Object]"]. Map зберігає фактичний тип, тому map.set({id:1}, value) зберігає посилання на об'єкт без жодного приведення.
Де зустрічається в реальних проектах
- React використовує WeakMap у reconciler (Fiber), щоб прив'язувати ефекти до fiber-вузлів без блокування їх збирання.
- Node.js AsyncHooks використовує WeakMap для відстеження async-ресурсів: завершені операції можна прибрати GC.
- Lodash використовує Set всередині
_.uniqдля дедуплікації масивів за O(n). - Preact використовує WeakSet для відстеження хуків, що вже були зафіксовані (committed).
- Express використовує Map для реєстрів параметрів middleware, де порядок вставки важливий.
Питання на співбесіді
Q: Що таке SameValueZero і чим відрізняється від ===?
A: SameValueZero вважає +0 і -0 рівними, і NaN рівним NaN. Звичайний === повертає false для NaN === NaN. Тому new Set([NaN, NaN]) дає Set {NaN} з одним елементом.
Q: Чому WeakSet і WeakMap не підтримують ітерацію?
A: Ітерація вимагала б від рушія перебрати всі ключі, а значить тимчасово тримати сильні посилання на них. Це блокує збирач сміття, тому ітерацію свідомо прибрали з API.
Q: Чи можуть ключі WeakMap бути примітивами - рядками або числами?
A: Ні. Примітиви - це значення, не посилання. GC відстежує посилання на об'єкти, тому лише об'єкти можуть бути слабкими ключами. Передача рядка кине TypeError.
Q: Чи є реальна різниця в продуктивності між Set і Array.includes()?
A: Set використовує хеш-таблицю, тому .has() працює за O(1) у середньому. Array.includes() сканує лінійно, O(n). Для великих колекцій або частих перевірок Set помітно швидший.
Q (рівень senior): Як реалізувати LRU-кеш за допомогою WeakMap?
A: WeakMap відповідає за прив'язку ключ-значення і автоматичне очищення, коли ключ прибирається GC. Для відстеження порядку витіснення все одно потрібен двозв'язний список або Map впорядкований за часом доступу, бо WeakMap не підтримує ітерацію. При кожному зверненні переміщуй запис на початок. При переповненні - витісняй хвіст. Якщо ключовий об'єкт прибирається GC ззовні, запис у WeakMap зникає сам, без потреби чіпати список вручну.
Приклади
Дедуплікація і перевірка наявності через Set
const visited = new Set();
function processPage(url) {
if (visited.has(url)) {
console.log('Вже оброблено:', url);
return;
}
visited.add(url);
console.log('Обробляємо:', url);
}
processPage('https://example.com'); // Обробляємо: https://example.com
processPage('https://example.com'); // Вже оброблено: https://example.com
processPage('https://other.com'); // Обробляємо: https://other.com
console.log(visited.size); // 2Set замінює типовий if (array.indexOf(url) !== -1) і скорочує час пошуку з O(n) до O(1). Такий самий патерн я використовував для дедуплікації навігаційної історії в SPA - перехід з масиву на Set зробив пошук по великій історії миттєвим.
Map з об'єктними ключами для контексту запитів
const requestContext = new Map();
function handleRequest(req) {
requestContext.set(req, {
startTime: Date.now(),
userId: req.headers['x-user-id']
});
}
function logRequest(req) {
const ctx = requestContext.get(req);
console.log(`Тривалість: ${Date.now() - ctx.startTime}мс`);
requestContext.delete(req);
}Зі звичайним об'єктом req як ключ перетворився б на "[object Object]", і всі запити перемішались би. Map зберігає посилання і тримає контекст кожного запиту окремо.
WeakMap для стану DOM-вузлів без витоків пам'яті
const clickCounts = new WeakMap();
document.querySelectorAll('button').forEach(button => {
clickCounts.set(button, 0);
button.addEventListener('click', () => {
const count = (clickCounts.get(button) || 0) + 1;
clickCounts.set(button, count);
console.log(`Натиснуто ${count} разів`);
});
});
// Коли кнопку видаляють з DOM і більше нічого на неї не посилається,
// запис у clickCounts автоматично прибирається збирачем сміттяЯкби тут використовувався звичайний Map, кожна видалена кнопка залишалась би в пам'яті, поки живе сам Map. WeakMap робить очищення автоматичним.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.