وصل تقرير عن خطأ (bug) تحدى كل غرائز تصحيح الأخطاء. أفاد المستخدمون على هواتف Android منخفضة المواصفات أن التطبيق يختفي ببساطة. ليس عند التشغيل، ولا عند نقرة أو سحبة معينة. بعد حوالي عشرين دقيقة من الجلسة، تتجمد الشاشة وتموت العملية. كانت السجلات (logs) خالية تماماً من أي أخطاء. ولم يتمكن فريق ضمان الجودة (QA) من إعادة إنتاج الخطأ على أجهزتهم عالية المواصفات. لم تكن هناك خطوات محددة لاتباعها. وبعد ثلاث ساعات من تحليل استهلاك الذاكرة (memory profiling)، اتضحت الصورة أخيراً. كان هناك مستمع حدث (event listener) واحد داخل React hook. هذا المستمع كان يحتفظ بمرجع (closed over) لمجموعة بيانات كبيرة. تم إلغاء تثبيت المكون (unmounted)، لكن المستمع ظل موجوداً، وظلت مجموعة البيانات في الذاكرة. في جهاز بذاكرة وصول عشوائي (RAM) سعة 2 جيجابايت، أدى هذا التراكم إلى استنفاد الذاكرة المخصصة (heap) فقام نظام التشغيل بإغلاق التطبيق. لم يكن هذا خطأً في بناء الجملة (syntax error) أو خللاً منطقياً، بل كان خطأً في النطاق (scope bug)، وكان خطأً قاتلاً.

كيف تتحول الـ Closure إلى تسريب للذاكرة

تعلم معظم الدروس التعليمية النطاق (scope) كأنه لغز أكاديمي حول مكان رؤية المتغير. أما في بيئة الإنتاج، فالنطاق هو عقد يتعلق بعمر الذاكرة. عندما تقوم دالة JavaScript بإغلاق نطاقها (closes over) على متغير، فإن المحرك يبقي هذا المتغير حياً طالما كان من الممكن الوصول إلى الـ closure نفسها. في مكون React، يعني هذا أن بياناتك تظل حية لفترة طويلة بعد مغادرة المستخدم وإزالة عقدة واجهة المستخدم (UI node).

تأمل في hook يقوم بتسجيل مستمع على كائن window. يتم تصيير المكون، ويتم إرفاق المستمع، ثم يتم إلغاء تثبيته لاحقاً. إذا كانت مرحلة التنظيف (cleanup phase) مفقودة أو معيبة، فسيظل المستمع موجوداً. كل عملية تثبيت جديدة تضيف نسخة شبحية أخرى من البيانات المحتجزة إلى الذاكرة (RAM). في محطة عمل المطور التي تتمتع بذاكرة وفيرة، قد لا تلاحظ هذا التضخم أبداً. أما في هاتف اقتصادي يعمل بنظام Android Go، فإن عشرين دقيقة من الاستخدام العادي كافية لاستنفاد الذاكرة المخصصة (heap) المتاحة. يتدخل نظام التشغيل ويقوم بإنهاء العملية. لا يوجد استثناء (exception) ليتم تسجيله؛ يقوم النظام ببساطة بقطع التيار.

هذا هو السبب في أن النطاق (scope) هو إدارة للذاكرة. البيئة المعجمية (lexical environment) ليست حداً فلسفياً، بل هي رسم بياني للاحتفاظ (retention graph). كل متغير تتركه داخل closure لم يتم جمعها (uncollected) هو بمثابة لبنة في جدار سيحاصر تطبيقك في النهاية.

ثلاث طرق يقتل بها النطاق تطبيقات الإنتاج

لا تبدو مشكلات النطاق متشابهة دائماً؛ فبعضها يستنزف الذاكرة ببطء، والبعض الآخر ينفجر فوراً. إليك الأنماط التي تؤدي إلى تعطل التطبيقات بشكل موثوق.

تلوث النطاق العام (Global Scope Pollution)

تسمح معماريات الـ Micro-frontend للفرق بالشحن بشكل مستقل، لكنها جميعاً تشترك في نفس كائن window. عندما يقوم تطبيق واحد بتعيين متغير عام مثل window.config أو يقوم بتعديل أداة مساعدة مشتركة على window ، فإنه لا يعيش في عزلة. قد يعتمد تطبيق فريق آخر على شكل مختلف لنفس هذا المتغير العام، أو قد يقوم بالكتابة فوقه أثناء عملية التشغيل (bootstrap) الخاصة به. النتيجة هي تصادم في الميزات يتوسع مع توسع مؤسستك. لا يملك المطور في مستودع (repository) واحد أي فكرة عن أن اختصاره هو تغيير جذري (breaking change) لفريق آخر. ومع زيادة مساحة السطح، تصبح هذه المتغيرات العامة ألغاماً مدفونة في تربة مشتركة.

تسريبات الذاكرة الناتجة عن الـ Closure

تُبنى تطبيقات الصفحة الواحدة (SPAs) لتعمل لساعات. وهذه الديمومة هي بالضبط السبب في أن الـ closures المسربة تصبح سامة. النمط شائع بشكل مخادع: يقوم useEffect بتسجيل callback مع ناقل أحداث عام (global event bus)، أو معالج WebSocket، أو مع الـ DOM نفسه. إذا كانت مصفوفة التبعيات (dependency array) غير مستقرة أو تم حذفها، فلن تتطابق عملية التنظيف أبداً مع الاشتراك الأصلي. تلتقط الـ closure كل ما هو موجود في نطاقها المعجمي، والذي قد يشمل مصفوفات ضخمة تم تحليلها، أو كتل JSON تم جلبها، أو مراجع لأشجار الـ DOM. كل عملية تنقل تضيف المزيد من الثقل. لا يعرف المستخدم لماذا تستهلك علامة تبويب المتصفح الخاصة به 800 ميجابايت؛ هم يعرفون فقط أن التطبيق أصبح بطيئاً وفي النهاية يتوقف عن العمل.

