یک بار، در یک پروژه‌ی داشبورد تحلیلی، همه‌چیز ناگهان از کار افتاد. هر بار که کاربر روی یک فیلتر خاص کلیک می‌کرد، صفحه فریز می‌شد و در کنسول پیام آشنا دیده می‌شد: Uncaught RangeError: Maximum call stack size exceeded. تا آن روز، این خطا را فقط در بحث بازگشت بی‌پایان می‌شناختم. آن روز فهمیدم که Maximum call stack size exceeded در JavaScript، در ظاهر یک خطای ساده به‌نظر می‌رسد، ولی در باطن، پنجره‌ای است به سمت درک عمیق از Call Stack، بازگشت (Recursion)، event loop، و مرزهای حافظه‌ی موتور JavaScript.

خطای Maximum call stack size exceeded دقیقاً چیست؟

خطای Maximum call stack size exceeded یک پیام خطا در JavaScript است که وقتی مطرح می‌شود که Call Stack موتور JavaScript به سقف ظرفیت خود رسیده باشد. Call Stack یک ساختار داده‌ی LIFO (Last In, First Out) است که هر فراخوانی تابع را به‌عنوان یک «قاب» (frame) روی هم انبار می‌کند. وقتی تعداد این قاب‌ها از حد مشخصی بیشتر شود، موتور JavaScript خطای زیر را پرتاب می‌کند:

Uncaught RangeError: Maximum call stack size exceeded
    at recursiveFunction (app.js:10)
    at recursiveFunction (app.js:11)
    at recursiveFunction (app.js:11)
    at recursiveFunction (app.js:11)
    ... (repeating thousands of times)

پیام خطا در موتورهای مختلف JavaScript متفاوت است:

  • V8 (Chrome/Node.js): RangeError: Maximum call stack size exceeded
  • SpiderMonkey (Firefox): InternalError: too much recursion
  • JavaScriptCore (Safari): RangeError: Maximum call stack size exceeded

نکته‌ی مهم این است که این خطا از نوع RangeError است، نه TypeError یا ReferenceError. RangeError وقتی رخ می‌دهد که یک مقدار خارج از دامنه‌ی مجاز باشد. در این مورد، «مقدار» همان تعداد فراخوانی‌های فعال در stack است که از سقف ظرفیت موتور تجاوز کرده است.

اگر با مبانی JavaScript آشنایی ندارید، ابتدا آموزش جاوااسکریپت از صفر را بخوانید تا مدل ذهنی درستی از توابع، stack، و اجرای کد شکل بگیرد. این خطا بدون درک Call Stack به‌طور کامل قابل تشخیص نیست.

Maximum call stack size exceeded یک شکایت از عمق است، نه از کد. JavaScript می‌گوید توابع تو، مثل آینه‌های رو در روی هم، تا بی‌نهایت در هم تکرار شده‌اند. راه‌حل، شکستن این انعکاس بی‌پایان است.

Call Stack چیست و چگونه کار می‌کند؟

برای درک درست این خطا، باید Call Stack را بشناسیم. Call Stack یکی از مفاهیم بنیادین در اجرای JavaScript است که نحوه‌ی مدیریت فراخوانی توابع را تعیین می‌کند.

تصور کنید که یک دفترچه با یک ستون عمودی دارید. هر بار که یک تابع فراخوانی می‌شود، JavaScript یک «قاب» (frame) جدید روی این ستون اضافه می‌کند. این قاب شامل:

  • نام تابع
  • آرگومان‌های ارسالی
  • متغیرهای محلی
  • خط بازگشت (خطی که باید بعد از اتمام تابع اجرا شود)

وقتی تابع به پایان می‌رسد و return می‌کند، قاب آن از بالای ستون حذف می‌شود. این فرآیند LIFO است: آخرین قابی که اضافه شد، اولین قابی است که حذف می‌شود.

در JavaScript، Call Stack یک سقف ظرفیت مشخص دارد. این سقف بستگی به موتور JavaScript، سیستم‌عامل، و پیکربندی دارد. در V8 (موتور Chrome)، سقف حدود 10,000 تا 15,000 قاب است. در Firefox، عدد کمی متفاوت است. سقف دقیق، به‌طور رسمی منتشر نمی‌شود چون بخشی از پیاده‌سازی داخلی است.

مثال ساده از Call Stack

function a() {
    b();
}

function b() {
    c();
}

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

a();

در این مثال، مسیر Call Stack به این ترتیب است:

  1. a() فراخوانی می‌شود → قاب a اضافه می‌شود.
  2. a درون خود، b() را فراخوانی می‌کند → قاب b اضافه می‌شود.
  3. b درون خود، c() را فراخوانی می‌کند → قاب c اضافه می‌شود.
  4. c لاگ می‌کند و به پایان می‌رسد → قاب c حذف می‌شود.
  5. b به پایان می‌رسد → قاب b حذف می‌شود.
  6. a به پایان می‌رسد → قاب a حذف می‌شود.

در این حالت، حداکثر 3 قاب روی stack وجود دارد. اما اگر c دوباره a را فراخوانی کند، حلقه‌ی بی‌پایان شکل می‌گیرد و stack پر می‌شود.

مثال بازگشت بی‌پایان

function infinite() {
    infinite();
}

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

هر فراخوانی infinite() یک قاب جدید روی stack اضافه می‌کند، بدون اینکه هیچ‌کدام حذف شوند. بعد از حدود 10,000 فراخوانی، موتور JavaScript خطا پرتاب می‌کند.

مبانی Call Stack در مفاهیم پایه جاوااسکریپت آمده است. برای درک عمیق‌تر event loop و ارتباطش با stack، ایونت‌ها در جاوااسکریپت را ببینید.

چرا این خطا از نوع RangeError است؟

