Як контексти закриття V8 зберігають пам’ять поза межами того, що використовує одна функція
V8 створює один спільний контекст за кожен виклик для кожної зовнішньої змінної, про яку згадує будь-яка внутрішня функція — тому невикористані сестринські змінні та постійні слухачі можуть зберігати великі значення.
Джерела: Interviewer: You have 5 years of experience and still don’t know how closures work internally
Перевірте, а не декламуйте: [[Scopes]]
Під час співбесіди на посаду фронтенд-розробника у Swiggy на дошці було розміщено короткий фрагмент DOM. Кандидат мав багаторічний досвід роботи з JavaScript та відповів на перше запитання за стандартною схемою. Однак наступне запитання розвіяло цю схему, як це трапляється з більшістю досвідчених розробників.
Ось початковий фрагмент:
function update() {
const elem = document.getElementById("root");
const allH1s = document.querySelectorAll("h1");
function handleClick() {
console.dir(handleClick);
}
elem.addEventListener("click", handleClick);
}
update();
Після повернення функції update() слухач кліків залишається на елементі #root. Чи охоплює функція handleClick об’єкт allH1s, зберігаючи таким чином весь NodeList живим?
Офіційна відповідь у підручнику — так: замикання зберігає своє лексичне середовище, тож усе, що знаходиться в області видимості функції update — включаючи elem та allH1s — залишається доступним під час підключення слухача. Ця відповідь описує мову специфікації більше, ніж те, що насправді зберігає Chrome. Інтерв’юер сказав відкрити DevTools та перевірити це.
Кожен об’єкт функції в Chrome має внутрішній список [[Scopes]], який відображає області видимості, що все ще зберігаються замиканням. Цей список можна побачити за допомогою console.dir. Обробник подій записує інформацію про себе після кожного кліку, тому ви можете:
- Відкрити сторінку з елементом
<h1 id="root">(або створити такий), вставити наведений фрагмент коду та запустити його. - Клацнути на заголовку, щоб
console.dir(handleClick)вивів цю функцію. - Розгорнути елемент
[[Scopes]], а потім —Closure (update).
Те, що відображається, — це не allH1s і не elem. Єдиний зовнішній об’єкт, який залишається, — це сам handleClick, і лише тому, що в тілі коду згадується ця назва у рядку console.dir(handleClick). Якщо замінити тіло коду на console.log("clicked"), запис Closure (update) зникає; у розділі [[Scopes]] відображається лише Global.
Отже, NodeList не зберігається обробником подій. Як тільки функція update() повертається, ніщо більше не посилається на нього, і механізм збору вільної пам’яті може її звільнити. Сам лише перегляд у інспекторі — це не доказ наявності пам’яті; про це пізніше підтверджує примусове очищення пам’яті, але це вже спростовує твердження про те, що «весь обсяг пам’яті зафіксовано».
Потім кандидат перейшов до іншої поширеної моделі: замикання зберігають лише ті змінні, які вони згадують, і handleClick ніколи не вказує на allH1s. Інтерв’юер попросив додати ще один рядок, не змінюючи handleClick.
Один рядок, який ніколи не виконується
function update() {
const elem = document.getElementById("root");
const allH1s = document.querySelectorAll("h1");
const printH1 = () => allH1s; // never called
function handleClick() {
console.dir(handleClick); // unchanged
}
elem.addEventListener("click", handleClick);
}
update();
printH1 — це невикористаний функція-стрілка, яка отримує значення з allH1s. Ніщо її не викликає, не повертає та не зберігає. Виконайте ті самі кроки перевірки після її додавання.
handleClick залишився незмінним символ за символом, проте тепер він показує NodeList, який ніколи не читається. Сама функція-стрілка відсутня у [[Scopes]] — ніщо не зафіксувало printH1, тож це прив’язування зникло разом із викликом — але змінна, яку вона згадувала, залишилася у спільному контексті.
„Whole scope“ не працює у першому випадку. „Only what this function uses“ — у другому. Правило V8 стосується виклику, а не кожної функції окремо.
Правило, якого насправді дотримується V8
Коли V8 компілює update, він сканує кожну внутрішню функцію та об’єднує змінні зовнішнього рівня, на які вони посилаються. Це об’єднання стає одним об’єктом контексту, виділеним у пам’яті типу heap, який створюється один раз на кожен виклик. Змінні, про які не згадує жодна внутрішня функція, залишаються у стеку та зникають після повернення update. Кожен замиканий об’єкт, створений під час цього виклику, посилається на той самий контекст. Не існує приватної колекції „my captures“ для кожної функції.
Застосування правила:
- Випадок 1: серед внутрішніх функцій є лише
handleClick; вона посилається на саму себе. Контекст ={ handleClick }.elemтаallH1sзалишаються локальними у стеку та зникають.
printH1 та handleClick. Об’єднання включає allH1s та handleClick. Контекст = { allH1s, handleClick }. Ніщо не посилається на printH1, тож це прив’язування зникає — але слот контексту, який воно змусило створитися, залишається активним завдяки тому самому об’єкту handleClick.Дві наслідки цього правила є причиною справжніх витоків.
Це вирішується шляхом читання коду ще до того, як щось запуститься
Форма контексту фіксується під час парсингу. Механізм не чекає, чи коли-небудь буде створена функція printH1. Навіть якщо її розмістити в неможливій гілці, allH1s все одно буде піднято до вищого рівня:
if (false) {
const printH1 = () => allH1s; // never even instantiated
}
// [[Scopes]] of handleClick: Closure (update) { allH1s: NodeList(3), handleClick }
Запуск цієї версії в Chrome 153 через протокол DevTools та наступне примусове очищення пам’яті все одно залишає NodeList живим. Присвоєння printH1 = null після створення нічого не змінює: сам помічник ніколи не фіксував пам’ять — це робив спільний слот контексту.
Збереження відбувається згідно з найдовше існуючим братом-замиканням
Ось чому запитання інтерв’юера стосувалося пам’яті. Простежте за фіксаціями:
[ <h1 id="root"> ] (still in the document)
↓ event listener list holds
[ handleClick ] (the function object)
↓ [[Context]] points to the ONE context for this call
[ { allH1s, handleClick } ] (the shared context)
↓ allH1s slot holds
[ NodeList(3) [h1#root, h1, h1] ]
meanwhile
[ printH1 ] <- gone, it was on the stack
printH1 більше не існує. Переведення, яке він спричинив — allH1s, що знаходяться в контексті, — існує довше за нього. handleClick фіксує контекст; список слухачів елемента фіксує handleClick; документ фіксує елемент. Доки вузол не буде видалений або слухач не від’єднаний, NodeList та вузли h1, на які він посилається, залишаються доступними.
Доведіть, що це пам’ять, а не ефект інспектора
[[Scopes]] відображає посилання. Лише процес збору сміття показує, які об’єкти зберігаються. Chrome може примусово запустити збір сміття; WeakRef спостерігає за об’єктом, не зберігаючи його. Вставте:
let ref;
function update() {
const elem = document.getElementById("root");
const allH1s = document.querySelectorAll("h1");
ref = new WeakRef(allH1s); // observe, don't retain
const printH1 = () => allH1s; // the one line
function handleClick() {
console.dir(handleClick);
}
elem.addEventListener("click", handleClick);
}
update();
Відкрийте панель „Пам’ять“, кілька разів натисніть „Зберути сміття“, а потім перевірте консоль:
> ref.deref()
▶ NodeList(3) [h1#root, h1, h1]
// still here after two forced GCs
Видаліть рядок printH1, перезавантажте та повторіть процедуру:
> ref.deref()
undefined
// collected — nothing was holding it
Ці два результати відрізняються одним невикористаним елементом: або NodeList зникає після повернення функції update, або залишається активним доти, доки існує обробник кліку.
Інструменти розробника також дозволяють фіксувати об’єкти, які ви записали. Якщо ви натиснули кнопку перед примусовим очищенням пам’яті, виконалося console.dir(handleClick), і консоль зберегла виведену функцію — а отже, і контекст — навіть після того, як слухач { once: true } було від’єднано. Очищення консолі (або уникнення запису підозрілих даних) перед тим, як довіряти знімку пам’яті, має велике значення під час пошуку справжніх витоків.
Де це проявляється у реальному коді
Три заголовки — це лише приклад. Замініть allH1s на великий об’єм даних, отриманих з мережі, оброблений файл або масив із мільйоном елементів. Замініть printH1 на допоміжний інструмент для дебаггінгу, невикористовуваний форматтер або функцію-колбек, призначену для подальшого використання. Приєднайте слухач, який буде працювати довго — resize для елемента window, обробник повідомлень через сокет або функцію setInterval.
function setup() {
const rows = new Array(1e6).fill(0).map((_, i) => ({ i })); // the big thing
const debug = () => rows.length; // mentions it, never called
function onResize() {
console.dir(onResize); // never touches rows
}
window.addEventListener("resize", onResize);
}
setup();
Після того, як функція setup() завершується та виконується процес очищення пам’яті, поле [[Scopes]] у функції onResize все ще відображає масив з мільйоном елементів, а об’єкт WeakRef, пов’язаний із ним, все ще діє. Функція обробки зміни розміру фіксує пам’ять, яку ніколи не іменує. Перевірки пропускають цю закономірність, оскільки сама функція виглядає безневинною, а елемент, який спричинив її використання, міг бути доданий значно пізніше.
Рішення
Кожен із наведених нижче варіантів перевірявся однаково: спочатку аналізувалося поле [[Scopes]], потім здійснювалося примусове очищення пам’яті, а нарешті виконувалася функція WeakRef.deref().
1. Уникайте вкладених функцій, які іменують велику зовнішню змінну
Способи захоплення значень:
const printH1 = () => allH1s;
Не рекомендується:
const printH1 = (list) => list;
Передача значення як параметра дозволяє уникнути зовнішнього посилання, тому жоден контекстний слот не з’являється. Ще краще — видаляти невикористовувані допоміжні функції.
2. Від’єднуйте слухач, коли закінчите його використання
Контекст існує лише доти, доки працює handleClick. Перервіть цей зв’язок:
// any one of these frees the NodeList on the next GC
elem.removeEventListener("click", handleClick);
elem.addEventListener("click", handleClick, { once: true });
elem.addEventListener("click", handleClick, { signal: controller.signal });
controller.abort();
Видалення #root з документа також звільняє його. Такий тип витоку віддає перевагу стабільним об’єктам, таким як window, document, сокети та довгоживучі кореневі елементи, оскільки ніщо зазвичай не видаляє слухача.
3. Коли сусідній елемент має зберегти доступ, після цього поставте значення null у зовнішнє прив’язування
let allH1s = document.querySelectorAll("h1");
const printH1 = () => allH1s;
// ...use it...
allH1s = null; // the context slot still exists, but now holds null
Після постановки значення null, [[Scopes]] все ще відображає allH1s, але значення є null, і NodeList можна зібрати. Це найменш елегантний варіант, проте корисний, коли сьогодні неможливо переструктурувати сусідній елемент.
Це специфікація чи V8?
V8. ECMAScript стверджує, що замикання захоплює свій запис середовища та не містить інформації про стратегію збирання сміття. Інженерна реалізація, що відповідає специфікації, може зберігати всі зв’язки в межах діапазону протягом усього часу існування замикання; інша може бути суворішою за V8. Оптимізація V8 полягає у тому, щоб зберігати «лише ті змінні, про які згадує внутрішня функція». Саме тому формулювання «весь діапазон» відповідає тексту специфікації, а не тому, що на практиці зберігає Chrome, Edge, Node чи Deno.
Не покладайтеся ні на одну з цих поведінок. Зберігайте великі значення в вузьких діапазонах, пишіть допоміжні функції, які приймають аргументи замість того, щоб захоплювати зовнішні змінні, та надавайте кожному довготривалому слухачеві задокументовану точку від’єднання.
Що перевіряв інтерв’юер
Це не те, що означає словникове визначення замикання. Важливо було усвідомити, що захоплення змінних відбувається у межах конкретного виклику, а не кожної вкладеної функції: елементи, створені під час одного виклику, ділять єдиний контекст; структура цього контексту фіксується під час парсингу; змінні зберігаються разом із найдовше існуючим елементом; а досвідчений інженер використовує DevTools замість того, щоб посилатися на діаграми.
Основні висновки
- Замикання не зберігають усі контексти, а лише ті імена, на які вони посилаються. Вони ділять один контекст на кожен виклик, який містить усі зовнішні змінні, про які згадує будь-яка внутрішня функція.
- Цей набір змінних обирається під час парсингу. Неактивні внутрішні функції та змінні, значення яких дорівнює нулю, все одно впливають на ті імена, про які вони згадують.
- Контекст існує стільки, скільки його найдовше існуюче замикання. Постійний слухач робить весь контекст постійним.
console.dir(fn) → [[Scopes]], а зберігання даних — за допомогою WeakRef разом із розділом Memory → Collect garbage. Спочатку очистіть консоль, щоб DevTools не був причиною зберігання даних.У інтерв’ю, які передбачають багаторазове обговорення, цінуються ті, хто може пов’язати поведінку двигуна з виявленням витоків. Здатність описувати записи середовища менш корисна, ніж знання того, яку панель відкрити, який іконка спричиняє збір сміття та як саме запис у консолі може підтримувати життєздатність підозрілої структури даних. Розглядайте [[Scopes]] як карту посилань, а WeakRef разом із примусовим збором сміття — як доказ зберігання даних, після чого перепроектуйте процес так, щоб відповідь більше не залежала від деталей оптимізатора.