حتماً برات پیش اومده که یه پروژه فرانت‌اند با JavaScript، React، Vue یا Angular ساخته باشی و در نگاه اول همه‌چیز کاملاً درست کار کنه. صفحه سریع باز میشه، کامپوننت‌ها بدون مشکل رندر میشن، درخواست‌ها به سرور ارسال میشن و حتی انیمیشن‌ها هم روان به نظر میرسن. اما وقتی چند دقیقه یا چند ساعت با برنامه کار می‌کنی، کم‌کم اوضاع تغییر می‌کنه. اسکرول سنگین میشه، بعضی کلیک‌ها با تأخیر جواب میدن، مصرف RAM مرورگر بالا میره و در حالت‌های بدتر ممکنه کل تب مرورگر کرش کنه.

اولین چیزی که معمولاً به ذهنمون میرسه اینه که سیستم کاربر ضعیفه، مرورگر مشکل داره یا تعداد کامپوننت‌های صفحه بیش از حد زیاده. این موارد ممکنه روی عملکرد برنامه تأثیر داشته باشن، اما خیلی وقت‌ها مشکل اصلی یه چیز پنهان‌تره: بخشی از حافظه که باید آزاد می‌شد، همچنان اشغال باقی مونده و هر بار که کاربر یک عملیات رو تکرار می‌کنه، مقدار بیشتری حافظه مصرف میشه. به این اتفاق Memory Leak یا نشت حافظه میگن.

Memory Leak از اون باگ‌هاییه که معمولاً در چند دقیقه اول تست پروژه خودش رو نشون نمیده. برنامه هیچ Error واضحی داخل Console نمایش نمیده و شاید تمام قابلیت‌ها هم به ظاهر درست کار کنن. مشکل زمانی دیده میشه که کاربر بارها بین صفحات جابه‌جا بشه، Modalها رو باز و بسته کنه، چند بار وارد یک بخش بشه یا برنامه رو برای مدت طولانی باز نگه داره. به همین دلیل ممکنه پروژه روی سیستم توسعه‌دهنده سالم به نظر برسه، اما در محیط واقعی بعد از مدتی کند و سنگین بشه.

Memory Leak دقیقاً یعنی چی؟

وقتی داخل JavaScript یک آرایه، آبجکت، تابع یا عنصر DOM می‌سازی، موتور JavaScript مقداری از حافظه رو به آن اختصاص میده. مدیریت این حافظه بیشتر به صورت خودکار انجام میشه و بخشی به اسم Garbage Collector وظیفه داره آبجکت‌هایی رو که دیگه قابل دسترسی نیستن پیدا کنه و حافظه آن‌ها رو آزاد کنه.

مثلاً کد زیر رو ببین:

let user = {
  name: "Ali",
  age: 28
};

user = null;

در ابتدا متغیر user به یک آبجکت داخل حافظه اشاره می‌کنه. وقتی مقدار آن رو برابر null قرار می‌دیم و هیچ Reference دیگری هم به آن آبجکت وجود نداره، Garbage Collector می‌تونه در زمان مناسب حافظه آن را آزاد کنه.

اما حالا این حالت رو در نظر بگیر:

const usersCache = [];

let user = {
  name: "Ali",
  age: 28
};

usersCache.push(user);
user = null;

با اینکه مقدار user برابر null شده، آبجکت هنوز داخل usersCache قرار داره و از طریق آن قابل دسترسیه. بنابراین Garbage Collector اجازه نداره آن را حذف کنه. اگر این Cache دائماً بزرگ‌تر بشه و هیچ داده‌ای از آن پاک نشه، به مرور حافظه بیشتری مصرف میشه.

پس Memory Leak زمانی اتفاق میفته که برنامه به چیزی که دیگه از نظر منطقی به آن نیاز نداره، همچنان Reference نگه داره. Garbage Collector نمی‌تونه حدس بزنه کدام داده واقعاً موردنیاز ماست. از دید آن، هر چیزی که هنوز قابل دسترسی باشه، باید داخل حافظه باقی بمونه.

چرا Garbage Collector همیشه مشکل را حل نمی‌کند؟

خیلی‌ها فکر می‌کنن چون JavaScript حافظه رو خودش مدیریت می‌کنه، پس Memory Leak در آن وجود نداره. این تصور اشتباهه. Garbage Collector فقط آبجکت‌هایی رو حذف می‌کنه که دیگه از هیچ مسیر فعالی قابل دسترسی نباشن؛ نه آبجکت‌هایی که ما از نظر منطقی دیگه لازم نداریم.

