بار اول که InternalError را در یک پروژه واقعی دیدم، در یک ویرایشگر متنی تحت مرورگر بود؛ کاربر با تایپ یک کاراکتر، کل صفحه سفید می‌شد و کنسول فقط یک پیام کوتاه نشان می‌داد: «too much recursion». آن روز فهمیدم این خطا برخلاف هم‌خانواده‌هایش، نه از ورودی خراب می‌آید و نه از اشتباه منطقی؛ از یک حلقه بی‌پایان درونی می‌آید که فقط با خواندن دقیق مسیر فراخوانی قابل کشف است.

InternalError در جاوااسکریپت دقیقاً چیست؟

InternalError یکی از کلاس‌های خطای جاوااسکریپت است که زمانی پرتاب می‌شود که موتور جاوااسکریپت به یک وضعیت داخلی غیرقابل ادامه برسد. برخلاف TypeError یا ReferenceError که از منطق کد شما ناشی می‌شوند، InternalError از محدودیت‌های درونی موتور می‌آید. شایع‌ترین پیام آن «too much recursion» است، ولی سناریوهای دیگری مثل «array initializer too large» یا «too many parentheses in regular expression» هم در مستندات آن دیده می‌شود. این کلاس خطا زیرمجموعه‌ای از Error است و همان پراپرتی‌های پایه — name، message، stack — را به ارث می‌برد.

نکته مهمی که در نگاه اول پنهان می‌ماند این است که InternalError فقط در برخی موتورها به‌عنوان یک نام مستقل وجود دارد. در مرورگر Firefox و موتور SpiderMonkey، این خطا با نام InternalError پرتاب می‌شود؛ ولی در Chrome، Edge و Safari، همان وضعیت با کلاس RangeError و پیام «Maximum call stack size exceeded» نمایش داده می‌شود. این تفاوت بین موتورها، یکی از اصلی‌ترین دلایل سردرگمی توسعه‌دهندگان است.

InternalError یک خطای استاندارد نیست؛ یک نام موتور-محور است که فقط در برخی پیاده‌سازی‌ها به‌عنوان کلاس مستقل ظاهر می‌شود.

اگر با خانواده کلاس‌های خطا آشنایی کامل ندارید، مرور «آموزش جاوااسکریپت از صفر» نقطه شروع مناسبی است؛ چون InternalError بدون درک سایر کلاس‌های خطا و تفاوت‌های آن‌ها، تنها یک نام عجیب به نظر می‌رسد.

چرا InternalError یک خطای غیراستاندارد است؟

یکی از پرتکرارترین سؤالاتی که در جلسات فنی مطرح می‌شود این است: چرا InternalError در استاندارد ECMAScript وجود ندارد، ولی در مرورگرها دیده می‌شود؟ پاسخ در فلسفه طراحی موتورهاست. استاندارد ECMAScript عمداً InternalError را به‌عنوان یک کلاس خطای الزامی معرفی نکرده، چون رفتار موتورها در مواجهه با محدودیت‌های درونی می‌تواند متفاوت باشد. به‌جای آن، استاندارد به موتورها اجازه داده که این وضعیت را با کلاس‌های موجود مثل RangeError گزارش کنند.

موتور SpiderMonkey تصمیم گرفت یک کلاس مستقل به نام InternalError اضافه کند تا در ابزارهای دیباگ، تفاوت بین «خطای محدوده عدد» و «خطای محدودیت درونی موتور» قابل تشخیص باشد. همین انتخاب، باعث شد امروز در مستندات MDN، InternalError با برچسب «non-standard» علامت‌گذاری شود. یعنی هرچند در عمل قابل استفاده است، ولی هیچ تضمینی وجود ندارد که در همه موتورها یا همه نسخه‌ها با همین نام ظاهر شود.

