Замыкание - это функция, которая помнит
Каждый раз, когда вы объявляете функцию в JavaScript, она незаметно сохраняет связь с переменными, которые её окружают. Даже если потом эта функция выполнится где-то совсем в другом месте, она всё равно будет видеть эти переменные. Это и есть замыкание в JavaScript.
Самый короткий пример:
makeGreeter отрабатывает, возвращает внутреннюю функцию и завершается. Логично было бы предположить, что локальная переменная name тут же исчезнет - функция-то уже отработала. Но внутренняя функция всё ещё обращается к name, поэтому JavaScript держит её в памяти. greetAda помнит "Ada". greetBoris помнит "Boris". Два замыкания - два независимо сохранённых значения.
Лексическая область видимости - основа замыканий в JavaScript
За замыканиями стоит правило, которое называется лексической областью видимости: функция видит переменные того места, где она была написана, а не того, где её вызывают. Слово «лексическая» здесь значит просто «определяется по расположению в исходном коде».
show выводит "Я снаружи", а не "Я внутри caller". Функция была объявлена рядом с внешним outer - его она и видит. То, что мы вызвали её из места, где случайно есть свой outer, роли не играет.
Замыкания в JavaScript - это просто лексическая область видимости, которая переживает внешнюю функцию. Переменная не исчезает, пока на неё кто-то держит ссылку.
Каждый вызов создаёт своё замыкание
Новый вызов внешней функции создаёт новые переменные, и любая внутренняя функция, возвращённая из этого вызова, помнит именно эти переменные. Именно поэтому greetAda и greetBoris выше не конфликтовали друг с другом.
Классический пример замыкания в JS - счётчик:
a и b хранят каждый свой count. Ни один код снаружи возвращённой функции не может добраться до этих переменных - count полностью приватна. И это не какая-то специальная фича языка, которую мы включили - это естественное следствие того, как работают замыкания в JavaScript.
Приватные переменные в JavaScript без классов
Поскольку захваченные переменные доступны только через возвращённую функцию, через замыкание js можно собирать небольшие объекты с по-настоящему приватным состоянием:
balance - это не свойство возвращённого объекта, переменная живёт внутри замыкания. Добраться до неё или изменить можно только через методы, которые вы наружу отдали. Классы с приватными полями #private умеют то же самое, но замыкания появились на десятки лет раньше и до сих пор встречаются в коде повсеместно.
Классический подвох с замыканием в цикле
Чаще всего на замыканиях спотыкаются именно в циклах. Посмотрите, что творит var:
Вы наверняка ожидаете 0, 1, 2. А получаете 3, 3, 3. Вот в чём дело: var имеет функциональную область видимости, поэтому на весь цикл существует только одна переменная i. Все три замыкания захватили одну и ту же переменную, и к моменту их вызова цикл уже отработал, а i стала равна 3.
Замените var на let:
Теперь в консоли выводится 0, 1, 2. Всё дело в том, что let имеет блочную область видимости - на каждой итерации цикла создаётся новое связывание для i, и каждое замыкание захватывает свою собственную копию. Это, пожалуй, главная причина, почему let стоит предпочитать var.
Замыкания захватывают переменные, а не значения
Тонкий, но принципиальный момент: замыкание держит ссылку на саму переменную, а не на слепок её значения в момент определения функции.
printMessage читает message в момент вызова, а не в момент создания. Если нужен снимок значения - сначала скопируйте его в локальную переменную. По сути, именно это и делает let внутри цикла for.
Практический пример: функция «один раз»
Вот небольшая утилита, которая с помощью замыкания гарантирует, что функция выполнится только единожды:
called и result - это приватное состояние, которое живёт ровно столько же, сколько и возвращаемая функция. Никаких глобальных флагов, никаких лишних объектов. Этот приём - маленькая функция-помощник, приватные переменные и замыкание - одна из самых полезных вещей, которые умеет JavaScript.
Пара слов о памяти
Замыкание держит захваченные переменные живыми до тех пор, пока на само замыкание есть хоть одна ссылка. Обычно это именно то, что нужно, но есть нюанс: если повесить замыкание на что-то долгоживущее (скажем, DOM-обработчик события или глобальный кеш) и оно захватит какой-нибудь объёмный объект, сборщик мусора не сможет его освободить, пока живо замыкание.
function attach() {
const hugeData = new Array(1_000_000).fill("...");
document.addEventListener("click", () => {
console.log(hugeData.length);
});
}
Пока слушатель висит, hugeData остаётся в памяти. Уберите слушатель (или не захватывайте лишнего) - и ссылка освободится. Микроменеджмент тут не нужен, достаточно просто помнить: замыкания и память связаны напрямую.
Что важно запомнить
- Замыкание - это функция плюс переменные, которые она «видела» в момент своего объявления.
- Каждый вызов внешней функции создаёт новый набор переменных для вложенных замыканий.
- Замыкания дают приватное состояние без всяких классов.
- В циклах используйте
let- так на каждой итерации будет своя привязка. - Замыкание захватывает саму переменную, а не её значение на момент создания.
Дальше: ключевое слово this
Замыкания отвечают за переменные вокруг функции. Следующий кусок пазла - на чём именно вызвана функция. В JavaScript за это отвечает this, и ведёт он себя совсем не так, как захваченные переменные, о которых мы только что говорили.
Часто задаваемые вопросы
Что такое замыкание в JavaScript?
Замыкание - это функция, которая «запоминает» переменные из того контекста, где она была объявлена, и продолжает иметь к ним доступ даже после того, как внешняя функция уже отработала. Формально в JavaScript замыканием является любая функция, но на практике об этом говорят тогда, когда функцию возвращают наружу или передают куда-то дальше, а она всё ещё использует переменные из своего исходного окружения.
Зачем вообще нужны замыкания?
С их помощью функция несёт с собой собственное приватное состояние. Получается, что данные привязаны к функции - и при этом не нужны ни классы, ни глобальные переменные. Классические сценарии: счётчики, коллбэки, которые должны сработать один раз, мемоизация, а также небольшие модули с публичным API, где внутренняя реализация спрятана от внешнего мира.
Почему замыкания в цикле с var ведут себя странно?
Дело в том, что var имеет функциональную область видимости, поэтому все итерации делят одну и ту же переменную. Замыкания, созданные внутри цикла, ссылаются на эту единственную переменную, и к моменту их вызова она уже содержит финальное значение. Решение - использовать let: у него блочная область видимости, и на каждой итерации создаётся своя отдельная привязка.