فرض کن داخل یک برنامه چت تمام پیام‌های دریافتی رو داخل آرایه messages ذخیره می‌کنی. رابط کاربری فقط 100 پیام آخر رو نمایش میده، اما آرایه اصلی تمام پیام‌هایی رو که از زمان باز شدن برنامه دریافت شدن نگه می‌داره. از دید Garbage Collector هیچ مشکلی وجود نداره، چون آرایه messages هنوز فعاله و تمام پیام‌ها از طریق آن قابل دسترسی هستن. اما از نظر طراحی برنامه، نگه داشتن هزاران پیام قدیمی کاملاً غیرضروریه.

متغیرهای Global؛ حافظه‌ای که تا پایان صفحه باقی می‌ماند

متغیرهای Global معمولاً تا زمانی که صفحه بازه در دسترس باقی می‌مونن. بنابراین هر داده‌ای که از طریق آن‌ها قابل دسترسی باشه نیز نمی‌تونه پاک بشه. یکی از اشتباه‌های قدیمی اینه که متغیری رو بدون let، const یا var ایجاد کنیم:

function loadUsers() {
  usersList = new Array(100000).fill({
    name: "Test User"
  });
}

در بعضی حالت‌ها usersList به آبجکت سراسری window اضافه میشه و تا بسته شدن صفحه داخل حافظه باقی می‌مونه. استفاده از Strict Mode جلوی این خطا رو می‌گیره:

"use strict";

function loadUsers() {
  usersList = [];
}

در این حالت JavaScript به جای ساختن متغیر ناخواسته، خطا میده. البته مشکل فقط متغیرهای اشتباهی نیست. ممکنه خودت عمداً یک Cache یا آرایه بزرگ رو در سطح Global تعریف کنی و بعد فراموش کنی اندازه آن رو محدود کنی. اصل مهم اینه که هر داده سراسری باید طول عمر و سیاست پاکسازی مشخصی داشته باشه.

Event Listenerهایی که حذف نمی‌شوند

یکی از رایج‌ترین دلایل Memory Leak در فرانت‌اند، Event Listenerهاییه که ثبت میشن اما هیچ‌وقت حذف نمیشن. فرض کن هر بار که یک Modal باز میشه، برای کلید Escape یک Listener روی document ثبت می‌کنی:

function openModal() {
  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape") {
      closeModal();
    }
  });
}

هر بار که openModal اجرا میشه، یک تابع جدید به عنوان Listener ثبت میشه. اگر Modal ده بار باز بشه، ده Listener روی document خواهیم داشت. حالا با فشردن Escape ممکنه closeModal چند بار اجرا بشه و همه این توابع نیز در حافظه باقی بمونن.

برای حذف Listener باید همان تابعی رو به removeEventListener بدیم که قبلاً ثبت شده:

function handleEscape(event) {
  if (event.key === "Escape") {
    closeModal();
  }
}

function openModal() {
  document.addEventListener("keydown", handleEscape);
}

function closeModal() {
  document.removeEventListener("keydown", handleEscape);
}

یه اشتباه رایج اینه که موقع حذف Listener دوباره یک تابع Anonymous بسازیم. این روش کار نمی‌کنه، چون هر تابع Anonymous یک Function Object جدید محسوب میشه و با تابع قبلی برابر نیست.

برای مدیریت راحت‌تر چند Listener می‌تونی از AbortController هم استفاده کنی:

const controller = new AbortController();

document.addEventListener(
  "keydown",
  handleEscape,
  { signal: controller.signal }
);

controller.abort();

با اجرای abort تمام Listenerهایی که با signal این Controller ثبت شدن حذف میشن.

Timerهایی که متوقف نمی‌شوند

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

function startDashboard() {
  setInterval(() => {
    fetch("/api/stats")
      .then(response => response.json())
      .then(updateDashboard);
  }, 5000);
}

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

راه درست اینه که شناسه Interval رو نگه داریم و هنگام خروج آن را متوقف کنیم:

let dashboardInterval;

function startDashboard() {
  dashboardInterval = setInterval(loadDashboardData, 5000);
}

function stopDashboard() {
  clearInterval(dashboardInterval);
  dashboardInterval = null;
}

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

Detached DOM؛ عنصری که حذف شده اما هنوز زنده است

Detached DOM به عنصری گفته میشه که از صفحه حذف شده، اما هنوز یک Reference جاوا اسکریپتی به آن وجود داره. از دید کاربر عنصر دیگه نمایش داده نمیشه، اما از دید حافظه همچنان زنده است.

const removedElements = [];