در پروژه‌های چندمرورگری، این تفاوت یک تله واقعی است. اگر کد شما به‌طور صریح instanceof InternalError را چک کند، در Chrome و Safari این شرط هرگز برقرار نمی‌شود و خطا از دست شما می‌رود. راه‌حل استاندارد، چک کردن هم‌زمان دو کلاس است. برای درک بهتر تفاوت‌های بین موتورهای جاوااسکریپت، مرور «آموزش es6 در جاوااسکریپت» می‌تواند مفید باشد؛ چون بخشی از تغییرات نسخه‌های جدید، به همین تفاوت‌های بین موتوری مربوط می‌شود.

یک نکته ظریف دیگر: حتی در Firefox، InternalError ممکن است در نسخه‌های مختلف نام متفاوتی داشته باشد یا در بعضی حالت‌ها به RangeError تبدیل شود. به همین دلیل، در تجربه من، اتکا به نام کلاس خطا برای تعیین رفتار برنامه، یک تصمیم شکننده است. بهترین رویکرد، تشخیص مبتنی بر محدودیت است، نه نام کلاس.

غیراستاندارد بودن InternalError یعنی نباید روی نامش حساب باز کنید؛ باید روی ماهیت محدودیتی که آن را فعال کرده تمرکز کنید.

تفاوت InternalError و RangeError در مرورگرهای مختلف

برای اینکه در پروژه‌های واقعی بتوانید سریع‌تر خطا را تشخیص دهید، بیایید رفتار موتورهای اصلی را در یک جدول کنار هم بگذاریم:

موتور / مرورگرنام کلاس خطاپیام رایج
SpiderMonkey (Firefox)InternalErrortoo much recursion
V8 (Chrome, Edge, Node.js)RangeErrorMaximum call stack size exceeded
JavaScriptCore (Safari)RangeErrorMaximum call stack size exceeded

این جدول، یکی از مهم‌ترین نکاتی را روشن می‌کند که در پرونده‌های دیباگ زیاد به کارم آمده: اگر کد شما برای Firefox نوشته شده و InternalError را می‌گیرد، در Chrome و Safari همان وضعیت با یک کلاس خطای متفاوت پرتاب می‌شود. بنابراین، هر منطق مدیریت خطا که بر اساس نام کلاس نوشته شده، باید یا هر دو را پوشش دهد یا از یک رویکرد عمومی‌تر استفاده کند.

در کنار این تفاوت، یک نکته دیگر هم وجود دارد: پیام خطا در موتورهای مختلف متفاوت است. V8 در نسخه‌های جدید پیام «Maximum call stack size exceeded» را دقیق‌تر می‌کند و گاهی اشاره‌ای به عمق stack دارد، در حالی که SpiderMonkey معمولاً پیام «too much recursion» را بدون جزئیات بیشتر برمی‌گرداند. به همین دلیل، اگر سیستم لاگ شما فقط پیام خطا را ثبت می‌کند، در پروژه‌های چندمرورگری، تحلیل خطا با دشواری بیشتری انجام می‌شود.

در نسخه‌های قدیمی‌تر مرورگرها، محدودیت‌های دیگری هم وجود داشت. برای مثال، در نسخه‌های قدیمی Firefox، پیام «too much recursion» وقتی رخ می‌داد که عمق بازگشت از یک آستانه عبور می‌کرد. آستانه دقیق در نسخه‌های مختلف متفاوت بوده و حتی به تنظیمات موتور بستگی داشته. به همین دلیل، هیچ‌وقت نباید روی یک عدد مشخص به‌عنوان «حداکثر عمق مجاز» حساب باز کنید. برای درک بهتر این نوع خطاها در کنار سایر خطاهای محدوده، مرور «خطای RangeError در جاوااسکریپت» می‌تواند دید جامع‌تری بدهد.

پنج سناریویی که InternalError را فعال می‌کنند

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

سناریو اول: بازگشت بدون شرط پایه (missing base case)

شایع‌ترین حالت. تابعی که خودش را بدون یک شرط توقف فراخوانی می‌کند، در نهایت با پر شدن stack موتور را متوقف می‌کند. این الگو در کدهای آموزشی زیاد دیده می‌شود، ولی در پروژه‌های واقعی هم گاهی رخ می‌دهد؛ مخصوصاً در توابع بازگشتی که پردازش درخت یا گراف را انجام می‌دهند و شرط توقف در یک حالت خاص فراموش می‌شود:

function traverse(node) {
  // شرط توقف فراموش شده
  return traverse(node.child);
}

سناریو دوم: شرط توقف با مقدار بسیار بزرگ

گاهی شرط توقف وجود دارد، ولی آستانه آنقدر بزرگ انتخاب شده که عملاً هرگز در عمل به آن نمی‌رسیم. مثلاً تنظیم آستانه روی یک عدد نجومی، به‌جای یک مقدار منطقی. موتور جاوااسکریپت قبل از رسیدن به آن عدد، stack را پر می‌کند و خطا می‌دهد.

سناریو سوم: بازگشت بی‌پایان در setter و getter

این سناریو، یکی از پنهان‌ترین علت‌های InternalError است. وقتی یک setter یا getter، پراپرتی‌ای را مقداردهی می‌کند که خودش را دوباره فعال می‌کند، بازگشت بی‌پایان شکل می‌گیرد. مثال کلاسیک آن، setterای است که به‌جای پراپرتی داخلی، همان پراپرتی عمومی را تنظیم می‌کند:

class Person {
  set name(name) {
    this.name = name; // بازگشت بی‌پایان
  }
}

راه‌حل استاندارد، استفاده از یک پراپرتی داخلی با پیشوند زیرخط است: this._name = name. همین تغییر کوچک، بازگشت را قطع می‌کند. برای درک عمیق‌تر این الگو و سایر مفاهیم شی‌گرایی، مرور «شی گرایی در جاوااسکریپت» پیشنهاد می‌شود.

سناریو چهارم: بازگشت در میان توابع async

در کدهای async، اگر یک تابع به‌اشتباه خودش را بدون await مناسب فراخوانی کند یا یک Promise را درون خودش تکرار کند، بازگشت می‌تواند شکل بگیرد. این نوع بازگشت معمولاً سریع‌تر از بازگشت همگام نیست، ولی در نهایت به همان خطا می‌رسد. برای درک بهتر رفتار زنجیره‌های async، مرور «Promise در جاوااسکریپت» و «async و await در جاوااسکریپت» می‌تواند در تشخیص این الگو کمک کند.

سناریو پنجم: ساختارهای بسیار بزرگ

علاوه بر بازگشت، ساختارهای بسیار بزرگ مثل آرایه‌های بسیار طولانی یا الگوهای بسیار پیچیده regex می‌توانند موتور را به محدودیت درونی برسانند و خطا را فعال کنند. در مستندات MDN، سه مثال اصلی برای این دسته ذکر شده: «array initializer too large»، «too many parentheses in regular expression» و «too many switch cases». در عمل، این دسته کمتر از بازگشت دیده می‌شود، ولی وقتی رخ می‌دهد، تشخیص آن ساده‌تر است چون به یک داده مشخص اشاره می‌کند.

در تجربه من، یک سناریوی ششم هم وجود دارد که در پروژه‌های با کتابخانه‌های سنگین زیاد دیده می‌شود: الگوهای پروکسی و getterهای خودکار که به‌طور نامحسوس بازگشت ایجاد می‌کنند. این الگوها در فریم‌ورک‌های reactive مثل Vue و MobX رایج‌ترند. اگر در پروژه‌ای با این کتابخانه‌ها کار می‌کنید، همیشه stack trace کامل را بررسی کنید تا ببینید بازگشت در کد شماست یا در کتابخانه.

دام پنهان: بازگشت بی‌پایان در setter و getter

از میان همه سناریوها، این یکی بیشترین زمان را در پرونده‌های واقعی از من گرفته. علتش این است که خطا، در نگاه اول بی‌ربط به نظر می‌رسد؛ چون setter و getter در ظاهر ساده‌اند. ولی وقتی موتور جاوااسکریپت به‌جای پراپرتی داخلی، همان پراپرتی عمومی را درون setter تنظیم می‌کند، یک حلقه بی‌پایان شکل می‌گیرد که فقط با نگاه دقیق به کد قابل تشخیص است.