خطای Maximum call stack size exceeded از نوع RangeError است، نه TypeError یا ReferenceError. این تفکیک، در تشخیص سریع کمک‌کننده است.

RangeError در JavaScript زمانی مطرح می‌شود که یک مقدار عددی خارج از دامنه‌ی مجاز باشد. مثال‌های معمول:

new Array(-1);
// RangeError: Invalid array length

new Array(Infinity);
// RangeError: Invalid array length

(12345).toFixed(200);
// RangeError: toFixed() digits argument must be between 0 and 100

در مورد Maximum call stack size exceeded، «مقدار» همان تعداد قاب‌های فعال روی stack است. وقتی این تعداد از سقف ظرفیت موتور JavaScript تجاوز می‌کند، RangeError مطرح می‌شود. این طراحی منطقی است: عمق stack یک مقدار عددی است و خارج از دامنه‌ی مجاز آن، خطای Range را تولید می‌کند.

جدول زیر تفاوت انواع خطاهای مشابه را نشان می‌دهد:

خطا موضوع شکایت مثال
RangeError مقدار خارج از دامنه مجاز Maximum call stack, Invalid array length
TypeError نوع مقدار با عملیات ناسازگار undefined.name
ReferenceError متغیر در هیچ scope وجود ندارد nonExistentVar
SyntaxError خطای نگارشی در کد const x = ;

جزئیات کامل سایر خطاها در خطای TypeError در جاوااسکریپت، خطای ReferenceError در جاوااسکریپت، و خطای SyntaxError در جاوااسکریپت آمده است.

نُه علت رایج این خطا

در تجربه‌ی من روی صدها پروژه‌ی JavaScript، خطای Maximum call stack size exceeded از نُه علت مشخص می‌آید. شناخت این علت‌ها، تشخیص را در چند ثانیه ممکن می‌کند.

  1. بازگشت بی‌پایان در توابع: تابعی که بی‌شرط خودش را فراخوانی می‌کند.
  2. بازگشت متقابل بین توابع: دو تابع که به هم فراخوانی می‌زنند.
  3. Circular Reference در JSON: JSON.stringify روی شیءهای چرخه‌ای.
  4. شیءهای عمیق و پیمایش بازگشتی: deep clone یا deep equal روی شیءهای پیچیده.
  5. MutationObserver و DOM: تغییر DOM در پاسخ به تغییر DOM.
  6. Getter و Setter بی‌نهایت: getter که به خودش دسترسی می‌زند.
  7. React/Vue/Angular state loops: تغییر state در پاسخ به تغییر state.
  8. Regex Catastrophic Backtracking: الگوهای regex با پیچیدگی نمایی.
  9. Event loop و stack overflow: فراخوانی synchronous بی‌پایان در event handler.

هر علت، نشانه‌های مخصوص به خود و راه‌حل اختصاصی دارد. در بخش‌های بعدی، هر علت را جداگانه باز می‌کنم.

بازگشت بی‌پایان در توابع

شایع‌ترین منبع Maximum call stack size exceeded، بازگشت بی‌پایان در توابع است. این حالت وقتی رخ می‌دهد که یک تابع، خودش را فراخوانی می‌کند بدون هیچ شرط توقف:

function countDown(n) {
    console.log(n);
    countDown(n - 1);
}

countDown(10);
// بعد از چند فراخوانی، خطا می‌دهد

در این مثال، تابع countDown هیچ شرط توقفی ندارد. عدد منفی می‌شود و به‌طور بی‌نهایت ادامه می‌یابد. راه‌حل: اضافه کردن شرط توقف (Base Case):

function countDown(n) {
    if (n < 0) return;  // Base Case
    console.log(n);
    countDown(n - 1);
}

countDown(10);
// 10, 9, 8, ..., 0 (متوقف می‌شود)

نکته‌ی مهم: در تجربه‌ی من، بازگشت بی‌پایان نه‌فقط از نبود Base Case می‌آید، بلکه از Base Case‌ای که هرگز برآورده نمی‌شود نیز می‌آید. مثال:

function halve(n) {
    if (n === 0) return;  // هرگز برآورده نمی‌شود اگر n اعشاری باشد
    console.log(n);
    halve(n / 2);
}

halve(10);
// 10, 5, 2.5, 1.25, 0.625, ... (هرگز به صفر دقیق نمی‌رسد)

در این مثال، تقسیم بر 2 به‌طور مداوم ادامه می‌یابد و n هرگز دقیقاً صفر نمی‌شود. راه‌حل: استفاده از شرط کمتر یا مساوی:

function halve(n) {
    if (n < 0.001) return;
    console.log(n);
    halve(n / 2);
}

این مشکل در تجربه‌ی من، به‌ویژه در محاسبات اعشاری شایع است. مبانی بازگشت در آموزش جاوااسکریپت از صفر آمده است.

بازگشت متقابل بین توابع

دومین منبع، بازگشت متقابل است: دو یا چند تابع که به‌طور متقابل همدیگر را فراخوانی می‌کنند:

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(10);
// true (کار می‌کند)

isEven(-1);
// Maximum call stack size exceeded (چون هیچ‌وقت به صفر نمی‌رسد)

در این مثال، وقتی n منفی باشد، هر دو تابع بدون رسیدن به صفر، به‌طور بی‌نهایت ادامه می‌دهند. راه‌حل: بررسی شرط در ابتدای هر تابع:

function isEven(n) {
    if (n < 0) n = -n;  // تبدیل به مثبت
    if (n === 0) return true;
    return isOdd(n - 1);
}

بازگشت متقابل در پیاده‌سازی توابع ریاضی (مثل فیبوناچی، فاکتوریل) شایع است. ولی در JavaScript، به‌دلیل سقف پایین Call Stack، بازگشت عمیق می‌تواند به خطا منجر شود. راه‌حل جایگزین: استفاده از حلقه یا memoization.

