Замыкание сбивает человечество с толку с момента его появления в JavaScript. И все же, если вы внимательно посмотрите на него, чтобы понять, как оно работает, то окажется, что это невероятно элегантная и простая конструкция. В этом блоге, наряду с теорией, я собираюсь провести вас через несколько примеров, чтобы построить эту мысленную концептуальную модель, которая (надеюсь) остается у вас в голове, и вы никогда не задаетесь вопросом, какое закрытие снова.

Вот концепции, с которыми мы познакомимся в этом блоге:

  • Поток исполнения
  • Контекст выполнения
  • Стек вызовов
  • Функции высшего порядка
  • Лексическая область видимости и как JS является языком с лексической областью видимости
  • Закрытие

Отправляясь в путешествие, чтобы понять закрытие

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

  • Когда эта программа выполняется, сначала определение функции для createFunction из строк с 1 по 6 сохраняется в глобальной памяти с идентификатором createFunction.
  • Затем в строке 8 создается идентификатор generatedFunction, который не инициализируется до тех пор, пока createFunction не запустится и не вернется. Мы знаем, что createFunction вот-вот будет запущен, а не просто ссылка, из-за парантезы с ним в строке 8.
  • Когда createFunction будет запущен, для него в памяти будет создан новый контекст выполнения, и createFunction() будет добавлен в стек вызовов. Так выглядит стек вызовов в данный момент:

  • Обратите внимание, что global () всегда находится внизу стека вызовов.
  • Затем определение createFunction будет найдено в (глобальной) памяти и начнется выполнение. Обратите внимание, что поток выполнения не перейдет со строки 8 на 1. Поток просто продолжает двигаться вперед. Для выполнения createFunction его определение извлекается из памяти.
  • Внутри createFunction сначала определение multiplyBy2 сохраняется в локальной памяти createFunction (вы можете взглянуть на строки 2–4, чтобы создать мысленную модель этого, но поток выполнения, как я сказал ранее, на самом деле не перейти к строке 2–4).
  • Затем определение multiplyBy2 подбирается и возвращается в вызывающую среду и сохраняется в generatedFunctionvariable. Фактически это означает, что определение multiplyBy2 получает новый ярлык: generatedFunction
  • После нажатия returnkeyword в строке 5 (это снова означает, что вы можете посмотреть на строку 5, чтобы прочитать, что возвращается, но поток выполнения не фактически переходит к строке 5 - он просто возвращает функцию multiplyBy2, когда встречает ключевое слово return в определении функции createFunction из памяти), контекст выполнения createFunction удаляется, как и его запись в стеке вызовов.

  • Затем в строке 10 создается идентификатор result, который унифицируется до тех пор, пока generatedFunction не запустится и не вернется.
  • Примечательно, что исходная среда, в которой было создано определение generatedFunction, больше не существует. Это даже не нужно, потому что определение multiplyBy2 теперь находится в глобальной памяти под меткой generatedFunction.
  • generatedFunction запускается, создается его собственный контекст выполнения, и его запись добавляется в стек вызовов.

  • При выполнении generatedFunction сначала аргумент 5 сохраняется в его локальной памяти с меткой number.
  • Затем выполняется вычисление number * 2, которое дает результат 10. Затем значение 10 возвращается в вызывающую среду и сохраняется в переменной result.
  • Тем временем контекст выполнения и запись стека вызовов для generatedFunction удаляются.

  • В строке 11 значение result отключено.

Примечательным моментом в этом обсуждении является то, что:

В строке 5 было возвращено определение multiplyBy2, а не его ссылка. Если бы ссылка была возвращена, как был бы возможен ее вызов (с новой меткой generatedFunction) в строке 10? Помните, что контекст выполнения и локальная память createFunction были удалены, как только функция вернулась !!

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

Кстати, функции, которые принимают и / или возвращают другие функции, называются функциями более высокого порядка.

Давайте рассмотрим еще один пример:

