Что ты такое, замыкания в JavaScript?

В этой статье я постараюсь подробно разобрать механизм реализации замыканий в JavaScript. Для этого я буду использовать браузер Chrome.

Начнем с определения:
Замыкания  - это функции, ссылающиеся на независимые (свободные) переменные. Другими словами, функция, определённая в замыкании, 'запоминает' окружение, в котором она была создана.
MDN

Если вам что-то не понятно в этом определении, это не страшно. Просто читайте дальше.

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

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

Итак, приступим:


Рисунок 1

Мы находимся в глобальном контексте вызова, он же Global (он же Window в браузере) и видим, что функция main уже лежит в текущем контексте и готова к работе.


Рисунок 2

Происходит это потому, что все Function Declaration (далее FD) всегда поднимаются наверх в любом контексте, сразу инициализируются и готовы к работе. Тоже самое происходит с переменными, объявленными через var, только их значения инициализируются как undefined.

Также важно понимать, что JavaScript точно также 'поднимает' переменные, объявленные через let и const. Разница лишь в том, что он не инициализирует их как var или как FD. Поэтому, когда мы пытаемся обратиться к ним до инициализации, получаем Reference Error.

Также у main мы видим внутренне скрытое свойство [[Scopes]] — это список внешних контекстов, к которым main имеет доступ. В нашем случае там лежит Global, так как main запущен в глобальном контексте.

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

Идем дальше:


Рисунок 3

Заходим в функцию main и первое, что бросается в глаза — это объект Local (в спецификации — localEnv). Там мы видим a, так как эта переменная объявлена через var и она 'всплыла' наверх, ну и по традиции видим все 3 FD (foo, bar, baz). Теперь давайте разберемся, откуда это все взялось.

При запуске любого контекста запускается абстрактная операция NewDeclarativeEnvironment, которая позволяет инициализировать LexicalEnvironment (далее LE) и VariableEnvironment. Также NewDeclarativeEnvironment принимает 1 аргумент — внешний LE, для того чтобы создать [[Scopes]], о котором мы говорили выше. LE — это API, который позволяет нам определять связь между идентификаторами и отдельными переменными, функциями. LE состоит из 2 составляющих:

  1. Record Environment — запись окружения, которая позволяет определить связи между идентификаторами и тем, что нам доступно в текущем контексте вызова
  2. Ссылка на внешний LE. У каждой функции при создании есть внутреннее свойство [[Scopes]]

VariableEnvironment — чаще всего это то же, что и LE. Разница между ними в том, что значение VariableEnvironment никогда не меняется, а LE может меняться во время выполнения кода. Для упрощения дальнейшего понимая предлагаю объеденить эти компоненты в один — LE.

Также в текущем Local есть this благодя тому, что произошел вызов ThisBinding — это тоже абстрактный метод, который инициализирует this в текущем контексте.

Разумеется, каждая FD сразу получила [[Scopes]]:


Рисунок 4

Видим, что все FD получили в [[Scopes]] массив [Closure main, Global], что логично.

Также на рисунке мы видим Call Stack — это структура данных, которая работает по принципу LIFO — last in first out. Так как JavaScript однопоточный, то одновременно может выполняться только один контекст. В нашем случае это контекст функции main. Каждый новый вызов функции создает новый контекст, который складывается в stack.

Наверху стека находится всегда текущий контекст исполнения. После того, как функция закончила свое выполнение и интерпретатор вышел из нее, контекст вызова удаляется из стека. Это все, что нам нужно знать о Call Stack в этой статье :)

Резюмируем произошедшее в текущем контексте:

  • Во время создания main получил [[Scopes]] со ссылками на внешнее окружение
  • Интерпретатор вошел в тело функции main
  • В Call Stack попал контекст выполнения main
  • Произошла инициализация this
  • Произошла инициализация LE

На самом деле, самое сложное уже позади. Переходим к следующему шагу в коде:

Теперь нам необходимо вызвать baz, чтобы получить результат.


Рисунок 5

В Call Stack добавился новый контекст вызова baz. Мы видим, что появился новый объект Closure. Сюда попадает то, что нам доступно из [[Scopes]]. Вот мы и подобрались к сути. Это и есть замыкания. Как вы видите на Рисунке 4 Closure (main) идет первым в списке 'резервных' контекстов у baz. Снова никакой магии.

Давайте вызовем foo:


Рисунок 6

Важно знать, что из какого бы места мы ни вызывали foo, она всегда будет ходить за ненайденными идентификаторами по своей цепочке [[Scopes]]. А именно в main и потом в Global, если в main не найдено.

После выполнения foo, она вернула значение, и ее контекст выбросился из Call Stack.
Переходим к вызову функции bar. В контексте выполнения bar есть переменная, с таким же именем, как и у переменной в LE foo — a. Но, как вы уже догадались, это ни на что не влияет. foo все равно будет брать значение из своего [[Scopes]].
Место вызова не влияет на Scope, только место создания
logachyova


Рисунок 7