Circular Reference در JSON و شیءها

سومین منبع، Circular Reference است. این حالت وقتی رخ می‌دهد که یک شیء، به خودش یا به‌طور چرخه‌ای به شیء دیگری اشاره کند:

const user = { name: "Ali" };
user.self = user;

JSON.stringify(user);
// TypeError: Converting circular structure to JSON

// در بعضی شرایط با توابع خاص:
// Maximum call stack size exceeded

در حالت اول، JSON.stringify خطای TypeError می‌دهد چون موتور JavaScript این چرخه را تشخیص می‌دهد. ولی در بعضی سناریوهای پیچیده‌تر، مثل پیاده‌سازی دستی deep clone یا deep equal، این چرخه تشخیص داده نمی‌شود و بازگشت بی‌پایان رخ می‌دهد:

function deepClone(obj) {
    const clone = {};
    for (const key in obj) {
        clone[key] = deepClone(obj[key]);
    }
    return clone;
}

const user = { name: "Ali" };
user.self = user;

deepClone(user);
// Maximum call stack size exceeded

راه‌حل: استفاده از WeakMap برای ردیابی اشیاء بازدیدشده:

function deepClone(obj, seen = new WeakMap()) {
    if (obj === null || typeof obj !== "object") return obj;
    if (seen.has(obj)) return seen.get(obj);
    
    const clone = Array.isArray(obj) ? [] : {};
    seen.set(obj, clone);
    
    for (const key in obj) {
        clone[key] = deepClone(obj[key], seen);
    }
    return clone;
}

این رویکرد، در پروژه‌های واقعی، ضروری است. در مرورگرهای مدرن، می‌توانید از structuredClone() استفاده کنید که به‌طور خودکار این چرخه را مدیریت می‌کند:

const user = { name: "Ali" };
user.self = user;

const clone = structuredClone(user);
// کار می‌کند، چرخه حفظ می‌شود

مبانی کار با JSON در JSON چیست و چگونه داده‌ها را ساختاردهی می‌کند و کار با JSON در پروژه‌های واقعی آمده است.

شیءهای عمیق و پیمایش بازگشتی

چهارمین منبع، پیمایش بازگشتی شیءهای عمیق است. حتی اگر شیء چرخه‌ای نباشد، اگر عمق آن بیشتر از سقف stack باشد، بازگشت بی‌پایان رخ می‌دهد:

function getDepth(obj) {
    let maxDepth = 0;
    for (const key in obj) {
        if (typeof obj[key] === "object" && obj[key] !== null) {
            maxDepth = Math.max(maxDepth, 1 + getDepth(obj[key]));
        }
    }
    return maxDepth;
}

// شیء با عمق 20,000 (مثلاً از یک API یا CSV)
const deepObject = buildDeepObject(20000);
getDepth(deepObject);
// Maximum call stack size exceeded

راه‌حل: تبدیل بازگشت به پیمایش iterative با صریح stack:

function getDepthIterative(obj) {
    let maxDepth = 0;
    const stack = [{ obj, depth: 0 }];
    
    while (stack.length > 0) {
        const { obj: current, depth } = stack.pop();
        maxDepth = Math.max(maxDepth, depth);
        
        for (const key in current) {
            if (typeof current[key] === "object" && current[key] !== null) {
                stack.push({ obj: current[key], depth: depth + 1 });
            }
        }
    }
    
    return maxDepth;
}

این رویکرد، در پردازش داده‌های حجیم (JSON از API، فایل‌های CSV، درخت‌های تصمیم) ضروری است. هرچه عمق بیشتر، احتمال برخورد با سقف stack بالاتر می‌رود.

بازگشت در DOM و MutationObserver

پنجمین منبع، بازگشت در DOM است. یک الگوی شایع: استفاده از MutationObserver که در پاسخ به تغییر DOM، خود DOM را تغییر می‌دهد:

const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
            if (node.nodeType === 1) {
                // تغییر DOM در پاسخ به تغییر DOM
                node.setAttribute("data-observed", "true");
            }
        });
    });
});

observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
});
// باعث بازگشت بی‌پایان می‌شود

در این مثال، هر بار که data-observed اضافه می‌شود، MutationObserver دوباره فعال می‌شود و حلقه بی‌پایان شکل می‌گیرد. راه‌حل: بررسی صریح قبل از تغییر:

const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        mutation.addedNodes.forEach((node) => {
            if (node.nodeType === 1 && !node.hasAttribute("data-observed")) {
                node.setAttribute("data-observed", "true");
            }
        });
    });
});

با این تغییر، از تغییر تکراری جلوگیری می‌شود. مبانی DOM در کار با DOM در جاوااسکریپت آمده است.

Getter و Setter بی‌نهایت

ششمین منبع، getter و setter بی‌نهایت است. اگر یک getter، به خودش دسترسی بزند یا یک setter، خودش را فراخوانی کند، بازگشت بی‌پایان رخ می‌دهد:

const obj = {
    get value() {
        return this.value;  // بازگشت بی‌پایان
    },
};

obj.value;
// Maximum call stack size exceeded

در این مثال، this.value درون getter، همان getter را فراخوانی می‌کند و بازگشت بی‌پایان رخ می‌دهد. راه‌حل: استفاده از یک property داخلی:

const obj = {
    _value: 0,
    get value() {
        return this._value;
    },
    set value(v) {
        this._value = v;
    },
};

obj.value = 10;
console.log(obj.value);  // 10

این مشکل، در پیاده‌سازی Proxy، کلاس‌های با getter/setter پیچیده، و الگوهای reactive (Vue 2) شایع است. مبانی شی‌گرایی در شی‌گرایی در جاوااسکریپت آمده است.

