Замыкания в JavaScript: приватность, счётчики и утечки
Замыкание — не абстракция из учебника, а инструмент с ценой. Разбираем, где оно уместнее класса и модуля, а где тихо держит память и мешает сборщику мусора.

Вам нужно спрятать переменную от внешнего кода: счётчик, кэш, приватное состояние. В JavaScript для этого есть четыре рабочих способа — замыкание через фабричную функцию, приватные поля класса (#field), модульная область видимости и старый добрый объект с Symbol. Все четыре решают одну задачу по-разному, и у каждого своя цена: одни держат память дольше, чем вы думаете, другие ломаются при наследовании, третьи не работают в старых рантаймах. Ниже — сравнение на конкретных примерах для актуального ECMAScript (ES2022 и новее, поддержка приватных полей есть в Node.js 12+ и всех современных браузерах).
Что вообще делает замыкание
Замыкание — это функция вместе с окружением, в котором она была создана. Когда внутренняя функция ссылается на переменную внешней, эта переменная не исчезает после завершения внешней функции: движок держит её живой, пока жива внутренняя функция. Именно на этом строится приватность.
// Фабрика счётчика: count недоступен снаружи
function createCounter() {
let count = 0; // живёт, пока живёт возвращённая функция
return {
increment() { count += 1; return count; },
value() { return count; },
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.count); // undefined — снаружи не видно
Переменную count нельзя прочитать или перезаписать извне. Это настоящая приватность на уровне рантайма, а не соглашение вроде _count.
Классическая ловушка: var в цикле
Область видимости и замыкания сталкиваются лбами в цикле. С var все итерации делят одну переменную:
// Печатает 3, 3, 3 — все колбэки видят одну и ту же i
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Печатает 0, 1, 2 — let создаёт новую привязку на каждой итерации
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0);
}
let и const получают собственную привязку на каждой итерации блока — это поведение стандартизировано в ES2015 и работает во всех поддерживаемых сегодня средах. Если видите обходной приём с IIFE вокруг var — это код до 2015 года, сейчас он не нужен.
Четыре способа спрятать состояние
1. Замыкание через фабрику
То, что показано выше. Плюс — работает где угодно, включая древние рантаймы, и даёт полную изоляцию. Минус — каждый вызов фабрики создаёт новый набор функций в памяти. Тысяча счётчиков — тысяча копий методов increment и value, потому что они замкнуты на разные count.
2. Приватные поля класса
class Counter {
#count = 0; // приватное поле, синтаксис ES2022
increment() { this.#count += 1; return this.#count; }
value() { return this.#count; }
}
const c = new Counter();
c.increment(); // 1
c.#count; // SyntaxError ещё на этапе парсинга
Методы живут в прототипе — один экземпляр методов на весь класс, а не копия на объект. Приватность жёсткая: обращение к #count снаружи — синтаксическая ошибка, а не undefined. Цена — синтаксис доступен с ES2022, в старых окружениях без транспиляции не запустится.
3. Модульная область видимости
// counter.js — переменная модуля видна только внутри файла
let total = 0;
export function increment() { total += 1; return total; }
export function value() { return total; }
Состояние спрятано на уровне модуля. Подходит для единственного экземпляра — например, глобального реестра или конфигурации. Не подходит, когда нужно много независимых экземпляров: модуль один, состояние общее.
4. Symbol как ключ
const countKey = Symbol('count');
function createCounter() {
const obj = { [countKey]: 0 };
obj.increment = () => (obj[countKey] += 1);
return obj;
}
Это не настоящая приватность: Object.getOwnPropertySymbols достанет ключ. Такой подход почти всегда проигрывает замыканию и приватным полям — оставляем его в списке ради полноты, разворачивать не будем.
Сравнение
| Подход | Приватность | Память на экземпляр | Много экземпляров | Требует |
|---|---|---|---|---|
| Замыкание (фабрика) | Полная | Копия методов на каждый объект | Да | ES5+ |
Приватные поля # | Полная | Методы в прототипе, общие | Да | ES2022 |
| Модуль | Полная в пределах файла | Одно состояние | Нет | ES-модули |
| Symbol-ключ | Слабая (достаётся рефлексией) | Копия методов | Да | ES2015 |
Когда что выбирать
- Один объект с приватным состоянием, экземпляров немного — замыкание через фабрику. Читается прямолинейно, работает везде.
- Много экземпляров, важна память — класс с
#-полями. Методы не дублируются, приватность жёсткая. - Единственное разделяемое состояние на приложение — модуль. Никакой фабрики не нужно.
- Нужны наследование и
instanceof— класс. Замыкание таких проверок из коробки не даёт.
Приватное поле #count занимает столько же, сколько обычное свойство. Замкнутая переменная тянет за собой всю функцию и её лексическое окружение — при тысячах объектов эта разница видна в куче.
Что ломается
Три ошибки, которые встречаются чаще всего.
- Утечка через замыкание в долгоживущем колбэке. Обработчик события держит ссылку на большой объект из внешней области, обработчик не снимается — объект не собирается сборщиком мусора. Снимайте слушатели через
removeEventListenerи не замыкайтесь на то, что нужно только один раз. - Общая переменная там, где ждали независимую. Если состояние объявлено вне фабрики, все «экземпляры» разделяют его. Проверьте, что
let count = 0стоит внутри фабричной функции, а не рядом с ней. - Доступ к приватному полю из чужого экземпляра.
#fieldпривязан к классу, где объявлен. Попытка прочитать#countу объекта, который не создан этим классом, броситTypeErrorв рантайме — учитывайте это в методах, принимающих внешние объекты.
Про память подробнее
Замыкание держит живым не одну переменную, а весь Environment Record внешней функции*. Если внутри фабрики создан массив на мегабайт, а возвращённая функция ссылается хотя бы на одну переменную из того же скоупа, движок может удержать и массив. Не всегда — современные движки вроде V8 умеют отбрасывать неиспользуемые привязки, но полагаться на это в горячем коде рискованно. Мерьте кучу через снапшоты в DevTools (вкладка Memory) до и после создания объектов, а не на глаз.
* Environment Record — внутренняя структура движка, которая хранит переменные лексического окружения. Именно её удержание и есть механика замыкания.
Что делать дальше
Возьмите один класс или фабрику из своего кода, где есть «приватные» свойства с префиксом _, и переведите на #-поля или замыкание — компилятор и линтер сразу покажут, кто трогал приватное состояние снаружи. Если приложение долгоживущее (SPA, серверный воркер), снимите heap snapshot после часа работы и поищите объекты, которые должны были умереть: чаще всего их удерживают именно замкнутые колбэки. Дальше стоит посмотреть спецификацию по лексическим окружениям и раздел про сборку мусора в документации вашего рантайма.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: MDN — Closures, MDN — Private class fields
Частые вопросы
А если у меня старый Node.js без поддержки #-полей?
Замыкания реально текут по памяти или это миф?
Что быстрее — класс или фабрика с замыканием?
Можно ли достать значение приватного поля через отладчик?
Почему в цикле с let колбэки видят разные значения, а с var одно?
Стоит ли вообще использовать Symbol для приватности?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.