Этот пример построен на основе basicExample.js. Для полноты картины давайте еще раз рассмотрим механизм потока выполнения, на этот раз немного быстрее, потому что в прошлый раз мы поняли его очень подробно.

  • Строки 1–10: определение createFunctionstored в памяти.
  • Строка 12: идентификатор result создан, все еще не инициализирован, пока createFunction не запустится и не вернется. createFunction запускается вместе с созданием собственного контекста выполнения и его записью в стеке вызовов.

  • При выполнении createFunction создается идентификатор value со значением 5. Определение multiplyBy2 сохраняется с идентификатором multiplyBy2. Все это хранится в локальной памяти, выделенной для createFunction.

Еще раз обратите внимание: когда функция createFunction запущена, поток выполнения НИКОГДА не возвращается со строки 12 на строку 1. Когда ему нужно выполнить это createFunction, он выполняет его из определения createFunction, хранящегося в объем памяти. Я постоянно возвращаюсь к строке 1 из 12, потому что именно так мы видим, что происходит.

  • Далее, когда вызывается multiplyBy2, для него создается новый контекст выполнения, и его запись помещается в стек вызовов.

  • В контексте выполнения multiplyBy2 значение value должно быть обновлено до его двойного значения. Но value не существует в локальной памяти / контексте выполнения multiplyBy2.

Что в этой ситуации делает JS-движок?

Перемещается ли он вниз по стеку вызовов в поисках определения value?

У вас может возникнуть соблазн подумать: да, это так!

Хорошо, чтобы вы были счастливы, я на минуту предположу, что это так. Таким образом, можно предположить, что определение value находится в локальной памяти родительского контекста (то есть createFunction) и обновляется, чтобы быть двойным по отношению к самому себе.

  • Затем нажимается закрывающая фигурная скобка multiplyBy2, функция завершается, ее контекст выполнения удаляется и она удаляется из стека вызовов.

  • Далее при выполнении createFunction значение value возвращается в вызывающую среду.
  • Строка 12: Теперь возвращаемое значение из createFunction() сохраняется с идентификатором result в глобальном контексте. Тем временем контекст выполнения createFunction также удаляется, и он также извлекается из стека вызовов.

  • Строка 13: значение result выводится на консоль.

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

Правильно ли мы поступили, предполагая это?

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

Я хотел бы оставить обсуждение того, как происходит поток выполнения и как обрабатываются контекст выполнения и стек вызовов, в качестве упражнения для вас. Вы можете обратиться к обсуждению выше, чтобы сделать это 😊

Здесь следует сосредоточить внимание на следующем:

В строке 11 функция createFunction возвращает определение функции multiplyBy2 и сохраняет его с новой меткой doubleItUp. Эта возвращенная функция имеет ссылки на переменную (value), которая не определена внутри ее собственной локальной памяти (см. строки с 4 по 7, но поток выполнения на самом деле не идет туда), но определяется в локальной памяти контекста, в котором multiplyBy2 был определен (т. е. в контексте createFunction , т. е. его родительский контекст ) .

Кроме того, когда эта возвращенная функция фактически вызывается, контекст createFunction даже больше не существует !!!

Итак, как мы можем ожидать, что эта возвращенная функция, которая теперь хранится в глобальной памяти с меткой doubleItUp, успешно запустится и обработает переменную value?

Итак, что происходит в строках 13-14? Если наше предыдущее предположение было верным, то при вызове doubleItUp он пытается удвоить значение value и вернуть его. Но, во-первых, он не может найти переменную в собственном контексте выполнения, поэтому (согласно нашему предположению) пытается найти переменную на один уровень ниже по стеку вызовов. Но на одном уровне ниже по стеку вызовов, на этом этапе ... барабанная дробь ... global контекст !! Потому что запись стека вызовов createFunction уже давно была удалена, когда createFunction вернулся !!

На мгновение переваривайте этот момент. При необходимости повторите запуск контекста выполнения для этого примера ...