این خطا در React، Vue و Angular

هفتمین منبع، مربوط به فریم‌ورک‌های مدرن است. در React، Vue و Angular، این خطا از الگوهای خاصی می‌آید:

در React

function Counter() {
    const [count, setCount] = useState(0);
    
    // اشتباه: تغییر state در رندر
    setCount(count + 1);
    
    return <div>{count}</div>;
}

در این مثال، setCount در طول رندر فراخوانی می‌شود، که باعث re-render بی‌پایان می‌شود. راه‌حل: انتقال تغییر state به useEffect با dependency array صحیح:

useEffect(() => {
    setCount(count + 1);
}, [count]);  // اجرا می‌شود فقط وقتی count تغییر کند

مشکل دیگر در React، بازگشت component درون خودش بدون شرط:

function Recursive() {
    return <Recursive />;  // بازگشت بی‌پایان
}

راه‌حل: اضافه کردن شرط توقف:

function Recursive({ depth = 0 }) {
    if (depth > 10) return null;
    return <Recursive depth={depth + 1} />;
}

در Vue

export default {
    data() {
        return { count: 0 };
    },
    watch: {
        count() {
            this.count++;  // بازگشت بی‌پایان
        },
    },
};

راه‌حل: بررسی شرط در watcher یا استفاده از computed properties.

در Angular

ngOnChanges() {
    this.value = this.value + 1;  // بازگشت بی‌پایان
}

راه‌حل: بررسی تغییرات با SimpleChanges و مقایسه‌ی مقدار قبلی با جدید.

مبانی React در React از صفر و مبانی Vue در Vue.js برای مبتدیان آمده است.

Regex Catastrophic Backtracking

هشتمین منبع، Regex Catastrophic Backtracking است. الگوهای regex با پیچیدگی نمایی می‌توانند باعث بازگشت بی‌پایان در موتور regex شوند:

const regex = /(a+)+$/;
const longString = "a".repeat(30) + "b";

regex.test(longString);
// Maximum call stack size exceeded در برخی پیاده‌سازی‌ها

در این مثال، الگوی (a+)+$ برای رشته‌ای که با b پایان می‌یابد، باعث بازگشت نمایی می‌شود. راه‌حل: بازنویسی regex با ساختار ساده‌تر:

const regex = /^a+$/;  // ساده‌تر و بدون backtracking نمایی

Catastrophic Backtracking در پردازش ورودی کاربر (validation) شایع است. ابزارهایی مثل regex101 می‌توانند پیچیدگی regex را تحلیل کنند.

event loop و stack overflow

نهمین منبع، ارتباط Call Stack با event loop است. در JavaScript، هر callback که در event loop اجرا می‌شود، از stack خالی شروع می‌کند. بنابراین بازگشت در callbackهای async به‌طور معمول باعث stack overflow نمی‌شود:

function loop() {
    setTimeout(loop, 0);  // این باعث stack overflow نمی‌شود
}

loop();  // به‌طور بی‌نهایت اجرا می‌شود بدون stack overflow

در این مثال، هر فراخوانی setTimeout یک callback جدید در event loop ثبت می‌کند. بعد از اجرای هر callback، stack خالی می‌شود. بنابراین stack overflow رخ نمی‌دهد. این تکنیک برای جلوگیری از stack overflow در بازگشت‌های عمیق استفاده می‌شود:

async function asyncLoop(n) {
    if (n <= 0) return;
    console.log(n);
    await new Promise(resolve => setTimeout(resolve, 0));
    return asyncLoop(n - 1);
}

asyncLoop(100000);  // بدون stack overflow

این رویکرد که به آن asynchronous recursion نیز گفته می‌شود، در پردازش داده‌های حجیم (batch processing) بسیار مفید است. جزئیات کامل async/await در async و await در جاوااسکریپت و Promise در جاوااسکریپت آمده است.

روش تشخیص اصولی در پنج گام

در تجربه‌ی من، تشخیص این خطا در چند دقیقه انجام می‌شود، اگر روش سیستماتیک داشته باشید:

گام اول: خواندن دقیق stack trace. پیام خطا، لیست فراخوانی‌های تکرارشونده را نشان می‌دهد. خط اول و آخر این لیست، نقطه‌ی شروع و توقف را تعیین می‌کنند.

گام دوم: استفاده از Pause on exceptions. در Chrome DevTools، تب Sources، گزینه‌ی Pause on exceptions را فعال کنید. این کار باعث می‌شود اجرا در لحظه‌ی وقوع خطا متوقف شود. سپس در تب Call Stack، تمام قاب‌های stack را ببینید.

گام سوم: بررسی بازگشت‌ها. کد را برای توابع بازگشتی بررسی کنید. آیا هر بازگشت، یک Base Case دارد؟ آیا Base Case همیشه برآورده می‌شود؟ آیا بازگشت روی داده‌ای که ممکن است بی‌پایان باشد (مثل in-tree یا چرخه‌ای) اجرا می‌شود؟

گام چهارم: بررسی getter و setter. اگر خطا از یک property آمده، بررسی کنید که getter یا setter بازگشت بی‌پایان ندارد. از console.trace() درون getter استفاده کنید.

گام پنجم: کاهش تدریجی برای تشخیص. کد را به بخش‌های کوچک‌تر تقسیم کنید و هر بخش را جداگانه اجرا کنید. این رویکرد، منبع را دقیقاً نشان می‌دهد.

ابزارهای تشخیص:

  • console.trace(): چاپ کامل stack trace.
  • Chrome DevTools: تب Sources، Pause on exceptions، Call Stack.
  • Firefox Developer Tools: debugger با stack view.
  • Node.js: node --stack-trace-limit=100 app.js برای stack trace عمیق‌تر.
  • VS Code Debugger: با breakpoint و call stack inspection.

