Замыкание это важный механизм JavaScript, понимание которого обязательно фронтендера. Он позволяет изящно реализовать принцип наименьшего раскрытия, благодаря инкапсулированию функций, сохраняя их состояние во внутренней области видимости, для последующего использования.
Замыкания в лямбда-вычислениях
Замыкания лежать в основе функционального программирования и даже в основе ООП. Изначально они были математической концепцией из области лямбда-вычислений, получившей воплощение во многих языка программирования, особенно в функциональных Haskell, Lisp, ML и т.д.
В упрощённом виде, основные идеи лямбда-вычислений таковы:
Функции - это примитивная единицы вычисления. Все прочие данные кодируются в них;
Единственная задача функции, это применение к аргументу: принять аргумент, вернуть вычисленное с его участием значение;
Функции анонимны, их описывают лямбда-абстракции вида:
λx. x + 5означающие: "функция от x, возвращающая x + 5". Функция состоит из пары: параметры + тело;Функция возвращающая или принимающая другую функцию, называется функцией высшего порядка.
Разработчик не сильно знакомый с математикой, сейчас такой: "Хмммм где-то я всё это слышал! Но где?!".

Теперь Вы знайте базу, о функциях лежащий в основе любого языка программирования и которые Вы каждый день пишите! Все их хорошо знакомые особенности, вышли из математической концепции, американского математика Алонзо Чёрча, разработанной в 30-х годах прошлого века.

