Замыкания в JavaScript: от основ до уверенного применения

Курс для frontend-разработчиков, которые хотят разобраться в замыканиях без лишней теории. Только код, примеры из реальной практики и разбор типичных ошибок. После курса вы будете уверенно использовать замыкания в React, Vue и Angular.

1. Что такое замыкание и как оно создаётся

Что такое замыкание и как оно создаётся

Представьте: вы пишете компонент на React, добавляете обработчик события внутри useEffect, и вдруг замечаете, что он читает устаревшее значение стейта. Или создаёте несколько кнопок в цикле, и все они ведут себя одинаково — хотя должны делать разное. За обоими сценариями стоит одна и та же механика: замыкание (closure). Понять его — значит перестать бороться с JavaScript и начать использовать его в свою пользу.

Функция внутри функции — вот где всё начинается

Замыкание возникает, когда функция «запоминает» переменные из того места, где она была создана, — даже если это место уже завершило выполнение. Звучит абстрактно, поэтому сразу к коду.

makeGreeting уже завершила работу, но возвращённая функция всё равно знает, кто такой name. Она не скопировала значение — она держит живую ссылку на лексическое окружение (lexical environment) родительской функции. Это и есть замыкание.

> Замыкание — это функция вместе с лексическим окружением, в котором она была создана.

Как JavaScript хранит переменные: лексическое окружение

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

