Пошук уроків, статей та іншого контенту
Дізнайтеся, як замикання впливають на життєвий цикл даних у пам'яті та чому доступне лексичне оточення може залишатися живим після завершення функції. Розберіть потенційне утримання великих об'єктів і зв'язок із garbage collection.
Замикання — це функція разом із лексичним оточенням, у якому її було створено. Завдяки цьому функція може звертатися до змінних зовнішньої функції навіть після завершення її виконання.
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2Після виклику createCounter() її виконання завершується. Проте змінна count не зникає, оскільки функція increment усе ще має до неї доступ.
У спрощеній моделі це можна уявити так:
counter → функція increment
↓
лексичне оточення
↓
count = 2Отже, завершення виклику функції не означає автоматичного видалення всіх локальних змінних.
Збирач сміття (garbage collector, GC) видаляє не «старі» або «завершені» об’єкти, а об’єкти, які більше недосяжні з коренів виконання.
Типові корені:
глобальні змінні;
локальні змінні активних викликів;
стек виконання;
об’єкти, доступні через зареєстровані обробники подій;
деякі внутрішні структури середовища виконання.
Якщо об’єкт можна отримати, переходячи за посиланнями від кореня, він вважається досяжним.
let object = {
name: "data"
};
let anotherReference = object;
object = null;
console.log(anotherReference.name); // dataОб’єкт не буде зібраний, тому що на нього все ще посилається anotherReference.
Те саме відбувається із замиканнями:
function createReader() {
const data = {
value: 42
};
return function read() {
return data.value;
};
}
const read = createReader();
console.log(read()); // 42data залишається досяжним через read, тому GC не може видалити цей об’єкт.
Коли ж останнє посилання на функцію-замикач зникає, уся недосяжна ланка може бути видалена:
let read = createReader();
console.log(read());
read = null;Після присвоєння null об’єкт-функція, її лексичне оточення та data можуть стати недосяжними.
Важливо: JavaScript не гарантує точний момент запуску garbage collector. Тому коректніше говорити, що об’єкт може бути зібраний, а не що він буде видалений негайно.
Концепція замикання пояснює поведінку програми, але не є точною схемою внутрішнього представлення пам’яті.
Наприклад:
function createFunction() {
const requiredValue = "visible";
const largeUnusedValue = new Array(10_000_000).fill("*");
return function getValue() {
return requiredValue;
};
}
const getValue = createFunction();
console.log(getValue()); // visibleНа рівні мови можна сказати, що функція була створена всередині createFunction і має доступ до її лексичного оточення. Але сучасний JavaScript-рушій може оптимізувати представлення цього оточення та не утримувати largeUnusedValue, якщо вона не потрібна для роботи функції.
Тому не варто робити висновок:
кожне замикання завжди утримує всі локальні змінні зовнішньої функції.
Точніше формулювання:
замикання утримує стан, необхідний для збереження доступної поведінки функції; конкретні оптимізації залежать від рушія.
Водночас не слід покладатися на оптимізації. Якщо великий об’єкт явно доступний із функції, він може залишатися досяжним.
Розглянемо фабрику, яка створює обробник для великого набору даних:
function createSearchHandler(records) {
return function search(query) {
return records.filter((record) =>
record.name.includes(query)
);
};
}
const records = Array.from({ length: 100_000 }, (_, index) => ({
id: index,
name: `Record ${index}`
}));
const search = createSearchHandler(records);
console.log(search("Record 999")); // масив знайдених записівПісля створення search зовнішня змінна records більше не потрібна безпосередньо. Але обробник використовує її, тому records залишається доступним через замикання.
Якщо search зберігається в глобальній змінній, у властивості довгоживучого об’єкта або в реєстрі обробників, великий масив теж може жити протягом усього цього часу.
Іноді корисно передбачити метод очищення:
function createSearchHandler(initialRecords) {
let records = initialRecords;
return {
search(query) {
if (!records) {
throw new Error("Обробник уже очищено");
}
return records.filter((record) =>
record.name.includes(query)
);
},
clear() {
records = null;
}
};
}
const records = Array.from({ length: 100_000 }, (_, index) => ({
id: index,
name: `Record ${index}`
}));
const handler = createSearchHandler(records);
console.log(handler.search("Record 999").length);
handler.clear();
// Після clear замикання більше не зберігає масив records.Виклик clear() розриває посилання з лексичного оточення на масив. Якщо інших посилань немає, масив може бути зібраний GC.
Це не «ручне звільнення пам’яті» в стилі C або C++. Ми лише прибираємо посилання, після чого об’єкт стає потенційно недосяжним.
Найчастіше проблема виникає, коли короткоживучий об’єкт захоплюється довгоживучим об’єктом.
const applicationState = {
handlers: []
};
function registerHandler() {
const largeData = new Array(5_000_000).fill({
status: "pending"
});
const handler = () => {
return largeData.length;
};
applicationState.handlers.push(handler);
}
registerHandler();Після завершення registerHandler():
локальна змінна largeData більше не доступна напряму;
функція handler зберігається в applicationState.handlers;
handler посилається на largeData через замикання;
тому largeData залишається досяжним.
Якщо реєстрація виконується багато разів, пам’ять може зростати:
for (let index = 0; index < 100; index += 1) {
registerHandler();
}У такому коді кожен обробник може утримувати власний великий масив.
Потрібно не лише додавати обробники, а й мати спосіб їх видаляти:
function createRegistry() {
const handlers = new Set();
return {
add(handler) {
handlers.add(handler);
return () => {
handlers.delete(handler);
};
},
run(value) {
for (const handler of handlers) {
handler(value);
}
}
};
}
const registry = createRegistry();
function subscribeWithLargeData(registry) {
const largeData = new Array(1_000_000).fill("item");
const handler = (value) => {
console.log(value, largeData.length);
};
return registry.add(handler);
}
const unsubscribe = subscribeWithLargeData(registry);
registry.run("event");
// Видаляємо обробник і розриваємо посилання на замикання.
unsubscribe();Після unsubscribe() обробник видаляється з Set. Якщо більше немає посилань на нього, його лексичне оточення разом із largeData може стати недосяжним.
DOM-елемент може утримувати зареєстрований обробник. Обробник, у свою чергу, може утримувати великі дані через замикання.
function mount(element, data) {
const handleClick = () => {
console.log(data.length);
};
element.addEventListener("click", handleClick);
return function unmount() {
element.removeEventListener("click", handleClick);
};
}
const data = new Array(1_000_000).fill("value");
const element = document.querySelector("#button");
const unmount = mount(element, data);
// Коли компонент більше не потрібен:
unmount();Для removeEventListener важливо передати ту саму функцію, яку було передано під час реєстрації. Тому зручним є повернення функції очищення.
Неправильний варіант:
element.addEventListener("click", () => {
console.log("clicked");
});
element.removeEventListener("click", () => {
console.log("clicked");
});Це дві різні функції. Другий виклик не видаляє перший обробник.
Замикання може утворювати цикли посилань:
function createObject() {
const object = {};
object.getSelf = function () {
return object;
};
return object;
}
let value = createObject();
value = null;Тут об’єкт посилається на функцію, а функція — на лексичне оточення з посиланням на об’єкт.
Сам по собі цикл не є витоком пам’яті. Сучасні garbage collector використовують аналіз досяжності, а не простий підрахунок посилань. Якщо весь цикл недосяжний від коренів, він може бути зібраний.
Проблема виникає, коли хоча б одна частина циклу залишається доступною через глобальний об’єкт, таймер, обробник або іншу структуру.
Таймери також можуть подовжувати життя замикання:
function startPolling() {
const responseCache = new Map();
const intervalId = setInterval(() => {
console.log(`Кеш містить ${responseCache.size} елементів`);
}, 1000);
return function stopPolling() {
clearInterval(intervalId);
responseCache.clear();
};
}
const stopPolling = startPolling();
// Коли опитування більше не потрібне:
stopPolling();Поки інтервал активний, callback залишається потрібним середовищу виконання. Callback утримує responseCache, тому кеш теж може залишатися в пам’яті.
Очищення має виконувати дві дії:
зупинити таймер;
очистити або замінити великі структури даних, якщо це потрібно.
let, const і замикання в циклахЗамикання часто використовують у циклах. Важливо розуміти різницю між let і var.
const functions = [];
for (let index = 0; index < 3; index += 1) {
functions.push(() => index);
}
console.log(functions[0]()); // 0
console.log(functions[1]()); // 1
console.log(functions[2]()); // 2Для кожної ітерації з let створюється окреме лексичне значення index.
З var змінна має функціональну область видимості:
const functions = [];
for (var index = 0; index < 3; index += 1) {
functions.push(() => index);
}
console.log(functions[0]()); // 3
console.log(functions[1]()); // 3
console.log(functions[2]()); // 3Усі функції посилаються на одну змінну, значення якої після завершення циклу дорівнює 3.
Це насамперед поведінкова відмінність, але вона також впливає на кількість створених лексичних середовищ і на обсяг стану, який потрібно підтримувати.
Для пошуку проблем із пам’яттю корисно аналізувати не сам факт використання замикань, а ланцюжок посилань.
У браузерних інструментах розробника можна:
зробити знімок heap;
зробити кілька знімків у різний час;
порівняти кількість об’єктів;
переглянути шляхи утримання (retainers);
знайти функції, обробники або колекції, через які об’єкти залишаються досяжними.
Зазвичай потрібно перевірити:
глобальні масиви та кеші;
Map і Set, які постійно зростають;
активні таймери;
підписки на події;
черги завдань;
замикання компонентів, які вже повинні бути знищені;
обробники, для яких не виконується відписка.
Зростання використання пам’яті після кількох операцій ще не доводить витік. GC запускається у непередбачуваний момент, а рушій може тимчасово залишати пам’ять для майбутніх алокацій. Важливіші ознаки — стабільне зростання кількості живих об’єктів і наявність довгого шляху посилань від кореня.
WeakMap, WeakSet і слабкі посиланняWeakMap може бути корисним, коли дані потрібно пов’язати з об’єктом, але не потрібно утримувати цей об’єкт у пам’яті.
const metadata = new WeakMap();
function attachMetadata(object, value) {
metadata.set(object, {
value
});
}
let component = {};
attachMetadata(component, "initialized");
console.log(metadata.get(component)); // { value: "initialized" }
component = null;Ключі WeakMap не утримують свої об’єкти так, як звичайний Map. Якщо на об’єкт більше немає сильних посилань, він може бути зібраний разом із пов’язаними даними.
WeakMap має обмеження:
його ключами можуть бути лише об’єкти;
його не можна перебирати;
не можна отримати його розмір;
не можна використовувати його для гарантованого спостереження за моментом видалення.
WeakMap не є універсальною заміною Map. Його варто використовувати тоді, коли саме слабке володіння є частиною логіки даних.
WeakRef і FinalizationRegistryJavaScript також має WeakRef і FinalizationRegistry, але вони не повинні використовуватися для основної логіки програми.
const registry = new FinalizationRegistry((name) => {
console.log(`Об'єкт було фіналізовано: ${name}`);
});
let object = {
name: "temporary"
};
registry.register(object, object.name);
const weakReference = new WeakRef(object);
console.log(weakReference.deref()?.name); // temporary
object = null;Виклик callback FinalizationRegistry:
не має гарантованого часу;
може відбутися значно пізніше;
може не відбутися до завершення процесу;
не повинен використовуватися для критично важливого очищення ресурсів.
Для файлів, мережевих з’єднань, таймерів, підписок і DOM-обробників краще застосовувати явні методи на кшталт close(), dispose(), unsubscribe() або stop().
Щоб замикання не створювали зайвого утримання пам’яті:
не зберігайте непотрібні callback-функції в глобальних структурах;
видаляйте обробники подій під час знищення компонента;
зупиняйте таймери та інтервали;
обмежуйте розмір кешів;
очищайте Map, Set і масиви, коли вони більше не потрібні;
повертайте функції очищення з фабрик підписок;
не захоплюйте великий об’єкт, якщо достатньо передати окреме значення;
аналізуйте heap snapshot, а не робіть висновки лише за графіком загального використання пам’яті;
не покладайтеся на певний момент роботи garbage collector.
Наприклад, замість захоплення всього об’єкта можна передати лише потрібну властивість:
function createLogger(user) {
const userId = user.id;
return function log(message) {
console.log(`[${userId}] ${message}`);
};
}Якщо функції потрібен лише id, немає сенсу утримувати в замиканні великий об’єкт користувача з усією історією, налаштуваннями та кешами. Проте фактичний ефект залежить від того, чи існують інші посилання на цей об’єкт і як рушій оптимізує середовище.
Функція-замикач може продовжувати використовувати локальні змінні після завершення зовнішньої функції.
function createGetter() {
const value = "persistent";
return () => value;
}
const getter = createGetter();Дані живуть, поки живе доступний шлях до getter і його оточення.
Замикання саме по собі не є витоком пам’яті. Воно стає проблемою, коли його зберігають довше, ніж потрібно, або коли воно утримує великі структури.
Анонімна функція, створена під час видалення, не є тією самою функцією, що була зареєстрована. Потрібно зберігати посилання на початковий callback.
WeakMap лише для «автоматичного очищення»Слабкі колекції мають спеціальну семантику і не підходять для перебору, підрахунку елементів або гарантованого часу очищення.
FinalizationRegistryФіналізація недетермінована. Вона не замінює явне завершення роботи з ресурсами.
Пам’ять, яку рушій зарезервував у процесі, не обов’язково є пам’яттю живих об’єктів. Для діагностики потрібно досліджувати досяжні об’єкти та шляхи їх утримання.
Замикання зберігає доступ до лексичного оточення після завершення зовнішньої функції.
Дані залишаються в пам’яті, поки існує досяжний шлях до них від коренів виконання.
Функція-замикач може утримувати великі об’єкти, кеші або DOM-структури.
Довгоживучі таймери, обробники подій, глобальні колекції та підписки часто подовжують життя замикань.
Циклічні посилання самі по собі не є витоком, якщо весь цикл недосяжний.
Конкретне представлення лексичних оточень оптимізує JavaScript-рушій, тому не кожна локальна змінна обов’язково фізично зберігається.
Для керування життєвим циклом варто мати явні операції clear, unsubscribe, removeEventListener, clearInterval або dispose.
WeakMap корисний для слабких зв’язків, але WeakRef і FinalizationRegistry не замінюють детерміноване очищення.
Підтверджувати підозри щодо витоків потрібно профілюванням heap і аналізом шляхів утримання.