Головна / Статті

Пояснення стеку викликів JavaScript: фрейми, переповнення, сліди стеку async/await

Як працює стек викликів JavaScript крок за кроком, чому він переповнюється, де знаходяться замикання, та як читати сліди стеку async/await у DevTools та Node.js.

Видавець: REACTAPP.TOP24 хв читання5598 слів

Джерела: Day 6 of Learning JavaScript: JavaScript’s Traffic Cop: How the Call Stack Controls Everything Your Code Does

Класичне запитання під час інтерв’ю передбачає кілька вкладених викликів функцій та запитує, що буде виведено та в якій послідовності. Кожна функція сама по собі є простою; проблемою є саме послідовність викликів. Ця послідовність не ґрунтується на інтуїції — її визначає механізм під назвою стек викликів. Як тільки ви зрозумієте його принцип, запитання про результат виконання стають механічними вправами, переповнення стеку більше не є загадковим, а сліди помилок перетворюються на точні карти. У цьому посібнику пояснюється, як працює стек, проходить аналіз програми кадр за кадром, розглядається причина переповнення стеку та способи його уникнення, а також показується, як використовувати інструменти DevTools та сліди стеку для дебаггінгу реального коду.

Чому мова з одним потоком потребує стеку викликів

Усе інше випливає з одного дизайнерського рішення: JavaScript виконує ваш код у єдиній потоці. Це навмисно, а не через недбалість. У будь-який момент двигун виконує саме одну частину вашого коду, ніколи не дві паралельно.

Це не означає, що нічого не відбувається одночасно. Таймери, запити до мережі та операції з файлами обробляються середовищем хоста (Web API браузера або Node.js), поза межами двигуна JavaScript. Браузери делегують цю роботу нативним пулам потоків або механізмам введення-виведення операційної системи, таким як epoll, іноді комунікуючи через межі процесів за допомогою IPC. Коли фонова робота завершується, її керувальна функція поміщається у чергу завдань. Очікування відбувається одночасно, але самі керувальні функції все одно виконуються одна за одною у тому ж єдиному потоці.

Якщо це здається абстрактним, уявіть ресторан із саме одним кухарем. Кухар швидкий та організований, але не може одночасно нарізати овочі та помішувати страву в каструлі. Кожне завдання виконується та відкладається, перш ніж почнеться наступне. Двигун JavaScript поводиться так само.

Цікаве питання полягає у тому, що відбувається, коли одне завдання запускає інше. Розгляньмо три невеликі функції, кожна з яких викликає наступну:

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

Коли printSquare викликає square, printSquare не може просто продовжувати роботу — спочатку йому потрібен результат. Тож щось має пам’ятати, що printSquare було призупинено, де саме воно призупинилося, і що контроль має повернутися до square, а не до скрипту верхнього рівня, після того як multiply завершиться.

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

Два види пам’яті: стек, хіп та замикання

Поширене запитання серед початківців звучить так: якщо у пристрою є 16 або 32 ГБ оперативної пам’яті, чому програма на JavaScript вичерпує стек після лише кількох тисяч вкладених викликів? Відповідь полягає у тому, що двигуни на кшталт V8 ділять пам’ять на дві абсолютно різні області – стек викликів та купу.

TWO SIDES OF JAVASCRIPT MEMORY:

CALL STACK (Execution & Order)   MEMORY HEAP (Storage & Data)
┌─────────────────────────────┐  ┌─────────────────────────────┐
│ [Frame: square(5)]          │  │ Object: { name: "Alice" }   │
│   n: 5                      │  │ Array: [10, 20, 30, 40]     │
│   ref: 0x8F3A ──────────────┼─>│ Function: multiply()        │
├─────────────────────────────┤  │ Object: { role: "admin" }   │
│ [Global Execution Context]  │  │ Large Strings & Closures    │
└─────────────────────────────┘  └─────────────────────────────┘
- Fast, ordered (LIFO)           - Large, unstructured
- Small, fixed memory limit      - Gigabytes dynamic storage
- Auto-cleared on return         - Cleaned by Garbage Collect

Стек викликів оптимізований для швидкості та суворої послідовності. Це невелика область фіксованого розміру, яка містить фрейми виконання, адреси повернення та примітивні локальні значення. Оскільки він працює за принципом «останній доданий — перший видалений», видалення завершеного фрейма майже не коштує зусиль: движок просто змінює вказівник стеку.