مثال زیر دو حالت را مقایسه می‌کند: حالت شکسته و حالت درست:

// حالت شکسته
class User {
  set displayName(value) {
    this.displayName = value; // بازگشت بی‌پایان
  }
}

// حالت درست
class User {
  set displayName(value) {
    this._displayName = value;
  }
  get displayName() {
    return this._displayName;
  }
}

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

در پروژه‌های بزرگ، این الگو گاهی از طریق ترکیب prototype و وراثت شکل می‌گیرد. اگر کلاس فرزند یک setter را override کند و درون آن از super استفاده نکند، ممکن است به‌طور ناخواسته همان setter والد را دوباره فعال کند. این نوع بازگشت، در نگاه اول از الگوی ساده setter متفاوت به نظر می‌رسد ولی ریشه‌اش یکی است: تنظیم یک پراپرتی که خودش را فرا می‌خواند.

یک سناریوی دیگر که در تیم‌های پروژه‌ای دیده‌ام، بازگشت در Proxyهاست. وقتی یک Proxy تمام پراپرتی‌ها را redirect می‌کند و handler خودش را دوباره فرا می‌خواند، یک حلقه بی‌پایان شکل می‌گیرد. این نوع بازگشت، به‌شکل خاص سخت‌تر دیباگ می‌شود چون stack trace آن کوتاه‌تر و مبهم‌تر است. در این موارد، استفاده از Reflect و هدف اصلی Proxy به‌جای خود Proxy، مشکل را حل می‌کند.

در بازگشت‌های ناشی از setter و getter، پاسخ درست همیشه در پراپرتی داخلی است؛ نه در تلاش برای محدود کردن عمق بازگشت.

چطور InternalError فعلی را در پروژه ایزوله کنیم؟

فرض کنید همین امروز یک InternalError در محیط تولید ظاهر شده و باید ریشه‌اش را پیدا کنید. روشی که در این نوع پرونده‌ها به کار می‌گیرم، چهار گام دارد و هر گام، یک شرط را در ذهن من حذف می‌کند.

گام اول: نگاه به name و message

اولین کاری که می‌کنم، نگاه به error.name و error.message است. اگر name دقیقاً "InternalError" باشد، با موتور Firefox طرفم. اگر name برابر "RangeError" و پیام «Maximum call stack size exceeded» باشد، با V8 یا JavaScriptCore طرفم. این تشخیص سریع، مسیر دیباگ را کوتاه می‌کند.

گام دوم: نگاه به stack trace

گام دوم، بررسی stack trace است. در بازگشت‌های ساده، stack trace یک الگوی تکراری دارد: همان تابع یا مجموعه کوچکی از توابع، پشت سر هم تکرار شده‌اند. شمارش تعداد لایه‌های تکراری، می‌تواند نشان دهد که بازگشت از کجا شروع شده. اگر stack trace خیلی کوتاه و مبهم بود، احتمال دارد بازگشت در setter، getter یا Proxy شکل گرفته باشد؛ چون این الگوها stack را کوتاه‌تر نگه می‌دارند.

گام سوم: برش دادن تابع بازگشتی

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

گام چهارم: ثبت شمارنده در تابع

در بازگشت‌های پنهان، یک تکنیک ساده و مؤثر این است که یک شمارنده در تابع بگذارید و مقدار آن را در حداکثر ۱۰۰ بار اول لاگ کنید. اگر شمارنده به‌سرعت به هزاران رسید، بازگشت تأیید می‌شود. اگر شمارنده به عدد مشخصی رسید ولی بازگشت ادامه پیدا کرد، احتمال دارد بازگشت از یک مسیر دیگر بیاید. این تکنیک در پرونده‌های واقعی، به‌شکل چشمگیری زمان دیباگ را کوتاه کرده است.

