WeakRef та finalizationregistry в JavaScript
WeakRef тримає посилання на об'єкт не блокуючи його збір сміттєзбирачем. FinalizationRegistry запускає callback коли цей об'єкт нарешті зібрано.
Теорія
TL;DR
- WeakRef як стікер з номером телефону: він не тримає людину в місті, але дозволяє перевірити чи вона ще тут
.deref()повертає об'єкт якщо він живий, абоundefinedякщо вже зібрано- FinalizationRegistry запускає callback після збору об'єкта GC - час виклику не гарантовано, затримка може бути в секунди і більше
- Використовуй ці інструменти для кешів, cleanup observer-ів або необов'язкового відстеження ресурсів
- Ніколи не використовуй FinalizationRegistry для критичного очищення типу закриття файлів або зняття блокувань
Швидкий приклад
// Звичайне посилання тримає об'єкт живим
let obj = { id: 1 };
const ref = obj;
obj = null;
console.log(ref.id); // 1 - ще живий, бо ref є сильним посиланням
// WeakRef не блокує збір сміття
let obj2 = { id: 2 };
const weakRef = new WeakRef(obj2);
obj2 = null;
// obj2 тепер можна зібрати
console.log(weakRef.deref()?.id); // 2 якщо живий, undefined якщо зібраноРізниця в тому, як GC рахує посилання. Сильні посилання враховуються. WeakRef - ні. Щойно всі сильні посилання зникли, об'єкт стає кандидатом на збір незалежно від кількості WeakRef що на нього вказують.
Головна різниця
Сміттєзбирач рахує сильні посилання щоб вирішити що тримати живим. WeakRef не додається до цього рахунку. Коли викликаєш .deref(), отримуєш або об'єкт, або undefined - заздалегідь невідомо що буде. FinalizationRegistry відстежує момент збору і ставить callback у чергу, але той виконується під час наступного циклу GC, а не в момент зникнення останнього сильного посилання.
Коли використовувати
- WeakRef: кеші де записи мають зникати коли оригінальний об'єкт більше не потрібний; патерн observer де listener-и не мають тримати subject живим; відстеження DOM-вузлів у внутрішній логіці фреймворків
- FinalizationRegistry: логування при видаленні записів з кешу через GC; звільнення нативних ресурсів у Node.js add-on bindings; очищення subscriptions в observable-бібліотеках коли явний unsubscribe відсутній
Як це працює в рушії
V8, SpiderMonkey і JavaScriptCore ведуть окрему таблицю слабких посилань, яка не впливає на досяжність об'єктів. Під час проходу GC, якщо на об'єкт немає сильних посилань, він позначається для збору незалежно від WeakRef що на нього вказують. Після звільнення об'єкта рушій ставить callback-и FinalizationRegistry у чергу, але точний час залежить від циклів GC, тиску на купу і середовища виконання. Я бачив як команди годинами дебажили cleanup-код припускаючи що ці callback-и поводяться як деструктори - не поводяться.
Типові помилки
Помилка 1: Очікування що callback FinalizationRegistry виконається одразу
Об'єкт може бути недосяжним секунди і більше до запуску callback. Деякі реалізації GC відкладають це на кілька циклів.
// Неправильно - покладаємось на callback для своєчасного очищення ресурсу
const registry = new FinalizationRegistry(() => {
fileHandle.close(); // Може не запуститись ще довго
});
registry.register(fileStream, fileHandle);
// Правильно - явне очищення, негайне і гарантоване
class FileStream {
close() {
this.fileHandle.close();
}
}
stream.close();Помилка 2: Передача самого об'єкта як held value
// Неправильно - реєстр тримає сильне посилання на obj, блокує збір
registry.register(obj, obj);
// Правильно - тільки ідентифікатор або метадані
registry.register(obj, obj.id);Помилка 3: Не обробляти undefined з .deref()
// Неправильно - кидає TypeError якщо об'єкт вже зібрано
const config = new WeakRef(globalConfig);
const value = config.deref().setting;
// Правильно
const target = config.deref();
const value = target?.setting; // безпечно повертає undefined якщо зібраноПомилка 4: WeakRef з примітивами
// Неправильно - кидає TypeError
new WeakRef(42);
new WeakRef("hello");
// Правильно - тільки об'єкти
new WeakRef({ value: 42 });
new WeakRef([1, 2, 3]);Примітиви незмінні і інтернуються рушієм. Для них немає поняття ідентичності об'єкта яку можна відстежити.
Помилка 5: Плутанина між WeakRef і WeakMap
WeakMap використовує слабкі ключі: ти пов'язуєш дані з об'єктом, і запис зникає автоматично коли ключ зібрано. WeakRef - явний handle де ти сам викликаєш .deref(). WeakMap для "метадані про об'єкти"; WeakRef для "чи цей конкретний об'єкт ще живий?"
// WeakMap - запис зникає автоматично коли ключ зібрано
const wm = new WeakMap();
let user = { id: 1 };
wm.set(user, { role: "admin" });
user = null; // запис зникне на наступному GC
// WeakRef - ти сам контролюєш перевірку
const wr = new WeakRef(user);
const alive = wr.deref(); // сам вирішуєш що робити з undefinedДе зустрічається в реальному коді
- React: відстеження DOM-вузлів у refs без блокування їх збору; FinalizationRegistry в cleanup нативних bindings
- Node.js streams: виявлення коли stream-об'єкти перестають використовуватись для запуску cleanup
- Electron: управління нативними window handles щоб уникнути memory leaks коли JS-об'єкти переживають закриті вікна
- Web Workers: автоматичне видалення мертвих workers з пула через FinalizationRegistry callbacks
- RxJS та інші observable-бібліотеки: очищення subscriptions коли observer зібрано без явного виклику
unsubscribe()
Питання на співбесіді
Q: Чому WeakRef не працює з примітивами типу числа або рядка?
A: Примітиви незмінні і інтернуються рушієм - немає поняття ідентичності об'єкта яку можна відстежити. Тільки об'єкти мають стабільні ідентичності що GC може моніторити і збирати.
Q: Якщо callback FinalizationRegistry не гарантований, коли варто його використовувати?
A: Для некритичних задач: логування, метрики, інвалідація кешу, звільнення ресурсів що мають fallback. Ніколи там де коректність залежить від своєчасного очищення.
Q: Чи можна зробити memory leak з WeakRef?
A: Так. Якщо зберігаєш результат .deref() у сильному посиланні і не очищаєш його, об'єкт залишається живим і задум зруйнований. Також якщо callback FinalizationRegistry тримає посилання на зібраний об'єкт через замикання (closure), утворюється цикл що блокує збір.
Q: Яка різниця між WeakRef і WeakMap, коли що використовувати?
A: WeakMap пов'язує дані з об'єктом як ключем - коли ключ зібрано, запис зникає автоматично. WeakRef - явний handle де ти сам викликаєш .deref(). WeakMap для "метадані про об'єкти"; WeakRef для "чи цей об'єкт ще існує?"
Q (Senior): Спроектуй кеш що автоматично видаляє записи коли значення зібрано GC, але також обмежує максимальний розмір. Які edge cases важливі?
A: WeakRef для значень і FinalizationRegistry для cleanup callback-ів, плюс LRU список сильних посилань для обмеження розміру. Ключові edge cases: callback може прийти після знищення кешу; утримання кешу в замиканні може блокувати збір кешованих об'єктів; callback може прийти після заміни запису під тим самим ключем - перевіряй перед видаленням; обмеження розміру є м'яким, не жорстким.
Приклади
Базовий: WeakRef у простому кеші
class SimpleWeakCache {
#cache = new Map();
set(key, value) {
this.#cache.set(key, new WeakRef(value));
}
get(key) {
const ref = this.#cache.get(key);
if (!ref) return undefined;
const value = ref.deref();
if (!value) {
this.#cache.delete(key); // Видаляємо застарілий запис
}
return value;
}
}
const cache = new SimpleWeakCache();
let user = { name: "Alice" };
cache.set("alice", user);
console.log(cache.get("alice")); // { name: "Alice" }
user = null; // Знімаємо сильне посилання
// Після наступного циклу GC: cache.get("alice") повертає undefinedМетод get завжди перевіряє .deref() перед поверненням. Якщо об'єкт зібрано, застарілий запис у Map видаляється щоб кеш не ріс безмежно.
Середній рівень: Кеш з автоочищенням через FinalizationRegistry
class AutoCache {
#cache = new Map();
#registry = new FinalizationRegistry((key) => {
// Виконується після збору значення GC - не миттєво
this.#cache.delete(key);
console.log(`Запис кешу "${key}" зібрано`);
});
set(key, value) {
this.#cache.set(key, new WeakRef(value));
this.#registry.register(value, key); // key - це held value, не сам об'єкт
}
get(key) {
return this.#cache.get(key)?.deref();
}
}
const cache = new AutoCache();
let session = { userId: 42, token: "abc123" };
cache.set("session:42", session);
console.log(cache.get("session:42")); // { userId: 42, token: "abc123" }
session = null;
// В майбутньому циклі GC: 'Запис кешу "session:42" зібрано'FinalizationRegistry отримує key як held value, а не сам об'єкт session. Якщо передати session, реєстр тримав би сильне посилання на нього і блокував збір.
Просунутий рівень: Перевірка часу виконання callback
// Callback FinalizationRegistry НЕ є синхронним
let collected = false;
const reg = new FinalizationRegistry(() => {
collected = true;
});
let temp = {};
reg.register(temp, null);
temp = null;
console.log(collected); // false - callback ще не виконався
// В скрипті з коротким часом роботи callback може взагалі не запуститись.
// Рушій вирішує коли запускати GC на основі тиску на купу, не твого коду.Це найпоширеніша пастка на senior-рівні. Встановлення змінної в null не запускає GC. Рушій запускає збір на основі внутрішньої евристики. Для всього що точно має відбутись використовуй явні методи очищення.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.