تتجمد علامة تبويب المتصفح لدى المستخدم بعد ثلاثين دقيقة. تتقطع واجهة المستخدم (UI). ثم ينهار المتصفح مع خطأ "نفاد الذاكرة" (out-of-memory error).

تراجع كود المكون (component) سطراً بسطر ولا تجد أي خطأ. هذا هو الجزء المثير للإحباط في تسريبات الذاكرة (memory leaks) في React. فالمشكلة لا تكمن في بناء JSX الخاص بك أو في منطق الـ hooks. بل تكمن في الفجوة بين المكون الخاص بك وبين جامع القمامة (garbage collector) في المتصفح. مستمع حدث (event listener) تائه أو إغلاق (closure) طويل الأمد يمنع الجامع من استعادة الذاكرة. تقوم بإلغاء تحميل المكون (unmount)، ولكن مرجعاً واحداً متبقياً يبقي الشجرة بأكملها حية في الـ heap. لا يمكنك العثور على هذه التسريبات بمجرد قراءة الكود؛ فالمشكلة تظل مخفية تحت السطح، غير مرئية للعين وصامتة في اختبارات الوحدة (unit tests).

لماذا تختبئ التسريبات في وضح النهار

تدير محركات JavaScript مثل V8 الذاكرة تلقائياً. عندما لا يوجد مسار مرجعي من الجذر (root) إلى كائن ما، يقوم المحرك بتمييز هذا الكائن كنفايات (garbage) ويستعيد المساحة. تعمل هذه العملية بشكل جيد حتى ينجو مرجع مخفي لفترة أطول مما كنت تنوي.

في React، غالباً ما يظهر الخطر عند الحد الفاصل بين المكونات والـ DOM. قد تقوم بإرفاق مستمع resize بـ window داخل نافذة منبثقة (modal)، أو تشترك في WebSocket داخل أداة (widget) في لوحة تحكم. عندما يغلق المستخدم النافذة المنبثقة أو ينتقل إلى صفحة أخرى، يتم إلغاء تحميل المكون (unmount). إذا استمر الاشتراك، سيرى المحرك مرجعاً صالحاً من كائن window العالمي وصولاً إلى المعالج (handler) الخاص بك، ومن المعالج الخاص بك عودةً إلى الـ closure الخاص بالمكون. يظل المكون، والـ props الخاصة به، وحالته (state)، وشجرة عقد الـ DOM الفرعية بأكملها مثبتة في الذاكرة. ومع مئات التفاعلات، تتراكم هذه الكائنات المثبتة. يرتفع استهلاك الذاكرة بنمط "أسنان المنشار" (sawtooth pattern) الذي لا ينخفض أبداً إلى مستواه الأصلي.

مشكلة لوحات تحكم الشركات (Enterprise Dashboards)

يحدث هذا غالباً في لوحات تحكم الشركات حيث يبقى المستخدمون في صفحة واحدة لساعات. فكر في لوحات المراقبة، أو عروض التحليلات، أو أنظمة التذاكر. يفتح المستخدم نافذة تفاصيل منبثقة، أو يقوم بتصفية مجموعة بيانات كبيرة، أو ينتقل بين التبويبات داخل تطبيق الصفحة الواحدة (single-page app). يبدو كل تفاعل فردي طبيعياً، ولكن مع مرور الوقت، تتراكم العقد اليتيمة (orphaned nodes) والمستمعون المنفصلون (detached listeners). لا يتباطأ التطبيق بسبب عملية رندر (render) واحدة مكلفة، بل لأن الـ heap ينمو ليصبح كبيراً بما يكفي للتسبب في توقفات متكررة ومكلفة لعملية جمع القمامة (garbage collection).

توقف عن التخمين بشأن الـ useEffect hooks الخاصة بك. الطريقة الوحيدة لمعرفة ما إذا كان هناك تسريب هي قياس الـ heap مباشرة. تمنحك Chrome DevTools هذه الرؤية.

صيد التسريبات باستخدام Chrome DevTools

أنت بحاجة إلى تسلسل قابل للتكرار وبضع دقائق من التركيز. افتح تطبيقك في Chrome، وشغل DevTools، وانتقل إلى علامة التبويب Memory.

سجل خط الأساس (baseline). اختر Heap snapshot وانقر على Take snapshot. يلتقط هذا كل كائن يعيش حالياً في JavaScript heap ويعرض لك الذاكرة الابتدائية. افعل ذلك بعد أن تستقر الصفحة في حالتها الخاملة الأولية، وليس أثناء التحميل الأولي، حتى تقيس فقط النمو الناتج عن إجراءات المستخدم.

قم بتحفيز الإجراء. قم بإجراء تفاعل واجهة المستخدم (UI) الذي تشتبه في أنه يسبب التسريب. افتح وأغلق نافذة منبثقة، أو قم بتبديل مخطط معقد، أو قم بتغيير المسار (route) ثم عد للخلف. بمجرد الانتهاء، أعد التطبيق إلى حالته المرئية الأصلية. هذه الخطوة حاسمة؛ فأنت تريد أن تبدو واجهة المستخدم مطابقة تماماً لما كانت عليه أثناء تسجيل خط الأساس. إذا نما الـ heap رغم أن واجهة المستخدم تبدو فارغة، فهذا دليل قوي على وجود تسريب.

فرض عملية جمع القمامة (garbage collection). انقر على أيقونة سلة المهملات في علامة التبويب Memory. يؤدي هذا إلى تحفيز دورة GC كاملة ويقوم بمسح الكائنات المؤقتة التي نجت بشكل مشروع حتى عملية الجمع التالية. ما يتبقى هو التسريبات الحقيقية، وهي الكائنات التي كان ينبغي جمعها ولكن تم إبقاؤها حية بواسطة مراجع عرضية.

