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

Объяснение работы JavaScript в браузере на собеседованиях: очереди, куча памяти, утечки памяти, замыкания.

Структурированный обзор рабочей среды браузера для подготовки к собеседованиям: стек вызовов, очереди задач и микрозадач, куча памяти и сбор мусора, утечки памяти, делегирование, прототипы и замыкания.

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

Источники: #1 FrontEnd Concepts For Interviews — Javascript Fundament

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

Стек вызовов: где выполняется ваш код

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

Почему сетевой запрос не замораживает страницу

Если одновременно может выполняться только одна операция, почему страница не тормозит в ожидании ответа от сервера? Потому что ждать начинает не JavaScript. Представьте экран, который обрабатывает большой набор данных, а затем последовательно вызывает несколько API. Вызов функции fetch() передаёт задачу веб-API браузера, который обрабатывает сетевые операции вне стека вызовов. Ваша функция сразу же возвращает обещание с состоянием «в ожидании», и пользователь может продолжать взаимодействовать с страницей.

Когда приходит ответ, обещание считывается, и его обработчики .then() планируются как микрозадачи, которые выполняются после того, как стек опустошен. Технически завершение работы с сетью происходит через механизм задач браузера, а в очередь микрозадач попадают реакции обещания. Суть на уровне собеседования заключается в том, что ваш код продолжения попадает в очередь, а не выполняется непосредственно.

Очередь задач против очереди микрозадач

Таймеры следуют другому пути: обработчик setTimeout() попадает в очередь задач, также называемую очередью макрозадач. Правило цикла событий заключается в том, что после завершения текущей задачи и опустошения стека сначала обрабатывается вся очередь микрозадач, прежде чем выбираться к следующей задаче. Эта функция планирует по одной задаче из каждой очереди; номера в сообщениях указывают на порядок их выполнения.

function triggerBothQueues() {
  console.log("1. Synchronous: Code starts on Call Stack");

  // Enqueue a Macrotask (Task Queue)
  setTimeout(() => {
    console.log("4. Task Queue (Macrotask): setTimeout callback executed");
  }, 0);

  // Enqueue a Microtask (Microtask Queue)
  Promise.resolve().then(() => {
    console.log("3. Microtask Queue: Promise.then callback executed");
  });

  console.log("2. Synchronous: Code ends on Call Stack");
}

triggerBothQueues();

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

Heap: место, где хранятся объекты

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