مبانی عیب‌یابی در چگونه خطاهای جاوااسکریپت را در کنسول مرورگر پیدا کنیم و ابزارهای اشکال‌زدایی جاوااسکریپت آمده است.

راهبردهای رفع اصولی

بعد از تشخیص، نوبت به رفع می‌رسد. راهبردهای رفع، بر اساس نوع خطا متفاوت است:

راهبرد اول: اضافه کردن Base Case

در بازگشت‌های بی‌پایان، اولین راه‌حل، اضافه کردن یک شرط توقف است:

function countDown(n) {
    if (n < 0) return;  // Base Case
    // ...
}

راهبرد دوم: بررسی داده‌ی ورودی

در بعضی موارد، داده‌ی ورودی نادرست باعث بازگشت بی‌پایان می‌شود. بررسی صریح داده را اضافه کنید:

function flatten(arr, depth = 0) {
    if (depth > 100) {
        throw new RangeError("Max depth exceeded");
    }
    // ...
}

راهبرد سوم: ردیابی اشیاء بازدیدشده

برای جلوگیری از چرخه در پیمایش بازگشتی:

function traverse(node, seen = new WeakSet()) {
    if (seen.has(node)) return;
    seen.add(node);
    // ...
}

راهبرد چهارم: بازنویسی به iterative

در مواردی که بازگشت عمیق است، تبدیل به حلقه با stack صریح:

