Объяснение стека вызовов JavaScript: кадры, переполнение, трассировки стека с async/await
Как работает стек вызовов JavaScript по отдельным кадрам, почему происходит переполнение стека, где находятся замыкания, и как читать трассировки стека для async/await в DevTools и Node.js.
Классический вопрос на собеседовании включает несколько вложенных вызовов функций и требует определить, что будет выведено и в каком порядке. Каждая функция сама по себе проста; людей сбивает с толку именно последовательность вызовов. Эта последовательность не основана на интуиции — она определяется механизмом под названием стек вызовов. Как только вы сможете его представить, вопросы о выводе данных превратятся в механическую задачу, переполнение стека перестанет казаться загадкой, а трассировки ошибок превратятся в точные карты. В этом руководстве подробно объясняется, как работает стек вызовов, шаг за шагом прослеживается выполнение программы, объясняется, почему происходит переполнение стека и как этого избежать, а также показано, как использовать инструменты DevTools и трассировки стека для отладки реального кода.
Почему однопоточному языку нужен стек вызовов
Все остальное следует из одного дизайнерского решения: JavaScript выполняет ваш код на одной потоке. Это сделано намеренно, а не из-за упущения. В любой момент движок выполняет ровно один фрагмент вашего кода, никогда не два параллельно.
Это не означает, что ничего не происходит одновременно. Таймеры, сетевые запросы и операции с файлами обрабатываются средой хоста (веб-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 приостановлен, где именно он остановился, и что после завершения работы multiply управление должно вернуться к square, а не к скрипту верхнего уровня.
Эта система отслеживания — это стек вызовов. Это запись движка о том, что выполняется в данный момент и что ждет возобновления. Без нее функция не смогла бы вернуться на правильное место, а ее значение возврата не имело бы куда попасть. Это не фоновая оптимизация; это фактический механизм, с помощью которого 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
Что содержит стековая рамка
Каждая стековая рамка тесно связана с тем, что в спецификации называется контекстом выполнения. Концептуально она хранит пять элементов:
- Функция и её аргументы, например
a = 5, b = 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 возвращает значение, после чего программа продолжает работу
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
Четыре правила сводят всё вышесказанное к следующему:
- Вызов добавляет новую рамку сверху. Всегда.
- Возврат удаляет эту рамку. Всегда.
- Самая верхняя рамка — это та, что выполняется. Исключений нет.
- Вызывающий код находится в рамке непосредственно под ней. Именно туда переходит управление при возврате из текущей функции.
Когда у стека заканчивается место: переполнение стека
Что на самом деле означает ошибка
У стека есть фиксированный размер, зарезервированный движком. Каждая рамка занимает часть этого пространства под аргументы, локальные переменные, адрес возврата и внутренние записи. Если добавлять достаточно рамок, не удаляя их, пространство исчерпывается. Тогда движок выбрасывает ошибку RangeError:
RangeError: Maximum call stack size exceeded
Это ошибка stack overflow. Знаменитый сайт вопросов и ответов для программистов на самом деле назван в честь именно этой ошибки. Обычной причиной является функция, которая постоянно вызывает себя без возможности остановки, что представляет собой сбой рекурсии.
Бесконечная рекурсия
Самый простой пример — это функция, единственная задача которой заключается в том, чтобы вызывать себя:
// 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 описываются надлежащие гребенчатые вызовы (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, которая, в свою очередь, ждала завершения функции c. a была первой функцией, совершившей внешний вызов, и поэтому последней завершившей его.
Трехэтапный метод для решения любых вопросов, связанных с выводом
- Перечислите вызовы в порядке, начиная с самого внешнего и двигаясь внутрь. Каждый вызов приостанавливает своего звонящего.
- Отслеживайте вершину стека. То, что находится на вершине, выполняется; все, что находится ниже, ждет.
- Записывайте каждый лог. Логи, сгенерированные внутри вызываемой функции, появляются раньше любых логов, сгенерированных после этого вызова в звонящей функции.
Шаблон всегда один и тот же: спускаться до самого глубокого вызова, а затем возвращаться обратно в обратном порядке. Это относится к синхронному коду. Колбэки от таймеров, обещаний и событий добавляют очередь поверх этой модели; наша статья о закрытиях функций и цикле событий рассматривает следующий уровень.
Просмотр стека в реальном времени в 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 │
└───────────────────────┘ └──────────────────────────────────────────────┘
Во вкладке «Стек вызовов» сверху указана функция calculateTax, ниже — checkoutCart, а в самом низу находится анонимный скрипт верхнего уровня; это та же структура, что и ранее. При нажатии на checkoutCart редактор переходит на 9-ю строку, а вкладка «Область видимости» показывает переменные этой функции, где значение total: 100 остаётся прежним, как и при вызове calculateTax. Возможность проверки состояния функции, находящейся в состоянии паузы, является одним из самых полезных инструментов отладки.
Пошаговое выполнение вызовов
Инструменты панели отладчика предоставляют три контроля для пошагового выполнения, которые напрямую соответствуют операциям стека:
- Step Over (F10) выполняет текущую строку и останавливается на следующей строке той же функции. Если в данной строке вызывается другая функция, её выполнение происходит полностью без паузы отладчика внутри неё.
- Step Into (F11) продолжает выполнение кода на текущей строке, добавляет новую структуру стека и останавливается на первом операторе внутри неё.
- 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. Верхний уровень стека показывает симптомы; уровни ниже обычно содержат причину.
Надежная процедура для любого трейса:
- Прочитайте тип и сообщение об ошибке, чтобы понять, какой тип сбоя произошел.
- Найдите первую строку с
at, указывающую на ваш собственный код, пропустив строки, связанные с выполнением программы и библиотеками. - Откройте этот файл и строку и изучите, что там происходит.
- Просмотрите строку, расположенную сразу ниже, поскольку некорректное значение или ошибочный вызов часто возникают у вызывающего кода.
Вывод стека по требованию с помощью 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 записывает в error.stack не более 10 фреймов. В сложных по структуре кодах, таких как пайплайны запросов 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, хотя этого не должно происходить. Разработчики, которые быстро решают такие проблемы, не умнее остальных; они просто однажды изучили структуру стека и его трейсы, после чего сразу переходят к соответствующему фрейму. Трейс стека — это механический отчет о том, что на самом деле выполнялось, и как только вы начинаете ему доверять, отладка становится значительно проще.