يعد هذا خطيراً بشكل خاص عندما تتغير مصفوفات التبعيات مع كل عملية تصيير (render). حيث تولد مرجع دالة جديد في كل دورة، ويتم تسجيله مع مستمع، ولا يتم تحرير المرجع القديم أبداً. النتيجة هي متحف من الـ closures الميتة، كل منها يكتنز البيانات التي وُلد بها.

أخطاء TDZ في الوحدات الديناميكية

إن منطقة الموت المؤقتة (Temporal Dead Zone) ليست مجرد حالة استثنائية نظرية. فعندما تحاول الوصول إلى متغير let أو const قبل تنفيذ إعلان عنه، سيقوم المحرك بإلقاء خطأ من نوع ReferenceError. وفي مستودعات الأكواد الموحدة (monorepos) الضخمة التي تحتوي على تبعيات دائرية واستيرادات ديناميكية، غالبًا ما يكون ترتيب التنفيذ الدقيق ضمنيًا. تقوم الوحدة A باستيراد الوحدة B، والتي تقوم بدورها باستيراد جزء (chunk) ديناميكيًا يعتمد بدوره على الوحدة A. فإذا لمس أحد المسارات متغيرًا لم ينتهِ من عملية التهيئة بعد، فسينهار التطبيق أثناء التحميل. هذه الإخفاقات تثير الجنون لأنها تعتمد على التوقيت؛ فتغيير بسيط في نقاط تقسيم المجمّع (bundler split points)، أو تأخير في الشبكة أثناء تحميل الكود، أو تغيير في تخزين الأجزاء (chunks) مؤقتًا، يمكن أن يغير الترتيب بما يكفي لتفعيل منطقة الموت المؤقتة (TDZ). يكون الانهيار غير متوقع، وعادة ما يشير تتبع المكدس (stack trace) إلى سطر كود بريء تمامًا.

تكتيكات دفاعية

لا يمكنك الاعتماد على تتبع المكدس (stack traces) لإنقاذك من أخطاء النطاق (scope bugs). أنت بحاجة إلى الوقاية والكشف.

ابدأ بالتحليل الساكن (static analysis). قم بتكوين ESLint لفرض حدود صارمة. قواعد مثل no-implicit-globals و no-shadow تكتشف الأخطاء الواضحة. ويعد التظليل (Shadowing) غادرًا بشكل خاص لأنه يخدعك ويجعلك تظن أنك تقوم بتعديل متغير محلي بينما أنت في الواقع تبني إغلاقًا (closure) على متغير خارجي، أو تنشئ نسخة مكررة عن طريق الخطأ. تجبر هذه القواعد على تحديد النوايا بوضوح وتمنع التصادمات الصامتة.

قم بتحليل استهلاك الذاكرة بنفس الانضباط الذي تطبقه على اختبارات الوحدة (unit tests). افتح Chrome DevTools، وخذ لقطة مكدس الذاكرة (heap snapshot) عند مسار البداية، ثم تنقل عبر تطبيقك لمدة خمس دقائق، وخذ لقطة أخرى. قارن بين الاثنين. قم بالتصفية بحثًا عن "Closure" وابحث عن الأعداد التي تزداد بلا حدود. ابحث عن عقد DOM المنفصلة (detached DOM nodes) التي لا تزال تحتفظ بمستمعي الأحداث (event listeners). إذا أظهرت اللقطة الثانية آلاف الإدخالات الجديدة لـ Closure بينما ظل عدد المستخدمين ثابتًا، فهذا يعني أنك وقعت في فخ دوال تحتجز بيانات محتجزة. هذا هو تسريب الذاكرة الخاص بك.

من الناحية الهيكلية، توقف عن محاولة الوصول إلى كائن window العالمي للحصول على الإعدادات. قم بتمرير الإعدادات كخصائص (props) أو من خلال سياق (context) محدد النوع. "حقن التبعيات" (Dependency injection) ليس مجرد مصطلح رنان للشركات هنا؛ بل هو ممارسة إعطاء الدالة كل ما تحتاجه من خلال الوسائط (arguments) بدلاً من تركها تسترق النظر إلى النطاق العالمي. النتيجة هي كود يمكنك اختباره دون الحاجة إلى محاكيات المتصفح (browser shims)، ووحدات لا تتصادم عندما يتم تشغيل تطبيقات متعددة داخل نفس الإطار.

أخيرًا، احترم مرحلة التنظيف (cleanup phase) بصرامة. كل addEventListener يحتاج إلى removeEventListener مطابق داخل عملية تنظيف التأثير (effect cleanup). بالنسبة للعمليات غير المتزامنة، استخدم AbortController ومرر إشارته (signal) إلى fetch بحيث يتم إلغاء الطلبات الجارية عند انتهاء المكون. هذه العادات تتحكم مباشرة في مدة حياة النطاق. إنها ليست مجرد كود نمطي (boilerplate)، بل هي إدارة للذاكرة.

ماذا يعني هذا لفريقك

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