إدارة الذاكرة وجامع القمامة في JavaScript: كيف تتجنب التسريبات في تطبيقات الويب الطويلة؟
مقدمة
تُعد إدارة الذاكرة وجامع القمامة في JavaScript: كيف تتجنب التسريبات في تطبيقات الويب الطويلة؟ من الموضوعات الأساسية عند بناء لوحات التحكم، وتطبيقات المحادثة، وأنظمة المراقبة، وتطبيقات الصفحة الواحدة. ففي هذه التطبيقات لا تُغلق الصفحة بعد تنفيذ مهمة قصيرة، بل قد تبقى مفتوحة ساعات أو أياماً. وإذا استمر التطبيق في الاحتفاظ بكائنات لم يعد يحتاج إليها، فسيرتفع استهلاك الذاكرة تدريجياً، ويتباطأ المتصفح، وقد تتعطل الواجهة أو تُغلق الصفحة تلقائياً.
تمتلك JavaScript جامع قمامة تلقائياً، ولذلك لا يحرر المطور الذاكرة يدوياً كما يفعل في لغات منخفضة المستوى. لكن هذه الآلية لا تعني استحالة حدوث التسربات؛ فجامع القمامة يحرر الكائنات غير القابلة للوصول فقط. أما الكائن الذي بقيت إليه إشارة مرجعية، ولو كانت غير مقصودة، فسيظل في الذاكرة. لذلك تتمحور الوقاية من التسربات حول تقليل المراجع طويلة العمر، وتنظيف الموارد عند انتهاء الحاجة إليها، وقياس الاستهلاك بدلاً من الاعتماد على التخمين.
كيف تعمل الذاكرة وجامع القمامة في JavaScript
تخزن JavaScript البيانات البسيطة، مثل الأرقام والقيم المنطقية، بطريقة مختلفة عن الكائنات والمصفوفات والدوال وعناصر DOM. الكائنات عادةً ما تُدار في منطقة الذاكرة المعروفة باسم الكومة، بينما تُستخدم مكدسات التنفيذ لحفظ سياق استدعاءات الدوال والقيم المحلية المؤقتة. عندما ينتهي تنفيذ دالة، تختفي متغيراتها المحلية غالباً من مكدس التنفيذ، لكن الكائنات التي كانت تشير إليها لا تُحرر بالضرورة إذا بقيت مراجع إليها في مكان آخر.
تعتمد محركات JavaScript الحديثة في جوهرها على مبدأ العلامة والمسح. يبدأ جامع القمامة من مجموعة من «الجذور» القابلة للوصول، مثل الكائن العام، ومكدس الاستدعاءات، والمتغيرات النشطة، وبعض المراجع الداخلية للمتصفح. ثم يتتبع كل كائن يمكن الوصول إليه من هذه الجذور، ويضع عليه علامة تدل على أنه ما زال مستخدماً. بعد ذلك يمسح الذاكرة ويحرر الكائنات التي لم تصل إليها عملية التتبع.
هذا يعني أن المشكلة ليست في أن الكائن «قديم» أو أن الدالة التي أنشأته انتهت، بل في وجود طريق مرجعي يقود إليه. وقد تستخدم المحركات أيضاً أساليب متقدمة مثل الجمع التوليدي والضغط التدريجي للذاكرة لتحسين الأداء، غير أن تفاصيل التنفيذ تختلف بين Chrome وFirefox وSafari. لذلك يجب ألا يبني المطور منطق التطبيق على توقيت تشغيل جامع القمامة، بل على إزالة المراجع غير الضرورية من الأساس.
ما هو تسرب الذاكرة وما أبرز أسبابه؟
تسرب الذاكرة هو احتفاظ التطبيق بذاكرة لم يعد يحتاج إليها، بسبب بقاء كائنات قابلة للوصول من جذور البرنامج. لا يعني ذلك دائماً وجود عيب في محرك JavaScript؛ ففي معظم الحالات يكون السبب مرجعاً أنشأه التطبيق ولم ينظفه. والخطر الحقيقي يظهر عندما تتكرر العملية: ينتقل المستخدم بين الصفحات أو يفتح نافذة ويغلقها مرات كثيرة، بينما يستمر حجم الكومة في الارتفاع بدلاً من العودة إلى مستوى مستقر.
من أكثر الأسباب شيوعاً المتغيرات العامة غير الضرورية. فعندما تُخزن بيانات جلسة مؤقتة أو عناصر DOM أو نتائج طلبات قديمة في نطاق عام، فإنها قد تبقى طوال عمر الصفحة. كذلك قد تنتج المتغيرات العامة ضمنياً عند إسناد قيمة إلى اسم غير معرّف، خصوصاً في الشيفرات القديمة غير الصارمة. لذلك من الأفضل استخدام const وlet، وتفعيل نمط الوحدات أو الوضع الصارم، وتحديد مالك واضح لكل بنية بيانات طويلة العمر.
وتُعد المستمعات للأحداث سبباً متكرراً للتسربات. إذا أضفت مستمعاً إلى window أو document أو عنصر يعيش مدة أطول من المكوّن، وكانت دالة المستمع تغلق فوق بيانات المكوّن، فقد يبقى المكوّن كله قابلاً للوصول. وينطبق الأمر نفسه على المؤقتات، واشتراكات WebSocket، وواجهة MutationObserver، وواجهة IntersectionObserver، وتدفقات البيانات، والطلبات المتكررة.
كما تظهر تسربات عناصر DOM المنفصلة عندما يُزال عنصر من الصفحة لكن يبقى مرجع JavaScript يشير إليه، أو عندما تشير الدالة المرتبطة به إلى شجرة عناصر كبيرة. هذه العناصر لا تظهر للمستخدم، لكنها تستمر في استهلاك الذاكرة. ومن الأمثلة الشائعة تخزين عناصر النوافذ المنبثقة المغلقة في مصفوفة، أو الاحتفاظ بنتائج بحث قديمة ومرفقاتها المرئية دون حد أقصى للحجم.
تنظيف مستمعات الأحداث والمؤقتات والاشتراكات
القاعدة العملية الأهم هي أن كل مورد يُنشأ أثناء عمر المكوّن يحتاج إلى مسار تنظيف واضح عند إزالته أو عند تغير حالته. يشمل ذلك كل استدعاء لـaddEventListener، وكل setInterval أو setTimeout طويل المدى، وكل اشتراك في قناة بيانات أو مراقب للـDOM. لا يكفي حذف العنصر المرئي من الصفحة؛ إذ قد يظل المستمع مسجلاً على كائن أطول عمراً.
يمكن استخدام AbortController لتجميع مستمعات الأحداث تحت إشارة واحدة، ثم إلغائها دفعة واحدة عند انتهاء عمر المكوّن. هذا الأسلوب يقلل احتمال نسيان إزالة مستمع معين، ويجعل شيفرة التنظيف أبسط.
class UserPanel {
constructor(container) {
this.container = container;
this.controller = new AbortController();
window.addEventListener("resize", this.handleResize.bind(this), {
signal: this.controller.signal
});
this.timerId = setInterval(() => {
this.refreshData();
}, 5000);
}
handleResize() {
this.container.classList.toggle("compact", window.innerWidth < 700);
}
refreshData() {
// تحديث البيانات عند الحاجة
}
destroy() {
this.controller.abort();
clearInterval(this.timerId);
this.container.replaceChildren();
this.container = null;
}
}
يجب الانتباه إلى أن استخدام bind مباشرة داخل addEventListener يجعل إزالة المستمع لاحقاً صعبة، لأن كل استدعاء لـbind ينشئ دالة جديدة. لذلك إما أن تحفظ مرجع الدالة المربوطة في خاصية، أو تستخدم AbortController كما في المثال. وبالمثل، ينبغي إغلاق اتصال WebSocket وإلغاء اشتراكه عند مغادرة الشاشة، بدلاً من تركه يرسل رسائل إلى مكوّن لم يعد موجوداً.
function subscribeToUpdates(socket, onMessage) {
socket.addEventListener("message", onMessage);
return () => {
socket.removeEventListener("message", onMessage);
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
};
}
الإغلاقات والنطاقات والمراجع التي لا تبدو واضحة
الإغلاق أو Closure هو قدرة الدالة على الاحتفاظ بالوصول إلى المتغيرات الموجودة في نطاقها الخارجي. وهو مفهوم أساسي ومفيد جداً في JavaScript، لكنه قد يسبب استهلاكاً غير مقصود للذاكرة إذا احتفظت دالة طويلة العمر ببيانات ضخمة لا تحتاج إليها فعلياً. على سبيل المثال، قد يحتفظ مستمع حدث صغير بمصفوفة كبيرة لأنه أُنشئ داخل نطاق يحتوي تلك المصفوفة.
function createSearchHandler(largeDataset) {
return function handleSearch(event) {
console.log(event.target.value);
// لا تستخدم largeDataset هنا، لكنها قد تبقى محتفظاً بها.
};
}
في هذا المثال قد تبقى largeDataset في الذاكرة ما دامت الدالة المعادة مسجلة كمستمع. الحل ليس تجنب الإغلاقات، بل تقليل ما تلتقطه الدالة. يمكن فصل الدالة عن البيانات الكبيرة، أو الاحتفاظ بمعرف مختصر، أو جلب البيانات من مخزن يمكن إدارته وإفراغه عند الحاجة. وينبغي كذلك الحذر عند تمرير كائنات حالة ضخمة إلى أدوات التسجيل أو تخزينها في مصفوفات التشخيص؛ فبيانات السجل قد تتحول نفسها إلى تسرب مستمر.
المخابئ المؤقتة مصدر آخر للمراجع الخفية. فالمصفوفة أو كائن Map الذي ينمو بلا حدود ليس تسرباً تقنياً دائماً، لأنه قد يكون مقصوداً، لكنه يؤدي عملياً إلى المشكلة ذاتها: زيادة مستمرة في الذاكرة. يجب وضع سياسة إخلاء، مثل حد أقصى للحجم، أو مدة صلاحية، أو استراتيجية «الأقل استخداماً مؤخراً». وعندما تكون المفاتيح كائنات ينبغي عدم منع جمعها، يمكن التفكير في WeakMap أو WeakSet، مع إدراك أن هذه البنى لا تسمح بالتكرار على عناصرها ولا تصلح بديلاً عاماً للمخابئ.
تنظيف دورة حياة المكونات في الأطر الحديثة
تساعد أطر الواجهات الحديثة في تنظيم دورة الحياة، لكنها لا تلغي مسؤولية التنظيف. في React، ينبغي أن تعيد دالة useEffect دالة تنظيف لإزالة المستمعات وإلغاء المؤقتات والطلبات أو الاشتراكات. وفي Vue تُنفذ أعمال التنظيف ضمن الخطافات المناسبة مثل onUnmounted. أما في Angular فتُدار الاشتراكات عادةً عبر ngOnDestroy أو آليات إلغاء منظمة. المبدأ واحد: ما يبدأ عند التركيب يجب أن يتوقف عند الإزالة.
import { useEffect, useState } from "react";
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => {
clearInterval(id);
};
}, []);
return <time>{time.toLocaleTimeString()}</time>;
}
من الأخطاء الشائعة في تطبيقات الصفحة الواحدة إنشاء مراقب أو اشتراك كلما تغيرت خاصية أو انتقل المستخدم إلى تبويب، من دون إيقاف النسخة السابقة. كذلك ينبغي التعامل مع الطلبات غير المتزامنة التي تنتهي بعد إزالة المكوّن. ورغم أن النتيجة لا تسبب تسرباً دائماً في كل حالة، فإن تحديث حالة مكوّن أزيل أو الاحتفاظ بسياق طلب قديم يربك دورة الحياة. يفيد استخدام AbortController أيضاً لإلغاء طلبات fetch التي لم تعد نتائجها مهمة.
const controller = new AbortController();
fetch("/api/reports", { signal: controller.signal })
.then(response => response.json())
.then(renderReport)
.catch(error => {
if (error.name !== "AbortError") throw error;
});
// عند مغادرة الصفحة أو إزالة المكوّن:
controller.abort();
كيف تكشف التسربات باستخدام Chrome DevTools
لا يمكن تأكيد وجود تسرب من خلال مراقبة رقم واحد بعد تحميل الصفحة، لأن جامع القمامة قد يعمل في وقت مختلف، وقد يرتفع الاستهلاك مؤقتاً بسبب عملية مشروعة. المنهج الأدق هو تنفيذ سيناريو قابل للتكرار: افتح شاشة معينة، نفذ إجراءً محدداً، أغلق الشاشة، وكرر ذلك عدة مرات. بعد ذلك افحص هل تعود الذاكرة إلى مستوى قريب من خط الأساس أم يستمر النمو.
في Chrome DevTools، تتيح لوحة Memory أخذ لقطات للكومة Heap Snapshots. ابدأ بلقطة أولى، ثم كرر سيناريو الاستخدام، وخذ لقطة ثانية أو ثالثة. قارن بين اللقطات للبحث عن الكائنات التي يزداد عددها، مثل مثيلات المكوّنات أو المصفوفات أو الدوال أو عناصر DOM. ومن المهم فحص مسار الاحتفاظ أو Retainers لمعرفة المرجع الذي يمنع تحرير الكائن، بدلاً من الاكتفاء بمعرفة اسمه.
تُظهر الأدوات أيضاً عرض Detached DOM tree الذي يساعد على اكتشاف العناصر التي انفصلت عن الوثيقة لكنها ما زالت محتفظاً بها. ويمكن الاستفادة من تسجيل تخصيصات الذاكرة زمنياً لملاحظة اللحظة التي بدأت فيها الزيادة. كما توفر لوحة الأداء مؤشرات على التوقفات الطويلة لجمع القمامة، وهي علامة محتملة على ضغط ذاكرة مرتفع. يجب دائماً القياس قبل التحسين، لأن تحسينات مبنية على الحدس قد تزيد التعقيد من دون حل السبب الحقيقي.
استراتيجية عملية لمنع التسربات في التطبيقات الطويلة
تبدأ الوقاية بتصميم ملكية الموارد. لكل مكوّن أو خدمة أو شاشة، حدد بوضوح ما الذي تنشئه وما الذي يجب أن تنظفه. اجعل دوال الإنشاء تعيد دالة إلغاء أو كائناً يملك دالة destroy أو dispose. بهذه الطريقة يصبح التنظيف جزءاً من واجهة البرمجية بدلاً من كونه خطوة اختيارية يتذكرها المطور لاحقاً.
ينبغي أيضاً تقليل الاعتماد على الحالة العامة، وتحديد سقف للمخابئ وسجلات النشاط، وعدم تخزين عناصر DOM عندما يكفي تخزين معرفاتها أو بياناتها الأولية. عند التعامل مع قوائم ضخمة، استخدم العرض الافتراضي بحيث لا تُنشأ آلاف العناصر المرئية في الوقت نفسه. وعند عرض صور أو ملفات كبيرة، حرر عناوين الكائنات التي أُنشئت بواسطة URL.createObjectURL باستخدام URL.revokeObjectURL بعد انتهاء استخدامها.
وأخيراً، أضف اختبارات يدوية أو آلية لمسارات الاستخدام المتكررة: فتح وإغلاق نافذة، الانتقال بين الصفحات، تغيير الحساب، وإعادة الاتصال. راقب الذاكرة في بيئة إنتاج قريبة من الواقع، لأن بعض التسربات لا تظهر إلا مع بيانات حقيقية أو جلسات طويلة. إن الانضباط في التنظيف، مع القياس الدوري، أكثر فاعلية من محاولة إجبار جامع القمامة على العمل.
خاتمة
جامع القمامة في JavaScript يحل مشكلة تحرير الذاكرة غير القابلة للوصول، لكنه لا يستطيع معرفة أن التطبيق لم يعد يحتاج إلى كائن ما دام مرجع إليه قائماً. لذلك فإن منع التسربات في تطبيقات الويب الطويلة يعتمد على إزالة مستمعات الأحداث، وإيقاف المؤقتات والاشتراكات والمراقبات، وإلغاء الطلبات غير اللازمة، وتقييد المخابئ، وتنظيف الموارد ضمن دورة حياة المكونات. ومع استخدام Heap Snapshots وعرض عناصر DOM المنفصلة في أدوات المطورين، يصبح تشخيص المشكلة مبنياً على أدلة واضحة، وتبقى الواجهة سريعة ومستقرة حتى بعد الاستخدام الطويل.
تعليقات
إرسال تعليق