Вот как это выглядит пошагово:

  • Вызывается makeGreeting('Alice') — создаётся лексическое окружение с name = 'Alice'.
  • Внутри создаётся анонимная функция, которая захватывает это окружение.
  • makeGreeting возвращает анонимную функцию и завершает работу.
  • Лексическое окружение с name не уничтожается, потому что на него ссылается возвращённая функция.
  • При вызове greetAlice() функция находит name в захваченном окружении.
  • !Схема работы замыкания: лексическое окружение родительской функции остаётся живым

    Именно поэтому каждый вызов makeGreeting создаёт независимое замыкание:

    greetAlice и greetBob — две разные функции с двумя разными лексическими окружениями. Они не делят переменную name между собой.

    Три условия, при которых возникает замыкание

    Замыкание создаётся автоматически — вам не нужно ничего специально делать. Оно появляется всегда, когда выполняются три условия:

  • Есть внешняя функция с локальными переменными.
  • Внутри неё создаётся другая функция.
  • Внутренняя функция обращается к переменным внешней.
  • Это не экзотика — это происходит в вашем коде постоянно. Каждый колбэк в addEventListener, каждый обработчик в useCallback, каждая функция внутри другой функции — потенциальное замыкание.

    Замыкание — это не копия, а ссылка

    Здесь кроется важный нюанс, который часто путает разработчиков. Замыкание захватывает не значение переменной в момент создания, а саму переменную — живую ссылку на ячейку памяти. Это означает, что если переменная изменится, замыкание увидит новое значение.

    Обе функции — increment и get — замкнуты на одну и ту же переменную count. Когда increment меняет count, get видит изменение. Это не баг, это фича: замыкания позволяют нескольким функциям разделять общее состояние.

    !Интерактивная демонстрация замыкания: как функции разделяют переменную count

    Замыкание без вложенных функций — миф

    Иногда можно услышать, что замыкание — это «функция, возвращающая функцию». Это неточно. Замыкание создаётся в момент определения внутренней функции, а не в момент возврата. Вот пример, где функция никуда не возвращается, но замыкание всё равно работает:

    Колбэк setTimeout замкнут на message. setup давно завершилась, но когда через секунду сработает таймер — message будет доступна. Именно поэтому в React-компонентах обработчики событий видят пропсы и стейт: они замкнуты на них в момент рендера.

    Почему это важно для React-разработчика

    В React каждый рендер — это новый вызов функции-компонента. Каждый раз создаются новые замыкания, которые захватывают актуальные значения пропсов и стейта на момент этого рендера. Вот почему useCallback и useMemo существуют: они позволяют контролировать, какие замыкания пересоздаются, а какие — нет.

    Без понимания замыканий поведение useEffect с зависимостями, «устаревшие» значения в обработчиках и бесконечные циклы ре-рендеров выглядят как магия. С пониманием — это предсказуемая механика.

    Замыкание — не абстрактная концепция из учебника. Это инструмент, который JavaScript использует под капотом каждый раз, когда вы пишете функцию внутри функции. Следующий шаг — разобраться, как именно работают области видимости и что значит «захватить переменную».

    2. Области видимости и захват переменных

    Области видимости и захват переменных

    Когда вы пишете let x = 5 внутри функции, где именно живёт эта переменная? Почему функция видит переменные родителя, но не видит переменные соседней функции? И почему var ведёт себя не так, как let? Всё это — вопросы об областях видимости (scope), и без чёткого понимания этой механики замыкания будут казаться непредсказуемыми.

    Область видимости — это не про файлы, а про вложенность

    Область видимости — это часть кода, в которой переменная доступна. В JavaScript есть три уровня:

    | Уровень | Где создаётся | Пример | |---|---|---| | Глобальная | Вне любых функций и блоков | var x = 1 на верхнем уровне | | Функциональная | Внутри функции (var, function) | function f() { var y = 2; } | | Блочная | Внутри {} (let, const) | if (true) { let z = 3; } |

    Ключевое правило: внутренняя область видит внешнюю, но не наоборот. Функция видит переменные своего родителя, дедушки и так далее вплоть до глобального уровня. Но родитель не видит переменные дочерней функции.

    Цепочка областей видимости: как JavaScript ищет переменную

    Когда функция обращается к переменной, JavaScript не просто смотрит в текущую область — он идёт вверх по цепочке областей видимости (scope chain). Сначала ищет в текущей функции, потом в родительской, потом в родителе родителя, и так до глобального объекта. Если нигде не нашёл — ReferenceError.

    Важно: JavaScript ищет переменную по лексической цепочке — по тому, где функция написана в коде, а не откуда она вызвана. Это называется лексической областью видимости (lexical scoping).

    getValue написана на глобальном уровне, поэтому её цепочка видимости идёт к глобальному value, а не к локальному value внутри wrapper. Место вызова не имеет значения.

    !Цепочка областей видимости: как JavaScript ищет переменную снизу вверх

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

    Как уже было сказано в первой статье, замыкание захватывает не значение, а переменную. Но что это означает на практике? Рассмотрим пример с изменяемой переменной:

    Каждый вызов makeAdder создаёт новое лексическое окружение с собственным x. add5 и add10 — независимые замыкания с разными x. Это фундаментальный момент: каждый вызов функции — новое окружение.

    Теперь посмотрим, что происходит, когда несколько замыканий разделяют одну переменную:

    Три функции замкнуты на одно и то же count. Это не три копии переменной — это три ссылки на одну ячейку памяти.

    var, let и const: принципиальная разница для замыканий

    var объявляет переменную в функциональной области видимости. let и const — в блочной. Эта разница критична в циклах и условиях.

    Именно это различие порождает классическую ошибку с замыканиями в циклах — она подробно разобрана в статье про типичные ошибки. Здесь важно зафиксировать принцип: let создаёт новую переменную на каждую итерацию блока, var — нет.

    Hoisting: переменные поднимаются, но не все одинаково

    Hoisting (поднятие) — механизм, при котором объявления переменных и функций перемещаются в начало своей области видимости до выполнения кода. Это влияет на то, что именно захватывает замыкание.

    var x поднимается в начало функции и инициализируется как undefined. Само присвоение = 5 остаётся на месте. С let и const поднятие тоже происходит, но переменная попадает в временную мёртвую зону (Temporal Dead Zone, TDZ) — обращение к ней до объявления вызывает ReferenceError.

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

    Модуль как замыкание: паттерн, который вы уже используете

    Понимание областей видимости объясняет, почему модульный паттерн (module pattern) работает именно так. Каждый ES-модуль (import/export) создаёт собственную область видимости. Переменные, не экспортированные явно, недоступны снаружи — они замкнуты внутри модуля.

    Это не магия модульной системы — это замыкание на уровне файла. increment и getCount замкнуты на count, которая живёт в области видимости модуля.

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

    3. Практические примеры: счётчики, кэш, обработчики событий

    Практические примеры: счётчики, кэш, обработчики событий

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

    Счётчик с приватным состоянием

    Самый классический пример замыкания — счётчик. Но не просто счётчик, а счётчик, чьё состояние нельзя изменить снаружи напрямую.

    Переменная count живёт в замыкании и доступна только через методы объекта. Это инкапсуляция без классов и private-полей. В React этот паттерн лежит в основе useReducer — редьюсер замкнут на текущее состояние и возвращает новое.

    Параметры initialValue и step тоже захватываются замыканием. Это позволяет создавать независимые счётчики с разными настройками:

    Мемоизация: кэш результатов через замыкание

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

    cache — это Map, который живёт в лексическом окружении memoize. Каждый вызов memoize создаёт новый независимый кэш. Возвращённая функция замкнута на этот кэш и обращается к нему при каждом вызове.

    В React этот паттерн реализован в хуке useMemo. Под капотом — то же самое: результат вычисления хранится между рендерами и возвращается без пересчёта, если зависимости не изменились.

    Важный нюанс: JSON.stringify(args) как ключ кэша работает для примитивов, но не для объектов (два разных объекта с одинаковым содержимым дадут одинаковый ключ, что может быть нежелательно). В продакшн-реализациях используют более сложные стратегии ключей.

    !Интерактивная демонстрация мемоизации: сравнение времени выполнения с кэшем и без

    Частичное применение функций

    Частичное применение (partial application) — создание новой функции путём фиксации части аргументов исходной. Замыкание захватывает зафиксированные аргументы.

    presetArgs захватывается замыканием. double — это multiply с зафиксированным первым аргументом 2. Это мощный инструмент для создания специализированных функций из общих.

    Реальный кейс из React: обработчики событий с параметрами.

    Каждая стрелочная функция () => onDelete(item.id) — замыкание, захватывающее конкретный item.id. Без замыканий пришлось бы передавать id через data-атрибуты и читать его из события.

    Обработчики событий: замыкания в DOM

    Классический паттерн: нужно добавить обработчик, который знает о контексте своего создания.

    Каждый обработчик замкнут на свои buttonId и message. Функция setupButton давно завершила работу, но обработчики продолжают иметь доступ к этим переменным.

    Важный момент: если вы добавляете обработчик и потом хотите его удалить — нужна именованная функция, потому что removeEventListener требует ссылку на ту же функцию.

    timeoutId — это переменная в замыкании, которая хранит идентификатор таймера между вызовами. Каждый новый вызов отменяет предыдущий таймер и создаёт новый. Без замыкания пришлось бы хранить timeoutId в глобальной переменной или в объекте.

    Все эти паттерны — счётчики, мемоизация, частичное применение, debounce — объединяет одна идея: замыкание как хранилище состояния, которое живёт между вызовами функции и недоступно снаружи. Это чище глобальных переменных и проще классов для многих задач.

    4. Типичные ошибки с замыканиями и как их исправить

    Типичные ошибки с замыканиями и как их исправить

    Замыкания работают предсказуемо — но только если понимать их механику. Большинство ошибок возникают не потому, что замыкания «сломаны», а потому что разработчик ожидает одного поведения, а получает другое. Разберём самые распространённые ловушки с конкретными диагнозами и лечением.

    Классика: var в цикле

    Это самая известная ошибка с замыканиями, и она до сих пор встречается в реальных проектах.

    let создаёт новую переменную i на каждую итерацию блока. Каждый обработчик замкнут на свою собственную копию i.

    Решение 2: IIFE для создания нового окружения (исторический подход)

    До появления let разработчики использовали IIFE (Immediately Invoked Function Expression — немедленно вызываемое функциональное выражение):

    Каждый вызов createHandler(i) создаёт новое замыкание с собственным index.

    Устаревшие значения в React: stale closure

    В React-разработке самая частая проблема с замыканиями — stale closure (устаревшее замыкание). Функция захватывает значение стейта в момент создания, но к моменту вызова стейт уже изменился.

    Эффект запускается один раз при монтировании. Колбэк setInterval замкнут на count = 0 из первого рендера. Каждую секунду он делает setCount(0 + 1) — счётчик застревает на 1.

    Решение 1: функциональное обновление стейта

    Когда передаёте функцию в setCount, React передаёт в неё актуальное значение стейта. Замыкание больше не нужно захватывать count.

    Решение 2: добавить зависимость и пересоздавать эффект

    Теперь при каждом изменении count эффект очищается и запускается заново с актуальным значением. Но это создаёт новый интервал каждую секунду — для таймеров лучше первый подход.

    Решение 3: useRef для хранения актуального значения

    useRef создаёт мутабельный объект, который не пересоздаётся между рендерами. Замыкание захватывает countRef (ссылку на объект), а countRef.current всегда содержит актуальное значение.

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

    Замыкание удерживает лексическое окружение живым. Если это окружение содержит большие объекты, а замыкание живёт долго — возникает утечка памяти (memory leak).

    Обработчик события живёт, пока существует элемент DOM. hugeData живёт, пока живёт обработчик. Если элемент не удаляется — hugeData никогда не будет собрана сборщиком мусора.

    Решение: не захватывать лишнее

    Диагностика: как понять, что проблема в замыкании

    Когда что-то работает не так, как ожидается, задайте себе три вопроса:

  • Когда создана функция? — Что именно она захватила в момент создания?
  • Когда вызвана функция? — Изменились ли захваченные переменные к этому моменту?
  • Сколько экземпляров замыкания существует? — Одно или несколько? Делят ли они переменную?
  • Большинство ошибок с замыканиями укладываются в одну из двух категорий: либо все замыкания разделяют одну переменную (проблема с var), либо замыкание захватило устаревшее значение (stale closure в React). Понимание этих двух паттернов закрывает 90% проблем.

    5. Задачи для самостоятельного решения

    Задачи для самостоятельного решения

    Теория без практики не работает. Эта статья — набор задач разного уровня сложности, которые проверяют понимание замыканий с разных сторон: от базовой механики до реальных паттернов из продакшн-кода. Для каждой задачи есть условие, подсказка и разбор решения.

    Уровень 1: Механика замыканий

    Задача 1. Что выведет код?

    Прочитайте код и предскажите вывод до запуска. Затем проверьте в консоли.

    Подсказка: Обратите внимание на то, какой x захватывает самая внутренняя функция — тот, что в outer, или тот, что в inner?

    Разбор: Самая внутренняя функция function() { return x; } создаётся внутри inner, поэтому она захватывает x = 20 из inner, а не x = 10 из outer. Вывод: 20.

    ---

    Задача 2. Счётчик с ограничением

    Напишите функцию createBoundedCounter(max), которая возвращает счётчик. Счётчик не должен превышать max. При достижении максимума метод increment ничего не делает.

    Подсказка: Храните count и max в замыкании. В increment добавьте проверку перед увеличением.

    Решение:

    ---

    Задача 3. Предсказание с var

    Что выведет этот код? Объясните почему.

    Затем исправьте код так, чтобы funcs[0]() вернул 0, funcs[2]()2, funcs[4]()4.

    Разбор: Все три вызова вернут 5. var создаёт одну переменную i для всего цикла. К моменту вызова функций цикл завершился и i = 5. Исправление — заменить var на let.

    !Интерактивный отладчик замыканий: пошаговое выполнение цикла с var и let

    Уровень 2: Практические паттерны

    Задача 4. Функция once с аргументами

    Реализуйте функцию once(fn), которая возвращает обёртку. Обёртка вызывает fn только при первом вызове и возвращает его результат. При последующих вызовах — возвращает тот же результат, не вызывая fn повторно.

    Подсказка: Нужны две переменные в замыкании: флаг called и cachedResult.

    Решение:

    ---

    Задача 5. Генератор уникальных ID

    Напишите createIdGenerator(prefix), который возвращает функцию. Каждый вызов возвращаемой функции генерирует уникальный ID вида prefix-1, prefix-2 и т.д. Разные генераторы должны иметь независимые счётчики.

    Решение:

    Каждый вызов createIdGenerator создаёт новое замыкание с независимым counter.

    ---

    Задача 6. Частичное применение с несколькими аргументами

    Реализуйте partial(fn, ...args) — функцию частичного применения. Возвращённая функция должна принимать оставшиеся аргументы и вызывать fn с полным набором.

    Подсказка: fns захватывается замыканием. Используйте Array.prototype.reduce.

    Решение:

    Замыкание захватывает массив fns. При каждом вызове возвращённой функции reduce последовательно применяет каждую функцию из массива к накопленному результату.

    ---

    Задача 10. Исправьте баг

    Найдите и исправьте проблему в этом React-компоненте:

    Разбор: Классический stale closure. notifications и dismissed захвачены в момент первого рендера и никогда не обновляются внутри setInterval. Компонент будет работать некорректно при изменении notifications или dismissed.

    Исправление:

    Функциональное обновление setDismissed получает актуальное currentDismissed от React, а notifications добавляется в зависимости эффекта — при его изменении интервал пересоздаётся.

    Как проверить своё понимание

    После решения каждой задачи задайте себе три вопроса:

  • Какие переменные захвачены в замыкании?
  • Когда создаётся замыкание и когда вызывается?
  • Есть ли риск stale closure или случайного разделения состояния?
  • Если вы можете ответить на эти вопросы для любого фрагмента кода — замыкания больше не будут источником неожиданных багов. Они станут инструментом, которым вы управляете осознанно.