Замыкания в JavaScript

Чтобы разобраться в том, что такое замыкания, сначала нужно понять контекст их выполнения.

Что такое контекст выполнения?

В JavaScript термин «контекст выполнения» относится к среде, в которой выполняется часть кода. Эта среда состоит из переменных, функций, объектов и других данных, к которым часть кода имеет доступ во время выполнения.

Контекст выполнения

Типы контекста выполнения

  • Глобальный контекст выполнения. Это стандартный или внешний контекст выполнения. Он создается при выполнении скрипта, и именно в нем выполняется код, не находящийся внутри какой-либо функции. В веб-браузере глобальный контекст выполнения связан с объектом window.
  • Контекст выполнения функции. Когда вызывается функция, для нее создается новый контекст выполнения. Этот контекст включает все переменные, объекты и функции, определенные в функции. Когда функция завершает процесс выполнения, ее контекст выполнения выгружается из стека выполнения (стека вызовов).

Алгоритм выполнения функций

Когда вызывается функция, JavaScript выполняет ряд четко определенных шагов для обеспечения правильного выполнения функции. Вот подробное описание этого процесса.

  1. Начало: создание контекста выполнения
  • Контекст выполнения. Когда вызывается функция, JavaScript создает новый контекст выполнения специально для этой функции. Этот контекст содержит все необходимые параметры, переменные и код функции.
  • Стек вызовов. Этот новый контекст выполнения затем помещается в стек вызовов — механизм, который JavaScript использует для управления порядком вызова функций.
  1. Выполнение: построчная обработка
  • Построчное выполнение. В контексте выполнения функции JavaScript обрабатывает ее код строка за строкой. Сюда входят работа с переменными, выполнение вычислений и любые другие операции, определенные в функции.
  • Цепочка областей видимости и замыкания. Во время выполнения функция имеет доступ к своим локальным переменным, переданным ей параметрам и любым переменным из внешней области видимости благодаря замыканиям.
  1. Окончание работы: очистка и удаление контекста
  • Выгрузка из стека вызовов. После завершения выполнения функции ее контекст выполнения выгружается из стека вызовов. Это означает, что контекст удаляется, и 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. Строка 1. Объявляем новую константную переменную num в глобальном контексте выполнения и присваиваем ей число 3.
  1. Строки 2-5. Здесь мы объявляем новую функцию с именем multiplyBy2. Создаем новую переменную multiplyBy2 в глобальном контексте выполнения и присваиваем ей определение функции. Эта функция принимает параметр inputNumber и содержит код для умножения его на 2, сохранения результата в переменной result и возврата result. Код внутри функции пока не оценивается, а просто сохраняется для будущего использования.
  1. Строка 6. Эта строка выглядит просто, но в ней происходит много событий. Мы объявляем новую переменную в глобальном контексте выполнения и обозначаем ее как output. Изначально эта переменная имеет значение undefined.
  1. Строка 6 (продолжение). Мы собираемся присвоить новое значение output, вызвав функцию multiplyBy2. Движок JavaScript ищет multiplyBy2 в глобальном контексте выполнения, находит определение функции и готовится к ее выполнению. В качестве аргумента передается переменная num. Движок находит в глобальном контексте выполнения num со значением 3 и передает это значение в функцию.
  1. Новый контекст выполнения для multiplyBy2. Создается новый локальный контекст выполнения, который мы назовем контекстом выполнения multiplyBy2. Этот контекст помещается в стек вызовов. Первое, что здесь нужно сделать, — это обработать параметры функции. В этом локальном контексте выполнения объявляется новая переменная inputNumber, которой присваивается значение 3 (переданный аргумент).
  1. Строка 3. В локальном контексте выполнения объявляем новую константную переменную result. Изначально result имеет значение undefined. Затем оценивается выражение inputNumber * 2. Движок ищет inputNumber, находит его в локальном контексте выполнения со значением 3 и умножает его на 2, в результате чего получается 6. Это значение затем присваивается  result.
  1. Строка 4. Возвращаем значение result, равное 6. На этом локальный контекст выполнения заканчивается. Переменные inputNumber и result уничтожаются. Контекст выгружается из стека вызовов, а возвращаемое значение (6) возвращается в контекст вызова (глобальный контекст выполнения).
  1. Строка 6 (продолжение). Возвращаемое значение (6) назначается output.
  1. Строка 7. Аналогичным образом объявляем в глобальном контексте выполнения еще одну переменную newOutput и присваиваем ей результат вызова multiplyBy2 с аргументом 10. Движок снова находит multiplyBy2, вызывает его и передает 10 в качестве аргумента.
  1. Новый контекст выполнения для multiplyBy2 (второй вызов). Создается новый локальный контекст выполнения и помещается в стек вызовов. Параметру inputNumber присваивается значение 10.
  1. Строка 3. В этом локальном контексте выполнения объявляется новая константная переменная result, которая инициализируется со значением undefined. Оценивается выражение inputNumber * 2. Движок находит inputNumber со значением 10, умножает его на 2, в результате чего получается 20, и присваивает это значение result.
  1. Строка 4. Функция возвращает result (равно 20). Локальный контекст выполнения завершается, переменные inputNumber и result уничтожаются, а контекст выгружается из стека вызовов. Возвращаемое значение (20) возвращается в контекст вызова.
  1. Строка 7 (продолжение). Возвращаемое значение (20) присваивается newOutput.
  1. Строка 8. Записываем значение output в консоль. В консоли отображается 6.
  1. Строка 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. Строки 1-8. Начинаем с определения функции с именем createCounter. В глобальном контексте выполнения объявляем новую переменную createCounter и присваиваем ей определение функции, охватывающее строки с 1 по 8. Код внутри функции пока не выполняется, он просто сохраняется для будущего использования.
  1. Строка 9. Объявляем новую переменную increment в глобальном контексте выполнения. Изначально эта переменная имеет значение undefined.
  1. Строка 9 (продолжение). Вызываем функцию createCounter и присваиваем ее возвращаемое значение increment. Движок JavaScript ищет createCounter в глобальном контексте выполнения, находит определение функции и готовится к ее выполнению.
  1. Новый контекст выполнения для createCounter. Создается новый локальный контекст выполнения, который называется контекстом выполнения createCounter. Этот контекст помещается в стек вызовов.
  1. Строка 2. Внутри этого локального контекста объявляем переменную counter и инициализируем ее со значением 0.
  1. Строки 3-6. Объявляем новую константную переменную myFunction и присваиваем ей определение функции. Эта функция будет увеличивать переменную counter и возвращать ее значение.
  1. Строка 7. Функция myFunction возвращается createCounter. Локальный контекст выполнения createCounter завершается, и контекст выгружается из стека вызовов. Переменные counter и myFunction больше не существуют в этом контексте.
  1. Строка 9 (продолжение). Возвращенная функция (myFunction вместе с ее замыканием) присваивается переменной increment. Теперь increment содержит определение функции, которое хранилось в myFunction.
  1. Строка 10. Объявляем новую переменную c1 в глобальном контексте выполнения.
  2. Строка 10 (продолжение). Вызываем функцию increment и присваиваем ее возвращаемое значение переменной c1. Движок JavaScript ищет increment, находит определение функции и готовится к ее выполнению.
  1. Новый контекст выполнения для increment. Для вызова этой функции создается новый локальный контекст выполнения, называемый контекстом выполнения increment. Этот контекст помещается в стек вызовов.
  1. Строка 4. Внутри этого локального контекста мы обновляем counter, увеличивая его на 1. Ищем counter в локальном контексте выполнения, но не находим его. Тогда мы ищем его в глобальном контексте и опять не находим. JavaScript интерпретирует это как counter = undefined + 1 и создает новую локальную переменную counter со значением 1 (поскольку undefined здесь действует как 0).
  1. Строка 5. Функция возвращает значение counter, равное 1. Это завершает локальный контекст выполнения, и counter уничтожается.
  1. Строка 10 (продолжение). Возвращенное значение (1) присваивается c1.
  1. Строка 11. Повторяем шаги 9-14 для c2. Снова вызывается функция increment, на этот раз возвращается 1 и присваивается c2.
  1. Строка 12. Повторяем шаги 9-14 для c3. Снова вызывается функция increment, на этот раз снова возвращается 1 и присваивается c3.
  1. Строка 13. Наконец, мы выводим значения  c1c2 и c3 на консоль. В консоли отображается example increment 1 1 and 1.

