closures ليست مجرد ميزة في JavaScript، بل هي سلاح ذو حدين: يمكنها تحسين الأداء أو تدميره. اكتشف كيف تعمل خلف الكواليس، وأين تقع الفخاخ الحقيقية في الإنتاج، وكيف تتجنب Memory Leaks التي تبطئ تطبيقاتك حتى في الشركات الكبرى.
في أحد أيام الجمعة، كان السيرفر الخاص بواحدة من أكبر منصات التجارة الإلكترونية في الشرق الأوسط يتعطل كل ساعتين دون سبب واضح. بعد ١٢ ساعة من البحث، اكتشف الفريق أنclosure واحدة في كود الـevent listener كانت تحتفظ بمرجع لـDOM element لم يعد موجوداً، مما تسبب في تسرب ذاكرة بمعدل ٥٠ ميجابايت كل دقيقة. المشكلة لم تكن في المنطق البرمجي، بل في فهم خاطئ لكيفية تعامل JavaScript مع الذاكرة. هذا بالضبط ما سنتحدث عنه اليوم: ليس فقط كيف تعمل closures، بل كيف تدمر تطبيقاتك إذا أسأت استخدامها.
closures في JavaScript ليست مجرد مفهوم أكاديمي يُشرح في الدروس التمهيدية. هي آلية أساسية تتحكم في كيفية تخزين المتغيرات، وكيفية تنفيذ الـcall stack، وحتى كيفية تعامل الـgarbage collector مع الذاكرة. إذا كنت تعتقد أنك تفهمها لأنك تستطيع كتابة مثال بسيط مثل عداد أو دالة مضاعفة، فأنت على بعد خطوة واحدة من كارثة حقيقية في الإنتاج. دعونا نبدأ بتشريح ما يحدث خلف الكواليس عندما تكتب دالة داخل دالة.
عندما تنشئ دالة داخل دالة أخرى في JavaScript، فإن الدالة الداخلية تحتفظ بإمكانية الوصول إلى متغيرات الدالة الخارجية حتى بعد انتهاء تنفيذ الدالة الخارجية. هذا ليس سحراً، بل هو نتيجة مباشرة لكيفية تعامل محرك JavaScript مع الـscope chain. لكن ما يحدث فعلاً في الذاكرة هو أكثر تعقيداً مما يبدو. محرك JavaScript لا يحتفظ فقط بقيم المتغيرات، بل يحتفظ بمرجع كامل لـlexical environment الذي يحتوي على تلك المتغيرات. هذا يعني أنه حتى إذا انتهت الدالة الخارجية، فإن الـlexical environment الخاص بها يبقى موجوداً طالما أن هناك دالة داخلية ما زالت تشير إليه.
لنأخذ مثالاً عملياً: عندما تكتب دالة مثل createCounter، فإن المتغير count لا يُنسى بعد انتهاء تنفيذ createCounter، بل يبقى موجوداً في الذاكرة طالما أن الدالة counter ما زالت موجودة. هذا السلوك مفيد جداً في حالات مثل الـevent handlers أو الـcallbacks، لكنه قد يكون كارثياً إذا استخدمت closures بشكل عشوائي دون فهم تأثيرها على الذاكرة. المشكلة الأكبر هي أن هذا التسرب لا يظهر في بيئات التطوير الصغيرة، بل يظهر فقط عندما يكون التطبيق قيد التشغيل لفترات طويلة ومع بيانات حقيقية.
// مثال كلاسيكي لكنه خطير في الإنتاج
function createCounter() {
let count = 0;
return function counter() {
count++;
console.log(count);
};
}
const myCounter = createCounter();
myCounter(); // 1
myCounter(); // 2
// المتغير count لا يزال موجوداً في الذاكرة طالما myCounter موجود
// حتى لو لم نعد نحتاج إليه بعد الآنلاحظ هنا أن المتغير count محصور داخل closure الخاص بالدالة counter. هذا يعني أنه لا يمكن الوصول إليه من خارج الدالة، لكنه أيضاً لا يمكن للـgarbage collector التخلص منه طالما أن myCounter ما زال موجوداً. في التطبيقات الكبيرة، يمكن أن يؤدي هذا إلى تراكم مئات أو آلافclosures غير ضرورية في الذاكرة، مما يسبب بطءاً تدريجياً في الأداء حتى يصل التطبيق إلى حالة الانهيار. هذا بالضبط ما حدث في المثال الذي ذكرته في بداية المقال، حيث كانتclosures تحتفظ بمراجع لعناصر DOM لم تعد موجودة، مما منع الـgarbage collector من تحرير الذاكرة.
closures لا تعمل في فراغ. هي تتفاعل مع الـevent loop و الـcall stack بطرق قد لا تكون واضحة للمطورين الجدد. عندما تنشئ closure داخل دالة تُستدعى بشكل متكرر (مثل داخل setInterval أو event listener)، فإن كل استدعاء جديد للدالة الخارجية ينشئ lexical environment جديداً، مما يعني أن كل closure جديدة تحتفظ بمرجعها الخاص للمتغيرات. هذا السلوك يمكن أن يكون مفيداً في بعض الحالات، لكنه قد يؤدي إلى كارثة إذا لم تكن حذراً.
لنأخذ مثالاً واقعياً: تخيل أنك تعمل على تطبيق دردشة حقيقي الوقت، وتستخدم setInterval لتحديث حالة الرسائل كل ثانية. إذا كتبت الكود بشكل خاطئ، فقد ينتهي بك الأمر بإنشاء مئاتclosures غير ضرورية في الذاكرة، كل منها تحتفظ بمرجع لنفس البيانات. المشكلة هنا ليست فقط في استهلاك الذاكرة، بل أيضاً في أن كل closure جديدة تضيف عبئاً على الـevent loop، مما يؤدي إلى تأخير في تنفيذ المهام الأخرى. هذا بالضبط ما حدث في تطبيق دردشة شهير عندما استخدم المطورون closures داخل setInterval دون فهم تأثيرها على الأداء، مما تسبب في تأخير يصل إلى ٥ ثوانٍ في تحديث الرسائل عند وجود أكثر من ١٠٠٠ مستخدم متصل.
// مثال كارثي:closures داخل setInterval
function startUpdating() {
let lastUpdate = Date.now();
setInterval(() => {
const now = Date.now();
console.log(`Last update was ${now - lastUpdate}ms ago`);
lastUpdate = now;
}, 1000);
}
startUpdating();
// كل ثانية، يتم إنشاء lexical environment جديد
// يحتوي على lastUpdate، مما يؤدي إلى تسرب ذاكرة تدريجي
// الحل الصحيح هو استخدام متغير خارجي أو clearInterval عند عدم الحاجةفي هذا المثال، كل استدعاء للدالة setInterval ينشئ lexical environment جديداً يحتوي على lastUpdate. هذا يعني أنه حتى بعد انتهاء الحاجة إلى التحديث، فإن الـlexical environment يبقى موجوداً في الذاكرة طالما أن الـinterval ما زال قيد التشغيل. الحل هنا ليس تجنب استخدام closures تماماً، بل فهم متى وكيف يتم إنشاء lexical environments جديدة، واستخدام آليات مثل clearInterval أو إزالة event listeners عندما لا تعود هناك حاجة إليها.
closures ليست مقتصرة على JavaScript النقي. هي موجودة في كل مكان، من الـReact hooks إلى الـNode.js streams. في الواقع، واحدة من أكثر المشاكل شيوعاً في تطبيقات React هي استخدام closures بشكل خاطئ داخل useEffect أو useCallback، مما يؤدي إلى سلوك غير متوقع أو حتى تسرب ذاكرة. المشكلة هنا ليست في closures نفسها، بل في عدم فهم المطورين لكيفية تعامل React مع الـre-renders والـdependency arrays.
لنأخذ مثالاً شائعاً: عندما تستخدم useEffect مع متغير من الـstate، فإن الـclosure التي ينشئها useEffect تحتفظ بمرجع للمتغير في الوقت الذي تم فيه إنشاء الـclosure. إذا تغير المتغير لاحقاً، فإن الـclosure لا ترى التغيير، مما يؤدي إلى سلوك غير متوقع. هذا بالضبط ما حدث في تطبيق شهير لإدارة المهام عندما استخدم المطورون useEffect مع متغير state دون إضافته إلى dependency array، مما تسبب في عدم تحديث الواجهة عند تغيير حالة المهمة.
// مثال خاطئ شائع في React
function TaskList() {
const [tasks, setTasks] = useState([]);
const [filter, setFilter] = useState('all');
useEffect(() => {
// هذا الـclosure تحتفظ بقيمة filter عند إنشائها
fetchTasks().then(data => {
if (filter === 'completed') {
setTasks(data.filter(task => task.completed));
} else {
setTasks(data);
}
});
}, []); // ❌ filter غير مضاف إلى dependency array
return (...);
}في هذا المثال، الـclosure داخل useEffect تحتفظ بقيمة filter عند إنشائها. حتى إذا تغيرت قيمة filter لاحقاً، فإن الـclosure لن ترى التغيير، مما يؤدي إلى عدم تحديث القائمة عند تغيير الفلتر. الحل هنا هو إضافة filter إلى dependency array، لكن هذا قد يؤدي إلى استدعاء useEffect بشكل متكرر، مما يسبب مشاكل أداء أخرى. هذا هو بالضبط التوازن الذي يجب على المطورين فهمه عند استخدام closures في React: متى تحتفظ بالمراجع، ومتى تسمح للـgarbage collector بالتخلص منها.
في Node.js، closures هي جزء أساسي من التعامل مع الـasync/await والـstreams. واحدة من أكثر المشاكل شيوعاً هي استخدام closures داخل loops مع متغيرات يتم تعديلها، مما يؤدي إلى سلوك غير متوقع. على سبيل المثال، إذا كتبت كوداً يقرأ ملفات متعددة باستخدام fs.readFile داخل loop، فإن كل closure تحتفظ بمرجع للمتغير i في الوقت الذي تم فيه إنشاء الـclosure، مما يؤدي إلى قراءة الملفات بشكل خاطئ. الحل هنا هو استخدام let بدلاً من var داخل loops، أو استخدام forEach مع قيمة ثابتة.
// مثال خاطئ في Node.js
const fs = require('fs');
const files = ['file1.txt', 'file2.txt', 'file3.txt'];
for (var i = 0; i < files.length; i++) {
fs.readFile(files[i], 'utf8', (err, data) => {
console.log(`Content of ${files[i]}:`, data);
// ❌ كل closure تحتفظ بمرجع لـi النهائي (3)
});
}
// الحل الصحيح: استخدام let أو forEach
files.forEach(file => {
fs.readFile(file, 'utf8', (err, data) => {
console.log(`Content of ${file}:`, data);
// ✅ كل closure تحتفظ بقيمة file الثابتة
});
});closures هي واحدة من أكثر أسباب تسرب الذاكرة شيوعاً في تطبيقات JavaScript. المشكلة ليست في closures نفسها، بل في كيفية استخدامها دون فهم تأثيرها على الذاكرة. عندما تحتفظ closure بمرجع لمتغير أو كائن لم تعد هناك حاجة إليه، فإن الـgarbage collector لا يستطيع التخلص منه، مما يؤدي إلى تراكم الذاكرة تدريجياً حتى يصل التطبيق إلى حالة الانهيار. هذا النوع من التسرب لا يظهر في بيئات التطوير الصغيرة، بل يظهر فقط في الإنتاج عندما يكون التطبيق قيد التشغيل لفترات طويلة ومع بيانات حقيقية.
أحد أكثر السيناريوهات شيوعاً هو الاحتفاظ بمراجع لعناصر DOM داخل closures. على سبيل المثال، إذا أنشأت event listener يستخدم closure تحتفظ بمرجع لعنصر DOM، فإن هذا المرجع يمنع الـgarbage collector من التخلص من العنصر حتى بعد إزالته من الـDOM. هذا بالضبط ما حدث في المثال الذي ذكرته في بداية المقال، حيث كانتclosures تحتفظ بمراجع لعناصر DOM لم تعد موجودة، مما تسبب في تسرب ذاكرة بمعدل ٥٠ ميجابايت كل دقيقة. الحل هنا هو إزالة event listeners عندما لا تعود هناك حاجة إليها، أو استخدام آليات مثل AbortController لإلغاء الاشتراكات.
// مثال لتسرب ذاكرة بسبب closures
function setupEventListeners() {
const butt document.querySelectorAll('button');
buttons.forEach(button => {
button.addEventListener('click', () => {
console.log(`Button ${button.textContent} clicked`);
// ❌ closure تحتفظ بمرجع لـbutton
// حتى بعد إزالة الزر من DOM، المرجع يبقى
});
});
}
// الحل الصحيح: إزالة event listeners عند عدم الحاجة
function setupSafeEventListeners() {
const buttons = document.querySelectorAll('button');
const listeners = [];
buttons.forEach(button => {
const listener = () => {
console.log(`Button ${button.textContent} clicked`);
};
button.addEventListener('click', listener);
listeners.push({ button, listener });
});
return () => {
listeners.forEach(({ button, listener }) => {
button.removeEventListener('click', listener);
});
};
}في هذا المثال، الحل الآمن هو تخزين event listeners في مصفوفة وإزالتها عند عدم الحاجة. هذا يمنع closures من الاحتفاظ بمراجع لعناصر DOM غير الضرورية. لكن حتى هذا الحل قد يكون غير كافٍ في بعض الحالات، خاصة إذا كانت هناك مراجع أخرى لنفس العناصر. لهذا السبب، من المهم دائماً مراقبة استخدام الذاكرة في التطبيقات الكبيرة باستخدام أدوات مثل Chrome DevTools أو Node.js heap snapshots. إذا رأيت أن استخدام الذاكرة يزداد تدريجياً دون سبب واضح، فمن المحتمل أن هناك closures تحتفظ بمراجع غير ضرورية.
closures ليست مجرد مشكلة ذاكرة. هي أيضاً مشكلة أداء عندما تُستخدم بشكل مفرط أو غير صحيح. كل closure جديدة تضيف عبئاً على الـcall stack و الـevent loop، مما يؤدي إلى بطء في تنفيذ المهام الأخرى. هذا التأثير قد يكون غير ملحوظ في التطبيقات الصغيرة، لكنه يصبح كارثياً في التطبيقات الكبيرة التي تعتمد على الأداء، مثل الألعاب أو تطبيقات الوقت الحقيقي.
لنأخذ مثالاً واقعياً: في لعبة تعتمد على Canvas، استخدم المطورون closures داخل loop الرسوم المتحركة لتحديث حالة اللعبة. كل closure جديدة تحتفظ بمرجع لمتغيرات اللعبة، مما أدى إلى بطء تدريجي في الأداء حتى وصل معدل الإطارات إلى ١٠ إطارات في الثانية بدلاً من ٦٠. المشكلة هنا ليست في استخدام closures نفسها، بل في إنشاءclosures جديدة في كل إطار بدلاً من إعادة استخدامclosures الموجودة. الحل هنا هو نقل المتغيرات المشتركة إلى خارج الـloop، أو استخدام كائنات لتجميع الحالة بدلاً من closures متعددة.
// مثال خاطئ:closures داخل loop الرسوم المتحركة
function animateGame() {
let score = 0;
let lives = 3;
function gameLoop() {
// ❌ كل إطار ينشئ closures جديدة
requestAnimationFrame(() => {
updateScore(score);
updateLives(lives);
gameLoop();
});
}
gameLoop();
}
// الحل الصحيح: استخدام كائن لتجميع الحالة
function animateGameOptimized() {
const state = {
score: 0,
lives: 3
};
function gameLoop() {
requestAnimationFrame(() => {
updateScore(state.score);
updateLives(state.lives);
gameLoop();
});
}
gameLoop();
}في هذا المثال، الحل الأمثل هو استخدام كائن لتجميع الحالة بدلاً من closures متعددة. هذا يقلل من عددclosures التي يتم إنشاؤها، مما يحسن الأداء بشكل كبير. لكن حتى هذا الحل قد لا يكون كافياً في بعض الحالات، خاصة إذا كانت هناك حاجة إلىclosures متعددة لأسباب منطقية. في هذه الحالات، من المهم قياس الأداء باستخدام أدوات مثل Chrome DevTools Performance tab، وتحديدclosures التي تستهلك معظم الموارد.
closures في JavaScript ليست مجرد ميزة برمجية، بل هي أداة قوية يمكن أن تحسن كودك أو تدمره. المفتاح هو فهم كيفية عملها خلف الكواليس، ومعرفة متى تستخدمها ومتى تتجنبها. إذا كنت تعمل على تطبيق صغير، فقد لا ترى تأثير closures على الأداء أو الذاكرة. لكن إذا كنت تعمل على تطبيق كبير أو معقد، فإن فهم closures يصبح ضرورياً لتجنب الكوارث التي لا تظهر إلا في الإنتاج.
نصيحة عملية واحدة أخيرة: إذا وجدت نفسك تستخدم closures داخل loops أو مع متغيرات تتغير بشكل متكرر، توقف وفكر. هل هناك طريقة لإعادة هيكلة الكود لتقليل عددclosures؟ هل يمكنك استخدام كائنات لتجميع الحالة بدلاً من closures متعددة؟ هل يمكنك إزالة event listeners عندما لا تعود هناك حاجة إليها؟ هذه الأسئلة البسيطة يمكن أن تنقذك من ساعات طويلة من تصحيح الأخطاء في الإنتاج. closures ليست جيدة أو سيئة، بل هي أداة. استخدمها بحكمة.