function removeCard() {
  const card = document.querySelector(".card");
  card.remove();
  removedElements.push(card);
}

عنصر card از DOM حذف شده، اما چون داخل removedElements ذخیره شده، Garbage Collector نمی‌تونه آن را پاک کنه. این عنصر ممکنه شامل ده‌ها فرزند، تصویر و Listener باشه؛ بنابراین یک Reference ساده می‌تونه کل یک زیرشاخه DOM رو داخل حافظه نگه داره.

اگر عنصری دیگه لازم نیست، Reference طولانی‌عمر آن را حذف کن:

let activeCard = document.querySelector(".card");

activeCard.remove();
activeCard = null;

لازم نیست تمام متغیرهای محلی رو به زور null کنی. این کار بیشتر زمانی مهمه که Reference داخل یک Store، Cache یا متغیر سراسری نگهداری شده باشه.

Closureها و نگه داشتن داده‌های بزرگ

Closure یکی از قابلیت‌های اصلی JavaScript هست. وقتی یک تابع داخلی به متغیرهای Scope بیرونی دسترسی داره، موتور JavaScript آن متغیرها رو برای استفاده بعدی نگه می‌داره.

function createHandler() {
  const hugeData = new Array(1000000).fill("JavaScript");

  return function handleClick() {
    console.log(hugeData[0]);
  };
}

const handler = createHandler();
document.addEventListener("click", handler);

تا وقتی Listener ثبت‌شده باشه، تابع handleClick زنده است و چون به hugeData دسترسی داره، آرایه بزرگ نیز داخل حافظه باقی می‌مونه. Closure ذاتاً مشکل‌ساز نیست؛ مشکل زمانی ایجاد میشه که طول عمر آن بیش از حد طولانی باشه یا داده‌های بزرگ و غیرضروری رو Capture کنه.

Cacheها و آرایه‌های نامحدود

Cache برای افزایش سرعت عالیه، اما اگر محدودیت نداشته باشه، می‌تونه حافظه رو به مرور پر کنه:

const userCache = new Map();

async function getUser(id) {
  if (userCache.has(id)) {
    return userCache.get(id);
  }

  const response = await fetch(`/api/users/${id}`);
  const user = await response.json();

  userCache.set(id, user);
  return user;
}

اگر برنامه اطلاعات کاربران زیادی رو دریافت کنه، Map دائماً بزرگ‌تر میشه. برای حل این مشکل باید زمان انقضا، محدودیت تعداد آیتم یا الگوریتمی مثل LRU داشته باشی.

همین مشکل در آرایه‌های Logging هم دیده میشه:

const MAX_LOGS = 200;
const logs = [];

function addLog(message) {
  logs.push({
    message,
    createdAt: new Date()
  });

  if (logs.length > MAX_LOGS) {
    logs.shift();
  }
}

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

Observer، WebSocket و Subscription

APIهایی مثل ResizeObserver، MutationObserver و IntersectionObserver باید در زمان پایان کار متوقف بشن:

const observer = new ResizeObserver(handleResize);
observer.observe(document.querySelector(".sidebar"));

// زمان پاکسازی
observer.disconnect();

اتصال‌های WebSocket و Subscriptionها هم باید بسته بشن:

socket.removeEventListener("message", handleMessage);
socket.close();

در RxJS نیز باید Subscription لغو بشه:

const subscription = stream.subscribe(handleValue);

// زمان پاکسازی
subscription.unsubscribe();

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

Memory Leak در React

در React بخش زیادی از مشکلات به useEffect مربوط میشه. هر چیزی که داخل Effect ایجاد میشه و خارج از کنترل خود React هست، احتمالاً به Cleanup نیاز داره.

نمونه اشتباه:

useEffect(() => {
  setInterval(() => {
    console.log("Loading...");
  }, 5000);
}, []);

نسخه درست:

useEffect(() => {
  const intervalId = setInterval(() => {
    console.log("Loading...");
  }, 5000);

  return () => {
    clearInterval(intervalId);
  };
}, []);

برای Event Listener هم باید تابع Cleanup داشته باشیم:

useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

برای Requestهای Fetch نیز میشه از AbortController استفاده کرد تا عملیات قدیمی هنگام Unmount شدن کامپوننت لغو بشه.

چطور Memory Leak را با Chrome DevTools پیدا کنیم؟

برای پیدا کردن Memory Leak باید یک سناریوی تکرارشونده بسازی. مثلاً وارد صفحه Profile شو، یک Modal رو باز کن، آن را ببند، از صفحه خارج شو و این کار رو ده بار تکرار کن. بعد بررسی کن آیا مصرف حافظه، تعداد Event Listenerها یا تعداد DOM Nodeها دائماً بیشتر شده یا نه.

