Объяснение работы JavaScript в браузере на собеседованиях: очереди, куча памяти, утечки памяти, замыкания.
Структурированный обзор рабочей среды браузера для подготовки к собеседованиям: стек вызовов, очереди задач и микрозадач, куча памяти и сбор мусора, утечки памяти, делегирование, прототипы и замыкания.
Источники: #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) часто представляется как конвейер из изолированных машин: каждая принимает входные данные, генерирует выходные и не влияет ни на что ещё. В то время как объектно-ориентированный код склонен к изменению состояния объектов, функциональное программирование избегает таких изменений и основывается на пяти принципах:
- Чистые функции: выходные данные зависят только от входных, без каких-либо внешних побочных эффектов.
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 отвечают за ожидание и возвращение результатов через очереди.
- Все запланированные микрозадачи выполняются до следующей задачи, например, обратного вызова таймера.
- Коллектор сохраняет всё, что доступно от корня структуры, независимо от того, нужно ли это или нет.