توسعه فرانت‌اند

JavaScript Memory Management: جلوگیری از Memory Leak

تفاوت حافظه زنده و leak، اصلاح ادعای reference حلقوی، cleanup تایمر و listener، cache و نمونه مستقل ساخت و حذف پنل با AbortSignal.

1404/12/26 3 دقیقه مطالعه 817 بازدید

حافظهٔ زنده با دادهٔ لازم یکی نیست

کاربر پنل را می‌بندد، اما timer آن هنوز هر ثانیه اجرا می‌شود. Callback به عنصرهای پنل اشاره دارد و برنامه هم راهی برای پایان دادن به آن ثبت نکرده است. از نگاه کاربر این بخش تمام شده؛ از نگاه runtime هنوز مسیر دسترسی به داده وجود دارد. Memory Leak معمولاً همین فاصله میان پایان نیاز برنامه و باقی ماندن reference است. بزرگ بودن مصرف حافظه به تنهایی leak نیست؛ شاید صفحه واقعاً دادهٔ زیادی نمایش می‌دهد. تشخیص به عمر داده و روند استفاده نیاز دارد.

JavaScript مدیریت حافظه و Garbage Collection را خودکار انجام می‌دهد. این خودکار بودن به معنی شناخت قصد محصول نیست. Runtime می‌تواند بفهمد object دیگر قابل دسترس نیست، اما نمی‌داند یک cache که از مسیر سراسری قابل دسترسی است، دیگر برای کاربر فایده ندارد. راهنمای مدیریت حافظه مدل reference و جمع‌آوری objectهای غیرقابل دسترس را توضیح می‌دهد. وظیفهٔ کد این است که عمر منابع و referenceهای بلندمدت را با نیاز واقعی هماهنگ کند.

Reference حلقوی به تنهایی leak نیست

دو object می‌توانند به هم اشاره کنند و وقتی هیچ مسیر دسترسی زنده‌ای به آن‌ها باقی نماند، قابل جمع‌آوری باشند. مشکل عمومی الگوریتم‌های قدیمی reference counting را نباید به همهٔ موتورهای امروزی JavaScript نسبت داد. به جای جست‌وجوی هر حلقه، مسیر نگهدارنده را پیدا کنید: global، listener روی window، cache مشترک یا callback زنده. یک object کوچک که دادهٔ بزرگی را نگه داشته، ممکن است علت اصلی باشد؛ اندازهٔ خود نگهدارنده تمام داستان را نشان نمی‌دهد.

همچنین null کردن هر متغیر در پایان هر تابع لازم نیست. متغیر محلی با پایان عمر مناسب می‌تواند بدون کار اضافه از دسترس خارج شود. Null کردن زمانی مفید است که reference واقعاً در یک محدودهٔ زنده باقی می‌ماند و دیگر لازم نیست. اگر object در چند جای دیگر هم نگه داشته شود، تغییر یک متغیر همهٔ referenceها را حذف نمی‌کند. تصمیم دربارهٔ cleanup باید از مسیر مالکیت داده بیاید، نه از یک قاعدهٔ کلی برای تمام assignmentها.

مالک منابع را هنگام ایجاد مشخص کنید

هر timer، observer یا subscription باید صاحب و پایان عمر مشخص داشته باشد. اگر یک component منبعی ایجاد می‌کند، حذف آن component معمولاً باید همان منبع را متوقف کند. Listener روی یک element جداشده همیشه به تنهایی leak نمی‌سازد؛ اگر کل مجموعه دیگر قابل دسترس نباشد، ممکن است جمع شود. Listener روی window که callback آن به پنل اشاره می‌کند، مسیر زندهٔ متفاوتی دارد. این تفاوت کمک می‌کند cleanup را روی منابع واقعی متمرکز کنیم، نه اینکه همهٔ listenerها را علت قطعی بدانیم.

برای listenerهای مرتبط می‌توان AbortSignal مشترک داشت و هنگام پایان عمر، controller را abort کرد. removeEventListener هم به هویت callback و گزینهٔ capture مناسب نیاز دارد. ایجاد تابع ناشناس تازه هنگام حذف، همان listener قبلی را معرفی نمی‌کند. مرجع addEventListener قرارداد signal و هویت تابع را بیان می‌کند. Abort کردن listener، timer را خودکار متوقف نمی‌کند؛ هر نوع منبع روش پایان خودش را دارد.

نمونهٔ کامل با ساخت و حذف پنل

