اپلیکیشن شما ده دقیقه خوب کار میکند. سپس اسکرول کردن سنگین (sticky) میشود. بعد از نیم ساعت، مصرف حافظه تب به یک گیگابایت میرسد. در نهایت، صفحه با خطای کمبود حافظه (out-of-memory) از کار میافتد و هیچ stack trace ای برای نشان دادن ندارید.
این یک مشکل عملکرد رندر (render performance) نیست. React DevTools Profiler آرام به نظر میرسد، زیرا مشکل بر سر تعداد دفعات بازرسم (redraw) کامپوننتها نیست. مشکل اینجاست که چه چیزهایی پس از unmount شدن، همچنان زنده میمانند. یک ارجاع (reference) سرگردان در جایی از JavaScript heap، کل درختی از DOM nodes، closureها و state را نگه میدارد. مرورگر نمیتواند هیچکدام از آنها را آزاد کند، بنابراین مصرف حافظه آنقدر بالا میرود تا فرآیند (process) از کار بیفتد.
خواندن کد منبع شما، نشانی از نشتی (leak) نخواهد داشت. این باگ در شکاف میان آنچه شما فکر میکنید unmount شده و آنچه garbage collector واقعاً میبیند، نهفته است. V8 فقط اشیایی را آزاد میکند که هیچ مسیر نگهدارندهای (retaining path) نداشته باشند. اگر یک event listener سرگردان، یک observer پاکنشده، یا یک closure طولانیمدت حتی یک اشارهگر (pointer) به یک fiber یا یک DOM node داشته باشد، کل زیردرخت (subtree) کامپوننت زنده میماند. شما یک modal را unmount میکنید، اما گرههای جدا شده (detached nodes) آن در حافظه باقی میمانند، زیرا یک listener روی window هنوز به یک handler که داخل آن modal تعریف شده، اشاره میکند.
برای اثبات اینکه نشتی کجا رخ میدهد، باید به heap نگاه کنید، نه به ویرایشگر کد.
چرا heap حقیقت را میگوید
Chrome DevTools پنجرهای مستقیم به آنچه garbage collector میبیند، به شما میدهد. تب Memory میتواند heap snapshotها را ثبت کند: فهرست کاملی از هر object، DOM node و closure که در حال حاضر در حافظه JavaScript نگه داشته شده است. با مقایسه دو snapshot — یکی قبل از نشتی مشکوک و دیگری بعد از آن — میتوانید دقیقاً مشخص کنید کدام اشیا نتوانستهاند از بین بروند.
این یک تئوری انتزاعی نیست. یک کامپوننت React که دچار نشتی شده باشد، میتواند هزاران شیء HTMLElement جدا شده (detached) را نگه دارد. این اشیا دیگر به سند (document) قابل مشاهده متصل نیستند، اما ارجاعات JavaScript مانع از جمعآوری (collection) آنها میشود. آنها در نمای مقایسهای (comparison view) با نام سازنده Detached HTMLElement ظاهر میشوند. وقتی میبینید تعداد آنها در حال افزایش است، نشتی خود را پیدا کردهاید.
گردش کار در Chrome DevTools
از حالت پاک شروع کنید. تبهای مرورگر بیربط را ببندید، افزونههای (extensions) بیربط را غیرفعال کنید و اجازه دهید اپلیکیشن شما به یک حالت پایدار (steady state) برسد. Chrome DevTools را باز کنید، به تب Memory بروید و Heap snapshot را انتخاب کنید. روی Take snapshot کلیک کنید. این نقطه مرجع (baseline)، ردپای حافظه اولیه شما را ثبت میکند.
حالا دقیقاً همان اقدام کاربری را که به آن مشکوک هستید انجام دهید. آن modal سنگین را باز و بسته کنید. ویجت را mount و unmount کنید. به یک route بروید و برگردید. هنگامی که رابط کاربری (UI) به حالت بصری اولیه خود بازگشت، روی آیکون سطل زباله در تب Memory کلیک کنید. این کار یک مرحله garbage collection سراسری را اجبار میکند. اشیای موقت مربوط به چرخه رندر (render cycle) باید حذف شوند. هر چیزی که باقی بماند، کاندیدای واقعی برای یک نشتی است.
دوباره روی Take snapshot کلیک کنید. اکنون دو تصویر از حافظه دارید. نما را از Summary به Comparison تغییر دهید. محدوده مقایسه (comparison scope) را روی اولین snapshot تنظیم کنید. ابزار فقط مواردی را که بین دو تصویر تغییر کردهاند به شما نشان میدهد و نویزهای زمان اجرا (runtime) را حذف میکند.
بر اساس Delta مرتب کنید. به دنبال تعداد اشیایی بگردید که افزایش یافتهاند. توجه ویژهای به سازندههایی (constructors) مانند Detached HTMLElement ، Array ، Function یا حتی نمونههای کلاس نامگذاری شده از کد خودتان داشته باشید. افزایش delta به این معنی است که اشیا در حین انجام اقدام شما ایجاد شدهاند و پس از آن جمعآوری نشدهاند.
ردیابی مسیر نگهدارنده (Retaining Path)
وقتی یک عنصر نشت کرده را پیدا کردید، آن را انتخاب کنید. پنل پایین، مسیر نگهدارنده (retaining path) را نمایش میدهد: زنجیرهای از ارجاعات که توضیح میدهد چرا این شیء هنوز زنده است. این زنجیره ممکن است از یک div جدا شده شروع شده، از طریق ویژگیهای داخلی React عبور کند، وارد یک closure شود و در نهایت به یک event listener که در داخل یکی از کامپوننتهای شما ثبت شده، ختم شود. آن آخرین حلقه در زنجیره، شماره خط کد شماست.
اینجاست که از تشخیص به یافتن علت اصلی (root cause) میرسید. اگر مسیر نگهدارنده به window.addEventListener ختم شود، میدانید که یک listener سراسری، کامپوننت شما را گروگان گرفته است. اگر به یک نمونه از IntersectionObserver ختم شود، میدانید که یک observer هنوز در حال نظارت بر گرهای است که باید توسط garbage collector جمعآوری میشد.
مقصران رایج در React
نشتیهای حافظه در React معمولاً در سه الگو قرار میگیرند.
listenerهای سراسری بیسرپرست. یک useEffect به window یا document متصل میشود تا موقعیت اسکرول، فشردن کلیدها یا رویدادهای تغییر اندازه (resize) را دنبال کند. اگر این effect یک تابع پاکسازی (cleanup function) که removeEventListener را فراخوانی میکند برنگرداند، listener در طول عمر صفحه باقی میماند. از آنجایی که listener یک closure است، محدوده (scope) کامل کامپوننت را مدتها پس از اینکه React کامپوننت را unmount کرد، زنده نگه میدارد.
ناظران (observers) پاکنشده. IntersectionObserver و ResizeObserver قدرتمند هستند، اما ارجاعاتی (references) بومی خارج از کنترل React ایجاد میکنند. اگر یک ناظر را درون یک کامپوننت نمونهسازی (instantiate) کنید و فراموش کنید که در مرحله پاکسازی (cleanup phase) تابع disconnect() را فراخوانی کنید، آن ناظر گره (node) هدف DOM را نگه میدارد و آن گره نیز فایبرهای React، props و state را نگه میدارد.
تلههای Closure. وقتی تابعی را درون یک کامپوننت تعریف میکنید و آن را به یک کتابخانه شخص ثالث، یک کش جهانی (global cache) یا حتی setTimeout میسپارید، آن تابع بر روی تمام متغیرهای محدوده لغوی (lexical scope) خود بسته میشود (closes over). اگر مالک خارجی تابع را نگه دارد، کل محدوده (scope) کامپوننت شما را نیز همراه خود نگه میدارد.
الگوهای پاکسازی که واقعاً کار میکنند
رفع یک نشت حافظه به معنای قطع کردن تمام مسیرهای نگهدارنده (retaining paths) است که در اسنپشات (snapshot) پیدا کردهاید.
همیشه یک تابع پاکسازی (cleanup function) از useEffect برگردانید. اگر یک شنونده (listener) را در effect اضافه میکنید، آن را در همانجا حذف کنید.
برای هر هندلر (handler) که به DOM یا window متصل میکنید، از useCallback استفاده کنید. بدون آن، هر رندر یک ارجاع تابعی جدید ایجاد میکند. اگر addEventListener را با یک ارجاع فراخوانی کنید و بعداً removeEventListener را با ارجاع دیگری صدا بزنید، عملیات حذف به صورت بیصدا (silently) شکست میخورد. شنونده اصلی برای همیشه روی window باقی میماند. useCallback ارجاع را پایدار نگه میدارد تا عملیات add و remove دقیقاً با هم مطابقت داشته باشند.
با ناظران (observers) نیز با همین انضباط برخورد کنید. نمونه (instance) ناظر را در یک ref یا یک متغیر محلی درون effect ذخیره کنید. در تابع پاکسازی، observer.disconnect() را فراخوانی کنید. تصور نکنید که unmounting کامپوننت باعث از بین رفتن ناظر میشود؛ این اتفاق نمیافتد.
اگر کامپوننت شما چیزی را در یک فضای نام جهانی (global namespace) یا یک سرویس سینگلتون (singleton service) منتشر میکند، آن ارجاعات را هنگام unmount حذف کنید. موتور V8 تنها زمانی میتواند حافظه را بازپس بگیرد که یک شیء واقعاً غیرقابل دسترس (unreachable) باشد. باقی گذاشتن یک هوک روی window یا یک ورودی در یک Map در سطح ماژول، پلی نامرئی ایجاد میکند که باعث رشد مداوم heap میشود.
نکته اصلی
نشت حافظه بلافاصله باعث کرش کردن اپلیکیشن شما نمیشود. آنها در طول جلسات طولانی کاربری، هر بار یک گره جدا شده (detached node) را انباشته میکنند. راه حل، ارتقای کتابخانه یا یک پرچم کامپایلر (compiler flag) نیست؛ بلکه عادت به اثبات منطق پاکسازی خود با استفاده از heap snapshots است.
