JavaScriptMiddleTechnical

Объясните понятие closure в JavaScript. Как closure может вызвать утечку памяти?

Замыкание — функция, которая запоминает переменные из внешней области видимости. Утечка памяти возникает, когда замыкание удерживает ссылку на большой объект (DOM-узел, данные) дольше, чем нужно.

Что такое замыкание

Замыкание (closure) возникает, когда внутренняя функция обращается к переменным из внешней функции после того, как внешняя уже завершила выполнение. Движок сохраняет ссылку на лексическое окружение (scope), не давая сборщику мусора удалить переменные.

function makeCounter(start = 0) {
  let count = start; // переменная живёт в замыкании
  return {
    increment() { return ++count; },
    decrement() { return --count; },
    value()     { return count; },
  };
}

const c = makeCounter(10);
c.increment(); // 11
c.increment(); // 12
c.value();     // 12
// count недоступен снаружи, но живёт, пока жив объект c

Практические применения

// 1. Частичное применение / каррирование
const add = (a) => (b) => a + b;
const add5 = add(5);
add5(3); // 8

// 2. Приватное состояние модуля (Module Pattern)
const store = (() => {
  let state = {};
  return {
    get: (key) => state[key],
    set: (key, val) => { state[key] = val; },
    reset: () => { state = {}; },
  };
})();

// 3. Мемоизация
function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

Утечки памяти через замыкания

Утечка возникает, когда замыкание удерживает ссылку на большой объект, а сам callback/обработчик остаётся зарегистрированным после ненужности.

// Плохо: DOM-узел и data живут, пока жив обработчик
function setupLeak() {
  const hugeData = new Array(100_000).fill('x');
  const el = document.getElementById('btn');

  el.addEventListener('click', function handler() {
    console.log(hugeData.length); // замыкание держит hugeData
    // handler никогда не удаляется!
  });
}

// Хорошо: явно удаляем обработчик
function setupClean() {
  const hugeData = new Array(100_000).fill('x');
  const el = document.getElementById('btn');

  function handler() {
    console.log(hugeData.length);
    el.removeEventListener('click', handler); // однократный обработчик
  }
  el.addEventListener('click', handler);
  // или: el.addEventListener('click', handler, { once: true });
}

// Утечка через setInterval
function intervalLeak() {
  const bigObject = { data: new Array(50_000).fill(0) };
  const id = setInterval(() => {
    console.log(bigObject.data.length); // bigObject никогда не GC
  }, 1000);
  // Нет clearInterval — bigObject держится вечно
  return id; // вызывающий должен сохранить и вызвать clearInterval
}

Диагностика утечек в Chrome DevTools

  • Вкладка Memory → Heap snapshot: сделать снимок до и после действия, сравнить retained size.
  • Allocation timeline: найти объекты, которые растут и не освобождаются.
  • Фильтр по типу: искать Detached HTMLElement — узлы, удалённые из DOM, но удерживаемые замыканием.

Подводные камни

  • Цикличные ссылки: объект ссылается на замыкание, замыкание на объект — до ES6 это гарантировало утечку в старых IE; в современных движках mark-and-sweep справляется, но в WeakMap/WeakRef-сценариях важно понимать граф ссылок.
  • Замыкание в цикле with var: классический баг — все callback-и делят одну переменную i. Решение: let или IIFE.
  • Большой внешний объект в маленькой функции: даже если замыканию нужна одна строка из объекта, держится весь объект — деструктурируйте нужное.
  • Таймеры без clearInterval/clearTimeout: timer callback держит весь внешний scope до очистки.
  • Обработчики событий на глобальных объектах: window.addEventListener, document.addEventListener — живут весь жизненный цикл страницы.
  • React useEffect без cleanup: подписка без возврата функции очистки держит ссылки на state и props старого рендера.
  • WebSocket / EventSource: если соединение не закрывается при unmount компонента, замыкание с callback удерживает DOM и данные.
  • Скрытые замыкания в Promise chain: незавершённые promise-цепочки удерживают весь контекст создания — убедитесь, что промисы завершаются или отменяются (AbortController).

Common mistakes

  • Смешивать «closure» с похожим механизмом без критерия выбора.
  • Игнорировать риск: неверно оценить границы применения темы «closure» и получить хрупкое решение.
  • Показывать только синтаксис и не объяснять поведение в runtime или сборке.

What the interviewer is testing

  • Объясняет лексическое окружение функции и риск удержания лишних объектов в памяти.
  • Показывает на примере, как работает: замыкание хранит ссылки на переменные внешней области, поэтому callback, таймер или обработчик события может продлить жизнь большого объекта после ухода компонента со страницы.
  • Называет production-нюанс и граничный случай для темы «closure».

Sources

Related topics