function factorial(n) {
    let result = 1;
    for (let i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

راهبرد پنجم: async recursion

برای بازگشت‌های بسیار عمیق، استفاده از async/await با setTimeout:

async function processItems(items) {
    for (const item of items) {
        await processItem(item);
    }
}

تبدیل بازگشت به حلقه

یکی از موثرترین راه‌حل‌ها، تبدیل بازگشت به حلقه است. این کار، هم مشکل stack را حل می‌کند و هم در بسیاری موارد، performance را بهبود می‌دهد.

مثال: فیبوناچی

پیاده‌سازی بازگشتی (به‌آرامی کند می‌شود):

function fib(n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

پیاده‌سازی iterative:

function fib(n) {
    if (n <= 1) return n;
    let prev = 0;
    let curr = 1;
    for (let i = 2; i <= n; i++) {
        [prev, curr] = [curr, prev + curr];
    }
    return curr;
}

مثال: درخت دودویی

پیاده‌سازی بازگشتی:

function traverse(node) {
    if (!node) return;
    console.log(node.value);
    traverse(node.left);
    traverse(node.right);
}

پیاده‌سازی iterative با stack صریح:

function traverse(root) {
    if (!root) return;
    const stack = [root];
    while (stack.length > 0) {
        const node = stack.pop();
        console.log(node.value);
        if (node.right) stack.push(node.right);
        if (node.left) stack.push(node.left);
    }
}

این تبدیل، در درخت‌های عمیق (مثل DOM یا درخت‌های تصمیم) ضروری است. مبانی آرایه‌ها و stack در آرایه‌ها در جاوااسکریپت آمده است.

Tail Call Optimization و محدودیت‌های آن

Tail Call Optimization (TCO) یک ویژگی در بعضی زبان‌های تابعی است که باعث می‌شود فراخوانی‌های tail (آخرین عملیات در تابع) بتوانند stack frame فعلی را جایگزین کنند. این ویژگی، در JavaScript به‌طور کامل پیاده‌سازی نشده است:

  • V8 (Chrome/Node.js): TCO را پیاده‌سازی نکرده است، حتی با "use strict".
  • SpiderMonkey (Firefox): در حالت strict، TCO را پیاده‌سازی کرده است.
  • JavaScriptCore (Safari): پیاده‌سازی محدودی دارد.

در نتیجه، در JavaScript نمی‌توانید به TCO برای جلوگیری از stack overflow اعتماد کنید. راه‌حل، استفاده از الگوهای جایگزین مثل Trampoline یا تبدیل به حلقه است.

الگوی Trampoline

Trampoline یک الگوی برنامه‌نویسی است که بازگشت‌های عمیق را به حلقه تبدیل می‌کند. در JavaScript، این الگو با return کردن یک تابع (thunk) به‌جای فراخوانی مستقیم پیاده‌سازی می‌شود:

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

const factorial = trampoline(function fact(n, acc = 1) {
    if (n <= 1) return acc;
    return () => fact(n - 1, n * acc);
});

console.log(factorial(100000));
// بدون stack overflow

در این الگو، هر بازگشت، یک تابع thunk برمی‌گرداند. حلقه‌ی while در trampoline، thunkها را یکی‌یکی فراخوانی می‌کند و stack را همیشه ثابت نگه می‌دارد.

مزیت اصلی: امکان استفاده از منطق بازگشتی بدون نگرانی از stack overflow. معایب: پیچیدگی بیشتر کد و performance کمتر نسبت به حلقه‌ی مستقیم. مبانی توابع高阶 در آموزش جاوااسکریپت از صفر آمده است.

structuredClone و مقابله با Circular Reference

در مرورگرهای مدرن (Chrome 98+، Firefox 94+، Safari 15.4+، Node.js 17+)، می‌توانید از structuredClone() برای کپی امن اشیاء با چرخه استفاده کنید:

const user = {
    name: "Ali",
    address: { city: "Tehran" },
};
user.self = user;

const clone = structuredClone(user);
console.log(clone.name);  // "Ali"
console.log(clone.self === clone);  // true (چرخه حفظ می‌شود)

مزایای structuredClone نسبت به پیاده‌سازی دستی:

  • مدیریت خودکار Circular Reference
  • پشتیبانی از انواع داده‌ی پیچیده (Map، Set، Date، ArrayBuffer)
  • Performance بهتر (پیاده‌سازی بومی در موتور)
  • کد کمتر و قابل اعتمادتر

محدودیت: structuredClone روی توابع، DOM nodes، و propertyهای getter کار نمی‌کند و خطا می‌دهد. برای این موارد، از پیاده‌سازی سفارشی استفاده کنید.

این خطا در محیط production

در محیط production، Maximum call stack size exceeded ابعاد جدی‌تری دارد:

قطع کامل سرویس

وقتی این خطا رخ دهد، اجرای اسکریپت در آن نقطه متوقف می‌شود. اگر این خطا در مسیر بحرانی باشد، کاربر ممکن است صفحه‌ی سفید ببیند یا با کلیک‌هایی مواجه شود که پاسخ نمی‌دهند. در فروشگاه‌های آنلاین، این مسئله به از دست دادن سفارش‌ها منجر می‌شود.

تجربه کاربری فریز شده

یکی از بدترین پیامدهای این خطا، «فریز کردن» tab مرورگر است. وقتی JavaScript در حلقه‌ی بی‌پایان گیر می‌کند (حتی بعد از stack overflow، ممکن است در event loop گیر کند)، مرورگر کند می‌شود و کاربر نمی‌تواند با صفحه کار کند. این مسئله، به‌ویژه در اپلیکیشن‌های SPA (Single Page Application) شایع است.

نشت اطلاعات

پیام خطا، مسیر فایل‌ها و نام توابع را افشا می‌کند. راه‌حل: در production، از sourcemap استفاده کنید و لاگ‌ها را در جای امن نگه دارید.

پایش و آلارم‌دهی

ابزارهایی مثل Sentry، Rollbar، و LogRocket این خطاها را جمع‌بندی می‌کنند. نکته: بخش بزرگی از Maximum call stack size exceededها از داده‌ی کاربر می‌آید (مثلاً JSON چرخه‌ای یا شیء عمیق). راه‌حل: خطاهای مربوط به داده‌ی کاربر را از خطاهای برنامه جدا کنید.

پیشگیری با تست

بیشتر این خطاها را می‌توان قبل از production با تست‌های خودکار کشف کرد:

  • Unit tests: تست توابع بازگشتی با ورودی‌های مرزی (شیء عمیق، آرایه بزرگ، داده‌ی چرخه‌ای).
  • Load tests: تست بار با داده‌های واقعی.
  • E2E tests: تست برنامه در مرورگر واقعی با داده‌های پیچیده.
  • Error monitoring: پایش خطاها در production.

اشتباهات رایج در برخورد با این خطا

در طول سال‌ها، الگوهای تکراری از اشتباهات دیده‌ام که هر کدام می‌تواند پروژه را به چالش بکشد:

اشتباه اول: افزایش سقف Stack

در Node.js، می‌توانید با --stack-size=10000 سقف stack را افزایش دهید. ولی این راه‌حل، فقط خطا را به تعویق می‌اندازد و مسئله را حل نمی‌کند. در مرورگر، چنین امکانی وجود ندارد.

اشتباه دوم: استفاده از try/catch به‌عنوان راه‌حل

در بعضی موارد، try/catch می‌تواند این خطا را بگیرد. ولی این رویکرد، مسئله را پنهان می‌کند بدون اینکه ریشه را حل کند:

try {
    deepRecursion();
} catch (error) {
    // خطا گرفته می‌شود ولی ریشه باقی می‌ماند
}

اشتباه سوم: فرض بی‌خطر بودن بازگشت

در JavaScript، بازگشت عمیق همیشه خطرناک است چون TCO پیاده‌سازی نشده است. فرض کنید بازگشت‌های خود را همیشه با احتیاط بنویسید.

اشتباه چهارم: نبود تست برای داده‌های عمیق

اگر تست‌ها فقط برای داده‌های سطحی نوشته شوند، خطاهای عمیق در production کشف می‌شوند. راه‌حل: تست‌های مرزی با داده‌های واقعی بنویسید.

اشتباه پنجم: نادیده گرفتن Circular Reference

در داده‌های واقعی (مثل ساختارهای گرافی، درخت‌های تصمیم)، Circular Reference شایع است. راه‌حل: همیشه با WeakMap یا WeakSet، اشیاء بازدیدشده را ردیابی کنید.

اشتباه ششم: نادیده گرفتن Sourcemap در production

اگر sourcemap در production فعال نباشد، پیام خطا مبهم است. راه‌حل: sourcemap را در production فعال کنید و به Sentry یا ابزارهای مشابه وصل کنید.

اشتباه هفتم: استفاده بی‌محابای Regex پیچیده

Regexهای پیچیده در ورودی کاربر می‌توانند به Catastrophic Backtracking منجر شوند. راه‌حل: از ابزارهای تحلیل regex استفاده کنید و الگوها را ساده کنید.

Maximum call stack size exceeded یک پیام از عمق است، نه از کد. JavaScript می‌گوید توابع تو مثل آینه‌های رو در روی هم، تا بی‌نهایت در هم تکرار شده‌اند. راه‌حل، شکستن این انعکاس بی‌پایان است.

پرسش‌های پرتکرار درباره Maximum call stack size exceeded

این پرسش‌ها از دل تجربه‌ی عملی و جلسات مشاوره جمع‌آوری شده‌اند. پاسخ هر کدام بر اساس سناریوهای واقعی است.

چرا این خطا از نوع RangeError است؟

RangeError وقتی مطرح می‌شود که یک مقدار عددی خارج از دامنه‌ی مجاز باشد. در این مورد، «مقدار» همان عمق Call Stack است که از سقف ظرفیت موتور JavaScript تجاوز کرده است. از نظر فنی، عمق stack یک مقدار عددی است و خارج از دامنه‌ی مجاز آن، خطای Range تولید می‌کند.

سقف Call Stack در JavaScript چقدر است؟

سقف دقیق، به‌طور رسمی منتشر نمی‌شود چون بخشی از پیاده‌سازی داخلی است. در V8 (Chrome/Node.js)، معمولاً حدود 10,000 تا 15,000 قاب است. در Firefox (SpiderMonkey) و Safari (JavaScriptCore)، عدد کمی متفاوت است. در Node.js، می‌توانید با --stack-size=N سقف را تغییر دهید.

تفاوت این خطا در مرورگرها چیست؟

موتورهای مختلف، پیام‌های متفاوتی تولید می‌کنند:

  • Chrome/Node.js: RangeError: Maximum call stack size exceeded
  • Firefox: InternalError: too much recursion
  • Safari: RangeError: Maximum call stack size exceeded

مفهوم در همه یکسان است و راه‌حل مشابه. برای دیباگ بین مرورگری، از window.onerror یا ابزارهای مثل Sentry استفاده کنید.

آیا JavaScript از Tail Call Optimization پشتیبانی می‌کند؟

به‌طور کامل، نه. در V8 (Chrome/Node.js)، TCO پیاده‌سازی نشده است، حتی با "use strict". در SpiderMonkey (Firefox)، در حالت strict، TCO پیاده‌سازی شده است. ولی چون اکثر کاربران از Chrome استفاده می‌کنند، نمی‌توانید به TCO اعتماد کنید. راه‌حل، استفاده از Trampoline یا تبدیل به حلقه است.

چگونه در React، از این خطا جلوگیری کنم؟

سه رویکرد: اول، هرگز state را در رندر تغییر ندهید. دوم، useEffect را با dependency array صحیح بنویسید. سوم، برای componentهای بازگشتی، شرط توقف داشته باشید. مثال:

function Recursive({ depth = 0 }) {
    if (depth > 10) return null;
    return <Recursive depth={depth + 1} />;
}

چگونه Circular Reference را در JSON تشخیص دهم؟

سه رویکرد: اول، از JSON.stringify() با replacer استفاده کنید:

JSON.stringify(obj, (key, value) => {
    if (key === "self") return undefined;
    return value;
});

دوم، از کتابخانه‌هایی مثل flatted یا circular-json استفاده کنید. سوم، از structuredClone در مرورگرهای مدرن استفاده کنید.

آیا structuredClone برای همه انواع داده کار می‌کند؟

نه. structuredClone برای توابع، DOM nodes، و propertyهای getter کار نمی‌کند و خطا می‌دهد. برای این موارد، از پیاده‌سازی سفارشی با WeakMap استفاده کنید.

چگونه در async/await، از stack overflow جلوگیری کنم؟

در async/await، هر await باعث می‌شود که stack خالی شود. بنابراین بازگشت async باعث stack overflow نمی‌شود:

async function loop(n) {
    if (n <= 0) return;
    await new Promise(r => setTimeout(r, 0));
    return loop(n - 1);
}

loop(1000000);  // بدون stack overflow

چگونه در jQuery، از این خطا جلوگیری کنم؟

در jQuery، الگوهای زنجیره‌ای (chaining) به‌طور معمول باعث stack overflow نمی‌شوند. ولی استفاده از $.each() در پاسخ به تغییرات DOM می‌تواند بازگشت بی‌پایان ایجاد کند. راه‌حل: بررسی شرط قبل از تغییر DOM.

آیا Web Workers از Call Stack جداگانه استفاده می‌کنند؟

بله. هر Web Worker، یک محیط اجرایی جداگانه با Call Stack مستقل دارد. بنابراین، کدهای سنگین می‌توانند به Web Worker منتقل شوند تا stack اصلی مرورگر آزاد بماند. این رویکرد، در پردازش داده‌های بزرگ مؤثر است.

چگونه از این خطا در پیمایش درخت DOM جلوگیری کنم؟

از پیمایش iterative با stack صریح استفاده کنید:

function walkTree(root) {
    const stack = [root];
    while (stack.length > 0) {
        const node = stack.pop();
        processNode(node);
        stack.push(...node.children);
    }
}

چرا خطا در بعضی مرورگرها پیام متفاوتی می‌دهد؟

هر موتور JavaScript پیام‌های خطای متفاوتی تولید می‌کند. این تفاوت‌ها در استاندارد ECMAScript تعریف نشده و به پیاده‌سازی بستگی دارد. برای دیباگ بین مرورگری، از ابزارهایی مثل Sentry یا window.onerror استفاده کنید.

آیا استفاده از SetTimeout در بازگشت، Performance را کاهش می‌دهد؟

بله، ولی مقدار آن ناچیز است. setTimeout هر بار به event loop می‌رود و در صف اجرا قرار می‌گیرد. تاخیر اضافی، معمولاً چند میلی‌ثانیه است. برای بازگشت‌های بسیار عمیق، این تاخیر معادل است با صرفه‌جویی در stack.

چگونه در Node.js، از این خطا جلوگیری کنم؟

در Node.js، می‌توانید از --stack-size=N سقف stack را افزایش دهید، ولی این رویکرد توصیه نمی‌شود. راه‌حل بهتر: تبدیل بازگشت به حلقه، استفاده از Trampoline، یا async recursion. همچنین، از --max-old-space-size برای مدیریت heap استفاده کنید.

چگونه Maximum call stack را در تست‌ها شبیه‌سازی کنم؟

با ایجاد بازگشت بی‌پایان:

function infinite() { return infinite(); }
infinite();  // Maximum call stack size exceeded

برای تست try/catch، این رویکرد می‌تواند در unit testها مفید باشد.

تفاوت این خطا در JavaScript و Python چیست؟

در Python، خطای مشابه RecursionError نام دارد و سقف پیش‌فرض معمولاً 1000 است (قابل تغییر با sys.setrecursionlimit). در JavaScript، RangeError نام دارد و سقف بالاتر است ولی در مرورگرها کم‌تر از Node.js. اگر با Python هم کار می‌کنید، مباحث خطای RecursionError در پایتون تفاوت‌ها را به‌طور کامل باز کرده است.

آیا این خطا روی Performance تأثیر دارد؟

خود خطا در لحظه‌ی وقوع رخ می‌دهد. اگر مدیریت شود، از نظر performance هزینه‌ی ناچیزی دارد. ولی بازگشت‌های عمیق (حتی بدون خطا) می‌توانند performance را کاهش دهند چون هر قاب stack، حافظه و زمان مصرف می‌کند.

چگونه در Vue، از این خطا جلوگیری کنم؟

در Vue، از watcher با شرط توقف استفاده کنید یا computed properties را ترجیح دهید:

watch: {
    count(newVal, oldVal) {
        if (newVal < 100) this.count++;
    }
}

همچنین، از v-if برای conditional rendering استفاده کنید.

آیا استفاده از reduce در آرایه‌های بزرگ می‌تواند این خطا را بدهد؟

reduce در JavaScript به‌طور iterative پیاده‌سازی می‌شود، بنابراین خطا نمی‌دهد حتی برای آرایه‌های با میلیون‌ها عنصر. ولی flat() و flatMap() روی آرایه‌های عمیق می‌توانند خطا بدهند. راه‌حل: استفاده از پیاده‌سازی iterative.

آیا این خطا می‌تواند از یک کتابخانه‌ی شخص ثالث بیاید؟

بله. این اتفاق زمانی می‌افتد که کتابخانه از بازگشت عمیق استفاده کند یا Circular Reference را مدیریت نکند. راه‌حل: نسخه‌ی کتابخانه را به آخرین به‌روز کنید، changelog را بررسی کنید، و در صورت نیاز به نسخه‌ی سازگار بازگردید.

تفاوت این خطا با Infinity Loop چیست؟

Maximum call stack size exceeded از بازگشت بی‌پایان می‌آید و stack را پر می‌کند. Infinity Loop معمولاً به‌معنای یک حلقه‌ی while(true) یا for بدون شرط توقف است که CPU را 100٪ مصرف می‌کند بدون پر کردن stack. تفاوت در نوع منابع مصرفی: stack برای اولی، CPU برای دومی.

چگونه در Jest، این خطا را تست کنم؟

با toThrow(RangeError):

test("throws on infinite recursion", () => {
    const infinite = () => infinite();
    expect(() => infinite()).toThrow(RangeError);
});

آنچه از سال‌ها کار با Call Stack در JavaScript آموختم

اگر بخواهم چکیده‌ی این سال‌ها را در چند جمله بگویم، سه اصل عملی دارم:

یک: هر بازگشت، باید یک Base Case قابل‌دسترس داشته باشد. در تجربه‌ی من، بخش بزرگی از Maximum call stack size exceededها از نبود Base Case یا Base Case‌ای که هرگز برآورده نمی‌شود می‌آید. هر تابع بازگشتی را با این سوال شروع کنید: «شرط توقف چیست؟» و «آیا این شرط همیشه برآورده می‌شود؟».

دو: بازگشت عمیق در JavaScript خطرناک است. برخلاف بعضی زبان‌ها، JavaScript از TCO پشتیبانی کامل نمی‌کند. بنابراین، هر بازگشت عمیق (بیش از چند هزار سطح) را به حلقه یا Trampoline تبدیل کنید. این رویکرد، هم پایدارتر است و هم در بسیاری از موارد، سریع‌تر.

سه: Circular Reference را همیشه مدیریت کنید. در داده‌های واقعی (JSON از API، ساختارهای گرافی)، Circular Reference شایع است. همیشه با WeakMap یا WeakSet، اشیاء بازدیدشده را ردیابی کنید. اگر با مرورگرهای مدرن کار می‌کنید، از structuredClone استفاده کنید.

در کنار این سه اصل، یک هشدار عملی هم دارم: Maximum call stack size exceeded در نگاه اول یک مشکل ساده به‌نظر می‌رسد، ولی وقتی در چارچوب کلی معماری برنامه دیده شود، تبدیل به یک سیگنال می‌شود. این سیگنال می‌گوید که مدل داده‌ی شما، الگوهای بازگشتی شما، یا مرزهای فریم‌ورک‌های شما نیاز به بازنگری دارند. اگر این سیگنال را جدی بگیرید، پروژه‌ی شما در ماه‌های بعد پایدارتر و قابل نگهداری‌تر خواهد بود.

هدف این مقاله، تمام‌کردن همه‌ی سناریوهای ممکن نبود. هدف، دادن یک چارچوب ذهنی برای تشخیص، پیشگیری و رفع این خطا بود. وقتی این چارچوب را درونی کنید، برخورد با Maximum call stack size exceeded از یک واکنش اضطراری به یک فرآیند منظم تبدیل می‌شود. و این، همان تفاوتی است که بین توسعه‌دهنده‌ی معمولی و توسعه‌دهنده‌ای که به کدش اعتماد دارد، وجود دارد.

اگر این خطا در پروژه‌ی شما به شکلی ظاهر شده که با الگوهای این مقاله حل نشده، برای من جالب است بدانم کدام سناریو بود. تجربه‌ی خودتان را در دیدگاه‌ها بنویسید؛ به‌ویژه اگر راه‌حلی پیدا کرده‌اید که هنوز در این مقاله نیست. 🌀