تب مرورگر کاربر شما پس از سی دقیقه فریز میشود. رابط کاربری (UI) دچار لگ میشود. سپس مرورگر با خطای کمبود حافظه (out-of-memory error) کرش میکند.
شما کد کامپوننت را خط به خط بررسی میکنید و هیچ مشکلی نمیبینید. این همان بخش دیوانهکننده نشت حافظه (memory leaks) در React است. باگ در نحو (syntax) JSX یا منطق هوکهای (hooks) شما نیست؛ بلکه در شکاف بین کامپوننت شما و Garbage Collector مرورگر نهفته است. یک شنونده رویداد (event listener) سرگردان یا یک closure با طول عمر بالا، مانع از بازپسگیری حافظه توسط Garbage Collector میشود. شما یک کامپوننت را unmount میکنید، اما یک ارجاع (reference) باقیمانده، کل درخت را در heap زنده نگه میدارد. شما نمیتوانید این نشتها را صرفاً با خواندن کد پیدا کنید. مشکل زیر سطح پنهان میماند، برای چشم نامرئی و در تستهای واحد (unit tests) بیصدا است.
چرا نشتها در مقابل چشم پنهان میمانند
موتورهای جاوااسکریپت مانند V8 حافظه را به صورت خودکار مدیریت میکنند. وقتی هیچ مسیر ارجاعی از ریشه (root) به یک شیء وجود نداشته باشد، موتور آن شیء را به عنوان زباله (garbage) علامتگذاری کرده و فضا را بازپس میگیرد. این فرآیند تا زمانی که یک ارجاع پنهان، بیشتر از آنچه شما قصد داشتید زنده بماند، به خوبی کار میکند.
در React، خطر اغلب در مرز بین کامپوننتها و DOM ظاهر میشود. ممکن است یک شنونده resize را در داخل یک modal به window متصل کنید، یا در یک ویجت داشبورد در یک WebSocket مشترک شوید (subscribe). وقتی کاربر modal را میبندد یا از صفحه خارج میشود، کامپوننت unmount میشود. اگر اشتراک (subscription) باقی بماند، موتور یک ارجاع معتبر از شیء جهانی window به سمت handler شما و از handler شما به سمت closure کامپوننت میبیند. کامپوننت، props، state و کل زیردرخت گرههای DOM آن در حافظه ثابت میمانند. طی صدها تعامل، این اشیاء ثابت انباشته میشوند. مصرف حافظه با الگوی دندانارهای (sawtooth pattern) بالا میرود که هرگز کاملاً پایین نمیآید.
مشکل داشبوردهای سازمانی
این اتفاق بیشتر در داشبوردهای سازمانی رخ میدهد که کاربران ساعتها در یک صفحه میمانند. پنلهای مانیتورینگ، نماهای تحلیلی یا سیستمهای تیکتینگ را در نظر بگیرید. یک کاربر یک modal جزئیات را باز میکند، یک مجموعه داده بزرگ را فیلتر میکند یا در یک اپلیکیشن تکصفحهای (SPA) تبها را عوض میکند. هر تعامل به تنهایی خوب به نظر میرسد. با این حال، با گذشت زمان، گرههای یتیم (orphaned nodes) و شنوندههای جدا شده (detached listeners) انباشته میشوند. اپلیکیشن نه به دلیل یک رندر سنگین، بلکه به این دلیل کند میشود که heap به قدری بزرگ میشود که باعث توقفهای مکرر و سنگین در فرآیند Garbage Collection میشود.
حدس زدن درباره هوکهای useEffect خود را متوقف کنید. تنها راه برای دانستن اینکه آیا نشت حافظه وجود دارد یا خیر، اندازهگیری مستقیم heap است. Chrome DevTools این قابلیت مشاهده را به شما میدهد.
شکار نشتها با Chrome DevTools
شما به یک توالی قابل بازتولید و چند دقیقه تمرکز نیاز دارید. اپلیکیشن خود را در Chrome باز کنید، DevTools را اجرا کنید و به تب Memory بروید.
یک خط پایه (baseline) ثبت کنید. گزینه Heap snapshot را انتخاب کرده و روی Take snapshot کلیک کنید. این کار تمام اشیایی را که در حال حاضر در JavaScript heap وجود دارند ثبت کرده و حافظه اولیه را به شما نشان میدهد. این کار را پس از اینکه صفحه در حالت بیکار (idle) اولیه خود قرار گرفت انجام دهید، نه در حین بارگذاری اولیه؛ تا فقط رشد ناشی از اقدامات کاربر را اندازهگیری کنید.
آن عمل را اجرا کنید. دقیقاً همان تعامل رابط کاربری را انجام دهید که مشکوک هستید باعث نشت میشود. یک modal را باز و بسته کنید، یک نمودار پیچیده را تغییر دهید، یا یک مسیر (route) را عوض کرده و به عقب برگردید. پس از اتمام، اپلیکیشن را به حالت بصری اولیه خود بازگردانید. این مرحله حیاتی است. شما میخواهید رابط کاربری دقیقاً مشابه زمانی باشد که در مرحله baseline بود. اگر با وجود اینکه رابط کاربری خالی به نظر میرسد، heap رشد کرده است، شواهد محکمی از نشت حافظه دارید.
اجبار به Garbage Collection. روی آیکون سطل زباله در تب Memory کلیک کنید. این کار یک چرخه کامل GC را تحریک کرده و اشیاء موقتی را که به درستی تا جمعآوری بعدی زنده ماندهاند، پاک میکند. آنچه باقی میماند، نشتهای واقعی هستند؛ اشیایی که باید جمعآوری میشدند اما توسط ارجاعهای تصادفی زنده نگه داشته شدهاند.
دومین snapshot را بگیرید. دوباره روی Take snapshot کلیک کنید. اکنون دو عکس از heap دارید که در شرایط یکسان رابط کاربری گرفته شدهاند.
نتایج را مقایسه کنید. نمای خود را از Summary به Comparison تغییر دهید. baseline را روی اولین snapshot خود و snapshot مورد مقایسه را روی دومین قرار دهید. نمای Comparison هر دسته از اشیاء را لیست کرده و delta (تغییر خالص در تعداد اشیاء بین دو تصویربرداری) را به شما نشان میدهد.
بر اساس Delta مرتب کنید. به دنبال دستههایی بگردید که به طور قابل توجهی افزایش یافتهاند. به ویژه روی Detached HTMLElement و گرههای React fiber تمرکز کنید. یک HTML element جدا شده (detached)، گرهای از DOM است که دیگر به درخت سند فعال متصل نیست، اما هنوز یک ارجاع جاوااسکریپت آن را نگه داشته است. اینها شواهد قطعی (smoking guns) هستند. اینها نباید پس از بستن یک modal یا unmount کردن یک کامپوننت وجود داشته باشند.
خواندن مسیر نگهدارنده (Retaining Path)
وقتی یک المان جدا شده (detached) را در اسنپشات انتخاب میکنید، Chrome مسیر نگهدارنده (retaining path) را در پنل پایین نشان میدهد. این مسیر، زنجیرهای از ارجاعات از ریشه (root) تا شیء انتخابشده است. آن را با دقت دنبال کنید. اغلب با یک event listener، یک IntersectionObserver ، یک ID مربوط به setInterval یا یک closure مواجه میشوید که به خط خاصی در کامپوننت شما اشاره میکند.
به دنبال نامهایی بگردید که میشناسید. اگر یک listener متصل به window را با نام تابعی از کد خودتان دیدید، لنگر (anchor) را پیدا کردهاید. شیئی که آن listener را نگه داشته، کل کامپوننت شما را زنده نگه میدارد. گاهی اوقات این زنجیره از میان یک کتابخانه شخص ثالث (third-party library) عبور میکند. در این موارد، بررسی کنید که آیا کتابخانه نیاز به یک فراخوانی صریح برای teardown دارد که فراموش کردهاید آن را در یک تابع cleanup اجرا کنید یا خیر.
رفع علتهای اصلی
پس از شناسایی مسیر نگهدارنده، راه حل معمولاً مکانیکی است اما نیازمند نظم و انضباط در کل تیم است.
از توابع cleanup استفاده کنید. همیشه وقتی listenerهایی را به window یا document اضافه میکنید، در useEffect یک تابع cleanup برگردانید. اگر effect شما در یک رویداد resize اشتراک میگیرد (subscribe میکند)، پیش از اینکه کامپوننت unmount شود، آن اشتراک را حذف کنید. تابع cleanup زمانی اجرا میشود که React کامپوننت را از هم میپاشد (teardown میکند)، و این به شما یک قلاب (hook) تضمینشده برای قطع ارتباطات خارجی میدهد.
ارجاعات را پایدار کنید. هندلرهای خود را در useCallback قرار دهید. این کار تضمین میکند که دقیقاً همان ارجاع تابعی را که در ابتدا به addEventListener داده بودید، به removeEventListener پاس میدهید. اگر یک تابع inline مانند window.addEventListener('resize', () => { ... }) ثبت کنید و بعداً بخواهید آن را با یک تابع inline دیگر حذف کنید، ارجاعات با هم مطابقت نخواهند داشت. در این صورت listener متصل باقی میماند و closure داخل آن، وضعیت (state) کامپوننت شما را زنده نگه میدارد. استفاده از useCallback با یک آرایه وابستگی (dependency array) پایدار، از این عدم تطابق هویت جلوگیری میکند.
پیوندهای جهانی را قطع کنید. به یاد داشته باشید که اشیاء هدف رویداد در مرورگر، مانند window و document ، تا پایان عمر صفحه زنده هستند. هر ارجاعی از آنها به کامپوننت شما، مانند یک لنگر جهانی عمل میکند. حذف listener آن لنگر را میشکند و به موتور V8 اجازه میدهد تا در چرخه بعدی garbage collection، وضعیت کامپوننت و گرههای DOM را پاکسازی کند.
یک modal را در نظر بگیرید که عرض پنجره را ردیابی میکند. بدون cleanup، هر بار که کاربر modal را باز میکند، یک listener جدید متصل میشود. listenerهای قدیمی هرگز جدا نمیشوند، زیرا نمونههای (instances) کامپوننتی که به آنها تعلق داشتند از بین رفتهاند، اما خودِ توابع به صورت anonymous باقی مانده و گم شدهاند. استفاده از یک هندلر نامگذاریشده که در useCallback قرار گرفته، به همراه یک تابع cleanup که removeEventListener را فراخوانی میکند، این چرخه را به شکلی تمیز میبندد.
یک نکته کاربردی واقعی
نشت حافظه (Memory leak) در React به ندرت با یک پیام خطای واضح خود را نشان میدهد. آنها خود را با تب مرورگری نشان میدهند که هر چه بیشتر باز بماند، سنگینتر میشود. وقت خود را برای حدس زدن اینکه کدام hook مقصر است تلف نکنید. تب Memory را باز کنید، garbage collection را به اجبار اجرا کنید و اسنپشاتها را با هم مقایسه کنید. اجازه دهید heap profiler مسیر نگهدارنده دقیق را به شما نشان دهد. سپس تابع cleanup را بنویسید، ارجاع callback را پایدار کنید و پیوند جهانی را قطع کنید. کاربران شما متوجه اصلاح مستقیم این مشکل نخواهند شد، اما متوجه خواهند شد که داشبورد در پایان روز همچنان به نرمی اجرا میشود.
