Замыкание сбивает человечество с толку с момента его появления в 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
подбирается и возвращается в вызывающую среду и сохраняется вgeneratedFunction
variable. Фактически это означает, что определениеmultiplyBy2
получает новый ярлык:generatedFunction
- После нажатия
return
keyword в строке 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: определение
createFunction
stored в памяти. - Строка 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.
Важные моменты, на которые следует обратить внимание в последнем примере:
- Поля в локальной среде
processValues
function, на которые не ссылается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. Если это кажется вам довольно сложным, я предлагаю вам проработать каждый из примеров ручкой и бумагой и посмотреть, как движется поток выполнения.