Пояснення JavaScript у браузері під час інтерв’ю: черги, купа, витоки пам’яті, замикання
Структурована екскурсія по рунтайму браузера для підготовки до інтерв’ю: стек викликів, черги завдань та мікрозавдань, купа пам’яті та збір сміття, витоки пам’яті, делегування, прототипи та замикання.
Джерела: #1 FrontEnd Concepts For Interviews — Javascript Fundament
Під час співбесід з фронт-енду рідко цінуються запам’ятовані фрази. Співбесідники намагаються з’ясувати, чи можете ви пояснити, що робить двигун з вашим кодом: де чекає калебек, чому об’єкт існує довше за свою функцію, чому один слухач у контейнері може замінити сотні. Цей посібник розглядає ці основи у порядку їхнього взаємозв’язку, щоб ви могли логічно обміркувати незнайоме питання замість того, щоб просто декламувати відповідь. Хороший самотест: після кожного розділу пояснюйте ідею вголос, ніби розповідаєте колезі, який про неї нічого не знає.
Стек викликів: де виконується ваш код
Кожен двигун JavaScript виконує код у стеку викликів — структурі типу «останній доданий, перший вилучений», яку можна уявити як масив, де операції додавання та вилучення виконуються лише з кінця. Коли викликається функція, додається елемент для її контексту виконання; при поверненні значення цей елемент вилучається. На головній смузі існує лише один такий стек, тому поки виконується якийсь елемент, ніщо інше не може працювати. Довгий синхронний цикл зупиняє відображення та прийом даних до завершення своєї роботи.
Чому мережевий запит не зупиняє сторінку
Якщо водночас може виконуватися лише одна операція, чому сторінка не затримується під час очікування відповіді від сервера? Тому що не JavaScript очікує. Уявіть собі систему, яка обробляє великий набір даних, а потім по черзі викликає кілька API. Виклик функції fetch() передає завдання Web 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) передає як аргументи, так і контекст виклику. Щодо скасування та крайніх випадків, див. створення утиліти debounce з нуля.
Основні висновки
- Ваш код виконується за допомогою однієї стеку викликів; API браузера здійснюють очікування та повертають результати через черги.
- Усі поточні мікрозавдання виконуються перед наступним завданням, наприклад перед викликом таймера.
- Колектор зберігає все, до чого можна дістатися від кореня, незалежно від того, чи це потрібно, чи ні.