ابزارهای مرورگر در این گام کمک بزرگی هستند؛ فهرست کامل‌تر آن‌ها را در «ابزارهای اشکال‌زدایی جاوااسکریپت» جمع کرده‌ام. یکی از ابزارهای مفید در این مسیر، امکان تنظیم breakpoint روی تابع بازگشتی است؛ با این کار می‌توانید تعداد فراخوانی‌ها را در چند ثانیه شمارش کنید.

الگوهای رفع و پیشگیری در کد مدرن

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

الگوی اول: شرط پایه صریح

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

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

در بسیاری از سناریوها، بازگشت فقط یک ابزار برای پیاده‌سازی حلقه است. در این موارد، تبدیل بازگشت به یک حلقه while یا for، عمق stack را به صفر می‌رساند و کلاس خطا را حذف می‌کند. این تغییر، در پردازش درخت‌ها و گراف‌های بزرگ که عمق بازگشت زیاد می‌شود، بسیار کاربردی است:

// بازگشتی
function sum(arr, index = 0, total = 0) {
  if (index >= arr.length) return total;
  return sum(arr, index + 1, total + arr[index]);
}

// حلقه‌ای
function sum(arr) {
  let total = 0;
  for (let i = 0; i < arr.length; i++) total += arr[i];
  return total;
}

الگوی سوم: محدود کردن عمق بازگشت

در الگوریتم‌هایی که بازگشت ذاتی دارند — مثل پیمایش درخت تصمیم — می‌توانید یک پارامتر depth اضافه کنید و وقتی از آستانه مشخصی عبور کرد، خطای کنترل‌شده پرتاب کنید. این کار، به‌جای سقوط ناگهانی، یک پیام معنادار به لایه بالاتر می‌دهد:

function process(node, depth = 0) {
  if (depth > 500) {
    throw new Error("Maximum traversal depth exceeded");
  }
  // ادامه پردازش
}

الگوی چهارم: مدیریت خطای متمرکز

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

الگوی پنجم: تست‌های تجاوز از مرز

نوشتن تست‌هایی که به‌شکل عامدانه عمق بازگشت را به مرزهای بالا می‌برند، یک راه مؤثر برای پیشگیری است. اگر تست‌های شما همیشه با داده‌های کوچک کار می‌کنند، احتمال دارد در تولید با داده‌های بزرگ‌تر خطا ظاهر شود. اضافه کردن چند تست با آرایه‌های بزرگ، درخت‌های عمیق و رشته‌های طولانی، این شکاف را از بین می‌برد.

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

اشتباهات رایجی که این خطا را تشدید می‌کنند

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

  • افزایش سقف stack به‌جای رفع بازگشت. در Node.js با فلگ --stack-size می‌توان سقف stack را بالا برد. این کار فقط خطا را به تعویق می‌اندازد؛ ریشه بازگشت هنوز وجود دارد و در داد‌های بزرگ‌تر دوباره ظاهر می‌شود.
  • استفاده از try/catch بدون عمق. اگر بازگشت بی‌پایان در لایه‌ای عمیق رخ دهد، try/catch نمی‌تواند بازگشت را متوقف کند. تنها راه، قطع بازگشت در همان نقطه‌ای است که شکل می‌گیرد.
  • نادیده گرفتن تفاوت موتورها. اگر کد شما فقط در Chrome تست شده، احتمال دارد در Firefox با نام InternalError به‌شکل متفاوتی ظاهر شود. تست در چند مرورگر، بخشی از راه‌حل است.
  • بازگشت‌های پنهان در کتابخانه‌ها. اگر خطا از یک کتابخانه شخص ثالث می‌آید، نباید فرض کنید مشکل از کد شما نیست. باید stack trace را بررسی کنید تا ببینید بازگشت از کجا شروع شده.
  • نادیده گرفتن بازگشت در Proxy و getter. این نوع بازگشت stack را کوتاه نگه می‌دارد و به‌همین دلیل در تست‌های ساده دیده نمی‌شود. در پروژه‌های reactive، بررسی دقیق‌تر این الگو ضروری است.
  • عدم مستندسازی محدودیت‌های عمق. اگر تابع شما یک آستانه عمق دارد، باید آن را در کد و در مستندات پروژه ثبت کنید. تیمی که نداند سقف کجاست، به‌سرعت آن را می‌شکند.

