Чтобы разобраться в том, что такое замыкания, сначала нужно понять контекст их выполнения.
Что такое контекст выполнения?
В JavaScript термин «контекст выполнения» относится к среде, в которой выполняется часть кода. Эта среда состоит из переменных, функций, объектов и других данных, к которым часть кода имеет доступ во время выполнения.
Типы контекста выполнения
- Глобальный контекст выполнения. Это стандартный или внешний контекст выполнения. Он создается при выполнении скрипта, и именно в нем выполняется код, не находящийся внутри какой-либо функции. В веб-браузере глобальный контекст выполнения связан с объектом
window
.
- Контекст выполнения функции. Когда вызывается функция, для нее создается новый контекст выполнения. Этот контекст включает все переменные, объекты и функции, определенные в функции. Когда функция завершает процесс выполнения, ее контекст выполнения выгружается из стека выполнения (стека вызовов).
Алгоритм выполнения функций
Когда вызывается функция, JavaScript выполняет ряд четко определенных шагов для обеспечения правильного выполнения функции. Вот подробное описание этого процесса.
- Начало: создание контекста выполнения
- Контекст выполнения. Когда вызывается функция, JavaScript создает новый контекст выполнения специально для этой функции. Этот контекст содержит все необходимые параметры, переменные и код функции.
- Стек вызовов. Этот новый контекст выполнения затем помещается в стек вызовов — механизм, который JavaScript использует для управления порядком вызова функций.
- Выполнение: построчная обработка
- Построчное выполнение. В контексте выполнения функции JavaScript обрабатывает ее код строка за строкой. Сюда входят работа с переменными, выполнение вычислений и любые другие операции, определенные в функции.
- Цепочка областей видимости и замыкания. Во время выполнения функция имеет доступ к своим локальным переменным, переданным ей параметрам и любым переменным из внешней области видимости благодаря замыканиям.
- Окончание работы: очистка и удаление контекста
- Выгрузка из стека вызовов. После завершения выполнения функции ее контекст выполнения выгружается из стека вызовов. Это означает, что контекст удаляется, и JavaScript возвращается к выполнению кода из того места, откуда была вызвана функция.
- Очистка локальных переменных. Локальные переменные и параметры, определенные в контексте выполнения функции, удаляются, освобождая память и ресурсы.
Пример для наглядности
Подробно разберем выполнение следующей функции:
1: const num = 3;
2: function multiplyBy2(inputNumber) {
3: const result = inputNumber * 2;
4: return result;
5: }
6: const output = multiplyBy2(num);
7: const newOutput = multiplyBy2(10);
8: console.log(output);
9: console.log(newOutput);
- Строка 1. Объявляем новую константную переменную
num
в глобальном контексте выполнения и присваиваем ей число3
.
- Строки 2-5. Здесь мы объявляем новую функцию с именем
multiplyBy2
. Создаем новую переменнуюmultiplyBy2
в глобальном контексте выполнения и присваиваем ей определение функции. Эта функция принимает параметрinputNumber
и содержит код для умножения его на 2, сохранения результата в переменнойresult
и возвратаresult
. Код внутри функции пока не оценивается, а просто сохраняется для будущего использования.
- Строка 6. Эта строка выглядит просто, но в ней происходит много событий. Мы объявляем новую переменную в глобальном контексте выполнения и обозначаем ее как
output
. Изначально эта переменная имеет значениеundefined
.
- Строка 6 (продолжение). Мы собираемся присвоить новое значение
output
, вызвав функциюmultiplyBy2
. Движок JavaScript ищетmultiplyBy2
в глобальном контексте выполнения, находит определение функции и готовится к ее выполнению. В качестве аргумента передается переменнаяnum
. Движок находит в глобальном контексте выполненияnum
со значением3
и передает это значение в функцию.
- Новый контекст выполнения для multiplyBy2. Создается новый локальный контекст выполнения, который мы назовем контекстом выполнения
multiplyBy2
. Этот контекст помещается в стек вызовов. Первое, что здесь нужно сделать, — это обработать параметры функции. В этом локальном контексте выполнения объявляется новая переменнаяinputNumber
, которой присваивается значение3
(переданный аргумент).
- Строка 3. В локальном контексте выполнения объявляем новую константную переменную
result
. Изначальноresult
имеет значениеundefined
. Затем оценивается выражениеinputNumber * 2
. Движок ищетinputNumber
, находит его в локальном контексте выполнения со значением3
и умножает его на2
, в результате чего получается6
. Это значение затем присваиваетсяresult
.
- Строка 4. Возвращаем значение
result
, равное6
. На этом локальный контекст выполнения заканчивается. ПеременныеinputNumber
иresult
уничтожаются. Контекст выгружается из стека вызовов, а возвращаемое значение (6
) возвращается в контекст вызова (глобальный контекст выполнения).
- Строка 6 (продолжение). Возвращаемое значение (
6
) назначаетсяoutput
.
- Строка 7. Аналогичным образом объявляем в глобальном контексте выполнения еще одну переменную
newOutput
и присваиваем ей результат вызоваmultiplyBy2
с аргументом10
. Движок снова находитmultiplyBy2
, вызывает его и передает10
в качестве аргумента.
- Новый контекст выполнения для multiplyBy2 (второй вызов). Создается новый локальный контекст выполнения и помещается в стек вызовов. Параметру
inputNumber
присваивается значение10
.
- Строка 3. В этом локальном контексте выполнения объявляется новая константная переменная
result
, которая инициализируется со значениемundefined
. Оценивается выражениеinputNumber * 2
. Движок находитinputNumber
со значением10
, умножает его на2
, в результате чего получается20
, и присваивает это значениеresult
.
- Строка 4. Функция возвращает
result
(равно20
). Локальный контекст выполнения завершается, переменныеinputNumber
иresult
уничтожаются, а контекст выгружается из стека вызовов. Возвращаемое значение (20
) возвращается в контекст вызова.
- Строка 7 (продолжение). Возвращаемое значение (
20
) присваиваетсяnewOutput
.
- Строка 8. Записываем значение
output
в консоль. В консоли отображается6
.
- Строка 9. Выводим в консоль значение
newOutput
. В консоли отображается20
.
Итак, мы подробно описали, как JavaScript обрабатывает объявления переменных, определения функций, а также создание и уничтожение контекстов выполнения. Мы еще не касались замыканий, но приведенное пояснение закладывает базу для их понимания, показывая основы работы функций и их областей видимости.
Можете использовать что-то вроде JavaScript Visualizer (ui.dev), чтобы увидеть выполнение кода.
Запустим код
Из предыдущего примера мы узнали о том, как выполняется функция. Посмотрим на эту функцию и попытаемся понять, что именно происходит.
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
Разберем выполнение функции createCounter
шаг за шагом.
- Строки 1-8. Начинаем с определения функции с именем
createCounter
. В глобальном контексте выполнения объявляем новую переменнуюcreateCounter
и присваиваем ей определение функции, охватывающее строки с 1 по 8. Код внутри функции пока не выполняется, он просто сохраняется для будущего использования.
- Строка 9. Объявляем новую переменную
increment
в глобальном контексте выполнения. Изначально эта переменная имеет значениеundefined
.
- Строка 9 (продолжение). Вызываем функцию
createCounter
и присваиваем ее возвращаемое значениеincrement
. Движок JavaScript ищетcreateCounter
в глобальном контексте выполнения, находит определение функции и готовится к ее выполнению.
- Новый контекст выполнения для createCounter. Создается новый локальный контекст выполнения, который называется контекстом выполнения
createCounter
. Этот контекст помещается в стек вызовов.
- Строка 2. Внутри этого локального контекста объявляем переменную
counter
и инициализируем ее со значением0
.
- Строки 3-6. Объявляем новую константную переменную
myFunction
и присваиваем ей определение функции. Эта функция будет увеличивать переменнуюcounter
и возвращать ее значение.
- Строка 7. Функция
myFunction
возвращаетсяcreateCounter
. Локальный контекст выполненияcreateCounter
завершается, и контекст выгружается из стека вызовов. Переменныеcounter
иmyFunction
больше не существуют в этом контексте.
- Строка 9 (продолжение). Возвращенная функция (
myFunction
вместе с ее замыканием) присваивается переменнойincrement
. Теперьincrement
содержит определение функции, которое хранилось вmyFunction
.
- Строка 10. Объявляем новую переменную
c1
в глобальном контексте выполнения. - Строка 10 (продолжение). Вызываем функцию
increment
и присваиваем ее возвращаемое значение переменнойc1
. Движок JavaScript ищетincrement
, находит определение функции и готовится к ее выполнению.
- Новый контекст выполнения для increment. Для вызова этой функции создается новый локальный контекст выполнения, называемый контекстом выполнения
increment
. Этот контекст помещается в стек вызовов.
- Строка 4. Внутри этого локального контекста мы обновляем
counter
, увеличивая его на1
. Ищем counter в локальном контексте выполнения, но не находим его. Тогда мы ищем его в глобальном контексте и опять не находим. JavaScript интерпретирует это какcounter = undefined + 1
и создает новую локальную переменную counter со значением 1 (поскольку undefined здесь действует как 0).
- Строка 5. Функция возвращает значение counter, равное 1. Это завершает локальный контекст выполнения, и counter уничтожается.
- Строка 10 (продолжение). Возвращенное значение (
1
) присваиваетсяc1
.
- Строка 11. Повторяем шаги 9-14 для
c2
. Снова вызывается функцияincrement
, на этот раз возвращается1
и присваиваетсяc2
.
- Строка 12. Повторяем шаги 9-14 для
c3
. Снова вызывается функцияincrement
, на этот раз снова возвращается1
и присваиваетсяc3
.
- Строка 13. Наконец, мы выводим значения
c1
,c2
иc3
на консоль. В консоли отображаетсяexample increment 1 1 and 1
.
Попробуйте выполнить код самостоятельно и посмотрите, что произойдет. Исходя из объяснения выше, вы могли бы ожидать, что в лог попадут 1
, 1
и 1
, но вместо этого в лог попадают 1
, 2
и 3
. Почему так происходит?
Оказывается, функция increment
каким-то образом запоминает значение counter
. Как это работает?
Является ли counter
частью глобального контекста выполнения? Попробуйте выполнить console.log(counter)
— увидите undefined
. Значит, дело не в этом.
Может быть, когда вы вызываете increment
, она каким-то образом возвращается в функцию, где она была создана (createCounter
)? Но это бессмысленно, потому что переменная increment
содержит определение функции, а не контекст, в котором она была создана. Так что это тоже не объяснение.
Здесь должен действовать другой механизм.
И этот механизм — замыкание. Вот он, тот самый недостающий элемент.
Объясним, как это работает. Когда вы объявляете новую функцию и присваиваете ее переменной, вы сохраняете не только определение функции, но и замыкание. Замыкание содержит все переменные, которые находятся в области видимости при создании функции. Здесь можно привести аналогию с рюкзаком. Определение функции всегда идет «в комплекте» со своего рода «рюкзаком», в котором хранятся все переменные, которые были в области видимости на момент создания функции.
Таким образом, когда вы вызываете increment
, используется не только определение функции, но и переменные, которые были в области видимости на момент определения функции. Вот почему counter
сохраняет свое значение между вызовами increment
. Counter
не является частью глобального контекста выполнения, но его значение запоминается благодаря замыканию.
Итак, объяснение выше было абсолютно неверным. Попробуем повторить его, но на этот раз правильно.
1: function createCounter() { 2: let counter = 0 3: const myFunction = function() { 4: counter = counter + 1 5: return counter 6: } 7: return myFunction 8: } 9: const increment = createCounter() 10: const c1 = increment() 11: const c2 = increment() 12: const c3 = increment() 13: console.log('example increment', c1, c2, c3)
Подробно разберем выполнение функции createCounter
.
- Строки 1-8. Начинаем с определения функции с именем
createCounter
. В глобальном контексте выполнения объявляем новую переменнуюcreateCounter
и присваиваем ей определение функции, охватывающее строки с 1 по 8. Код внутри функции пока не выполняется, он просто сохраняется для будущего использования.
- Строка 9. Объявляем новую переменную
increment
в глобальном контексте выполнения. Изначально эта переменная имеет значениеundefined
.
- Строка 9 (продолжение). Вызываем функцию
createCounter
и присваиваем ее возвращаемое значениеincrement
. Движок JavaScript ищетcreateCounter
в глобальном контексте выполнения, находит определение функции и готовится к ее выполнению.
- Новый контекст выполнения для createCounter. Создается новый локальный контекст выполнения, называемый контекстом выполнения
createCounter
. Этот контекст помещается в стек вызовов.
- Строка 2. Внутри этого локального контекста объявляем переменную
counter
и инициализируем ее со значением0
.
- Строки 3-6. Объявляем новую константную переменную
myFunction
и присваиваем ей определение функции. Эта функция будет увеличивать переменнуюcounter
и возвращать ее значение. Важно, что здесь создается замыкание. Замыкание включает переменнуюcounter
, фиксируя ее текущее значение (0
) в определении функции.
- Строка 7. Функция
myFunction
(включая ее замыкание) возвращаетсяcreateCounter
. Локальный контекст выполненияcreateCounter
завершается, и контекст выгружается из стека вызовов. Переменныеcounter
иmyFunction
больше не существуют в этом контексте. ОднакоmyFunction
сохраняет доступ кcounter
через замыкание.
- Строка 9 (продолжение). Возвращенная функция (
myFunction
вместе со своим замыканием) присваивается переменнойincrement
. Теперьincrement
содержит определение функции, которое хранилось вmyFunction
.
- Строка 10. Объявляем новую переменную
c1
в глобальном контексте выполнения.
- Строка 10 (продолжение). Вызываем функцию
increment
и присваиваем ее возвращаемое значение переменнойc1
. Движок JavaScript ищетincrement
, находит определение функции и готовится к ее выполнению.
- Новый контекст выполнения для increment. Для вызова этой функции создается новый локальный контекст выполнения, называемый контекстом выполнения
increment
. Этот контекст помещается в стек вызовов. Первое, что мы делаем в этом локальном контексте, — используем замыкание.
- Строка 4. Внутри этого локального контекста мы обновляем
counter
, увеличивая его на1
. Посколькуcounter
находится в замыкании, его значение извлекается (оно равно0
), увеличивается до1
и обновляется в замыкании.
- Строка 5. Функция возвращает обновленное значение
counter
, равное1
. Локальный контекст выполнения дляincrement
завершается, и контекст выгружается из стека вызовов.
- Строка 10 (продолжение). Возвращенное значение (
1
) присваиваетсяc1
.
- Строка 11. Повторяем шаги 9-14 для
c2
. Снова вызывается функцияincrement
, значение counter в замыкании (1
) увеличивается до2
, возвращается значение2
и присваиваетсяc2
.
- Строка 12. Повторяем шаги 9-14 для
c3
. Снова вызывается функцияincrement
, значение counter в замыкании (2
) увеличивается до3
, возвращается значение3
и присваиваетсяc3
.
- Строка 13. Выводим в консоль значения
c1
,c2
иc3
. В консоли отображаетсяexample increment 1 2 3
.
Итак, что же здесь происходит? Все дело в том, что когда объявляется функция, она содержит не только определение функции, но и замыкание. Замыкание — это коллекция всех переменных, которые находились в области видимости на момент создания функции.
Вы можете задаться вопросами: любая ли функция имеет замыкание? Относится ли это и к тем функциям, которые создаются в глобальной области видимости? Ответ — да. Функции, созданные в глобальной области видимости, имеют замыкание, но поскольку они находятся в глобальной области видимости, у них есть доступ ко всем глобальным переменным, так что в этом случае концепция замыкания становится менее актуальной.
Замыкания становятся реально полезными, когда одна функция возвращает другую. Возвращенная функция имеет доступ к переменным, которые не находятся в глобальной области видимости, но существуют в ее замыкании.
В итоге функция increment
запоминает значение counter
с помощью замыкания. Каждый вызов функции increment
обновляет и возвращает значение counter
, показывая, как замыкания позволяют функциям сохранять доступ к собственным лексическим областям видимости даже после того, как создавшая их функция завершила выполнение. В этом и заключается суть замыканий в JavaScript: они позволяют функциям «помнить» среду, в которой те были созданы.
Заключение
В курсе Уилла Сентанса приводится отличная аналогия: «Когда функция создается, передается или возвращается из другой функции, она несет с собой «рюкзак». В этом «рюкзаке» находятся все переменные, которые были в области видимости, когда функция объявлялась».
Читайте также:
- 45 суперхаков JavaScript, которые должен знать каждый разработчик
- 8 продвинутых вопросов для собеседования по JavaScript
- 21 лайфхак для новичков в JavaScript
Читайте нас в Telegram, VK и Дзен
Перевод статьи Bhaskar Pandey: Mastering JavaScript Closures: A Deep Dive