Готовы к следующему? Хорошо, теперь, когда мы уверены, что переменная value не может быть получена из родительской области видимости multiplyBy2 и что этот пример выполняется успешно, возникает вопрос: откуда взялось определение value ??

Ответ, о котором вы, наверное, догадались ... из ЗАКРЫТИЯ multiplyBy2. Ага! Наконец-то мы добрались !!

Но подождите секунду. Мы не знаем, что такое закрытие в первую очередь.

Так что давайте поговорим об этом более формально.

Когда создается multiplyBy2 или, если на то пошло, какая-либо функция, она получает небольшой объем памяти, в котором хранятся переменные, на которые эта функция ссылается в своем определении. В JavaScript это хранилище памяти, а также общая концепция называется Закрытие. Это похоже на то, что окружающая среда помещается в коробку, закрывается и отправляется вместе с функцией, куда бы функция ни отправлялась.

Значения, содержащиеся в закрытии функции, берутся из лексической области функции. Лексическая область функции - это среда, в которой функция была создана.

Давайте сопоставим это с нашим примером closuresInFullForm.js.

Когда createFunction вернул multiplyBy2, он не только вернул определение multiplyBy2, но и небольшой запас ссылок, которые он сохранил в своем определении. Итак, это store содержало поле value и было отправлено обратно в глобальный контекст вместе с определением multiplyBy2.

После этого, как и ожидалось, определение multiplyBy2 получило новый ярлык: doubleItUp, но хранилище (также известное как закрытие) осталось нетронутым и неизменным. Когда бы мы ни захотели запустить doubleItUp, который раньше был multiplyBy2, движку JS потребуется ссылка на value, которую он сначала искал в:

  • (вновь созданный) контекст выполнения doubleItUp не нашел его там.
  • затем поискал в закрытии, нашел его там и обработал. Следовательно, в строке 14 будет 10.

Уф !! Это была довольно дискуссионная дискуссия.

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

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

  • Поля в локальной среде processValuesfunction, на которые не ссылается processValues, не добавлялись в закрытие. Это мера оптимизации, принятая разработчиками JavaScript. Итак, value4 теряется навсегда, как только createFunction завершает выполнение.
  • Данные, хранящиеся в store закрытия, являются частными и не могут быть доступны напрямую каким-либо образом. Только при запуске doCalculations значения value1, value2 и value3 обновляются. См. Вывод выше.
  • Это скрытое свойство [[scope]] возвращаемой функции processValues, которое делает возможным механизм закрытия.
  • Это порядок, в котором движок JS ищет ссылки:

Почему мы говорим, что JavaScript - это язык с лексической областью видимости

Это связано с тем, что каждая функция, созданная в JS, содержит свое закрытие, и это закрытие происходит из того места, где эта функция была первоначально определена, то есть из лексической области видимости функции. Таким образом, функции в некотором роде запоминают свое место рождения. Эта лексическая область видимости не имеет ничего общего с той средой / областью, в которой эта функция была вызвана / вызвана.

Вот пример:

  • Когда в строке 19 выполняется createFunction, он возвращает определение функции processValues плюс ее закрытие.
  • Когда doCalculations выполняется в строке 21, его закрытие запоминает только лексическую область видимости, в которой он был определен изначально. Таким образом, его закрытие содержит value1, value2 и value3 из (теперь удаленного) контекста выполнения createFunction. Помните, что контекст выполнения createFunction был удален, но значения value1, value2 и value3 остались в закрытии.
  • doCalculations был вызван / вызван в контексте выполнения global. Данные в global не влияют на работу doCalculations. Итак, value3 в глобальной области не влияет на doCalculations.

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

Сгибаем мышцы понимания закрытия с еще несколькими примерами

Обновление переменной закрытия несколько раз

Вывод:

В строке 13 печатается 1.

В строке 14 печатается 2.

Требуется быстрое объяснение?