Купа — це значно більший, менш організований резервуар. Там знаходяться об’єкти, масиви, функції та замикання, а стек містить лише легкий посилання на них, яке на діаграмі позначається як адреса на кшталт 0x8F3A. Пам’ять купи звільняється за допомогою збирача сміття, а не шляхом повернення з функції.

Це розмежування пояснює парадокс. Переповнення стеку не означає, що у вашому комп’ютері закінчилась пам’ять. Це означає, що невелика спеціалізована область, яка використовується для відстеження активних викликів, заповнена. Розглядайте цю діаграму як ментальну модель: реальні двигуни мають більше нюансів, наприклад, змінні, зафіксовані через закриття, зберігаються у контекстах, виділених на хіпі, а не безпосередньо у самій структурі виклику.

Форма стеку: останній доданий — перший видалений

Стек викликів є структурою типу LIFO: останній доданий — перший видалений. Те, що було додано останнім, — це те, що видаляється першим. Уявіть стек чистих тарілок у кафетерії: свіжі тарілки ставляться зверху, і ви завжди берете ту, що зверху; ви ніколи не берете тарілку з середини чи знизу.

Виклики функцій дотримуються точно таких самих правил:

Every time you call a function   → a new "plate" (frame) is placed on TOP
Every time a function finishes   → its "plate" is removed from the TOP
JavaScript always runs the function whose plate is currently on TOP

Що містить кадр стеку

Кожен кадр є кадром стеку, тісно пов’язаним із тим, що в специфікації називається контекстом виконання. Концептуально він записує п’ять елементів:

  1. Функція та її аргументи, наприклад a = 5, b = 5.
  2. Локальні змінні, оголошені всередині тіла функції.
  3. Адреса повернення — це інструкція у викликаючій функції, де має продовжитися виконання після завершення цієї функції.
  4. Посилання на ланцюг областей видимості — зовнішнє лексичне середовище, яке дозволяє функції читати змінні з більш високих областей видимості.
  5. Зв’язування this, яке застосовується під час виклику.

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

WHEN YOUR SCRIPT STARTS:

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Always here first
  └──────────────────────────────────┘

Кожен виклик додається на вершину цієї стек-структури, і одне правило поєднує все це: движок завжди виконує фрейм, який знаходиться на самій вершині. Як тільки додається новий фрейм, той, що знаходиться під ним, призупиняється.

Відстеження програми по одному фрейму за раз

Давайте простежимо, як printSquare(5) працює в межах стеку. Уважне виконання цього кроку один раз зазвичай достатньо, щоб процес став автоматичним. Ось програма знову:

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

Ланцюжок викликів — це printSquare, потім square всередині нього, а далі multiply всередині цього.

Крок 1: запуск скрипту

Перш ніж виконується printSquare(5), движок починає виконувати файл та створює глобальний контекст виконання.

STEP 1: Script starts running

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Pushed first, always
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

Двигун також реєструє визначення функцій multiply, square та printSquare. Визначення функції нічого не створює; лише її виклик формує відповідну структуру.

Крок 2: виклик printSquare(5)

Двигун доходить до printSquare(5), створює для неї структуру та розміщує її зверху.

STEP 2: printSquare(5) is called

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare зараз знаходиться нагорі, тому він виконується. Його перше речення, const result = square(n), є ще одним викликом, тому printSquare повинен передати керування, перш ніж зможе зробити щось інше.

Крок 3: printSquare викликає square(5)

Фрейм для square(5) вставляється над printSquare.

STEP 3: square(5) is called inside printSquare

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting, paused
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare зупинено у очікуванні значення. Увесь фокус зосереджений на square, чия єдина інструкція return multiply(n, n) запускає ще один виклик.

Крок 4: square викликає multiply(5, 5)

Проходить ще один крок, і купа даних досягає чотирьох елементів, що є найглибшою точкою в цій програмі.

STEP 4: multiply(5, 5) is called inside square -- Stack peak!

  CALL STACK:
  ┌──────────────────────────────────┐
  │  multiply(5, 5)                  │  <-- TOP - runs now
  ├──────────────────────────────────┤
  │  square(5)                       │  <-- Waiting
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

multiply не викликає нічого іншого. Він обчислює 5 * 5, повертає 25 та завершує роботу, тож його фрейм можна видалити.

Крок 5: multiply повертає результат, і його фрейм видаляється

Коли функція завершується, її фрейм видаляється з верхньої частини. Фрейм, який знаходиться безпосередньо під нею, тобто square, знову стає верхнім та продовжує роботу саме з того місця, де зупинився.

STEP 5: multiply(5, 5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Still waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)