کد زیر را با نام panel-lifetime.html ذخیره کنید و در مرورگر دارای پشتیبانی از AbortSignal برای listener باز کنید. فایل مستقل است و package، تصویر یا درخواست شبکه ندارد. دو دکمه یک پنل را می‌سازند و حذف می‌کنند. پنل یک interval و listener تغییر اندازه دارد. تابع mountPanel یک cleanup برمی‌گرداند؛ صاحب آن هنگام حذف پنل cleanup را اجرا و reference آن را پاک می‌کند. این ساختار برای توضیح مالکیت منابع است و گزارش اندازه‌گیری حافظه یا اجرای آزمایش ارائه نمی‌دهد.

<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>عمر پنل</title><style>body{font-family:system-ui,sans-serif;background:#edf3f4;color:#19303a;padding:2rem}main{max-width:38rem;margin:auto;padding:1.5rem;background:white;border-radius:1rem}button{font:inherit;padding:.6rem .9rem;background:#075c55;color:white;border:0;border-radius:.4rem;margin-inline-end:.5rem}:focus-visible{outline:3px solid #bb6500;outline-offset:3px}section{margin-block:1rem;padding:1rem;border:1px solid #96b0ba}</style></head><body><main><h1>عمر یک پنل</h1><button id="open" type="button">ساخت پنل</button><button id="close" type="button" disabled>حذف پنل</button><p id="status" role="status">پنل ساخته نشده است.</p><div id="slot"></div></main><script>
  const open = document.getElementById('open');
  const close = document.getElementById('close');
  const status = document.getElementById('status');
  const slot = document.getElementById('slot');
  let dispose = null;
  function mountPanel(container) {
    let panel = document.createElement('section');
    let output = document.createElement('p');
    const heading = document.createElement('h2');
    heading.textContent = 'وضعیت نمونه';
    panel.append(heading, output);
    container.append(panel);
    const controller = new AbortController();
    let ticks = 0;
    const render = () => { output.textContent = `تیک: ${ticks.toLocaleString('fa-IR')}؛ عرض صفحه: ${window.innerWidth}`; };
    window.addEventListener('resize', render, { signal: controller.signal });
    const timer = setInterval(() => { ticks += 1; render(); }, 1000);
    render();
    return () => {
      if (!panel) return;
      controller.abort();
      clearInterval(timer);
      panel.remove();
      panel = null;
      output = null;
    };
  }
  open.addEventListener('click', () => {
    if (dispose) return;
    dispose = mountPanel(slot);
    open.disabled = true;
    close.disabled = false;
    close.focus();
    status.textContent = 'پنل ساخته شد.';
  });
  close.addEventListener('click', () => {
    dispose?.();
    dispose = null;
    close.disabled = true;
    open.disabled = false;
    open.focus();
    status.textContent = 'پنل و منابع مربوط به آن آزاد شدند.';
  });
  window.addEventListener('pagehide', () => { dispose?.(); dispose = null; }, { once: true });
</script></body></html>

Cleanup چند بار هم که فراخوانی شود، پس از حذف نخست کار تازه‌ای انجام نمی‌دهد. controller listener را برمی‌دارد، clearInterval timer را متوقف می‌کند و panel از DOM حذف می‌شود. سپس referenceهای نگهداری‌شدهٔ پنل و خروجی null می‌شوند. خود تابع dispose نیز در صاحب اصلی null می‌شود تا closure بی‌دلیل زنده نماند. عنوان و وضعیت دکمه‌ها در همان HTML وجود دارند و focus هنگام حذف روی دکمهٔ فعال برمی‌گردد؛ از عنصر ناپدیدشده انتظار focus نداریم.

رویداد pagehide برای همین نمونه cleanup را فراخوانی می‌کند. سیاست نگهداری state در صفحات واقعی، به رفتار ناوبری و بازگشت صفحه وابسته است و ممکن است طراحی دیگری لازم باشد. متن status می‌گوید منابع مربوط متوقف شده‌اند، نه اینکه Garbage Collector فوراً تمام byteها را آزاد کرده است. زمان اجرای GC را از این دکمه تعیین نمی‌کنیم. تفاوت پایان منطقی منبع با زمان بازپس‌گیری حافظه، برای گزارش درست نتیجه مهم است.

Closure را بر اساس دادهٔ نگهداری‌شده بخوانید

Closure امکان دسترسی تابع به متغیرهای محیطش را فراهم می‌کند و بخش عادی زبان است. مشکل وقتی پیش می‌آید که تابعی بلندمدت، داده‌ای را بیشتر از نیاز نگه دارد. مثلاً handler یک فهرست بزرگ را فقط برای خواندن یک شناسه capture کرده است. می‌توان شناسهٔ لازم را جدا نگه داشت یا عمر handler را کوتاه کرد. حذف همهٔ closureها راه‌حل نیست؛ باید بفهمیم کدام reference لازم است و چه زمانی دیگر مصرف نمی‌شود.

Callback درخواست غیرهم‌زمان هم می‌تواند دادهٔ صفحهٔ قبلی را موقتاً نگه دارد. اگر عملیات تمام شود و reference آزاد گردد، این نگهداری موقت الزاماً leak نیست. درخواست‌هایی که بی‌پایان می‌مانند یا صف کارهایی که دائم اضافه می‌شود، مسئلهٔ متفاوتی دارند. لغو و کنترل نتیجهٔ قدیمی را در Promise و async/await بررسی کرده‌ایم. Cleanup باید نتیجهٔ دیررس را هم در نظر بگیرد تا صفحهٔ حذف‌شده دوباره از مسیر callback زنده نشود.

Cache نیاز به سیاست خروج دارد

Map سراسری از نتایج جست‌وجو ممکن است در ابتدا سریع‌تر باشد، ولی اگر هیچ حد اندازه یا زمان اعتبار ندارد، با استفادهٔ طولانی بزرگ می‌شود. این رشد از نظر GC مجاز است، چون Map هنوز قابل دسترس است. برای cache، سقف تعداد، سیاست حذف و زمان اعتبار را بر اساس داده انتخاب کنید. نگهداری تمام پاسخ‌ها با کلید یکتا، cache مفید برای همیشه نمی‌سازد. مسئولیت حذف باید در همان قرارداد ذخیره‌سازی ثبت شود.

WeakMap برای metadata مرتبط با objectهایی که نباید تنها به خاطر کلید cache زنده بمانند کاربرد دارد. اما فهرست‌پذیری و مشاهدهٔ زمان جمع‌آوری را مانند Map فراهم نمی‌کند. WeakRef و FinalizationRegistry هم ابزار عمومی برای cleanup ضروری و زمان‌بندی دقیق نیستند. فایل، اتصال یا عملیات لازم را نباید فقط به احتمال اجرای نهایی‌سازی سپرد. اگر پایان منبع برای درستی برنامه مهم است، cleanup صریح و قابل فراخوانی انتخاب روشن‌تری است.

مشاهدهٔ رشد، نقطهٔ شروع تشخیص است

در تشخیص واقعی، یک مسیر تکرارپذیر تعریف کنید: ورود به صفحه، ساخت چند پنل، حذف آن‌ها و بازگشت. سپس روند را در شرایط مشابه ببینید. افزایش حافظه پیش از GC طبیعی است و افت بعدی می‌تواند نشان دهد دادهٔ موقت آزاد شده است. یک عدد از یک لحظه، علت leak را مشخص نمی‌کند. راهنمای حافظه در Chrome DevTools روش‌های مشاهدهٔ heap و allocation را معرفی می‌کند. این نوشته ابزارها را توضیح می‌دهد و روی سایت زنده اندازه‌گیری انجام نمی‌دهد.

Heap Snapshot می‌تواند objectها و مسیرهای نگهداری آن‌ها را نشان دهد. Detached DOM یعنی node از document جدا شده، اما ممکن است به دلیل reference دیگری باقی مانده باشد. باید مسیر retaining را دنبال کنید و مالک را پیدا کنید؛ حذف تصادفی همهٔ referenceها به صفحه شاید رفتار لازم را بشکند. خود ابزار تشخیص و console هم گاهی object را برای بررسی نگه می‌دارند. سناریوی اندازه‌گیری باید این اثر را در نظر بگیرد و نتیجه با همان شرایط مقایسه شود.

State و منابع را با یک عمر ثابت فرض نکنید

یک store مشترک ممکن است از componentها بیشتر عمر کند. حذف یک صفحه به معنی پاک شدن خودکار دادهٔ آن store نیست. هنگام logout یا تغییر حساب، سیاست پاک‌سازی باید صریح باشد. مقالهٔ Zustand و عمر state این موضوع را ادامه می‌دهد. Observerهای رابط هم صاحب مشخص می‌خواهند؛ نمونهٔ Intersection Observer روش قطع مشاهده را نشان می‌دهد. یک الگوی cleanup می‌تواند برای چند منبع استفاده شود، ولی نوع پایان هر منبع متفاوت است.

برای پیشگیری، کنار ساخت هر منبع بنویسید چه کسی آن را می‌بندد و چه زمانی. Cleanup را در مسیر موفقیت و خطا حفظ کنید و صف یا cache را محدود نگه دارید. اگر برنامه مصرف حافظهٔ زیادی دارد، قبل از نسبت دادن آن به leak، دادهٔ ضروری، منابع بومی و پردازش موقت را جدا بررسی کنید. اصلاح خوب مسیر نگهداری غیرضروری را حذف می‌کند و رفتار لازم کاربر را نگه می‌دارد؛ هدف کاهش کورکورانهٔ همهٔ referenceها نیست.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!