RangeError: Maximum call stack size exceeded в JavaScript — как исправить

опубликовано
обновлено
читать
≈ 7 мин
автор
среда и версия
JavaScript в браузерах и Node.js
содержание решать задачи JavaScript с нуля

RangeError: Maximum call stack size exceeded означает, что во время одного непрерывного вычисления накопилось слишком много незавершённых вызовов функций. Firefox сообщает о том же сбое как InternalError: too much recursion. Обычно причина — бесконечная рекурсия, но корректный рекурсивный алгоритм на очень глубоком входе тоже может исчерпать стек.

Не привязывай исправление к «максимальному числу вызовов». Размер доступного стека и стоимость одного вызова зависят от движка, версии, окружения и самой функции. Код, который прошёл на одной машине, может упасть на другой; проверять нужно условие завершения и форму входных данных.

JavaScript / 01

Рекурсия должна приближаться к остановке

Рекурсия должна приближаться к остановке01 Аргумент не меняется walk(3) walk(3) walk(3) … Стек растёт: базовый случай не достигается. 02 Аргумент уменьшается walk(3) walk(2) walk(1) walk(0) → return Каждый вызов приближает выполнение к базовому случаю.01Аргумент не меняетсяwalk(3)walk(3)walk(3)…Стек растёт: базовый случай не достигается.02Аргумент уменьшаетсяwalk(3)walk(2)walk(1)walk(0) → returnКаждый вызов приближает выполнение кбазовому случаю.
Базовый случай помогает, только если каждый рекурсивный шаг приближает к нему вход: повторный walk(3) наращивает стек, а цепочка 3 → 2 → 1 → 0 начинает возвращать вызовы.

Что хранит стек вызовов

При вызове функции движок сохраняет контекст, чтобы после return продолжить выполнение с нужного места. Пока внутренняя функция не завершилась, вызвавшая её функция остаётся в стеке. Поэтому цепочка main() → parse() → read() занимает три активных кадра, а после возврата из read() верхний кадр снимается.

Рекурсия использует тот же механизм. Если функция вызывает себя до того, как предыдущий вызов успел завершиться, кадры копятся. Когда движок больше не может расширять стек, он останавливает выполнение с ошибкой переполнения.

Как прочитать стек ошибки

Открой трассировку и найди первый сверху кадр из своего кода. Затем смотри не на последнюю строку списка, а на повторяющийся фрагмент: консоль может обрезать длинную трассировку. Повтор и отличает эту ошибку от соседних: у TypeError с чтением свойства undefined стек обычный, и там ищут первое пропавшее звено, а не цикл.

Рисунок в трассировкеВероятная причинаЧто проверить
Много одинаковых walkПрямая рекурсияБазовый случай и аргументы следующего вызова
Чередуются isEven и isOddВзаимная рекурсияОбщую меру прогресса обеих функций
Повторяется строка get name или set nameАксессор обращается к тому же свойствуОтдельное поле для хранения значения
Имена повторяются, но аргумент приближается к базеВход допустим, но слишком глубокЦикл или явный список ожидающей работы

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

Базовый случай отсутствует или недостижим

У рекурсивной функции должно быть состояние, в котором она возвращает результат без нового вызова. Здесь такого состояния нет:

function sumTo(n) {
  return n + sumTo(n - 1)
}

sumTo(3)
// RangeError: Maximum call stack size exceeded

Базовый случай останавливает цепочку, а проверка входа не даёт начать движение с неподдерживаемого значения:

function sumTo(n) {
  if (!Number.isInteger(n) || n < 0) {
    throw new RangeError('n должно быть целым неотрицательным числом')
  }

  if (n === 0) return 0
  return n + sumTo(n - 1)
}

sumTo(3) // 6

Проверка n === 0 сама по себе недостаточна, если функция принимает отрицательное число и продолжает уменьшать его. В этом случае ноль уже недостижим. Не расширяй базу до n <= 0, если отрицательные значения запрещены предметной областью: лучше отклонить неверный вход явно.

Аргумент рекурсивного вызова не уменьшается

Даже при правильной базе следующий вызов может случайно получить исходный объект вместо дочернего:

function countNodes(node) {
  if (node === null) return 0

  let count = 1
  for (const child of node.children) {
    count += countNodes(node)
  }
  return count
}

Цикл нашёл child, но передал в countNodes() прежний node. Первый непустой массив children запускает бесконечную цепочку. Исправление находится в одном аргументе:

function countNodes(node) {
  if (node === null) return 0

  let count = 1
  for (const child of node.children) {
    count += countNodes(child)
  }
  return count
}