Для функції square вираз return multiply(n, n) тепер став return 25. Вона повертає це значення, і воно по черзі видаляється.

Крок 6: square повертає значення та printSquare продовжує роботу

STEP 6: square(5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)
  2. square(5)       --> returned 25 (stored as const result inside printSquare)

У функції printSquare оператор присвоєння const result = square(n) завершується з значенням result, яке дорівнює 25. Наступна інструкція, console.log(result), виводить 25.

Крок 7: відбувається запис у журнал та розбирання стеку

STEP 7: console.log(25) executes, then printSquare finishes -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Program done
  └──────────────────────────────────┘

  FINAL OUTPUT:
  1. multiply(5, 5) --> returned 25
  2. square(5)       --> returned 25
  3. console.log(25) --> PRINTED: 25
  4. printSquare(5)  --> finished (returns undefined implicitly)

printSquare неявно повертає undefined та видаляється, залишаючи лише глобальний контекст. Єдине значення, яке потрапляє у консоль, — це 25, яке записує printSquare; значення, повернуті функціями multiply та square, передаються безслідно.

Весь перебіг подій за мить

Ті самі сім кроків, стиснуті для швидкого огляду:

STEP 1: Script starts
  Stack: Global
  Output: (none)

STEP 2: printSquare(5) called
  Stack: printSquare -> Global
  Output: (none)

STEP 3: square(5) called
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 4: multiply(5, 5) called
  Stack: multiply -> square -> printSquare -> Global
  Output: (none)

STEP 5: multiply returns 25
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 6: square returns 25
  Stack: printSquare -> Global
  Output: (none)

STEP 7: console.log(25), printSquare done
  Stack: Global
  Output: 25

Four rules summarize everything above:

  1. A call pushes a new frame on top. Every time.
  2. A return pops that frame off. Every time.
  3. The topmost frame is the one executing. No exceptions.
  4. The caller is the frame directly beneath. That is where control goes when the current function returns.

When the stack runs out of room: stack overflow

What the error really means

The stack has a fixed size, reserved by the engine. Each frame consumes part of it for arguments, locals, the return address and bookkeeping. Push enough frames without popping them and the space runs out. The engine then throws a RangeError:

RangeError: Maximum call stack size exceeded

Це є переповнення стеку. Відомий сайт з питаннями та відповідями для програмістів насправді отримав свою назву саме через цю помилку. Зазвичай причиною є функція, яка постійно викликає себе без жодних механізмів зупинки, що є проявом неправильної рекурсії.

Нескінченна рекурсія

Найпростішим прикладом є функція, єдиною функцією якої є виклик самої себе:

// No base case -- this never stops
function forever() {
  return forever();
}

forever(); // RangeError: Maximum call stack size exceeded

У візуальному вигляді стек зростає, поки двигун не відмовляється працювати далі:

STACK GROWING OUT OF CONTROL:

  ┌──────────────────────────────────┐
  │  forever()                       │  <-- frame #10,000+ ... crash!
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  ...                             │  (thousands more)
  ├──────────────────────────────────┤
  │  forever()                       │  <-- frame #1
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RESULT: RangeError: Maximum call stack size exceeded

Кожен виклик forever() додає одну кадру, і оскільки функція ніколи не повертається, ніщо також не видаляється. Зрештою закінчується виділений простір, і виникає помилка RangeError.

Сама рекурсія не є проблемою — це легітимна та часто елегантна техніка. Проблема полягає у рекурсії без можливості виходу. Кожна рекурсивна функція потребує базового випадку — умови, за якої вона припиняє викликати себе та повертається.

Базовий випадок, якого ніколи не досягають

Більш підступна версія спричиняє проблеми у багатьох розробників. У наведеній нижче функції є базовий випадок, але її рекурсивний крок рухається у неправильному напрямку:

function countDown(n) {
  if (n === 0) return; // Base case IS here, but never reached
  countDown(n + 1);    // Bug: going UP, not down -- n never reaches 0
}

countDown(1); // RangeError: Maximum call stack size exceeded

Існує захисний код if (n === 0) return, проте у рекурсивному виклику передається значення n + 1. Починаючи з 1, значення зростають до 2, 3, 4 та далі, тому нуль ніколи не досягається, і стек у будь-якому разі переповнюється. Виправлена версія підраховує значення у зворотному напрямку та використовує n <= 0 як захисний механізм, тож навіть несподіване від’ємне або дробове початкове значення призводить до завершення роботи:

function countDown(n) {
  if (n <= 0) return; // Base case: stop when we reach or pass zero
  console.log(n);
  countDown(n - 1);   // Subtracting: we're actually getting closer to 0
}

