المستوى المطلوب: مبتدئ. لو شغّلت JavaScript قبل كده وفهمت معنى الدالة والمتغيّر، الكلام ده ليك. مش مطلوب منك تعرف لا async ولا prototypes ولا حتى ES Modules.
هتفهم في المقال ده ليه الدالة الجوّانية بتفتكر متغيّرات الدالة الخارجية حتى بعد ما الخارجية خلصت شغلها. ده مش تفصيلة أكاديمية — ده اللي بيخلّي React hooks تشتغل، وبيخلّي مكتبات زي Lodash تقدر تعمل debounce و throttle، وبيخلّيك تكتب counter آمن من غير ما حد يعدّل قيمته من بره.
Closures في JavaScript: المتغيّرات اللي مش بتموت لمّا الدالة تخلص
المشكلة باختصار
أغلب المبتدئين بيفترضوا إن لمّا الدالة تخلص تنفيذها، كل المتغيّرات اللي اتعرّفت جوّاها بتتمسح من الذاكرة. ده افتراض منطقي ومعقول. والمشكلة إنه غلط في حالة واحدة بالظبط: لمّا الدالة بترجع دالة تانية جوّاها بتستخدم متغيّرات الأم.
الكود ده مثلاً، لو سألتك هيطبع كام، إيه إجابتك؟
function makeGreeter(name) {
return function () {
console.log("أهلاً يا " + name);
};
}
const greet = makeGreeter("حايس");
greet();
الإجابة الحدسية: undefined أو خطأ، لأن name اتعرّف داخل makeGreeter اللي خلصت تنفيذها بالفعل. الإجابة الفعلية: "أهلاً يا حايس". ده Closure.
المثال أولاً: درج المكتب
تخيّل إن عندك مكتب فيه 5 أدراج. كل درج جوّاه ورقة مكتوب عليها رقم. الأدراج كلها تابعة للمكتب — لو الشركة قرّرت تشيل المكتب، الأدراج بتتشال معاه طبيعي.
دلوقتي تخيّل سيناريو غريب: ضفت لمكتبك ساعة حائط معلّقة فوقه، والساعة دي بتقرأ الرقم اللي في الدرج الأوّل وتظهره. لمّا الشركة شالت المكتب، الساعة فضلت معلّقة على الحيط، ولمّا حد سألها "إيه الرقم؟" قالتله الرقم اللي كان في الدرج الأول. ليه؟ لأن الساعة "فاكرة" مكان الدرج، ومادام في حد فاكر، الدرج عمره ما هيتشال فعلياً من المخزن.
الساعة هنا = الدالة الجوّانية. الدرج = المتغيّر الخارجي. المكتب = الدالة الخارجية. مفيش حد قادر يحذف الأدراج طول ما الساعة لسه شغّالة.
التعريف العلمي الدقيق
الـ Closure هو دالة مرتبطة بـ الـ lexical environment اللي اتعرّفت فيه. الـ lexical environment ده هيكل بيانات داخلي في محرّك JavaScript بيتخزّن فيه: المتغيّرات المعرّفة في النطاق ده + مرجع للنطاق الأب (parent scope reference). الترتيب ده بيكوّن سلسلة اسمها scope chain.
لمّا الدالة الخارجية بترجع دالة جوّانية بتستخدم متغيّرات من الأم، محرّك JavaScript بيحطّ علامة على الـ environment ده تقول "ممنوع الـ garbage collector يلمسك". الـ environment بيفضل في الذاكرة طول ما في reference نشط للدالة الجوّانية.
المرجع: ECMA-262 §9.1 Lexical Environments — وهي المواصفة الرسمية للغة.
المثال العملي الأول: دالة العدّاد
الـ counter مثال كلاسيكي يبيّن قوة الـ closure. عاوز عدّاد بيزيد بـ 1 كل مرة تنده عليه، ومحدش يقدر يعدّل قيمته من بره.
function makeCounter() {
let count = 0;
return function () {
count = count + 1;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
console.log(counter.count); // undefined — مفيش وصول مباشر
المتغيّر count محبوس جوّا الـ closure. مفيش طريقة من بره makeCounter توصّلك له. ده encapsulation حقيقي بدون حاجة لـ class أو #private fields.
الاستخدام الحقيقي: متغيّرات خاصة (Private State)
قبل ما class تيجي في ES2015، الناس كانت بتعمل OOP في JavaScript بالكامل عن طريق الـ closures. البنك التالي مثال حقيقي:
function createAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
if (amount <= 0) throw new Error("القيمة لازم تكون موجبة");
balance = balance + amount;
return balance;
},
withdraw(amount) {
if (amount > balance) throw new Error("الرصيد مش كافي");
balance = balance - amount;
return balance;
},
getBalance() {
return balance;
}
};
}
const myAccount = createAccount(1000);
myAccount.deposit(500); // 1500
myAccount.withdraw(200); // 1300
myAccount.balance = 0; // مش هتأثر — مفيش property اسمها balance
console.log(myAccount.getBalance()); // 1300
الـ balance محمي تماماً. الكود من بره مش قادر يعدّله إلا عبر الدوال اللي عرّفناها. ده نفس مبدأ private في Java و C# — بس بدون كلمة private.
الفخ الكلاسيكي: Closures جوّا الـ Loop
الكود ده شائع جداً في كود قديم بـ var:
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
// المتوقّع: 0, 1, 2
// الفعلي: 3, 3, 3
السبب: var بيعمل متغيّر واحد على مستوى الدالة كلها. كل callback مخزّن نفس الـ reference لنفس i. لمّا الـ loop يخلص i بقى 3، فالكل بيقرأ 3.
الحل بـ let اللي بيعمل block scope جديد لكل دورة:
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 100);
}
// النتيجة: 0, 1, 2 — كل closure مخزّن نسخته الخاصة
الفرق بين var و let هنا مش رفاهية — ده الفرق بين كود صح وكود غلط في 90% من الحالات.
trade-offs: الذاكرة مش ببلاش
الـ closures بتاكل ذاكرة. كل closure بيحتفظ بنسخة من الـ environment بتاعه. قياس بسيط على Node 22:
const counters = [];
for (let i = 0; i < 1_000_000; i++) {
counters.push(makeCounter());
}
// قبل: ~12 MB heap
// بعد: ~96 MB heap
// التكلفة التقريبية: 84 بايت لكل closure (بيختلف حسب الـ engine)
لو بتعمل 10 آلاف closure ده مش هيبيّن، لكن لو بتعمل عشرات الملايين الفرق بيتلاحظ. Trade-off واضح: بتكسب encapsulation وstate آمن، بتخسر ذاكرة وبعض التعقيد في الـ debugging.
المرجع للقياس: V8 Blog — Memory Usage and Closures.
متى لا تستخدم Closures
الـ closure مش الحل لكل مشكلة. تجنّبه في الحالات دي:
- لو State بسيط وعام: متغيّر module-level في ملف اعتيادي أبسط وأرخص في الذاكرة.
- لو محتاج Inheritance أو Polymorphism: استخدم
classالعادية. الـ ES Classes فيها#private fieldsوأبسط في القراءة. - في الكود الحرج للأداء (Hot Loops): لو الكود بيتنفّذ ملايين المرّات في الثانية، الـ allocations الإضافية ممكن تظهر في الـ profiler.
- لو الفريق صغير ومش متعوّد: الكود اللي مفهوم لكل الناس أهم من الكود الأنيق اللي يفهمه واحد بس.
الخطوة التالية
افتح ملف JavaScript عندك دلوقتي، ودوّر على أي let counter = 0; أو let cache = {}; على مستوى الـ module. لفّها جوّا factory function ترجّع object بـ getter وsetter. هتلاحظ إن الـ state بقى محمي وكودك بقى أنضف. لو لاقيت مشكلة، اكتب اسم الملف والسطر في كومنت تحت المقال — هرد على كل واحد.
المصادر
- ECMA-262 — Lexical Environments (المواصفة الرسمية للغة)
- MDN — Closures
- V8 Blog — Garbage Collection و Closures
- Node.js v22 Release Notes
- كتاب You Don't Know JS Yet — Scope & Closures لـ Kyle Simpson (Edition 2nd, 2020)