در کنار این شش اشتباه، یک الگوی مکرر دیگر هم وجود دارد که در تیم‌های بزرگ دیده‌ام: تعیین آستانه‌های مختلف برای بازگشت در بخش‌های مختلف کد. این عدم یک‌دستی، باعث می‌شود در بعضی مسیرها خطا خیلی زود ظاهر شود و در بعضی دیگر دیرتر. یک سیاست واحد در سراسر پروژه، بهترین راه‌حل است.

تست کردن بازگشت با ماتریس سناریو

چیزی که در پروژه‌های بالغ به‌شکل منظم دیده‌ام، تست‌های اختصاصی برای بازگشت است. ماتریسی که در پروژه‌ها استفاده می‌کنم، این شکلی است:

سناریوورودیخروجی مورد انتظار
بازگشت با شرط پایهعدد کوچکنتیجه صحیح
بازگشت بدون شرط پایههر ورودیInternalError یا RangeError
آستانه بسیار بزرگعدد نجومیInternalError یا RangeError
بازگشت در setterمقداردهی پراپرتیInternalError یا RangeError
بازگشت در getterخواندن پراپرتیInternalError یا RangeError
بازگشت در Proxyدرخواست پراپرتیInternalError یا RangeError
ساختار بزرگ (آرایه)آرایه با میلیون‌ها عضوInternalError یا RangeError
الگوی regex بزرگregex با پرانتزهای زیادInternalError یا RangeError

هر ردیف از این ماتریس، یک سناریوی واقعی را پوشش می‌دهد. اگر این تست‌ها را در پروژه خود بگذارید، نه‌فقط خطاهای زمان اجرا را زودتر می‌گیرید، بلکه وقتی تیم شما بزرگ‌تر می‌شود، رفتار برنامه در برابر تغییرات، قابل پیش‌بینی می‌ماند.

یک نکته در تست‌های بازگشتی: مطمئن شوید که تست‌ها به‌طور واقعی به مرز بازگشت می‌رسند. بعضی از فریم‌ورک‌های تست، خطاها را در لایه‌ای بالاتر می‌گیرند و اگر شما به آن توجه نکنید، تست‌های موفق می‌سازید که در محیط واقعی شکست می‌خورند. تنظیم breakpoint روی تابع بازگشتی، در این مرحله کمک بزرگی می‌کند.

پرسش‌های پرتکرار درباره خطای InternalError

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

آیا InternalError در همه مرورگرها ظاهر می‌شود؟

خیر. InternalError به‌عنوان یک کلاس مستقل، فقط در موتور SpiderMonkey (Firefox) دیده می‌شود. در V8 (Chrome، Edge، Node.js) و JavaScriptCore (Safari) همان وضعیت با کلاس RangeError و پیام «Maximum call stack size exceeded» نمایش داده می‌شود.

آیا می‌توانم به‌طور صریح InternalError را در کد چک کنم؟

می‌توانید، ولی به‌شرط اینکه بدانید در بقیه مرورگرها این شرط برقرار نمی‌شود. توصیه من این است که علاوه بر instanceof InternalError، instanceof RangeError را هم چک کنید تا در همه موتورها پوشش داده شود.

چرا InternalError در Node.js با نام دیگری ظاهر می‌شود؟

چون Node.js روی موتور V8 کار می‌کند و V8 از کلاس RangeError برای این وضعیت استفاده می‌کند. این تفاوت، بخشی از تفاوت‌های موتور است و نباید آن را به‌عنوان باگ در نظر گرفت.

آیا افزایش stack size راه‌حل است؟

افزایش stack size فقط خطا را به تعویق می‌اندازد. اگر بازگشت بی‌پایان باشد، در نهایت با داده‌های بزرگ‌تر دوباره خطا ظاهر می‌شود. راه‌حل واقعی، قطع بازگشت در ریشه است.