countDown(5); // Prints: 5, 4, 3, 2, 1

Непряма рекурсія

Цикл також може охоплювати кілька функцій. Тут ping викликає pong, а pong у свою чергу знову викликає ping:

function ping() {
  pong(); // ping calls pong
}

function pong() {
  ping(); // pong calls ping back: circular loop
}

ping(); // RangeError: Maximum call stack size exceeded

Жодна з функцій не викликає себе, але разом вони створюють нескінченний цикл викликів, і результатом є той самий переповнення. Непрямі цикли важче виявити у реальному коді, оскільки їхні складові часто знаходяться у різних модулях — наприклад, обробник подій, який ініціює оновлення, що знову викликає ту саму подію.

На яку глибину може сягати стек?

Максимальна глибина залежить від движка та від кількості пам’яті, необхідної кожній фрейму. Наведені нижче значення є приблизними орієнтовними показниками, а не гарантіями; вони варіюються залежно від версій, платформ та параметрів конфігурації, тому, якщо цей ліміт має значення, вимірюйте його у вашому власному цільовому середовищі:

CALL STACK LIMITS BY RUNTIME (APPROXIMATE):

  - Chrome & Node.js (V8 Engine):        ~10,000 to 12,000 frames
  - Safari (JavaScriptCore Engine):       ~40,000 to 45,000 frames
  - Firefox (SpiderMonkey Engine):        ~50,000+ frames

Причина такої різниці полягає у тому, що ліміт насправді є обмеженням пам’яті, а не кількістю викликів. Функція з багатьма параметрами та локальними змінними створює більші фрейми, тому їх менше може вміститися до досягнення цього ліміту.

Усунення переповнення стеку на практиці

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

Заміна рекурсії на цикл

Якщо вхідні дані можуть бути великими, перепишіть рекурсію у вигляд циклу for або while. Цикл виконується в межах одного фрейма, тому мільйони ітерацій зовсім не збільшують глибину стеку. У наведеному нижче прикладі рекурсивний підсумок, ймовірно, переповнить стек при глибині близько 20 000 у V8, тоді як ітеративна версія без проблем обробляє мільйон ітерацій:

// Recursive version: crashes if n is 20,000
function sumRecursive(n) {
  if (n === 0) return 0;
  return n + sumRecursive(n - 1);
}