Сбор мусора и доступность

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

  • Корни по определению являются доступными: глобальные переменные, а также параметры и локальные переменные функций, находящихся в данный момент в стеке.
  • Доступные значения — это всё, к чему можно добраться, следуя по ссылкам от корня.
  • Всё, к чему невозможно добраться от любого корня, считается мусором. Коллектор не интересуется тем, собираетесь ли вы всё ещё использовать объект; если существует доступная ссылка, он остаётся. Именно так происходят утечки памяти. Для более подробного рассмотрения с акцентом на инструменты см. поиск утечек памяти в JavaScript с использованием концепций доступности и хранителей.

    Два классических способа утечек памяти

    Таймер, который никогда не останавливается

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

    function startDashboardTracker() {
      const hugeDataPayload = new Array(1000000).fill("data");
    
      // Timer is launched
      setInterval(() => {
        // Because hugeDataPayload is referenced here,
        // it stays trapped in memory forever!
        console.log("Tracker pinging... Payload length:", hugeDataPayload.length);
      }, 1000);
    }
    
    startDashboardTracker();
    // Even after startDashboardTracker finishes executing and leaves the Call Stack,
    // hugeDataPayload CANNOT be garbage collected because the timer's closure holds a reference to it.
    

    Когда функция startDashboardTracker возвращается, можно ожидать, что массив станет мусором. Однако этого не происходит: браузер сохраняет активный интервал, в этом интервале хранится функция-обратный вызов, а клоузурная переменная этой функции содержит hugeDataPayload. Сохраняйте ID, возвращаемый функцией setInterval(), и вызывайте clearInterval(), когда работа завершена или компонент интерфейса удаляется; разрыв первой связи освобождает всю цепочку.

    Слушатель на глобальном объекте

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

    function setupSearchWidget() {
      // Heavy data tied to the widget scope
      const heavySearchIndex = new Array(1000000).fill("search_index_item");
    
      function handleResize() {
        console.log("Window resized. Index size:", heavySearchIndex.length);
      }
    
      // Listener attached to the global 'window' object (a Root)
      window.addEventListener("resize", handleResize);
    }
    
    setupSearchWidget();
    
    // Later, user navigates away and the Search Widget DOM element is deleted.
    // BUT 'window' still holds a reference to 'handleResize',
    // which holds a reference to 'heavySearchIndex'.
    

    Когда пользователь уходит и DOM-структура вижета удаляется, элемент window, являющийся корнем дерева, по-прежнему содержит функцию handleResize, которая обращается к переменной heavySearchIndex. Необходимо вызвать метод removeEventListener при уничтожении вижета, передав ту же самую ссылку на функцию, которая была зарегистрирована; новая функция-стрелка считается отдельным объектом и ничего не удаляет.

    Распространение событий и делегирование

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

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

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Event Bubbling Example</title>
        <style>
            #grandparent {
                background-color: lightcoral;
                padding: 20px;
            }
            #parent {
                background-color: lightblue;
                padding: 20px;
            }
            #child {
                background-color: lightgreen;
                padding: 20px;
            }
        </style>
    </head>
    <body>
    
    <div id="grandparent">
        <h1>Grandparent - only one event listener</h1>
        <div id="parent">
            Parent
            <div id="child">
                Child
            </div>
        </div>
    </div>
    
    <script>
        document.getElementById('grandparent').addEventListener('click', function(event) {
            console.log('Grandparent clicked!');
            console.log('target: ', event.target.getAttribute("id"));
            // if (event.target.getAttribute("id") === 'child') {
            //     //
            // }
        });
    
        document.getElementById('parent').addEventListener('click', function(event) {
            console.log('Parent clicked!');
            // event.stopPropagation();
        });
    
        document.getElementById('child').addEventListener('click', function(event) {
            console.log('Child clicked!');
            // event.stopPropagation();
        });
    </script>
    
    </body>
    </html>
    

    При нажатии на самое внутреннее поле записываются данные о дочернем, родительском и бабушкином элементах. Бабушкин элемент читает значение event.target, чтобы узнать, на какой элемент именно был совершен клик; комментированный блок if показывает, как один обработчик может разветвляться в зависимости от ID, тем самым избавляя дочерние элементы от необходимости собственных обработчиков. Это и есть миниатюрная версия механизма делегирования: один обработчик, расположенный высоко в иерархии, обслуживает все потомки, включая те, которые добавлены позже, при этом потребляя гораздо меньше памяти.

    Комментарии к вызовам event.stopPropagation() показывают противоположный контроль: когда они включены у дочернего элемента, они прерывают обработку события до того, как оно достигнет родительских элементов (см. документацию к stopPropagation). Используйте их осторожно, поскольку это также мешает обработчикам, расположенным выше по иерархии, включая корневой обработчик фреймворка. Стоит попробовать работу с этим файлом напрямую; простой addEventListener должен показаться знакомым раньше, чем абстракции фреймворка.

    Кратко о функциональном программировании

    Функциональное программирование (FP) часто представляется как конвейер из изолированных машин: каждая принимает входные данные, генерирует выходные и не влияет ни на что ещё. В то время как объектно-ориентированный код склонен к изменению состояния объектов, функциональное программирование избегает таких изменений и основывается на пяти принципах:

    1. Чистые функции: выходные данные зависят только от входных, без каких-либо внешних побочных эффектов.
  • Неизменяемость: преобразования возвращают новые значения вместо того, чтобы изменять старые.
  • Функции первого класса: функции — это значения, которые можно хранить и передавать дальше.
  • Функции высшего порядка: функции, которые принимают или возвращают другие функции, что позволяет осуществлять их комбинирование (см. этот обзор функций высшего порядка).
  • Декларативный стиль: описывается желаемый результат, например с помощью map и filter, вместо управления счётчиками и состоянием.
  • Классы и прототипы

    Начиная с ES2022, классы поддерживают настоящие приватные поля с префиксом #. Здесь #balance доступен только внутри тела класса.

    // 1. PURE JAVASCRIPT (Native ES2022+)
    class BankAccount {
      #balance = 0; // Native JS private field (using #)
    
      constructor(owner, initialBalance) {
        this.owner = owner;
        this.#balance = initialBalance;
      }
    
      deposit(amount) {
        this.#balance += amount;
      }
    }
    
    const account = new BankAccount("Alex", 1000);
    // account.#balance; ❌ Uncaught SyntaxError (Hard runtime privacy)
    

    Чтение account.#balance снаружи вызывает SyntaxError, поэтому приватность обеспечивается самим языком, а не конвенциями.

    Под спудом JavaScript по-прежнему использует наследование по прототипам: объекты делегируют поиск свойств прототипу. До ES6 структуры, похожие на классы, создавались вручную с помощью функций-конструкторов, как в этой версии субкласса сберегательного счёта.

    // 1. BASE CONSTRUCTOR FUNCTION (Parent Class)
    function BankAccount(owner, initialBalance) {
      this.owner = owner; // Public property
    
      // PRIVATE VARIABLE (Data Privacy via Closure)
      // Lives in function scope, completely hidden from the outside world!
      var balance = initialBalance;
    
      // Public method privileged to access the private 'balance' variable
      this.deposit = function (amount) {
        balance += amount;
        this._logTransaction("Deposited quot; + amount + ". New Balance: quot; + balance);
      };
    }
    
    // PROTECTED / SHARED METHODS (Prototypal Inheritance)
    // Attached to the prototype so methods aren't re-created for every single instance
    BankAccount.prototype._logTransaction = function (action) {
      console.log("[" + this.owner + "]: " + action);
    };
    
    // 2. DERIVED CONSTRUCTOR FUNCTION (Child Class)
    function SavingsAccount(owner, initialBalance, interestRate) {
      // Call Parent Constructor with 'this' context (Super Call)
      BankAccount.call(this, owner, initialBalance);
    
      this.interestRate = interestRate;
    }
    
    // Inherit parent prototype methods on the child
    SavingsAccount.prototype = Object.create(BankAccount.prototype);
    SavingsAccount.prototype.constructor = SavingsAccount; // Reset constructor reference
    
    // Child Public Method
    SavingsAccount.prototype.addInterest = function () {
      // Calls the inherited prototype method
      this._logTransaction("Applied " + this.interestRate + "% interest");
    };
    
    // 3. USAGE
    var mySavings = new SavingsAccount("Alex", 1000, 5);
    mySavings.deposit(200);   // -> [Alex]: Deposited $200. New Balance: $1200
    mySavings.addInterest();  // -> [Alex]: Applied 5% interest
    

    Обратите внимание, что переменная balance является приватной благодаря замыканию: она находится в области видимости конструктора, и к ней может иметь доступ только функция deposit. Функция _logTransaction расположена в прототипе, поэтому все экземпляры используют одну и ту же функцию; префикс подчёркивания в её названии традиционно означает «защищённую», но не гарантирует фактической защиты. Вызов BankAccount.call(this, ...) соответствует ручному использованию функции super(), а метод Object.create связывает прототипы до восстановления перезаписанной свойства constructor.

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

    Закрытия

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

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

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

    Практическое применение замыкания: дебаунсинг

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

    function debounce(func, delay = 300) {
      let timer; // 1. The closure variable that holds the timeout reference
    
      return function (...args) {
        // 2. If the function is called again before the delay finishes,
        // cancel the previous scheduled run
        clearTimeout(timer);
    
        // 3. Schedule a new run after the delay
        timer = setTimeout(() => {
          func.apply(this, args);
        }, delay);
      };
    }
    
    // --- Usage Example ---
    
    function handleSearch(query) {
      console.log(`Searching API for: ${query}`);
    }
    
    // Wrap our search function in the debounce function
    const debouncedSearch = debounce(handleSearch, 500);
    
    // Simulating fast typing into an input field:
    debouncedSearch("a");    // Timer starts...
    debouncedSearch("ap");   // Cancels "a", resets timer...
    debouncedSearch("app");  // Cancels "ap", resets timer...
    debouncedSearch("apple");// Cancels "app", resets timer...
    
    // Only "Searching API for: apple" will log to the console
    // 500ms after the last keypress!
    

    Закрытый объект timer представляет собой состояние. Каждый вызов сбрасывает текущий таймер и запускает новый, поэтому срабатывает только последний вызов в серии: быстрое введение «a», «ap», «app», «apple» приводит к одному поиску по слову «apple» через 500 мс после последнего нажатия клавиши. Метод func.apply(this, args) передаёт как аргументы, так и контекст вызова. Информацию о отмене и крайних случаях см. в статье написание утилиты для обработки задержек с нуля.

    Основные выводы

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