Главная / Статьи

Понимание замыканий в JavaScript и цикла событий

Закрытия в JavaScript сохраняют жизнеспособность внешних переменных; цикл событий упорядочивает стек вызовов, микозадачи и макрозадачи. Оба аспекта объясняются с примерами кода, который можно запустить.

Издатель: REACTAPP.TOP16 мин чтения3605 слов

Источники: JavaScript Closures Explained in 2 Minutes, The JavaScript Event Loop — Finally Explained With Zero Confusion

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

Понимание замыканий

Что такое замыкание на самом деле

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

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

function createCounter() {
  let count = 0;
return function () {
    count++;
    return count;
  };
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Обратите внимание: функция createCounter() выполняется и возвращает значение, но внутренняя функция, которую она возвращает, по-прежнему имеет доступ к count. Именно этот постоянный доступ после того, как внешний вызов технически завершен, и является закрытием.

Типичные ситуации, когда возникают закрытия

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

  • Частные переменные – способ скрытия данных, чтобы код снаружи не мог к ним напрямую обращаться.
  • Фабрики функций – создание настроенных функций на основе параметров.
  • Обратные вызовы – сохранение значений, чтобы они оставались доступными при выполнении обратного вызова.
  • Таймеры – доступ к переменным изнутри функций setTimeout или setInterval.
  • Обработчики событий – хранение данных, связанных с конкретным событием.
  • React – понимание обратных вызовов, эффектов и проблемы «устаревшего замыкания».
  • Почему это важно

    Замыкания позволяют сохранять состояние без использования глобальных переменных, что делает код более чистым и безопасным.

    function createAccount(initialBalance) {
      let balance = initialBalance;
      return {
        deposit(amount) {
          balance += amount;
        },
        getBalance() {
          return balance;
        }
      };
    }
    const account = createAccount(100);
    account.deposit(50);
    console.log(account.getBalance()); // 150
    console.log(account.balance);      // undefined
    

    В этом примере к переменной balance нельзя получить доступ напрямую извне функции. К ней имеют доступ только методы объекта, возвращаемые функцией createAccount.

    Такая структура дает несколько значимых преимуществ:

    • Лучшая инкапсуляция
    • По-настоящему приватное состояние
    • Прощее и более читаемое кодирование
    • Уменьшенная зависимость от глобальных переменных

    Пример классической циклической структуры

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

    Вот что происходит при использовании var:

    for (var i = 1; i <= 3; i++) {
      setTimeout(() => {
        console.log(i);
      }, 1000);
    }
    // Output:
    // 4
    // 4
    // 4
    

    Результат сначала кажется не тем, чего ожидают многие, и причина в том, что var имеет область видимости на уровне функции, а не блока. Все три обратных вызова через setTimeout в итоге ссылаются на одну и ту же переменную i. К моменту их выполнения цикл уже завершился, и значение i уже стало равным 4.

    Теперь сравните это с использованием let:

    for (let i = 1; i <= 3; i++) {
      setTimeout(() => {
        console.log(i);
      }, 1000);
    }
    // Output:
    // 1
    // 2
    // 3
    

    Поскольку ключевое слово let имеет блочный диапазон видимости, при каждом прохождении цикла создается собственная независимая переменная i. Это означает, что каждый колбэк работает с разным значением этой переменной, в результате числа выводятся в том порядке, в котором мы и ожидаем.

    Типичный вопрос на собеседовании

    Вопрос: Что такое замыкание в JavaScript?

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

    Дополнительный вопрос: Где на самом деле используются замыкания?

    Хороший ответ охватывает практическое применение, а не только определение:

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

    Главный вывод

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

    Понимание цикла событий

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

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

    console.log("1");
    
    setTimeout(() => {
      console.log("2");
    }, 0);
    
    console.log("3");
    

    Читая его сверху вниз, можно ожидать, что консоль выведет сначала 1, затем 2, а потом 3 — таймаут установлен на ноль миллисекунд, так что задержки вообще не должно быть.

    Но если запустить его в консоли браузера или в Node, реальный результат будет таким:

    1
    3
    2
    

    Так почему код, запланированный к выполнению «немедленно», на самом деле выполняется после кода, написанного позже в файле?

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

    Основное противоречие в сути JavaScript

    Прежде чем рассматривать диаграммы и определения, есть один факт, который необходимо усвоить:

    JavaScript является строго однопоточным.

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

    Это ограничение порождает очевидный вопрос. Представьте код, который должен ждать три полных секунды ответа от медленного API — при наличии всего одной поточности и отсутствии дополнительных ресурсов, что сможет делать остальная часть страницы во время этого ожидания?

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

    Но на самом деле всё происходит иначе. Современные веб-приложения регулярно транслируют видео, отправляют фоновые сетевые запросы, тихо обновляют статистику и принимают ввод с клавиатуры одновременно, не застывая.

    Так как же языку с однопоточной архитектурой удаётся это делать?

    Ответ: сам JavaScript является однопоточным, но среда, в которой он работает — браузер или Node.js — нет.

    Кухня ресторана как ментальная модель

    Самый ясный способ понять эту архитектуру — представить загруженную кухню фаст-фуда:

    [ Customer Orders ] ──> [ The Solo Chef ] (Call Stack)
                                  │
                                  ▼ Hands off slow jobs
                            [ The Kitchen Staff ] (Web APIs)
                                  │
                                  ▼ Ready order slips
                            [ Order Counter ] (Queues)
                                  ▲
                                  │ Checks constantly
                            [ The Floor Manager ] (The Event Loop)
    

    Вот как распределяются роли:

    1. Один шеф-повар (стек вызовов). Он быстр и умел, но может работать только над одним заказом за раз — доводя его до конца, прежде чем приступить к следующему.
    2. Помощники на кухне (Web API). Когда на выполнение заказа требуется длительное время — из-за таймера или медленного ответа курьера — шеф-повар не стоит и не смотрит на часы. Вместо этого он передает заказ помощнику с указаниями присмотреть за ним и доложить, когда он будет готов, а сам возвращается к текущей задаче.
    3. Прилавок для заказов (очереди обратных вызовов и микозадач). Как только таймер истекает, помощник не прерывает шеф-повара посреди работы, подкладывая заказ ему в руки. Это приведет к хаосу. Вместо этого готовый заказ кладется на прилавок в ожидании.
  • Менеджер зала (цикл обработки событий). Этот менеджер постоянно перемещается туда-сюда между стойкой и поваром, соблюдая одно строгое правило: никогда не прерывать повара во время работы. Как только руки повара полностью освобождаются, менеджер берет следующую карточку, ожидающую на стойке, и передает её.
  • Это в миниатюре весь системный механизм. Учитывая структуру ресторана, пришло время заменить эти роли на их реальные названия в движке JavaScript.

    Движок называет эти четыре компонента по-разному, но каждый из них напрямую соответствует той или иной роли в ресторане.

    На приведённой ниже диаграмме показано, как среда выполнения (браузер или Node.js) оборачивает движок JavaScript и соединяет эти компоненты между собой.

    ┌───────────────────────────────────────────────────────────┐
    │                     BROWSER / RUNTIME                     │
    │                                                           │
    │   ┌─────────────────┐             ┌───────────────────┐   │
    │   │   CALL STACK    │             │     WEB APIs      │   │
    │   │ (JS Engine/V8)  │             │                   │   │
    │   │                 │ ──────────> │  setTimeout()     │   │
    │   │  fnA()          │             │  fetch()          │   │
    │   │  console.log()  │             │  DOM Events       │   │
    │   └────────┬────────┘             └─────────┬─────────┘   │
    │            ▲                                │             │
    │            │                                │ Finished    │
    │            │                                ▼             │
    │            │                      ┌───────────────────┐   │
    │            │                      │  MICROTASK QUEUE  │   │
    │            │                      │  (Promises, VIP)  │   │
    │            │                      └─────────┬─────────┘   │
    │            │                                │             │
    │      EVENT LOOP                             ▼             │
    │   "Is Stack empty?"               ┌───────────────────┐   │
    │            └───────────────────── │  MACROTASK QUEUE  │   │
    │                                   │ (Timers, Clicks)  │   │
    │                                   └───────────────────┘   │
    └───────────────────────────────────────────────────────────┘
    

    Стек вызовов — «то, что происходит прямо сейчас»

    Стек вызовов — это обычная структура типа LIFO (последний вошел, первый вышел), которой управляет сам движок JavaScript; это тот же механизм, что используется в V8 в Chrome и Node, а также в JavaScriptCore в Safari.

    Каждый раз, когда вы вызываете функцию, она добавляется на вершину этого стека. Как только функция возвращается или завершает свою работу, она удаляется с вершины.

    function stepThree() {
      console.log("Done!");
    }
    
    function stepTwo() {
      stepThree();
    }
    
    function stepOne() {
      stepTwo();
    }
    
    stepOne();
    

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

    • stepOne добавляется на стек.
    • stepOne вызывает stepTwo, поэтому stepTwo также добавляется на стек.
    • stepTwo вызывает stepThree, в результате чего stepThree добавляется на вершину стека.
    • stepThree выполняет команду console.log, которая выводит «Done!», после чего stepThree удаляется с стека.
  • stepThree завершается, поэтому он удаляется.
  • stepTwo завершается, поэтому он удаляется.
  • stepOne завершается, поэтому он удаляется.
  • Стек снова становится пустым.
  • Если какая-либо функция в этом стеке требует, скажем, десяти секунд интенсивных вычислений подряд, весь процесс замораживается на эти десять секунд. Ничто другое не может выполняться до тех пор, пока стек не опустеет — это прямое следствие того, что у «повара» только две руки.

    Web API — где на самом деле происходит «ожидание»

    Вот что удивляет многих на ранних этапах карьеры: setTimeout, fetch и обработчики событий DOM не являются частью самого языка JavaScript.

    Вы не найдете их нигде в спецификации ECMAScript. Они предоставляются средой, в которой выполняется ваш код — браузером или, в случае Node.js, комбинацией биндингов на C++ и библиотеки libuv.

    Рассмотрим следующий вызов:

    setTimeout(callback, 2000);
    

    Когда стек вызовов выполняет инструкцию setTimeout, он на самом деле лишь просит внутренний механизм таймера браузера начать отсчет 2000 миллисекунд. Как только эта инструкция обработана, сам метод setTimeout немедленно удаляется из стека вызовов, и выполнение продолжается с следующей части кода. Сам отсчет происходит полностью вне единственной потоковой модели JavaScript, в среде хоста.

    Очередь макрозадач — обычная очередь

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

    Как только Web API завершает свою фоновую задачу — таймер достигает нуля, приходит полная сетевая ответная информация, пользователь что-то нажимает — среда выполнения помещает соответствующий обратный вызов в эту очередь.

    Все элементы, находящиеся в этой очереди, ведут себя корректно: это стандартная очередь FIFO (первый вошел — первым вышел), при которой каждый элемент ждет своей очереди на выполнение в стеке вызовов.

    Типичные примеры макрозадач включают:

    • Обратные вызовы, запланированные с помощью setTimeout() и setInterval()
    • Слушатели событий DOM, такие как click, scroll или keydown
    • setImmediate(), характерный исключительно для Node.js

    Очередь микрозадач — ускоренный путь

    Параллельно с этой обычной очередью существует более маленькая очередь с гораздо более высоким приоритетом: очередь микрозадач.

    Любой элемент, помещённый сюда, фактически перемещается в начало очереди. Как только стек вызовов опустошается, цикл событий сначала проверяет очередь микрозадач — прежде чем рассматривать содержимое очереди макрозадач.

    К элементам, попадающим в очередь микрозадач, относятся:

    • Обработчики, привязанные с помощью .then(), .catch() или .finally() к обещаниям
    • Код, который выполняется после оператора await внутри функции с модификатором async
    • Любые элементы, запланированные с помощью функции queueMicrotask()
    • Обратные вызовы, инициированные объектом MutationObserver

    Основное правило, лежащее в основе цикла

    Если отбросить метафоры, цикл событий на самом деле представляет собой бесконечный цикл while, выполняющийся внутри среды выполнения, и он постоянно повторяет пять строгих шагов:

    1. Очистите стек вызовов. Продолжайте выполнять синхронный код до тех пор, пока в стеке не останется ничего.
    2. Полностью опустошите очередь микрозадач. Пока есть ожидающая микрозадача, выполняйте её. Если её выполнение приводит к появлению ещё одной микрозадачи, выполняйте и её тоже. Цикл не перейдет к следующему шагу, пока в очереди микрозадач не останется ничего.
    3. Обработайте отрисовку, если вы находитесь в браузере. Движок проверяет, не пора ли перерисовать экран — обычно примерно 60 раз в секунду, то есть каждые 16,6 миллисекунд — и если да, он пересчитывает макет и снова отрисовывает экран.
    4. Выполните ровно одну макрозадачу. Посмотрите на очередь макрозадач, возьмите ту задачу, которая ожидает дольше всего, поместите её в стек вызовов и позвольте ей выполниться до конца.
    5. Начните цикл заново с первого шага.

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

    Разбор хорошо известной задачи на собеседовании

    Поскольку механизмы уже определены, стоит рассмотреть фрагмент кода, который сбивает с толку огромное количество разработчиков:

    console.log("Start");
    
    setTimeout(() => {
      console.log("Timeout Callback");
    }, 0);
    
    Promise.resolve().then(() => {
      console.log("Promise Callback");
    });
    
    console.log("End");
    

    Если проследить за каждой строкой этих четырех компонентов, неожиданный результат становится понятным.

    Сначала синхронная обработка

    console.log("Start") помещается в стек, выводит текст "Start", а затем удаляется из стека.

    Далее в стек помещается setTimeout(...). Он указывает слою Web API браузера начать таймер с задержкой в 0 миллисекунд с указанным обратным вызовом. Поскольку задержка равна нулю, этот таймер фактически сразу же истекает в фоновом режиме, поэтому браузер немедленно помещает () => console.log("Timeout Callback") в очередь макрозадач. Сам вызов setTimeout затем удаляется из стека.

    После этого в стек помещается Promise.resolve().then(...). Обещание реализуется мгновенно, и его обработчик .then() — () => console.log("Promise Callback") — напрямую попадает в очередь микрозадач. Этот вызов также удаляется из стека.

    Наконец, помещается console.log("End"), который выводит текст «End», после чего удаляется.

    Когда стек опустеет

    На этом этапе в стеке вызовов больше ничего не остается. Цикл событий просыпается и проверяет свои две очереди: в очереди микрозадач хранится обратный вызов Promise, а в очереди макрозадач — обратный вызов Timeout.

    Сначала выполняются микрозадачи

    Цикл сначала проверяет очередь микрозадач. Он находит обратный вызов Promise, добавляет его в стек, и выводится текст «Promise Callback». Стек снова опустошается, причем теперь очередь микрозадач также пуста.

    Затем выполняется одна макрозадача

    Поскольку микрозадач больше нет, цикл наконец переходит к очереди макрозадач. Он берет самую старую запись — обратный вызов Timeout, добавляет её в стек, выводит текст «Timeout Callback» и удаляет её.

    Если объединить всё вместе, итоговая последовательность выводимых сообщений будет выглядеть следующим образом:

    Start
    End
    Promise Callback
    Timeout Callback
    

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

    Это приводит к распространённому недопониманию того, что на самом деле обещает значение задержки. Многие начинающие считают, что setTimeout(fn, 1000) означает «выполнить эту функцию ровно через 1000 миллисекунд». На деле это не так. Эта функция гарантирует лишь то, что кallback не будет добавлен в очередь задач до тех пор, пока не пройдёт как минимум 1000 миллисекунд. Как только он попадает в очередь, ему всё равно придётся ждать своей очереди, пока стек вызовов не опустеет и все незавершённые микрозадачи не будут выполнены.

    Если стек вызовов занят в течение пяти секунд из-за выполнения ресурсоемкой операции сортировки или обработки изображения, функция-ответчик setTimeout(fn, 100) будет находиться в очереди без действий в течение всих пяти секунд, прежде чем цикл событий сможет к ней обратить внимание. Короче говоря, задержка по таймеру представляет собой минимальное время ожидания, а не гарантированное время выполнения.

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

    Представьте себе функцию вроде этой:

    function infiniteMicrotasks() {
      Promise.resolve().then(() => {
        infiniteMicrotasks();
      });
    }
    
    infiniteMicrotasks();
    

    Рассмотрим, что происходит: после первого вызова стек очищается. Затем цикл событий проверяет очередь микрозадач и находит запись. Она выполняется, но при этом сразу же планируется ещё одна микрозадача. Цикл событий снова проверяет очередь, видит, что она по-прежнему занята, и выполняет следующую запись — которая, в свою очередь, планирует ещё одну микрозадачу. Стек продолжает опустошаться после каждой микрозадачи, но цикл событий так и не выходит из этого этапа проверки. Он никогда не продвигается настолько далеко, чтобы браузер смог перерисовать интерфейс или обработать элементы, ожидающие в очереди макрозадач.

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

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

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

    Во-первых, разбейте дорогостоящие синхронные циклы. Если вам необходимо обработать массив из, скажем, 500 000 элементов на главном потоке, избегайте выполнения этой операции в одном непрерывном цикле for, поскольку это заблокирует интерфейс. Разделите работу на части и возвращайте управление циклу событий между ними с помощью setTimeout(chunkHandler, 0) или requestIdleCallback(). Это позволит браузеру обрабатывать клики и перерисовывать кадры между частями работы.

    Во-вторых, будьте точны в понимании того, что на самом деле делают async/await внутри. Это синтаксический сахар, надстроенный поверх обещаний: любой код, написанный до первого await, выполняется синхронно, в то время как всё, что следует за await, оборачивается в микозадачу-колбэк, которая остаётся в состоянии паузы до тех пор, пока обещание не будет выполнено.

    В-третьих, никогда не рассматривайте таймеры как точные стоп-часы. Если вы создаёте что-то вроде таймера внутри приложения или аудио-секвенсора, не полагайтесь на setInterval или setTimeout, чтобы достичь точных значений в миллисекундах. Нагрузка на систему, ограничение работы фоновых вкладок браузером ради экономии заряда и перегрузка очередей приводят к отклонениям. Вместо этого измеряйте пройденное время по отношению к системному часу с помощью performance.now().

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

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