// Iterative version: uses 1 stack frame, handles any n safely
function sumIterative(n) {
  let total = 0;
  for (let i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}

console.log(sumIterative(1000000)); // 500000500000 (instant, zero stack growth)

Трамплінінг

Якщо ви віддаєте перевагу рекурсивному стилю, трамплін дозволяє зберегти його без збільшення стеку. Замість того, щоб викликати себе, рекурсивна функція повертає «танк» — функцію без аргументів, яка описує наступний крок. Трамплін виконує ці «танки» по одному у простому циклі while; кожен «танк» повертається до того, як почнеться наступний, тож глибина стеку залишається постійною.

function trampoline(fn) {
  return function (...args) {
    let result = fn(...args);
    while (typeof result === "function") {
      result = result();
    }
    return result;
  };
}

// Return a function thunk instead of a direct recursive call
function safeSum(n, acc = 0) {
  if (n === 0) return acc;
  return () => safeSum(n - 1, acc + n);
}

const runSum = trampoline(safeSum);
console.log(runSum(100000)); // 5000050000 (no stack overflow!)

Зверніть увагу на параметр накопичувача acc у функції safeSum: сума, яка нараховується, передається далі у аргументах, а не зберігається у стеку. Це має наслідком необхідність виділення додаткової пам’яті для кожного кроку та те, що така конвенція менш зрозуміла для читачів, тому трамполін-алгоритми краще використовувати лише у справді рекурсивних алгоритмах.

Уникайте передачі великих масивів у виклики функцій

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

const hugeArray = new Array(150000).fill(1);

// CRASH: RangeError: Maximum call stack size exceeded
// Math.max(...hugeArray);

// SAFE: Process with a loop or reduce
const maxVal = hugeArray.reduce((max, val) => (val > max ? val : max), -Infinity);

Залежно від двигуна, виклик на кшталт Math.max(...hugeArray) може викликати ту саму помилку RangeError, як тільки масив містить близько ста тисяч елементів або більше. Функція reduce чи звичайний цикл обробляють дані без такого обмеження.

Не покладайтеся на оптимізацію кінцевих викликів

Старіші посібники та специфікація ECMAScript 2015 описують Proper Tail Calls (PTC), які часто називають оптимізацією кінцевих викликів. Ідея полягає у тому, що коли останньою дією функції в суворому режимі є повернення результату іншого виклику, двигун може повторно використати поточну структуру замість створення нової, що дозволяє безмежній рекурсії працювати в постійному обсязі пам’яті стеку.

На практиці серед основних движків лише JavaScriptCore у Safari його реалізує. V8, який використовується в Chrome та Node.js, а також SpiderMonkey у Firefox вирішили не включати його до стандартного JavaScript, головним чином тому, що видалення фреймів стирає історію, від якої залежать стек-трейси та засоби дебагування. У продакшн-коді вважайте тейл-коли недоступними та використовуйте цикли або трамполіни для глибокої рекурсії.

Прогнозування результату у запитаннях під час співбесіди

Запитання про результат — це запитання про стек

Запитання на кшталт «Що виведе цей код?» зустрічаються майже у кожній співбесіді з JavaScript, і більшість з них є прикриттям для вправ зі стеком викликів. Співбесідник не перевіряє запам’ятовування; він перевіряє, чи розумієте ви порядок виконання, а цей порядок повністю визначається стеком. Спробуйте це запитання перед тим, як читати далі:

function a() {
  console.log("a start");
  b();
  console.log("a end");
}

function b() {
  console.log("b start");
  c();
  console.log("b end");
}

function c() {
  console.log("c");
}

a();

Відстеження кадр за кадром

Програма виводить:

a start
b start
c
b end
a end

Багато хто очікує, що a end з’явиться раніше. Ось чому цього не відбувається. Виклик a() відкладає його виконання, і a починає працювати.

CALL STACK after a() is called:
  ┌──────────────────┐
  │  a()             │  <-- running
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

Перше повідомлення в a фіксує початок. Наступним є виклик b(), тому фрейм для b додається зверху.

CALL STACK after b() is called inside a:
  ┌──────────────────┐
  │  b()             │  <-- running now
  ├──────────────────┤
  │  a()             │  <-- paused, waiting for b to finish
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a зараз у стані паузи, і його останній виклик console.log("a end") ще не виконався; він не зможе цього зробити, доки b повністю не завершиться. Усередині b фіксується запис b start, а потім викликається c(), що додає третю фрейму.

CALL STACK after c() is called inside b:
  ┌──────────────────┐
  │  c()             │  <-- running now
  ├──────────────────┤
  │  b()             │  <-- paused
  ├──────────────────┤
  │  a()             │  <-- paused
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

c записує c, більше нічого не має робити та повертається. Його фрейм видаляється, і b знову опиняється зверху.

CALL STACK after c() returns:
  ┌──────────────────┐
  │  b()             │  <-- resumes now
  ├──────────────────┤
  │  a()             │  <-- still waiting
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

b продовжує роботу відразу після виклику c(), записує b end та повертається. Його фрейм видаляється, і a знову опиняється на вершині.

CALL STACK after b() returns:
  ┌──────────────────┐
  │  a()             │  <-- resumes now
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a продовжує роботу після виклику своєї функції b(), записує a end, повертається та видаляється. Залишається лише глобальний контекст. Повний результат у порядку:

a start
b start
c
b end
a end

Правило, яке потрібно запам’ятати: викликаючий код не продовжує роботу після виклику, доки викликана функція повністю не завершиться. a end мусив чекати, поки завершиться весь b, а b у свою чергу чекав, поки завершиться весь c. a був першою функцією, яка здійснила виклик, тому й останньою завершила роботу.

Триетапний метод для будь-якого запитання щодо виводу

  1. Викладіть виклики у порядку, починаючи з найзовнішніх та просуваючись всередину. Кожен виклик зупиняє свого викликаючого код.
  2. Відстежуйте вершину стеку. Те, що знаходиться на вершині, виконується; все, що знаходиться нижче, чекає.
  3. Розміщуйте кожен запис журналу. Записи журналу всередині викликаної функції з’являються перед будь-яким записом журналу, який слідує після цього виклику у викликаючому коді.

Шаблон завжди однаковий: спускатися аж до найглибшого виклику, а потім підніматися назад у зворотному порядку. Це стосується синхронного коду. Колбеки від таймерів, обіцянок та подій додають чергу до цієї моделі; наша стаття про замикання та цикл подій розглядає цей наступний рівень.

Перевірка стеку в реальному часі в Chrome DevTools

Складання діаграм на папері ідеально підходить для інтерв’ю, але у повсякденній роботі ви зазвичай будете спостерігати за стеком безпосередньо в дебагері браузера.

Зупинка виконання за допомогою оператора debugger

Додавання debugger; у рядок зупиняє виконання в цьому місці щоразу, коли відкриті DevTools. Відкрийте їх за допомогою F12, або клацніть правою кнопкою миші на сторінці, виберіть «Перевірити» та перейдіть на вкладку «Джерела».

function calculateTax(subtotal) {
  const rate = 0.08;
  debugger; // Execution pauses right here
  return subtotal * rate;
}

function checkoutCart() {
  const total = 100;
  const tax = calculateTax(total);
  console.log(total + tax);
}

checkoutCart();

Коли виконання зупиняється на рядку debugger, бічна панель відображає поточну стек-трейу разом із змінними у межах діапазону:

DEVTOOLS SOURCES PANEL BREAKDOWN:

  ┌───────────────────────┐  ┌──────────────────────────────────────────────┐
  │ CALL STACK PANE       │  │ SCOPE PANE (Updates based on clicked frame)  │
  ├───────────────────────┤  ├──────────────────────────────────────────────┤
  │ calculateTax  (line 3)│  │ Local Scope:                                 │
  │ checkoutCart  (line 9)│  │   subtotal: 100                              │
  │ (anonymous)   (line 13│  │   rate: 0.08                                 │
  └───────────────────────┘  └──────────────────────────────────────────────┘

У вікні Call Stack найвище вказано calculateTax, під ним — checkoutCart, а внизу знаходиться анонімний скрипт найвищого рівня, що є тією самою структурою, яку було проаналізовано раніше. Клацання на checkoutCart переміщує редактор на 9-ту рядок, а вікно Scope переходить на змінні цього фрейму, де відображається total: 100 точно так, як це було під час виклику calculateTax. Можливість перевірки стану функції, яка знаходиться у стані паузи, є одним із найкорисніших інструментів для відлагоджування.

Прокручування через виклики

Інструментна панель дебагера пропонує три контроли прокручування, які безпосередньо відповідають операціям стеку:

  1. Step Over (F10) виконує поточний рядок та зупиняється на наступному рядку тієї самої функції. Якщо цей рядок викликає іншу функцію, цей виклик виконується повністю без паузи дебагера всередині нього.
  2. Step Into (F11) продовжує виконання на поточній лінії, додає нову структуру керування та зупиняється на першій інструкції цієї структури.
  3. Step Out (Shift+F11) виконує решту коду поточної функції, видаляє її структуру керування та зупиняється у функції-викликачі відразу після повернення від результату виконання.

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

Сліди стеку: стек керування у момент збою

Що повідомляє слід стеку

Коли виникає помилка, яку не вдалося перехопити, движок формує слід стеку — знімок кожної структури керування, яка була у стеку у момент помилки. Він показує, що саме виконувалося, хто це запустив та точну лінію, де сталася проблема. Слід стеку — це просто текстовий опис стеку керування, тому всі раніше зазначені правила залишаються актуальними під час його читання.

Сліди часто здаються загрозливими — це низка шляхів та чисел, — але як тільки їхня структура стає знайомою, вони є найшвидшим шляхом до знахідки помилки.

Анатомія сліду

Розгляньмо невелику ланцюгову структуру, у якій найвнутрішня функція кидає помилку:

function c() {
  throw new Error("something went wrong");
}

function b() {
  c();
}

function a() {
  b();
}

a();

При запуску в Node.js він генерує такий вихід:

Error: something went wrong
    at c (/box/index.js:2:9)
    at b (/box/index.js:6:3)
    at a (/box/index.js:10:3)
    at Object.<anonymous> (/box/index.js:13:1)
    at Module._compile (node:internal/modules/cjs/loader:1705:14)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)

Перший рядок вказує тип та повідомлення про помилку — Error та щось пішло не так. Ось що саме пішло не так. Інші типи, з якими ви часто стикаєтесь, включають TypeError, ReferenceError, SyntaxError та RangeError.

Кожен рядок, що починається з at, — це один елемент стеку на момент виникнення помилки, у такому форматі:

at functionName (filename:lineNumber:columnNumber)

Отже, at c (/box/index.js:2:9) означає, що функція c виконувалась у файлі /box/index.js на 2-й рядку, у 9-му стовпці, а саме там знаходиться оператор throw.

at Object.<anonymous> позначає код, який виконується на верхньому рівні модуля, поза межами будь-якої іменованої функції. Тут a() викликається з верхнього рівня модуля. В інших частинах запису <anonymous> зазвичай вказує на вбудований керувальний функцію без імені.

Нижчі рядки, такі як Module._compile, Module.load та wrapModuleLoad, — це внутрішні елементи Node.js, які завантажують та виконують ваш файл. Ви їх не писали та зазвичай можете їх проігнорувати.

Як читати

Саме порядок спричиняє плутанину. Спочатку друкується найновіша картинка, тому список відображається у зворотному порядку до моменту здійснення дзвінків:

Read this direction (for debugging):
  ↑ TOP = where the error actually happened
  |
  | at c (/box/index.js:2:9)       <-- Error thrown HERE
  | at b (/box/index.js:6:3)       <-- b called c
  | at a (/box/index.js:10:3)      <-- a called b
  | at Object.<anonymous> (...)    <-- global code called a
  ↓
BOTTOM = where execution started

Нижня частина треку — це точка входу, де розпочалося виконання. Верхня частина — це місце, де виникла помилка. Виконання просувалося вгору через кадри, поки її викид не зупинив його.

Щоб відлагодити, почніть з верхньої частини та відкрийте файл та рядок першого запису at: саме там сталася збійна ситуація. Потім читайте вниз, оскільки справжня причина часто знаходиться на один-два кадри нижче. Помилка з’явилася в c, але який викликаючий код передав c некоректні дані? Перевірте b. А хто так викликав b? Перевірте a. Верхній кадр показує симптоми; кадри нижче зазвичай містять причину.

Надійна процедура для будь-якого треку:

  1. Прочитайте тип та повідомлення про помилку, щоб зрозуміти, який тип збою стався.
  2. Знайдіть перший рядок at, який вказує на ваш власний код, пропускаючи фрейми часу виконання та бібліотек.
  3. Відкрийте цей файл та рядок та перевірте, що там відбувається.
  4. Огляньте фрейм, який знаходиться безпосередньо під ним, адже проблемна цінність чи помилковий виклик часто походять від того, хто їх викликав.

Друк стеку за потреби за допомогою console.trace()

Ви можете надрукувати поточний стек у будь-який момент без помилок, викликавши console.trace():

function deepFunction() {
  console.trace("Who called me?");
  // prints current call stack right here
}

function middle() {
  deepFunction();
}

function outer() {
  middle();
}

outer();

Результат виглядає ось так:

Trace: Who called me?
    at deepFunction (/box/index.js:2:11)
    at middle (/box/index.js:7:3)
    at outer (/box/index.js:11:3)
    at Object.<anonymous> (/box/index.js:15:1)

Це особливо корисно з незнайомим кодом, коли неможливо визначити, звідки викликається функція: додайте console.trace(), запустіть сценарій, і з’явиться повний шлях. Перед розголошенням коду видаліть ці виклики.

Дайте імена своїм функціям

Справді анонімні функції відображаються як <anonymous> у записах трасування, що майже не дає можливостей для пошуку. Наведені нижче приклади ілюструють цю різницю, хоча перший потребує корекції.

const doSomething = () => {
  throw new Error("broken");
};

doSomething();
Error: broken
    at <anonymous> (/box/index.js:2:9)  <-- useless for debugging

У сучасних двигунах функція-стрілка, яка присвоюється безпосередньо змінній типу const, отримує свою назву з самої змінної, тому в цьому конкретному прикладі відображається at doSomething, а не <anonymous>. Нецікаві позначення <anonymous> з’являються через вбудовані функції-колбеки, які передаються безпосередньо до таких методів, як map, then чи слухачів подій. Оголошення з ім’ям є однозначним:

function doSomething() {
  throw new Error("broken");
}

doSomething();
Error: broken
    at doSomething (/box/index.js:2:9)  <-- instantly tells you what crashed

У кодовій базі, наповненій вбудованими калебеками, десятки ідентичних <anonymous> фреймів ускладнюють відстеження слідів виконання. Надання імен калебекам або їх перенесення у функції з іменами робить сліди більш зрозумілими.

Сліди виконання Async/await та інструменти для продакшну в V8 та Node.js

Для бекенд-сервісів та великих фронтенд-додатків V8 надає API для керування процесом запису слідів виконання.

Збір більшої кількості фреймів за допомогою Error.stackTraceLimit

За замовчуванням V8 записує максимум 10 фреймів у error.stack. У складних кодових структурах, таких як пайплайни запитів у Express чи NestJS або ланцюги мідлверів у Redux, ці десять фреймів можуть бути використані кодом фреймворку ще до того, як з’являться ваші власні функції. Ви можете підвищити цей ліміт у глобальному масштабі:

// Capture up to 50 frames for deep diagnostics
Error.stackTraceLimit = 50;

// Or capture everything without any limit:
// Error.stackTraceLimit = Infinity;

Це налаштування контролює лише кількість кадрів, які записуються у трейсі. Воно не впливає на фактичну максимальну глибину стеку, а дуже високі значення, особливо Infinity, ускладнюють створення кожної помилки, тому у продакшені краще використовувати помірні значення.

Чистіші користувацькі помилки за допомогою Error.captureStackTrace

Коли ви визначаєте власні класи помилок, сам конструктор зазвичай потрапляє у трейс. Error.captureStackTrace дозволяє його виключити, тож трейс починається з моменту, коли помилка була фактично винесена:

class DatabaseError extends Error {
  constructor(message) {
    super(message);
    this.name = "DatabaseError";
    // Omit the constructor call itself from the stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DatabaseError);
    }
  }
}