آیا بازگشت در Promiseها هم InternalError می‌دهد؟

بله. اگر یک زنجیره Promise به‌شکل بی‌پایان تکرار شود، در نهایت موتور با محدودیت stack مواجه می‌شود. نوع خطا بسته به موتور، InternalError یا RangeError است. برای درک بهتر زنجیره‌های async، مرور «Unhandled Promise Rejection» می‌تواند مفید باشد.

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

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

آیا الگوی بازگشت در setter را می‌توان با تعریف پراپرتی داخلی حل کرد؟

بله. این استانداردترین راه‌حل است. با استفاده از یک پراپرتی داخلی (مثلاً با پیشوند _)، setter و getter به‌جای فراخوانی خودشان، به آن پراپرتی دسترسی پیدا می‌کنند و بازگشت قطع می‌شود.

نگاه معمارانه: مهار عمق بازگشت به‌عنوان تصمیم طراحی

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

لایه اول: تعیین سیاست واحد عمق

تیم‌های حرفه‌ای، برای همه توابع بازگشتی یک سیاست واحد تعیین می‌کنند. مثلاً «عمق بازگشت در هیچ تابعی نباید از ۵۰۰ عبور کند». این سیاست، در بازبینی کد اجرا می‌شود و در مستندات پروژه ثبت می‌گردد. با یک سیاست واحد، رفتار برنامه در همه مسیرها قابل پیش‌بینی می‌شود.

لایه دوم: تبدیل بازگشت به الگوریتم صریح

در بسیاری از الگوریتم‌ها، بازگشت فقط یک پیاده‌سازی طبیعی است، نه بخشی از منطق. در این موارد، تبدیل بازگشت به یک حلقه صریح (با stack یا queue دستی) نه‌فقط خطا را حذف می‌کند، بلکه کنترل عمق را هم به‌طور کامل در اختیار برنامه می‌گذارد. این تصمیم، در پردازش داده‌های بزرگ، تفاوت بین «کار می‌کند» و «مقیاس‌پذیر است» را می‌سازد.

لایه سوم: ثبت و پایش عمق در تولید

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

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

عمق بازگشت یک منبع محدود است؛ هر سیاستی که این منبع را مدیریت کند، از ظهور InternalError در تولید جلوگیری می‌کند.

یک عادت کوچک، یک کلاس خطای قابل پیش‌بینی

InternalError در نگاه اول کلاس خطای گیج‌کننده‌ای به نظر می‌رسد، چون هم غیراستاندارد است و هم بین موتورها تفاوت دارد. ولی در واقع، ریشه همه نمونه‌های آن یک چیز است: یک محدودیت درونی که موتور را وادار به توقف می‌کند. اگر این محدودیت را بشناسید و در طراحی کد لحاظ کنید، این کلاس خطا از یک خطر پنهان به یک هشدار قابل مدیریت تبدیل می‌شود.

سه عادت عملی که پیشنهاد می‌کنم همین امروز شروع کنید: اول، در هر تابع بازگشتی، یک شرط پایه صریح بگذارید و آن را در بازبینی کد چک کنید. دوم، بازگشت‌های پنهان در setter، getter و Proxy را با استفاده از پراپرتی داخلی از میان بردارید. سوم، در تست‌های خود ماتریس سناریوهای بازگشتی را بگنجانید تا رفتار این خطا در زمان تغییرات ناخواسته، قابل پیش‌بینی بماند.

اگر خطای مشابهی را در یک پروژه واقعی تجربه کرده‌اید — مخصوصاً جایی که ریشه بازگشت از آنچه انتظار داشتید دور بوده — تجربه‌تان را در دیدگاه بنویسید. برای من جالب است بدانم کدام بخش از تشخیص این خطا بیشترین زمان شما را گرفت، و آیا الگویی پیدا کردید که با آنچه در این متن آمده، تفاوت داشت. تجربه‌های واقعی شما، این متن را برای خواننده بعدی دقیق‌تر می‌کند. 🔍