داخل Chrome DevTools از منوی More Tools ابزار Performance Monitor رو باز کن. این بخش JS Heap Size، تعداد DOM Nodeها و Event Listenerها رو به صورت زنده نمایش میده. اگر بعد از هر بار باز و بسته کردن Modal تعداد Listenerها بیشتر میشه، احتمالاً Cleanup انجام نشده. اگر DOM Nodeها بعد از بستن صفحه کم نمیشن، ممکنه Detached DOM داشته باشی.

برای بررسی دقیق‌تر وارد تب Memory شو و Heap Snapshot بگیر. یک Snapshot در حالت اولیه ثبت کن، عملیات مشکوک رو چند بار انجام بده و Snapshot دوم رو بگیر. سپس دو Snapshot رو با هم مقایسه کن. آبجکت‌هایی که تعدادشان افزایش پیدا کرده و بعد از Garbage Collection همچنان باقی موندن، گزینه‌های مشکوکی هستن.

بخش Retainers یا Retaining Path بهت نشون میده چه چیزی مانع حذف یک آبجکت شده. ممکنه یک DOM Node از طریق Array، یک Callback از طریق Event Listener یا یک آبجکت از طریق Closure داخل حافظه نگه داشته شده باشه. معمولاً Retaining Path سریع‌ترین راه برای رسیدن به ریشه اصلی مشکل محسوب میشه.

آیا مصرف بالای RAM همیشه Memory Leak است؟

نه. برنامه‌های مدرن ممکنه به صورت طبیعی حافظه زیادی مصرف کنن. نمایش تصاویر بزرگ، هزاران ردیف، پردازش فایل یا اجرای بازی تحت وب می‌تونه RAM زیادی مصرف کنه، بدون اینکه نشت حافظه وجود داشته باشه.حتی بعد از آزاد شدن آبجکت‌ها، مرورگر ممکنه فوراً حافظه رو به سیستم‌عامل برنگردونه. به همین دلیل فقط نگاه کردن به Task Manager کافی نیست. چیزی که باید بررسی کنی، رشد مداوم و تکرارشونده حافظه بعد از انجام یک عملیات یکسانه.

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

یک چک‌لیست سریع قبل از انتشار پروژه

قبل از اینکه نسخه نهایی برنامه رو منتشر کنی، یک بار مسیرهای اصلی رو بررسی کن و ببین آیا منابعی که ساخته میشن، هنگام خروج از صفحه هم پاک میشن یا نه. چند بار بین Routeها جابه‌جا شو، Modalها رو باز و بسته کن و تعداد Listenerها و DOM Nodeها رو زیر نظر بگیر. مطمئن شو برای تمام Intervalها، Observerها، اتصال‌های WebSocket و Subscriptionها یک Cleanup مشخص وجود داره. Cacheها و آرایه‌های سراسری رو هم بررسی کن تا بدون محدودیت بزرگ نشن. این بررسی کوتاه شاید در ظاهر زمان‌بر به نظر برسه، اما می‌تونه جلوی ساعت‌ها عیب‌یابی بعد از انتشار و شکایت کاربران از کند شدن برنامه رو بگیره. 

جمع‌بندی

Memory Leak در JavaScript معمولاً به این دلیل ایجاد میشه که ما به آبجکتی که دیگه به آن نیاز نداریم، همچنان Reference نگه می‌داریم. از دید Garbage Collector آن آبجکت هنوز زنده است و حذف کردنش می‌تونه برنامه رو خراب کنه، بنابراین حافظه آن آزاد نمیشه.

Event Listenerهای حذف‌نشده، Timerهای متوقف‌نشده، Detached DOMها، Closureهای طولانی‌عمر، Cacheهای نامحدود، Observerها، WebSocketها و Subscriptionها از رایج‌ترین دلایل نشت حافظه هستن. در React، Vue و Angular نیز همین مشکل وجود داره و فقط روش Cleanup کمی متفاوته.

بهترین راه جلوگیری اینه که برای هر چیزی که ایجاد می‌کنی، زمان پایان آن را هم مشخص کنی. اگر Listener ثبت می‌کنی، محل حذفش رو هم بنویس. اگر Timer راه میندازی، جایی برای clear کردنش داشته باش. اگر Cache می‌سازی، محدودیت حجم و زمان انقضا براش تعریف کن. همین نگاه ساده باعث میشه برنامه بعد از ساعت‌ها استفاده هم سریع و پایدار باقی بمونه.