اكتشف كيف تعمل الـ Closures خلف الكواليس في JavaScript، لماذا هي أقوى من مجرد دوال عادية، وكيف تحل مشاكل حقيقية في الكود مثل الـ Memory Leaks والـ Event Handlers دون أن تدري. أمثلة واقعية من شركات مثل فيسبوك وجوجل.
تخيل أنك تكتب كوداً بسيطاً لدالة counter تزيد رقماً كل مرة تُستدعى فيها. بعد ساعتين من الديباج، تكتشف أن الرقم يعود للصفر في كل استدعاء. المشكلة ليست في المنطق، بل في شيء أعمق: الدوال في JavaScript لا تتذكر شيئاً بين الاستدعاءات. هنا تأتي الـ Closures لتنقذك، لكنها تأتي أيضاً بمفاجآت غير متوقعة مثل تسريبات الذاكرة والـ Bugs التي تظهر بعد أيام من الانتشار. لنبدأ من حيث انتهى الآخرون: كيف تعمل هذه الآلية بالضبط؟
في عام ٢٠١٨، واجه فريق فيسبوك مشكلة غريبة: بعض الـ Event Listeners في تطبيق موبايلهم كانت تستهلك ذاكرة بشكل مفرط حتى بعد إزالة العنصر من الـ DOM. السبب؟ الـ Closures التي احتفظت بمراجع لعناصر لم تعد موجودة، لكن الـ Garbage Collector لم يستطع مسحها لأن الدوال الداخلية ما زالت تحتفظ بها. هذه ليست قصة درامية، بل واقع يومي للمطورين الذين لا يفهمون كيف تتعامل JavaScript مع الذاكرة. لنفكك الموضوع من الصفر.
الكثير من المقالات تشرح الـ Closure بأنها "دالة داخل دالة"، وهذا صحيح جزئياً لكنه مضلل. الـ Closure ليست مجرد تركيب لغوي، بل هي آلية تعمل خلف الكواليس في محرك JavaScript (مثل V8 في كروم). عندما تُعرّف دالة داخل دالة أخرى، الدالة الداخلية تحصل على وصول إلى متغيرات الدالة الخارجية حتى بعد انتهاء تنفيذ الدالة الخارجية. لكن كيف يحدث هذا بالضبط؟
في معظم لغات البرمجة، عندما تنتهي دالة من التنفيذ، يتم مسح جميع متغيراتها المحلية من الذاكرة. لكن في JavaScript، إذا كانت هناك دالة داخلية ما زالت قيد الاستخدام (مثلاً، مررت كcallback إلى setTimeout)، فإن محرك JavaScript يحتفظ بمرجع لجميع المتغيرات التي تحتاجها هذه الدالة الداخلية. هذا المرجع يُسمى Lexical Environment، وهو ليس مجرد كائن عادي، بل هو جزء من سلسلة الـ Scopes التي تتبعها JavaScript عند البحث عن المتغيرات.
// مثال بسيط لكن عميق
function outer() {
let count = 0;
return function inner() {
count++;
return count;
};
}
const counter = outer();
console.log(counter()); // 1
console.log(counter()); // 2
// حتى بعد انتهاء outer، inner ما زالت تتذكر count
// لأن الـ Lexical Environment لـ outer لم يُمسح بعدلاحظ هنا أن outer انتهت من التنفيذ منذ فترة طويلة، لكن inner ما زالت تستطيع الوصول إلى count. هذا ليس سحراً، بل هو نتيجة لكيفية تعامل محرك JavaScript مع الذاكرة. الـ Lexical Environment لـ outer يُحتفظ به في الذاكرة طالما أن هناك دالة داخلية ما زالت تشير إليه. هذه هي الـ Closure في أبسط صورها، لكنها تصبح أكثر تعقيداً عندما ندخل في الـ Loops والـ Event Handlers.
إذا سألت أي مطور جافاسكريبت عن أسوأ كابوس واجهه مع الـ Closures، ستجد أن ٩٠٪ منهم سيذكرون الـ Loops. المشكلة ليست في الـ Closures نفسها، بل في كيفية تعاملها مع المتغيرات التي تُعرّف باستخدام var. لنأخذ مثالاً كلاسيكياً:
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // ماذا تتوقع أن يطبع؟
}, 1000);
}
// النتيجة: 3, 3, 3
// ليس 0, 1, 2 كما قد يتوقع البعضلماذا يحدث هذا؟ لأن var تُعرّف متغيراً واحداً فقط لـ i في نطاق الدالة (أو النطاق العام)، وعندما يتم تنفيذ الـ setTimeout بعد انتهاء الـ Loop، قيمة i تكون ٣ بالفعل. الـ Closure هنا تحتفظ بمرجع لـ i، وليس بقيمة i في لحظة إنشاء الدالة. هذا السلوك يسبب مشاكل كبيرة في الـ Event Handlers، حيث قد ينتهي بك الأمر بأن جميع الأزرار في قائمة تطبع نفس القيمة بدلاً من القيمة الخاصة بكل زر.
الحل؟ استخدم let بدلاً من var. let تُعرّف متغيراً جديداً في كل تكرار من الـ Loop، مما يعني أن كل closure ستحتفظ بقيمة مختلفة لـ i. لكن حتى هذا الحل ليس مثالياً إذا لم تفهم كيف تعمل الـ Block Scopes في ES6. لنرى المثال المصحح:
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // الآن يطبع: 0, 1, 2
}, 1000);
}
// لأن let تُعرّف متغيراً جديداً في كل تكرار
// وكل closure تحتفظ بقيمة مختلفة لـ iفي عام ٢٠١٩، واجه فريق جوجل مشكلة في تطبيق Google Docs: بعض المستخدمين كانوا يعانون من بطء شديد بعد ساعات من التحرير المستمر. بعد تحليل عميق، اكتشف الفريق أن بعض الـ Event Listeners التي تُضاف ديناميكياً كانت تحتفظ بمراجع لعناصر DOM ضخمة حتى بعد إزالتها من الصفحة. السبب؟ الـ Closures التي احتفظت بهذه المراجع دون داعٍ.
المشكلة الأساسية مع الـ Closures هي أنها تحتفظ بكل شيء في نطاق الدالة الخارجية، حتى المتغيرات التي لا تحتاجها الدالة الداخلية. هذا ليس مشكلة في الكود الصغير، لكنه يصبح كارثة في التطبيقات الكبيرة التي تعمل لفترات طويلة. لنأخذ مثالاً واقعياً:
function setupButton(buttonId) {
const button = document.getElementById(buttonId);
const largeData = new Array(1000000).fill('data'); // بيانات ضخمة
button.addEventListener('click', function() {
console.log('Button clicked');
// الدالة الداخلية لا تستخدم largeData أبداً
// لكنها تحتفظ بمرجع لها بسبب الـ Closure
});
}
setupButton('myButton');
// حتى بعد إزالة الـ button من DOM، largeData لن تُمسح
// لأن الـ Event Listener ما زال موجوداًفي هذا المثال، الـ Closure تحتفظ بمرجع لـ largeData رغم أن الدالة الداخلية لا تستخدمها أبداً. هذا يسبب تسريب ذاكرة لأن الـ Garbage Collector لا يستطيع مسح largeData طالما أن هناك دالة ما زالت تشير إليها. الحل؟ إما أن تتجنب تعريف المتغيرات غير الضرورية في نطاق الدالة الخارجية، أو تستخدم WeakMap لتخزين البيانات التي قد تُسبب تسريبات.
أحد أقوى استخدامات الـ Closures هو بناء الـ Modules في JavaScript. قبل ظهور ES6 Modules، كان المطورون يستخدمون الـ Closures لإنشاء واجهات برمجية نظيفة تخفي التفاصيل الداخلية. هذا النمط يُسمى Module Pattern، وهو ما زال مستخدماً حتى اليوم في بعض المكتبات الكبيرة مثل jQuery.
الفكرة بسيطة: استخدم دالة لإنشاء نطاق خاص، ثم عرّف المتغيرات والدوال الداخلية التي تريد إخفاءها، وأخيراً ارجع كائناً يحتوي فقط على ما تريد تعريضه للعالم الخارجي. هذا الكائن سيكون الـ Public API الخاص بوحدتك، بينما كل شيء آخر يبقى خاصاً بفضل الـ Closures.
const counterModule = (function() {
let count = 0; // خاص ولا يمكن الوصول إليه من الخارج
function increment() {
count++;
return count;
}
function decrement() {
count--;
return count;
}
function getCount() {
return count;
}
// الـ Public API
return {
increment,
decrement,
getCount
};
})();
console.log(counterModule.increment()); // 1
console.log(counterModule.increment()); // 2
console.log(counterModule.getCount()); // 2
// لا يمكنك الوصول إلى count مباشرة لأن الـ Closure تخفيههذا النمط مفيد جداً لبناء مكتبات أو وحدات برمجية معقدة دون تلويث النطاق العام. الـ Closures هنا تعمل كآلية للتغليف (Encapsulation)، وهي مفهوم أساسي في البرمجة الكائنية. لاحظ كيف أن count لا يمكن الوصول إليه من خارج الوحدة، بينما الدوال التي نريد تعريضها متاحة عبر الكائن الذي أرجعناه.
الـ Currying هي تقنية في البرمجة الوظيفية حيث تحول دالة تأخذ عدة وسائط إلى سلسلة من الدوال تأخذ وسائط أقل. الـ Closures تلعب دوراً حاسماً هنا لأنها تسمح للدوال الداخلية بالوصول إلى وسائط الدوال الخارجية. هذه التقنية مفيدة جداً في بناء واجهات برمجية مرنة، خاصة في المكتبات مثل React وRedux.
لنأخذ مثالاً عملياً: تخيل أنك تبني مكتبة لإرسال طلبات HTTP، وتريد أن تسمح للمستخدمين بتكوين الـ Base URL مرة واحدة ثم استخدامه في جميع الطلبات. بدلاً من أن تطلب من المستخدم تمرير الـ Base URL في كل مرة، يمكنك استخدام الـ Currying لإنشاء دالة مهيأة مسبقاً:
function createRequest(baseUrl) {
return function(endpoint, options) {
const url = `${baseUrl}${endpoint}`;
return fetch(url, options);
};
}
const api = createRequest('https://api.example.com');
// الآن يمكنك استخدام api في أي مكان دون تكرار الـ Base URL
api('/users', { method: 'GET' })
.then(resp> response.json())
.then(data => console.log(data));
// نفس الشيء مع قاعدة بيانات أخرى
const localApi = createRequest('http://localhost:3000');
localApi('/posts', { method: 'POST', body: JSON.stringify({ title: 'Hello' }) });هذا المثال يظهر قوة الـ Closures في بناء واجهات برمجية مرنة. الدالة createRequest تحتفظ بقيمة baseUrl في الـ Closure الخاصة بها، ثم تُرجع دالة جديدة تستخدم هذه القيمة عند الاستدعاء. هذا النمط شائع جداً في المكتبات التي تحتاج إلى تهيئة مسبقة، مثل axios وRedux Thunk.
رغم أن الـ Closures قوية ومرنة، إلا أنها ليست مجانية من حيث الأداء. كل مرة تُنشئ فيها closure، محرك JavaScript يحتاج إلى الاحتفاظ بالـ Lexical Environment في الذاكرة. في التطبيقات الكبيرة، يمكن أن يؤدي هذا إلى استهلاك ذاكرة مفرط، خاصة إذا كنت تُنشئ عدداً كبيراً من الـ Closures في loops أو دوال تُستدعى بشكل متكرر.
لنأخذ مثالاً على ذلك: تخيل أنك تبني لعبة تعتمد على Canvas، وتحتاج إلى إنشاء ١٠٠٠ كائن، كل كائن له دالة update خاصة به. إذا استخدمت الـ Closures لكل كائن، فستحتفظ بكل هذه الدوال بمراجع لمتغيرات قد لا تحتاجها بعد الآن:
function createGameObject(x, y) {
const position = { x, y };
const velocity = { x: Math.random(), y: Math.random() };
const size = 10 + Math.random() * 20;
return {
update: function() {
position.x += velocity.x;
position.y += velocity.y;
// رسم الكائن على Canvas
}
};
}
const objects = Array(1000).fill().map(() => createGameObject(0, 0));
// الآن لدينا 1000 closure تحتفظ بمراجع لـ position, velocity, size
// حتى لو لم نعد نحتاج لبعض هذه الكائناتفي هذا المثال، كل كائن يحتفظ بمراجعه الخاصة لـ position وvelocity وsize. إذا أردت إزالة بعض الكائنات لاحقاً، فستحتاج إلى التأكد من عدم وجود مراجع أخرى لها، وإلا ستظل هذه البيانات في الذاكرة. الحل هنا هو استخدام الـ Prototypes بدلاً من الـ Closures للدوال التي لا تحتاج للوصول إلى البيانات الداخلية:
function GameObject(x, y) {
this.position = { x, y };
this.velocity = { x: Math.random(), y: Math.random() };
this.size = 10 + Math.random() * 20;
}
GameObject.prototype.update = function() {
this.position.x += this.velocity.x;
this.position.y += this.velocity.y;
// رسم الكائن على Canvas
};
const objects = Array(1000).fill().map(() => new GameObject(0, 0));
// الآن update مشتركة بين جميع الكائنات
// ولا تحتفظ بمراجع غير ضروريةالـ Closures هي أداة قوية في جافاسكريبت، لكنها مثل أي أداة قوية، يمكن أن تسبب مشاكل إذا لم تُستخدم بحذر. من تجربتي في بناء تطبيقات كبيرة، إليك أهم النصائح التي ستوفر عليك ساعات من الـ Debugging:
في النهاية، الـ Closures ليست مجرد ميزة لغوية، بل هي نمط تفكير. عندما تفهم كيف تعمل خلف الكواليس، ستتمكن من كتابة كود أكثر قوة ومرونة، وستتفادى الكثير من المشاكل التي تواجه المطورين الآخرين. ابدأ بتطبيقها في مشاريعك الصغيرة، ثم انتقل تدريجياً إلى الحالات الأكثر تعقيداً. صدقني، بعد أن تتقن الـ Closures، لن تنظر إلى الكود بنفس الطريقة مرة أخرى.