Попробуйте выполнить код самостоятельно и посмотрите, что произойдет. Исходя из объяснения выше, вы могли бы ожидать, что в лог попадут  11 и 1, но вместо этого в лог попадают  12 и 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. Строки 1-8. Начинаем с определения функции с именем createCounter. В глобальном контексте выполнения объявляем новую переменную createCounter и присваиваем ей определение функции, охватывающее строки с 1 по 8. Код внутри функции пока не выполняется, он просто сохраняется для будущего использования.
  1. Строка 9. Объявляем новую переменную increment в глобальном контексте выполнения. Изначально эта переменная имеет значение undefined
  1. Строка 9 (продолжение). Вызываем функцию createCounter и присваиваем ее возвращаемое значение increment. Движок JavaScript ищет createCounter в глобальном контексте выполнения, находит определение функции и готовится к ее выполнению.
  1. Новый контекст выполнения для createCounter. Создается новый локальный контекст выполнения, называемый контекстом выполнения createCounter. Этот контекст помещается в стек вызовов.
  1. Строка 2. Внутри этого локального контекста объявляем переменную counter и инициализируем ее со значением 0.
  1. Строки 3-6. Объявляем новую константную переменную myFunction и присваиваем ей определение функции. Эта функция будет увеличивать переменную counter и возвращать ее значение. Важно, что здесь создается замыкание. Замыкание включает переменную counter, фиксируя ее текущее значение (0) в определении функции.
  1. Строка 7. Функция myFunction (включая ее замыкание) возвращается createCounter. Локальный контекст выполнения createCounter завершается, и контекст выгружается из стека вызовов. Переменные counter и myFunction больше не существуют в этом контексте. Однако myFunction сохраняет доступ к counter через замыкание.
  1. Строка 9 (продолжение). Возвращенная функция (myFunction вместе со своим замыканием) присваивается переменной increment. Теперь increment содержит определение функции, которое хранилось в myFunction.
  1. Строка 10. Объявляем новую переменную c1 в глобальном контексте выполнения.
  1. Строка 10 (продолжение). Вызываем функцию increment и присваиваем ее возвращаемое значение переменной c1. Движок JavaScript ищет increment, находит определение функции и готовится к ее выполнению.
  1. Новый контекст выполнения для increment. Для вызова этой функции создается новый локальный контекст выполнения, называемый контекстом выполнения increment. Этот контекст помещается в стек вызовов. Первое, что мы делаем в этом локальном контексте, — используем замыкание.
  1. Строка 4. Внутри этого локального контекста мы обновляем counter, увеличивая его на 1. Поскольку counter находится в замыкании, его значение извлекается (оно равно 0), увеличивается до 1 и обновляется в замыкании.
  1. Строка 5. Функция возвращает обновленное значение counter, равное 1. Локальный контекст выполнения для increment завершается, и контекст выгружается из стека вызовов.
  1. Строка 10 (продолжение). Возвращенное значение (1) присваивается c1.
  1. Строка 11. Повторяем шаги 9-14 для c2. Снова вызывается функция increment, значение counter в замыкании (1) увеличивается до 2, возвращается значение 2 и присваивается c2.
  1. Строка 12. Повторяем шаги 9-14 для c3. Снова вызывается функция increment, значение counter в замыкании (2) увеличивается до 3, возвращается значение 3 и присваивается c3.
  1. Строка 13. Выводим в консоль значения c1c2 и c3. В консоли отображается example increment 1 2 3.

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

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

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

В итоге функция increment запоминает значение counter с помощью замыкания. Каждый вызов функции increment обновляет и возвращает значение counter, показывая, как замыкания позволяют функциям сохранять доступ к собственным лексическим областям видимости даже после того, как создавшая их функция завершила выполнение. В этом и заключается суть замыканий в JavaScript: они позволяют функциям «помнить» среду, в которой те были созданы.

Заключение

В курсе Уилла Сентанса приводится отличная аналогия: «Когда функция создается, передается или возвращается из другой функции, она несет с собой «рюкзак». В этом «рюкзаке» находятся все переменные, которые были в области видимости, когда функция объявлялась».

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Bhaskar Pandey: Mastering JavaScript Closures: A Deep Dive

Предыдущая статьяРеализация шаблона Saga на Go: практический подход