Вернёмся к нашим баранам замыканиям. В теории лямбда-исчислений у функции есть окружение состоящее из свободных переменных, которая используется в функции, но не является её параметром и не объявлена внутри неё. Например в выражении λx. x + y:
x- параметр функции (связанная переменная);y- свободная переменная, откуда-то из внешнего окружения.
Но что если наша лямбда функция λx. x + y попадёт в другой контекст, например станет частью функции высшего порядка? Как быть со свободной переменной y? Тут то на сцене и появляется замыкание, фиксирующее значение свободных переменных, в момент создания функции. Теперь наша функция будет вести себя стабильно и предсказуемо в любом контексте.
Для возникновения замыкания, функцию нужно вызвать, не в той области видимости, в которой она была объявлена. Реализуем пример, рассмотренную выше лямбда функцию на JS:
function counter() { let count = 0 // Свободная переменная для внутренней функции // Функция лямбда-абстракция return function(step) { count = count + step return count } } const myCounter = counter() // Замыкание состоящее из внутренней функции и её лексического окружения в виде переменной count console.log(myCounter(1)) // 1 console.log(myCounter(1)) // 2 console.log(myCounter(1)) // 3
Обязательным условием создания замыкания является наличие внешней функции, во внутренней области видимости. В примере выше, внутренняя функция замыкается на внешнюю переменную count, что не даёт сборщику мусора уничтожить данные хранящиеся в ней, даже после того как внешняя область видимости завершила свою работу.
Замыкания представляет собой живую ссылку, с доступом к полноценной переменной, а не моментальный снимок. Поэтому оно не ограничено только чтением данных, но может изменять её значение. Представление о том что замыкания сохраняют значения ошибочно, они сохраняют состояние переменной.
Замыкания являются частной особенностью функций и ничего другого. Концептуально замыкания работают на уровне областей видимости, а не переменных. По сути это пара: функции + её лексическое окружение, а всё вместе это, состояние внутренней функции, которое сохраняется в замыкании.
Замыкания и ООП
С ООП замыкания связывает некоторые общие принципы. По сути в примере со счётчиком реализованным через замыкания:
Переменная
count- это приватное поле;Сама функция
counter- это публичный интерфейс, работающий с приватным состоянием.
Это позволяет нам реализовать полноценный объект с приватным состоянием, при помощи замыканий:
function Counter(initialValue = 0) { let count = initialValue // Приватное свойство // Публичные методы return { increment: function(step = 1) { return count += step }, decrement: function(step = 1) { return count -= step }, getValue: function() { return count }, reset: function() { return count = initialValue } } } const myCounter = Counter(10) // Создаём экземляр объекта console.log(myCounter.getValue()) // 10 console.log(myCounter.increment(5)) // 15 console.log(myCounter.increment(2)) // 17 console.log(myCounter.decrement(3)) // 14 console.log(myCounter.reset()) // 10
Выше при помощи связки функция + замыкание мы создали настоящий класс, который в нативном виде выглядел бы так:
class Counter { #count #initValue constructor(initialValue = 0) { this.#count = initialValue this.#initValue = initialValue } increment(step = 1) { return this.#count += step } decrement(step = 1) { return this.#count -= step } getValue() { return this.#count } reset() { return this.#count = this.#initValue } } const myCounter = new Counter(10)
Замыкание в фреймворках
Матёрый фронтенедер посмотрев на пример с классом из функции, увидит в нём что-то знакомое и родное. Да это же типичный стор из Pinia или Vuex, где наш пример мог выглядеть так:
export const useCounterStore = defineStore('counter', () => { const count = ref(0) const initValue = 0 function increment(step) { count.value + step } function decrement(step) { count.value - step } function reset () { count.value = initValue } return {count, increment, decrement, reset} })
По сути Pinia использует замыкания для инкапсуляции приватного состояния стора внутри объекта синголтона. Вот так вот, мы за несколько шагов дошли от математической абстракции, практически столетней давности, до современного JS фреймворка и всё что их объединяет, это замыкания!
Замыкание в стрелочных функциях
Замыкания в стрелочных функциях работает так-же как и в обычных:
const counter = () => { let count = 0 return (step) => { count = count + step return count } } const myCounter = counter() console.log(myCounter(1)) // 1 console.log(myCounter(1)) // 2 console.log(myCounter(1)) // 3
Замыкание в циклах
Замыкание в цикле for
Появится ли замыкание в цикле for зависит как мы объявим итератор. Если через var, то замыкания не будет:
for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i) // 3, 3, 3 — а не 0, 1, 2 }, 100) }
В консоли мы увидим 3 3 3, так как на момент срабатывания setTimeout() цикл завершит свою работу и будет работать с итоговым значением переменной i.
Но вот если переменную объявить через let, то картина будет другой:
for (let i = 0; i < 3; i++) { setTimeout(function() { console.log(i) // 0, 1, 2 }, 100) }
let создаёт отдельное замыкание, со своим лексическим окружением, для каждой итерации. Поэтому каждая итерация ссылается на своё уникальное состояние. Тоже самое может случиться если навесить обработчик события используя var:
var buttons = document.querySelectorAll('div') for (var i = 0; i < buttons.length; i++) { buttons[i].addEventListener('click', function() { console.log('Нажата кнопка ' + i) }) }
В примере выше при клике по любому из дивов, всегда будет выводится одно и тоже сообщение: Нажата кнопка 4, так как 4 это последнее значение переменной на момент завершения работы цикла. А если поменять var, на let, то всё будет работать корректно и в консоль будет выводится индекс элемента по которому кликнул пользователь:
var buttons = document.querySelectorAll('div') for (let i = 0; i < buttons.length; i++) { buttons[i].addEventListener('click', function() { console.log('Нажата кнопка ' + i) }) }
Замыкание в итераторах
Итераторы forEach, map, filter создают замыкания во время своей работы, поэтому примеры с таймером, показывают разные значения на каждой итерации:
[0, 1, 2].forEach(function(i) { setTimeout(function() { console.log(i) // 0, 1, 2 }, 100) }) [0, 1, 2].map(function(i) { setTimeout(function() { console.log(i); // 0, 1, 2 }, 100) }) [0, 1, 2].filter(function(i) { setTimeout(function() { console.log(i) // 0, 1, 2 }, 100) return true })
Тут объяснить такое поведение проще простого. Данные методы на каждой итерации вызывают переданную коллбэк функцию, со своим окружением в виде переменной i, на которую и замыкается коллбэк функция внутри setTimeout().
Замыкания в коллбэках
Пожалуй тут с замыкания встречаются чаще всего, так как в JavaScript коллбэки используются буквально на каждом шагу в связи с его асинхронной природой.
Замыкания при Ajax-запросах
Замыкания легко встретить в Ajax-запросах:
function ajaxRequest(url) { let requestId = Math.random().toString(36).substring(2) // Генерим уникальный индетификатор для запроса fetch(url).then(response => { if (!response.ok) { throw new Error('Чёт пошло не так!') } return response.json() }).then(data => { console.log(`Запрос ${requestId} успешно завершён:`, data) // Стрелочная функция замыкает requestId }).catch(error => { console.log(`Запрос ${requestId} провалился:`, error.message) // И тут тоже замыкание }) } ajaxRequest('https://jsonplaceholder.typicode.com/todos/1') ajaxRequest('https://jsonplaceholder.typicode.com/todos/2')
В пример выше коллбэки в then() и catch() замыкаются на переменной requestId из внешней функции и поэтому когда с сервера приходит асинхронный ответ, в консоль выведется айдишник соответствующего запроса.
Замыкание в обработчиках событий
Напишем простой обработчик событий со своим лексическим окружением:
function clickSetup(elemId, message) { const elem = document.getElementById(elemId) elem.addEventListener('click', function() { console.log(message) }) } clickSetup('myElem', 'По элементу кликнули!')
В примере выше коллбэк функция передаваемая обработчику события, замыкается на аргументе message, в момент подписки на событие, выводя его значение при клике, происходящем уже после того как функция clickSetup() отработает.
Оптимизация замыканий
Современные движки JavaScript исключают из области видимости замыкания, любые переменные к котором нет явного обращения. Делается это ради оптимизации, так как замыкания порождаемые функциями обратного вызова, используемыми в JS повсеместно, со временем начинали потреблять слишком много памяти. Это легко проверить запустив код с деббагером:
function greeting() { let hello = 'Привет' let userName = 'Васятка' setTimeout(function() { debugger console.log(hello) }, 1000) } greeting()
Пример выше нужно запустить с открытыми инструментами разработчика (F12), тогда деббагер остановит его на 6-й строке. Далее нужно перейти на вкладку Источники (бурж. Sources), далее справа открыть Области действия (бурж. Scope), затем открыть Замыкание (бурж. Closure) и там будет только переменная hello:

Так-же если перейти на вкладку Консоль (бурж. Console), и там ввести имя переменной hello, то в ответ консоль выведет её значение. Если попробовать проделать, тот-же самый трюк с переменной userName, то Вы получите ошибку: Uncaught ReferenceError: userName is not defined. Это говорит о том что она была удалена сборщиком мусора и больше не доступна в приложении.
Короткое замыкание
Если внутренняя функция будет обращаться ко внешнем переменным, но сама не будет вызвана:
function greeting(studentName) { return () => { console.log(`Привет ${studentName}`) } } greeting('Васятка') // Здесь нет замыканий!
То замыкание создастся на короткое время, но сразу после того как функция greeting() отработает, ссылка на неё теряется и она съедается сборщиком мусора, а вместе с ней и лексическое окружение захваченное внутренней функцией. Пронаблюдать этот эффект не получится, так что можно сказать что в примере выше, нет замыканий с практической точки зрения, но есть замыкание с технической точки зрения.
Цепочка замыканий
Замыкания могут выстраиваться в целые цепочки:
function createCalculator(initValue) { let value = initValue function add(amount) { value += amount return value } function get() { return value } function apply(number) { if (arg) { return add(number) } else { return get() } } return apply } const calc = createCalculator(10) console.log(calc(5)) // 15 console.log(calc()) // 15 console.log(calc(3)) // 18
В примере выше внешняя функция createCalculator() создаёт лексическое окружение, с переменной value и возвращает внутреннюю функцию apply(). Сама функция apply() не имеет прямого доступа к переменной value, но вызывает другие внутренние функции, имеющие к ней доступ и таким образом получается цепочка замыканий.
Передача замыканий
Замыкания можно передавать в качестве аргумента как и любые другие значения:
function greeting(helloPhrase) { let userName = 'Васятка' return function() { console.log(`${helloPhrase}, ${userName}!`) } } function executeCallback(callback) { callback() } executeCallback(greeting('Hola')) // Передаём замыкание в другую функцию
При передаче замыкание сохранит скрытую ссылку, на исходную область видимости и своё лексическое окружение. По сути передаются эти ссылки, а не экземпляры вызываемых функций. Замыкание будет существовать, до тех пор пока существует, хоть одна ссылка на экземпляр функции создавшей замыкание. Поэтому тут нужно быть осторожным и следить чтобы в памяти не зависали большие объекты.
Польза замыканий
Напишем простенький модуль для работы с формой, основанный на замыканиях:
function createFormController(formName) { // Кэшируем все нужные элементы формы const form = document.querySelector(`[name="${formName}"]`) const name = form.querySelector('[name="name"]') const email = form.querySelector('[name="email"]') const button = form.querySelector('[type="submit"]') const message = form.querySelector('.message') let isSubmitting = false function showMessage(messageText, isError = false) { message.textContent = messageText message.style.display = 'block' if (isError && !message.classList.contains('message_error')) { message.classList.add('message_error') } else { message.classList.remove('message_error') } } function getData() { return JSON.stringify({ name: name.value, email: email.value }) } function reset() { name.value = '' email.value = '' message.style.display = 'none' } function setSubmitting(submitting) { isSubmitting = submitting if (button) { button.disabled = submitting button.value = submitting ? 'Отправка...' : 'Отправить' } } function validate() { // Простая валидация if (!name.value) { showMessage('Введите имя', true) return false } if (!email.value.includes('@')) { showMessage('Введите корректный email', true) return false } message.style.display = 'none' return true } form.addEventListener('submit', (event) => { event.preventDefault() if (!validate()) { return false } setSubmitting(true) fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: getData() }).then((result) => { setSubmitting(false) reset() if(result.ok) { showMessage('Форма отправлена!') } else { showMessage('Ошибка при отправке формы!') } }) }) } const contactForm = createFormController('callback')
Данный модуль отлично демонстрирует все преимущества которые даёт использование замыканий:
Кэширование. Поиск по
DOMдереву это дорогая операция, наш модуль знает об этом, поэтому кэширует элементы формы внутри замыкания. Затем методы данного модуля используют эти ссылки, а не выполняют каждый раз поиск по деревуDOM, тем самым экономя ресурсы;Инкапсуляция. Данный модуль предоставляет для пользования только один внешний интерфейс, в виде функции
createFormController(). Вся остальная логика скрыта внутри него, что сильно повышает стабильность кода.Декомпозиция. По факту наш импровизированный модуль, это обычная функция
createFormController(). Но за счёт использования замыкании и внутренних функций, мы смогли избежать спагетти кода, разбив его на небольшие внутренние функции. Это сильно улучшает читаемость кода и позволяет соблюдатьSRP(бурж. Single Responsibility Principle - Принцип единой ответственности). Вместо одной мега-функции полностью отвечающей за всё что происходит с формой, код разбит на множество компактных частей, каждая из которых выполняет, только одно небольшое действие.
Вывод
Замыкания это важнейший механизм JavaScript, являющийся фундаментом для таких фич языка как: модули и асинхронность. Замыкания повсеместно используются в популярных библиотеках и фреймворках JS. Поэтому без понимания принципов их работы, невозможно писать эффективный и безопасный код на JavaScript. А о том насколько популярны вопросы, по замыканиям на собесах и говорить не приходится. В данной статья мы подробно изучили, от куда замыкания пришли в программирование и как они ведут себя в современном JavaScript.