Строка 11 запускает outer и сохраняет определение функции и закрытие inner с меткой newFunction. Когда newFunction запускается в строке 13, он начинает поиск определения counter и находит его в замыкании, текущее значение counter равно 0, обновляет его до 1 и печатает. Когда newFunction снова запускается в строке 14, он снова находит определение counter в замыкании, на этот раз существующее значение counter равно 1, которое обновляет его до 2 и выводит на печать.

Это удобный способ запоминания данных из предыдущих запусков функций, формально известный как мемоизация. Если бы newFunction потребовал сложных и трудоемких вычислений, JS-движку было бы легко запомнить предыдущее значение из предыдущего запуска и продолжить с этого момента при следующем вызове.

Несколько функций могут иметь одно и то же закрытие

Вывод:

Довольно простые вещи, я оставляю вам объяснение этого примера в качестве упражнения.

Единственное, что следует отметить, это то, что каждая функция (inner1, inner2 и inner3) в массиве возвращаемых функций имеет доступ к одному и тому же закрытию. Таким образом, несколько функций, возвращаемых вместе из функции более высокого порядка, имеют одно и то же закрытие.

Несколько случаев закрытия

Теперь все становится интереснее. В строке 11 функция inner была возвращена с ее закрытием и получила новый ярлык newFunction1. Вызов newFunction1 в строках 12 и 13 обновляет переменную счетчика в своем закрытии и выводит ее как 1 (в строке 12) и 2 (в строке 13).

Строка 15 содержит новый вызов outer, который возвращает новую копию inner, и новый экземпляр закрытия, который получает новую метку newFunction2. Итак, строки 16 и 17 снова печатают 1 и 2.

Замыкания, связанные с newFunction1 и newFunction2, изолированы друг от друга.

Другой пример нескольких экземпляров закрытия

На этот раз вывод: по 1 в каждой из строк 12, 13, 16 и 17.

Строка 11: outer возвращается, а определение и закрытие inner функции сохраняются с меткой newFunction1. Каждый раз, когда newFunction1 вызывается в строках 12 и 13, функция запускается, но на этот раз ей не нужно искать определение counter в замыкании. Его определение находится прямо в локальной памяти newFunction1. Каждый раз, когда вызывается newFunction1, counter снова инициализируется 0 и увеличивается до 1.

В строке 15 возвращается новая копия inner с новым экземпляром закрытия для метки newFunction2. Тот же процесс повторяется снова. Опять же, поток выполнения не должен искать определение counter за пределами локальной памяти newFunction2. Каждый вызов newFunction2 в строках 16 и 17 заново инициализирует счетчик до 0 и каждый раз увеличивает его до 1.

В обоих вышеупомянутых случаях концепция закрытия является избыточной.

Чтение глобальных данных

Вывод:

Строка 13: 1

Строка 13: 2

Строка 13: 3

Строка 13: 4

Это происходит потому, что на этот раз каждый вызов возвращенной inner функции в форме newFunction1 и newFunction2 обновлял и печатал глобальную переменную counter.

Почему? Потому что, когда вызываются newFunction1 или newFunction2, они не могут найти определение counter в своей локальной памяти или в закрытии. Таким образом, JS-движок ищет counter на нижних уровнях стека вызовов, находит его в глобальном и обновляет.

Случаи использования закрытия

Замыкание важно для понимания некоторых функций JavScript, например следующих.

  • Запоминание: как объяснялось выше, дает нашей функции постоянную память о своих предыдущих входах и выходах.
  • Итераторы и генераторы. Используйте лексическую область видимости и замыкание, чтобы получить самые современные шаблоны для обработки данных в JS.
  • Шаблон модуля: сохраняет состояние в течение всего времени существования приложения, не загрязняя глобальное пространство имен.
  • Асинхронный JavaScript. Обратные вызовы и обещания полагаются на закрытие для сохранения состояния в асинхронной среде.

Это все, ребята. Я знаю, что это было довольно долгое чтение, но, надеюсь, оно прояснило вам эту эзотерическую особенность JS. Если это кажется вам довольно сложным, я предлагаю вам проработать каждый из примеров ручкой и бумагой и посмотреть, как движется поток выполнения.