Що таке call stack в JavaScript?
Call stack (стек викликів) - це структура даних, яку JavaScript використовує для відстеження того, яка функція зараз виконується і куди повернутись після її завершення.
Теорія
TL;DR
- Call stack працює за принципом LIFO (Last In, First Out): остання викликана функція завершується першою
- Кожен виклик функції створює stack frame (фрейм стеку) із локальними змінними,
thisі адресою повернення - У JavaScript один call stack - одночасно виконується тільки одна операція
- Коли стек порожній, event loop може запустити наступну задачу з черги
- Нескінченна рекурсія без базової умови переповнює стек і кидає
RangeError: Maximum call stack size exceeded
Як працює стек
Коли JavaScript викликає функцію, він додає (push) фрейм на стек. Коли функція повертає значення, фрейм знімається (pop). Двигун завжди виконує те, що зверху.
function greet(name) {
return `Hello, ${name}`;
}
function run() {
const message = greet('Alice'); // greet додається, потім знімається
console.log(message); // run ще на стеку
}
run();
// Стек: [run] -> [run, greet] -> [run] -> []Після виклику run() стек виглядає як [run]. Коли всередині викликається greet, стає [run, greet]. Коли greet повертає значення - знову [run]. Після завершення run стек порожній.
Stack frames
Кожен фрейм - це не просто покажчик. Він містить локальні змінні функції, значення this і адресу повернення (куди продовжити після виходу). Саме тому глибока рекурсія їсть пам'ять: кожен фрейм займає місце і жоден не звільняється поки функція не повернеться.
Stack overflow (переповнення стеку)
Без базової умови в рекурсивній функції фрейми накопичуються нескінченно.
function countdown(n) {
console.log(n);
countdown(n - 1); // ніколи не зупиняється
}
countdown(10000); // RangeError: Maximum call stack size exceededV8 (Chrome, Node.js) зазвичай кидає помилку десь після 10 000-12 000 фреймів. Базова умова вирішує проблему:
function countdown(n) {
if (n < 0) return; // базова умова
console.log(n);
countdown(n - 1);
}Однопотоковість
У JavaScript один call stack. Це архітектурне рішення, а не обмеження. Воно робить код передбачуваним: немає гонки потоків, немає спільного змінного стану, немає замків.
Але це означає: одна важка операція блокує все. Цикл while на 5 секунд заморожує вкладку, бо стек ніколи не звільняється і інший код не може запуститись. CPU-важкі задачі варто переносити в Web Worker.
Call stack і event loop
Event loop стежить за call stack. Коли стек порожній, event loop бере наступний колбек з черги задач і додає його на стек. Асинхронні колбеки, таймери і обробники Promise чекають у черзі і виконуються тільки після звільнення стеку.
console.log('start');
setTimeout(() => {
console.log('timeout'); // додається в чергу, запускається після очищення стеку
}, 0);
console.log('end');
// Виведення:
// start
// end
// timeoutНавіть із затримкою 0мс setTimeout виконується після end. Колбек чекає в черзі, поки console.log('end') ще на стеку.
Читання stack trace
Коли виникає помилка, stack trace показує стек викликів у той момент. Читати знизу вгору - отримаєш порядок викликів.
function a() { b(); }
function b() { c(); }
function c() { throw new Error('oops'); }
a();
// Error: oops
// at c (file.js:3)
// at b (file.js:2)
// at a (file.js:1)Нижній рядок (a) - де почалось виконання. Верхній (c) - де впало. Більшість розробників читають тільки перший рядок і зупиняються - це помилка.
Типові помилки
1. Думати, що async-колбеки виконуються в поточному стеку
function fetchData() {
fetch('/api/data').then(res => {
console.log('inside then');
});
console.log('after fetch');
}
fetchData();
// Виведення:
// after fetch
// inside then <- виконується ПІСЛЯ того як fetchData вже знятий зі стекуКолбек .then виконується після того, як fetchData вже пішов зі стеку. Він чекав у черзі мікрозадач.
2. Вважати, що setTimeout(fn, 0) запускається одразу
Нуль мілісекунд означає "після того як поточний стек порожній", а не "прямо зараз". Якщо стек зайнятий 2 секунди - колбек чекатиме ці 2 секунди плюс 0мс.
3. Блокувати стек синхронними циклами
const start = Date.now();
while (Date.now() - start < 3000) {} // busy-wait на 3 секунди
console.log('done');
// Жодна подія, клік або таймер не спрацював ці 3 секундиПереноси важку роботу в Web Worker або розбивай на частини через setTimeout.
4. Неправильно читати stack trace
Stack trace читається знизу вгору за порядком викликів, але сама помилка - зверху. Читай весь trace, щоб знайти де виклик почався.
Де зустрічається
- Browser DevTools: панель "Call Stack" показує живий стек під час покрокового виконання
- React: функції рендеру компонентів з'являються на стеку під час реконсиляції; помилки показують component stack trace
- Express: функції middleware виконуються як вкладені виклики; необроблені помилки піднімаються по стеку до error handler
- Node.js:
Error.captureStackTraceдозволяє захопити стек у будь-який момент для діагностики
Питання на співбесіді
Q: Що відбувається коли call stack порожній?
A: Event loop перевіряє чергу мікрозадач (Promise) і повністю її очищає. Після цього бере одну задачу з черги макрозадач (setTimeout, setInterval) і додає її на стек.
Q: Чому довгий цикл for блокує UI у браузері?
A: Цикл тримає call stack зайнятим весь цей час. Event loop не може додати жодний колбек (обробник кліку, запит на перерисовку, таймер) поки стек не звільниться. Рішення - розбити роботу на частини через setTimeout або requestAnimationFrame.
Q: Чи використовує async/await call stack інакше?
A: Код до першого await виконується на стеку звично. На await функція призупиняється і її фрейм знімається зі стеку. Коли Promise вирішується, функція відновлюється через мікрозадачу і новий фрейм додається для коду після await.
Q: Що таке stack frame?
A: Блок пам'яті, який двигун виділяє для одного виклику функції. Містить локальні змінні, об'єкт arguments, прив'язку this і адресу повернення. Фрейм звільняється коли функція повертає значення.
Q: Чи можна збільшити максимальний розмір стеку?
A: У Node.js так: node --stack-size=65536 app.js. У браузерах - ні. Практичне рішення для глибокої рекурсії - переписати ітеративно або використати trampolining.
Приклади
Базовий stack trace
function add(a, b) {
return a + b; // стек: [add]
}
function calculate(x, y) {
const result = add(x, y); // стек: [calculate, add] -> [calculate]
return result;
}
function main() {
const total = calculate(5, 3); // стек: [main, calculate] -> [main] -> []
console.log(total); // 8
}
main();main викликає calculate, яка викликає add. Кожен виклик додає фрейм. Кожне повернення знімає один. Після завершення main стек порожній і виводиться 8.
Обробка замовлення з відстеженням помилок
function processOrder(order) {
validateOrder(order);
const total = calcTotal(order);
notifyUser(order, total);
}
function validateOrder(order) {
if (!order.items || order.items.length === 0) {
throw new Error('Order has no items');
// Стек в цей момент: [validateOrder, processOrder]
}
}
function calcTotal(order) {
return order.items.reduce((sum, item) => sum + item.price, 0);
}
function notifyUser(order, total) {
console.log(`Замовлення ${order.id} підтверджено. Сума: $${total}`);
}
processOrder({ id: 42, items: [{ price: 19.99 }, { price: 5.50 }], email: 'user@example.com' });
// Замовлення 42 підтверджено. Сума: $25.49Якщо validateOrder кидає помилку, вона піднімається через processOrder. Stack trace покаже обидва фрейми, що полегшує пошук проблеми.
Асинхронний код і стек
async function getUserData(userId) {
// Стек: [getUserData] поки виконується цей рядок
const response = await fetch(`/api/users/${userId}`);
// Стек порожній під час очікування - getUserData призупинена
// Стек: [getUserData] відновлюється тут коли Promise вирішується
return response.json();
}
async function renderProfile(userId) {
const user = await getUserData(userId);
console.log(`Рендеримо профіль для ${user.name}`);
}
renderProfile(1);
// Стек не заблокований під час fetch - інший код може виконуватисьПід час fetch стек не заблокований. Функція призупиняється, звільняючи стек для іншої роботи. В цьому і є сенс async/await.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.