В итоге baz вернет 300 и будет выброшен из Call Stack. Затем тоже самое произойдет с контекстом main, наш фрагмент кода закончит выполняться.

Резюмируем:

  • Во время создания функции происходит установка [[Scopes]]. Это очень важно для понимания замыканий, так как при поиске значений интерпретатор сразу идет по этим ссылкам
  • Затем, когда эта функция вызывается, создается активный контекст исполнения, который помещается в Call Stack
  • Выполняется ThisBinding и устанавливается this для текущего контекста
  • Выполняется инициализация LE, и все аргументы функции, переменные, объявленные через var и FD становятся доступными. Далее, если встречаются переменные, объявленные через let или const, они тоже добавляются в LE
  • Если интерпретатор не найдет в текущем контексте какой-либо идентификатор, то для дальнейшего поиска используется [[Scopes]], которые перебираются все, по очереди. Если значение найдено, то ссылка на него попадает в специальный объект Closure. При этом для каждого контекста, на который замыкается текущий, создается отдельный Closure с нужными переменными
  • Если значение не найдено ни в одном Scopes, включая Global, возвращается ReferenceError

Вот и все!

Надеюсь, эта статья была вам полезной и теперь вы понимаете, как работает механизм замыкания в JavaScript.

Всем пока :) И до новых встреч. Ставьте лайки и подписывайтесь на мой канал :)
Поделиться публикацией

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 11

    0
    Если значение не найдено ни в одном Scopes, включая Global, возвращается ReferenceError
    Я бы упомянул еще что так называемый Global Scopes это просто объект window, и вот уже выше него ничего нету.
      +3
      Global Scopes

      Не совсем так, зависит от среды выполнения.


      В Node.js это вроде бы global object:


      (function(){console.log(this)})()

      Object [global] {
      DTRACE_NET_SERVER_CONNECTION: [Function],
      DTRACE_NET_STREAM_END: [Function],
      ...

      В Deno тоже какой то (другой) глобальный объект


      { Deno, denoMain, window, atob, btoa, fetch, clearTimeout, clearInterval, setTimeout, setInterval, location, crypto, Blob, File, CustomEventInit, CustomEvent, EventInit, Event, EventListener, EventTarget, URL, URLSearchParams, Headers, FormData, TextEncoder, TextDecoder, Request, performance, onmessage, workerMain, workerClose, postMessage, Worker }

      Спасибо за статью, очень круто (хоть и нужно читать несколько раз). Но раз уж вы полезли в такие дебри, было бы очень круто добавить еще ссылки на спецификацию.

        +2
        Не совсем так как зависит от среды выполнения.
        Спасибо, буду знать, я использую JS только на фронте веб сайтов, поэтому с этим мог ошибиться, думал что window есть везде :)
          0
          Рад, что вам понравилось, спасибо) Чуть позже добавлю ссылки
        0
        Согласен, добавил в статью, спасибо
          0

          Позволю себе влезть еще раз


          Если значение найдено, то оно попадает в специальный объект Closure. При этом для каждого контекста, на который замыкается текущий, создается отдельный Closure с нужными переменными

          Это выражение немного сбивает с толку, так как создается впечетление, что переменные копируются в Closure по значению. На самом деле туда передаются "ссылки", которые вычисляются каждый раз при обращении к ним.


          Человеческим языком это означает, что функции могут "мутировать" даже примитивы. Например:


          function createFuncs() {
              var a = 5;
          
              function first() {
                  console.log(`first.before-update: ${a}`);
                  a += 1;
                  console.log(`first.after-update: ${a}`);
              }
              function second() {
                  console.log(`second.before-update: ${a}`);
                  a += 1;
                  console.log(`second.after-update: ${a}`);
              }
          
              return { first, second };
          }
          
          const funcs = createFuncs();
          funcs.first();
          funcs.second();
          funcs.first();
          

          Результат:


          first.before-update: 5
          first.after-update: 6
          second.before-update: 6
          second.after-update: 7
          first.before-update: 7
          first.after-update: 8

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

            0
            Резонно, поправил, спасибо за подробный комментарий
            0

            Само разжевывание замыкания вышло еще сложнее, чем если вообще взять и самостоятельно разобрать его, даже не имея достаточной базы, и ничего не значащие "вот это важно понимать", "это очень важно-при-важно" напрасно тратят время читателя, но ничего не дают понять, слово-паразит прям

              0
              В этой статье я постараюсь подробно разобрать механизм реализации замыканий в JavaScript
              я не зря же это написал в начале статьи) если она показалась вам сложной, то интернет полон поверхностных статей, прочитав которые вы прекрасно сможете с этим работать.
              0
              Но, как вы уже догадались, это ни на что не влияет. foo все равно будет брать значение из своего [[Scopes]].

              На этом моменте я испугалась, что ничего не поняла))) (я новичок). Допишите для самых тугих «потому что место вызова не влияет на Scope, только место создания». Спасибо за статью, особенно за скрины отладчика!
                0
                Рад, что вам понравилось) добавил комментарий

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

              Самое читаемое