Другий аргумент просить V8 пропустити цю функцію та всі фрейми над нею. Умова if забезпечує роботу класу в двигунах, які не мають цього специфічного для V8 API.

Асинхронні стек-трейси з async/await

Історично асинхронний код створював скорочені стек-трейси. Колбек від setTimeout або обіцянка виконується на новому стеку дуже після того, як початковий викликаючий код повернувся, тому ланцюжок викликів зникає.

Сучасний V8 у Chrome та Node.js забезпечує безкоштовні асинхронні стек-трейси для async/await. Якщо щось виникає після відновлення виконання після await, V8 відновлює ланцюжок функцій, які очікують, лише тоді, коли це дійсно потрібно, позначаючи ці фрейми як async:

Error: Query failed
    at executeQuery (db.js:14:11)
    at async getUserData (user.js:22:9)
    at async handleRequest (server.js:45:5)

Ця перевага стосується вбудованих конструкцій async/await. Довгі ручно написані ланцюги .then() та код, заснований на калебеках, не отримують такої самої можливості доопрацювання, що є ще однією причиною віддавати перевагу await у коді, який потрібно дебагувати.

Стек викликів у кількох правилах

  • JavaScript виконує ваш код у одному потоці. Оперативне середовище обробляє фонові завдання одночасно, але кожен калебек чекає своєї черги у цьому потоці.
  • Стек викликів — це невелика, швидка структура фіксованого розміру типу LIFO, яка фіксує, яка функція зараз виконується та де кожен викликаючий буде продовжувати роботу; об’єкти та замикання знаходяться у хеші та посилаються зі стеку.
  • Кожен виклик додає нову структуру до стеку, а повернення — видаляє її. Верхня структура завжди виконується, а всі структури нижче неї призупиняються.
  • Переповнення стеку, яке фіксується як RangeError: Maximum call stack size exceeded, означає, що вичерпано обсяг пам’яті стеку, зазвичай через рекурсію без досяжної базової ситуації, прямої чи непрямої.
  • Для глибокої рекурсії краще використовувати цикли чи спеціальні механізми, а також уникати передачі дуже великих масивів у функції. Правильні тейл-коли підтримуються лише в Safari, тому не варто на них покладатися.
  • У DevTools опції Step Over, Step Into та Step Out відповідають виконанню виклику, входу в відповідну структуру та виходу з неї.
  • Трек стеку — це запис стеку у момент виникнення помилки: верхня частина вказує на місце, де виникла помилка, нижня — на точку входу, а причина часто знаходиться на одній-двох рівнях нижче верхньої частини.
  • console.trace(), функції з іменами, Error.stackTraceLimit, Error.captureStackTrace та вбудовані конструкції async/await роблять інформацію про стек викликів більш змістовною.
  • Колбеки, які знаходяться у черзі, наприклад обробники setTimeout, виконуються лише після того, як стек повністю спорожніє, саме тому стек викликів є основою для розуміння асинхронного JavaScript.

Чому це має сенс

Багато проблем, які забирають години на вирішення, насправді є проблемами стеку: функція повертається раніше, ніж очікувалося, калебек виконується у неправильному порядку, значення є undefined, хоча мало б бути іншим. Розробники, які швидко вирішують ці проблеми, не є розумнішими; вони просто один раз опанували структуру стеку та його сліди, тож тепер можуть одразу переходити до відповідного фрейму. Слід стеку — це механічний запис того, що насправді виконувалося, і як тільки ви починаєте йому довіряти, налагодження коду стає значно простішим.