Для дерева мерой прогресса служит переход к потомку; для строки или массива — сокращение необработанной части; для числа — приближение к границе. Назови эту меру словами и проверь, что каждый рекурсивный путь строго её уменьшает.

Взаимная рекурсия маскирует возврат к прежнему состоянию

Функции могут переполнять стек, вызывая друг друга. В примере isEven(4) передаёт 3 в isOdd(), а та возвращает 4 обратно:

function isEven(n) {
  if (n === 0) return true
  return isOdd(n - 1)
}

function isOdd(n) {
  if (n === 0) return false
  return isEven(n + 1)
}

isEven(4)

Трассировка при этом чередует два имени. Обе функции должны двигаться в одну сторону:

function isEven(n) {
  if (n === 0) return true
  return isOdd(n - 1)
}

function isOdd(n) {
  if (n === 0) return false
  return isEven(n - 1)
}

isEven(4) // true

В публичной функции отдельно проверь, что n — неотрицательное целое число. Иначе взаимная рекурсия может быть согласованной, но всё равно никогда не дойти до нуля.

Геттер и сеттер могут вызывать сами себя

Чтение свойства запускает его getter, а запись — setter. Если внутри аксессора обратиться к тому же свойству, операция снова запустит этот аксессор:

class User {
  get name() {
    return this.name
  }

  set name(value) {
    this.name = value
  }
}

const user = new User()
user.name = 'Лена'

Храни значение в другом поле. Приватное поле не обращается к публичному аксессору:

class User {
  #name = ''

  get name() {
    return this.#name
  }

  set name(value) {
    this.#name = value
  }
}

Имена get name и set name в стеке указывают на механизм, но причину подтверждает строка внутри аксессора. Проверь также косвенную цепочку: getter fullName может читать другое свойство, чей getter снова обращается к fullName.

Корректная рекурсия тоже бывает слишком глубокой

Список из нескольких узлов безопасно обходится рекурсивно, а тот же алгоритм на длинной цепочке может переполнить стек. Базовый случай при этом достижим, и каждый шаг действительно уменьшает остаток данных. Проблема в числе одновременно активных вызовов.

Для линейной структуры замени рекурсию циклом:

function sumList(head) {
  let total = 0

  for (let node = head; node !== null; node = node.next) {
    total += node.value
  }

  return total
}

Для дерева или графа храни ожидающую работу явно. Массив со счётчиком головы работает как очередь: обработанные элементы остаются слева, а индекс движется вправо без shift().

function findNode(root, predicate) {
  if (root === null) return null

  const queue = [root]

  for (let head = 0; head < queue.length; head += 1) {
    const node = queue[head]
    if (predicate(node)) return node

    for (const child of node.children) {
      queue.push(child)
    }
  }

  return null
}

Если структура может содержать циклические ссылки, добавь Set посещённых узлов. Иначе цикл или очередь устранят переполнение стека, но обход всё равно не завершится. Выбор между стеком и очередью зависит от нужного порядка обхода, а не от сообщения об ошибке.

Event loop не очищает стек посреди синхронной рекурсии

Event loop выбирает следующее задание только после завершения текущего. Он не вмешивается между синхронными вызовами walk() и не снимает их кадры автоматически. Поэтому ожидание «следующего тика» не объясняет и не исправляет обычное переполнение стека — как и await, который вне async-функции просто не разрешён.

Если завернуть следующий шаг в setTimeout() или другую асинхронную границу, текущий вызов успеет завершиться, а продолжение начнётся отдельным заданием. Но это меняет контракт функции, порядок выполнения и обработку ошибок; длинная цепочка ещё и создаёт множество заданий. Такой приём уместен для намеренно порционного процесса, а не как универсальная замена цикла.

Короткий порядок исправления

  1. Найди первый повторяющийся кадр своего кода и определи повтор: одна функция, две функции или аксессор.
  2. Сравни аргументы соседних вызовов и сформулируй меру прогресса к базовому случаю.
  3. Проверь границы входа: тип, знак, пустую структуру и циклические ссылки.
  4. Исправь базу или переход, если состояние повторяется либо движется в неверную сторону.
  5. Если переход верен, но вход бывает глубже доступного стека, замени рекурсию циклом, явным стеком или очередью.

Не подбирай «безопасную глубину» экспериментом и не зашивай её в код как свойство JavaScript. Ограничение принадлежит конкретному запуску, а устойчивое исправление либо гарантирует завершение, либо убирает зависимость алгоритма от глубины стека вызовов.

Источники