التقط لقطة ثانية. انقر على Take snapshot مرة أخرى. أصبح لديك الآن صورتان للـ heap تم التقاطهما تحت نفس ظروف واجهة المستخدم.

قارن النتائج. قم بتغيير العرض من Summary إلى Comparison. اضبط خط الأساس (baseline) على لقطتك الأولى، واللقطة المقارنة على الثانية. تعرض قائمة Comparison كل فئة من فئات الكائنات وتظهر لك الـ delta، وهو صافي التغيير في أعداد الكائنات بين الالتقاطتين.

الفرز حسب Delta. ابحث عن الفئات التي زادت بشكل كبير. ركز بشكل خاص على Detached HTMLElement و React fiber nodes. عنصر HTML المنفصل (detached) هو عقدة DOM لم تعد مرتبطة بشجرة المستند النشطة، ومع ذلك لا يزال هناك مرجع JavaScript يمسك بها. هذه هي "الأدلة القاطعة" (smoking guns)؛ إذ لا ينبغي أن توجد بعد إغلاق النافذة المنبثقة أو إلغاء تحميل المكون.

قراءة مسار الاحتفاظ (Retaining Path)

عندما تختار عنصراً منفصلاً (detached element) في اللقطة (snapshot)، يعرض Chrome مسار الاحتفاظ (retaining path) في اللوحة السفلية. هذا المسار هو سلسلة من المراجع من الجذر (root) وصولاً إلى الكائن المحدد. تتبعه بعناية؛ فغالباً ما ستجد مستمعاً للحدث (event listener)، أو IntersectionObserver، أو معرف setInterval ID، أو closure يشير إلى سطر محدد في مكونك (component).

ابحث عن أسماء تعرفها. إذا رأيت مستمعاً مرتبطاً بـ window باسم دالة من قاعدة الكود الخاصة بك، فقد وجدت المرساة (anchor). الكائن الذي يحمل ذلك المستمع هو ما يبقي مكونك بالكامل حياً. أحياناً تمر السلسلة عبر مكتبة خارجية (third-party library). في هذه الحالات، تحقق مما إذا كانت المكتبة تتطلب استدعاءً صريحاً لعملية التفكيك (teardown call) نسيت استدعاءه في دالة تنظيف (cleanup function).

إصلاح الأسباب الجذرية

بمجرد تحديد مسار الاحتفاظ، يكون الإصلاح عادةً ميكانيكياً ولكنه يتطلب انضباطاً من الفريق بأكمله.

استخدم دوال التنظيف (cleanup functions). قم دائماً بإرجاع دالة تنظيف في useEffect عندما تضيف مستمعين إلى window أو document. إذا كان الـ effect الخاص بك يشترك في حدث تغيير الحجم (resize event)، فقم بإزالة هذا الاشتراك قبل أن يتم عمل unmount للمكون. تعمل عملية التنظيف عندما يقوم React بتفكيك المكون، مما يمنحك خطافاً (hook) مضموناً لقطع الاتصالات الخارجية.

ثبّت المراجع (Stabilize references). قم بتغليف المعالجات (handlers) الخاصة بك في useCallback. يضمن ذلك تمرير نفس مرجع الدالة تماماً إلى removeEventListener الذي مررته في الأصل إلى addEventListener. إذا قمت بتسجيل دالة مضمنة (inline function) مثل window.addEventListener('resize', () => { ... }) وحاولت لاحقاً إزالتها باستخدام دالة مضمنة أخرى، فلن تتطابق المراجع. سيبقى المستمع متصلاً، وسيبقي الـ closure الموجود بداخله حالة المكون (component state) حية. يمنع استخدام useCallback مع مصفوفة تبعيات (dependency array) مستقرة حدوث عدم تطابق الهوية هذا.

اقطع الروابط العالمية (Sever global links). تذكر أن كائنات أهداف الأحداث في المتصفح، مثل window و document ، تعيش طوال عمر الصفحة. أي مرجع منها إلى مكونك يعمل كمرساة عالمية. إزالة المستمع تكسر تلك المرساة وتسمح لمحرك V8 engine بمسح حالة المكون وعقد DOM أثناء دورة الـ garbage collection التالية.

لننظر في نافذة منبثقة (modal) تتبع عرض النافذة. بدون عملية تنظيف، في كل مرة يفتح فيها المستخدم النافذة المنبثقة، يتم ربط مستمع جديد. لا تنفصل المستمعات القديمة أبداً لأن مثيلات المكونات (component instances) التي تنتمي إليها قد اختفت، ومع ذلك كانت الدوال نفسها مجهولة (anonymous) وضاعت. استخدام معالج مسمى (named handler) مغلف في useCallback بالإضافة إلى دالة تنظيف تستدعي removeEventListener يغلق الحلقة بشكل نظيف.

خلاصة عملية

نادراً ما تعلن تسريبات الذاكرة (Memory leaks) في React عن نفسها برسالة خطأ واضحة. بل تعلن عن نفسها من خلال علامة تبويب (tab) تصبح أثقل كلما بقيت مفتوحة لفترة أطول. لا تضيع الوقت في التخمين حول أي hook هو المذنب. افتح علامة تبويب Memory، وافرض عملية garbage collection، وقارن بين اللقطات (snapshots). دع الـ heap profiler يوضح لك مسار الاحتفاظ الدقيق. ثم اكتب دالة التنظيف، وثبّت مرجع الـ callback، واقطع الرابط العالمي. لن يلاحظ مستخدموك الإصلاح بشكل مباشر، لكنهم سيلاحظون أن لوحة التحكم (dashboard) لا تزال تعمل بسلاسة في نهاية اليوم.