هل تساءلت يوماً لماذا يفشل الكود الخاص بك في التعامل مع الـ Loops رغم أنك تستخدم let؟ السر يكمن في Closures — آلية JavaScript القوية التي تحتجز المتغيرات في ذاكرة الدوال حتى بعد انتهائها. سنفكك هذه الآلية خطوة بخطوة بأمثلة حقيقية من عالم الإنتاج.
في أحد المشاريع الكبيرة لشركة ناشئة في دبي، كان فريق Frontend يعاني من مشكلة غريبة: عند إرسال طلبات متوازية باستخدام forEach، كانت جميع الردود تحمل نفس البيانات رغم أن الـ API يرسل بيانات مختلفة لكل طلب. المشكلة لم تكن في السيرفر، بل في طريقة تعامل JavaScript مع المتغيرات داخل الـ Loops. هنا بالضبط يظهر دور Closures — ليس كمفهوم أكاديمي، بل كأداة حقيقية تتحكم في كيفية احتفاظ الدوال بالبيانات في الذاكرة.
Closures ليست مجرد ميزة لغوية، بل هي آلية أساسية تتحكم في كيفية تعامل JavaScript مع الـ Scope والذاكرة. عندما تنشئ دالة داخل دالة أخرى، فإن الدالة الداخلية تحتجز مرجعاً لكل المتغيرات التي تستخدمها من الـ Scope الخارجي، حتى لو انتهت الدالة الخارجية من التنفيذ. هذا يعني أن المتغيرات لا تُحذف من الذاكرة كما نتوقع، بل تبقى حية طالما أن الدالة الداخلية موجودة في مكان ما في الكود.
عندما تنفذ JavaScript دالة، فإنها تنشئ ما يسمى بـ Execution Context. هذا السياق يحتوي على جميع المتغيرات والدوال التي يمكن الوصول إليها داخل تلك الدالة. عند انتهاء الدالة، يفترض أن يُحذف هذا السياق من الذاكرة، لكن هنا يأتي دور Closures. إذا كانت هناك دالة داخلية لا تزال موجودة (مثلاً لأنها أُرجعت من الدالة الخارجية أو وُضعت في Event Listener)، فإن JavaScript تحتجز جميع المتغيرات التي تستخدمها الدالة الداخلية داخل ما يسمى بـ Closure Scope.
لنأخذ مثالاً عملياً: عندما تنشئ دالة counter داخل دالة createCounter، فإن counter تحتجز المتغير count من الـ Scope الخارجي. حتى لو انتهت createCounter من التنفيذ، فإن count يبقى موجوداً في الذاكرة طالما أن counter لا تزال موجودة. هذا السلوك ليس خطأً في التصميم، بل هو ميزة قوية تسمح لنا بإنشاء دوال تحتفظ بحالتها الداخلية دون الحاجة إلى متغيرات عامة أو Classes.
// مثال كلاسيكي على Closure
function createCounter() {
let count = 0;
return function counter() {
count++;
return count;
};
}
const myCounter = createCounter();
console.log(myCounter()); // 1
console.log(myCounter()); // 2
console.log(myCounter()); // 3
// حتى لو أنشأنا عداداً آخر، فكل عداد يحتفظ بحالته الخاصة
const anotherCounter = createCounter();
console.log(anotherCounter()); // 1
console.log(myCounter()); // 4في هذا المثال، المتغير count ليس متغيراً عاماً، بل هو متغير محلي داخل createCounter. ومع ذلك، تستمر الدالة counter في الوصول إليه حتى بعد انتهاء createCounter. هذا هو بالضبط ما نعنيه بـ Closure — الدالة counter تحتجز المتغير count في ذاكرتها الخاصة. هذه الآلية هي ما يجعل مكتبات مثل React تستخدم Hooks مثل useState بكفاءة، حيث تحتجز الحالة داخل الـ Closure الخاص بكل مكون.
أحد أكثر الأخطاء شيوعاً التي يقع فيها المطورون هو استخدام Closures داخل الـ Loops. لنفترض أنك تريد إنشاء مجموعة من الأزرار، وكل زر عند النقر عليه يعرض رقمه. قد تكتب الكود التالي:
// مثال خاطئ شائع
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // سيطبع 5 خمس مرات!
}, 1000);
}لماذا يطبع هذا الكود الرقم 5 خمس مرات بدلاً من الأرقام من 0 إلى 4؟ السبب هو أن var لا يملك Block Scope، بل يملك Function Scope. عندما تنتهي الـ Loop، يكون قيمة i تساوي 5، وكل الـ setTimeoutCallbacks تحتجز مرجعاً لنفس المتغير i. عندما تنفذ الـ Callbacks بعد ثانية، فإنها جميعاً ترى القيمة النهائية لـ i.
الحل التقليدي لهذه المشكلة كان استخدام IIFE (Immediately Invoked Function Expression) لإنشاء scope جديد في كل تكرار:
// الحل باستخدام IIFE
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // يطبع 0, 1, 2, 3, 4
}, 1000);
})(i);
}لكن مع ظهور let في ES6، أصبح الحل أبسط بكثير. لأن let يملك Block Scope، فإن كل تكرار من الـ Loop ينشئ متغيراً جديداً i، وبالتالي تحتجز كل callback قيمة مختلفة:
// الحل باستخدام let
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // يطبع 0, 1, 2, 3, 4
}, 1000);
}في تطبيقات الويب الحقيقية، تُستخدم Closures في كل مكان تقريباً. أحد الأمثلة الواضحة هو الـ Event Listeners. عندما تضيف listener لحدث معين، فإن الـ Callback تحتجز جميع المتغيرات التي تستخدمها من الـ Scope الخارجي. هذا يسمح لك مثلاً بالوصول إلى البيانات التي تحتاجها عند حدوث الحدث:
function setupButton(buttonId, userData) {
const button = document.getElementById(buttonId);
button.addEventListener('click', function() {
console.log('Button clicked for user:', userData.name);
// هنا تحتجز الـ Callback المتغير userData
});
}
setupButton('myButton', { name: 'Ahmed', id: 123 });مثال آخر هو الـ Module Pattern، الذي كان يستخدم قبل ظهور ES6 Modules لإنشاء كود محمي من التلوث العام. باستخدام Closures، يمكنك إنشاء متغيرات خاصة لا يمكن الوصول إليها من خارج الـ Module:
// Module Pattern باستخدام Closure
const userModule = (function() {
let users = [];
function addUser(user) {
users.push(user);
}
function getUsers() {
return [...users]; // إرجاع نسخة لتجنب التعديل الخارجي
}
return {
addUser,
getUsers
};
})();
userModule.addUser({ name: 'Sara' });
console.log(userModule.getUsers()); // [{ name: 'Sara' }]
// لا يمكن الوصول إلى المتغير users مباشرة من الخارجفي هذا المثال، المتغير users محمي داخل الـ Closure الخاص بـ userModule. لا يمكن تعديله مباشرة من الخارج، بل فقط من خلال الدوال التي يعرضها الـ Module. هذه الآلية هي ما يجعل المكتبات مثل jQuery تستخدم Closures لحماية المتغيرات الداخلية من التلوث العام.
رغم قوة Closures، إلا أنها قد تسبب مشاكل في إدارة الذاكرة إذا لم تُستخدم بحذر. لأن الدوال الداخلية تحتجز مراجع للمتغيرات الخارجية، فإن هذه المتغيرات لا تُحذف من الذاكرة حتى تنتهي جميع الدوال التي تحتجزها. في التطبيقات الكبيرة، قد يؤدي هذا إلى ما يسمى بـ Memory Leaks، حيث تبقى البيانات في الذاكرة دون داعٍ، مما يؤدي إلى بطء التطبيق أو حتى تحطمه.
لنأخذ مثالاً واقعياً: في تطبيق React، إذا أنشأت Event Listener داخل useEffect دون تنظيفه، فإن الـ Callback تحتجز مراجع للمتغيرات من الـ Component Scope. إذا كان الـ Component يعاد إنشاؤه عدة مرات، فإن كل نسخة تحتجز نسختها الخاصة من المتغيرات، مما يؤدي إلى تراكم البيانات في الذاكرة:
// مثال على Memory Leak في React
useEffect(() => {
function handleResize() {
console.log('Window resized', someState); // تحتجز someState
}
window.addEventListener('resize', handleResize);
// نسيان إزالة الـ Listener عند إعادة تحميل الـ Component
}, [someState]);الحل هنا هو إزالة الـ Listener عند إعادة تحميل الـ Component أو عند إلغاء تحميله. بهذه الطريقة، لا تحتجز الـ Callback المتغيرات دون داعٍ:
useEffect(() => {
function handleResize() {
console.log('Window resized', someState);
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [someState]);في الكود غير المتزامن، تلعب Closures دوراً حاسماً في الحفاظ على البيانات بين مراحل التنفيذ المختلفة. لنفترض أنك تريد جلب بيانات من عدة APIs بشكل متوازٍ، ثم معالجة النتائج معاً. باستخدام Closures، يمكنك الاحتفاظ بالبيانات المؤقتة داخل الـ Callbacks:
function fetchMultipleUsers(userIds) {
const results = [];
userIds.forEach((id, index) => {
fetch(`https://api.example.com/users/${id}`)
.then(resp> response.json())
.then(user => {
results[index] = user;
if (results.filter(Boolean).length === userIds.length) {
console.log('All users fetched:', results);
}
});
});
}
fetchMultipleUsers([1, 2, 3]);في هذا المثال، تحتجز كل callback المتغير results من الـ Scope الخارجي، مما يسمح لها بتحديث المصفوفة المشتركة. لكن هذا النمط قد يسبب مشاكل إذا لم تُنظف الـ Callbacks بشكل صحيح، خاصة في التطبيقات التي تستخدم الـ Event Loop بكثافة.
مع ظهور async/await، أصبح التعامل مع Closures في الكود غير المتزامن أسهل وأكثر وضوحاً. لكن حتى هنا، يجب الانتباه إلى كيفية احتفاظ الـ Closures بالمتغيرات:
async function fetchMultipleUsers(userIds) {
const results = await Promise.all(
userIds.map(async (id) => {
const resp await fetch(`https://api.example.com/users/${id}`);
return response.json();
})
);
console.log('All users fetched:', results);
}Closures ليست مجرد ميزة لغوية، بل هي أداة قوية تتحكم في كيفية تعامل JavaScript مع الذاكرة والبيانات. لتتقنها، عليك أن تفهم ليس فقط كيفية استخدامها، بل أيضاً متى وكيف يمكن أن تسبب مشاكل. من تجربتي، أفضل الممارسات هي: استخدم let بدلاً من var في الـ Loops لتجنب مشاكل الـ Closure، نظف الـ Event Listeners والـ Callbacks لمنع الـ Memory Leaks، واستخدم الـ Module Pattern لحماية الكود الخاص بك من التلوث العام. وعندما تواجه مشكلة غريبة في الكود، اسأل نفسك: هل هناك Closure تحتجز متغيراً بطريقة غير متوقعة؟ في معظم الأحيان، ستكون الإجابة نعم.
الخطوة التالية هي ممارسة كتابة الكود الذي يستخدم Closures بشكل واعٍ. جرب إنشاء دوال تحتفظ بحالتها الداخلية، أو استخدم الـ Module Pattern لتنظيم الكود الخاص بك. وكلما واجهت مشكلة، ارجع إلى أساسيات الـ Scope والذاكرة — لأن Closures ليست سوى نتيجة طبيعية لكيفية تعامل JavaScript مع هذين المفهومين.