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

JavaScript Автор: Среда и версия: JavaScript в браузерах и Node.js
содержание

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. Ограничение принадлежит конкретному запуску, а устойчивое исправление либо гарантирует завершение, либо убирает зависимость алгоритма от глубины стека вызовов.

Источники