هذا المقال يتطلب مستوى: متوسط
لو فهمت الإغلاق صح، هتقدر تعمل متغيّرات خاصة، عدّادات، وكاش من غير ما تلوّث النطاق العام، وهتوقف باجات في الحلقات كانت بتضيّع عليك ساعات. المقال ده بيوضّح الفكرة بمثال بسيط، بعدين علميًا، وبيوريك الفخ اللي بيقع فيه أغلب الناس.
الإغلاقات في جافاسكريبت: الدالة اللي بتفتكر
الإغلاق (Closure) مش ميزة نادرة بتستخدمها مرة في السنة. ده أساس شغّال في كل callback، كل event handler، وكل دالة بترجّع دالة. الافتراض هنا إنك عارف الفرق بين تعريف الدالة واستدعائها، وشايف الكود بيتنفّذ في المتصفح أو Node.
المشكلة باختصار
لو عملت متغيّر جوّه دالة، المفروض إنه يتمسح لما الدالة تخلّص. لكن في حالات المتغيّر بيفضل عايش وبتتغيّر قيمته مع كل استدعاء، وإنت مش فاهم مين ماسكه. ده مش عطل، ده إغلاق شغّال. وفهمه بيحوّل سلوك غريب لأداة قوية.
الفكرة بمثال بسيط قبل التعريف العلمي
تخيّل موظف طلع من المكتب ومعاه حقيبة. جوّه الحقيبة ورقة مكتوب عليها رقم. المكتب اتقفل وراح، بس الموظف لسه ماسك الحقيبة. كل ما تسأله "الرقم كام؟" يفتح الحقيبة ويزوّده واحد ويقولّك النتيجة. المكتب (النطاق) خلص، لكن الورقة (المتغيّر) لسه محفوظة معاه لأنه شايلها في حقيبته.
ده بالظبط الإغلاق. الحقيبة هي "البيئة المعجمية" (Lexical Environment) اللي الدالة الداخلية بتشيلها معاها. دلوقتي التعريف الدقيق: الإغلاق هو دالة مربوطة بالمراجع الخاصة بالمتغيّرات اللي في نطاقها المحيط وقت تعريفها، فتظل قادرة على قراءتها وتعديلها حتى بعد انتهاء تنفيذ الدالة الأم. الربط بيتحدد بمكان كتابة الكود (lexical scope)، مش بمكان استدعائه.
مثال تنفيذي: عدّاد بحالة خاصة
الكود ده بيعمل عدّاد. المتغيّر count مش ظاهر برّه، محدش يقدر يعدّله غير عن طريق الدالة اللي رجعت:
function makeCounter() {
let count = 0; // متغيّر خاص، مخفي عن العالم الخارجي
return function () {
count += 1; // الدالة الداخلية بتعدّل count المحفوظ
return count;
};
}
const next = makeCounter();
console.log(next()); // 1
console.log(next()); // 2
console.log(next()); // 3
const other = makeCounter();
console.log(other()); // 1 — إغلاق مستقل بنسخته الخاصة من count
ركّز في آخر سطرين. other بدأ من 1 تاني، لأن كل استدعاء لـ makeCounter بيعمل بيئة جديدة ومتغيّر count جديد. الإغلاقات مش بتتشارك نفس المتغيّر إلا لو اتعرفوا في نفس النطاق. النقطة دي هي مفتاح الفخ الجاي.
الفخ الشهير: ليه الحلقة بتطبع نفس الرقم
الكود ده المفروض يطبع 0 و1 و2. بيطبع 3 و3 و3. السبب: var بيعمل متغيّر واحد على مستوى الدالة، وكل الـ callbacks بتقفل على نفس المتغيّر ده. لما الـ setTimeout يشتغل، الحلقة تكون خلصت وi بقى 3.
// المشكلة: var متغيّر واحد مشترك
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i); // 3 3 3
}, 100);
}
// الحل: let ينشئ ربطًا جديدًا لكل دورة
for (let i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i); // 0 1 2
}, 100);
}الفرق إن let بيعمل ربط (binding) جديد لكل دورة من دورات الحلقة، فكل إغلاق بيقفل على نسخته الخاصة. ده سلوك محدَّد في معيار ECMAScript من إصدار ES2015. لو شغّال على كود قديم بـ var، البديل قبل let كان دالة فورية IIFE بتاخد i كباراميتر، بس دلوقتي let أنضف وأوضح.
سيناريو واقعي: كاش صغير بدل حساب متكرر
افترض إن عندك دالة بتحسب حاجة تقيلة، وبتتنادى كتير بنفس المدخلات. تقدر تستخدم إغلاق يمسك كائن كاش خاص:
function makeMemoized(fn) {
const cache = new Map(); // محفوظ في الإغلاق، خاص بالدالة
return function (n) {
if (cache.has(n)) return cache.get(n);
const result = fn(n);
cache.set(n, result);
return result;
};
}
const slowSquare = (n) => { /* عملية تقيلة */ return n * n; };
const fastSquare = makeMemoized(slowSquare);لو الدالة الأصلية بتاخد 40 مللي ثانية للحساب، والاستدعاء التاني بنفس المدخل بيرجع من الكاش في أقل من 0.1 مللي ثانية، يبقى إنت وفّرت أكثر من 99% من الزمن على الاستدعاءات المكرّرة. المكسب حقيقي، لكن معاه ثمن.
الـ trade-off: الذاكرة اللي مش بتترمي
الإغلاق بيمسك مرجعًا للمتغيّرات، مش نسخة منها. معنى كده إن أي حاجة الإغلاق ماسكها مش هيرميها جامع القمامة طول ما الإغلاق نفسه عايش. ده بيسبب تسريب ذاكرة لو مش واخد بالك:
function attach() {
const huge = new Array(1_000_000).fill('x'); // مصفوفة كبيرة
return function () {
return huge.length; // الإغلاق ماسك huge بالكامل
};
}
const handlers = [];
for (let k = 0; k < 100; k++) handlers.push(attach());
// 100 إغلاق × كل واحد ماسك مصفوفة مليون عنصرمصفوفة بمليون مرجع بتاخد تقريبًا 8 ميجابايت (8 بايت للمرجع الواحد على معمارية 64-بت). لو عندك 100 إغلاق زي دول عايشين، بتضيّع حوالي 800 ميجابايت في الذاكرة بلا داعي. الافتراض هنا إنك على V8 (Chrome/Node)؛ الأرقام بتتغيّر شوية بين المحرّكات لكن المبدأ واحد. القياس الحقيقي بيتعمل من تبويب Memory في DevTools بأخذ لقطة heap والبحث عن كلمة closure لرؤية الحجم المحتجَز.
متى لا تستخدم الإغلاق
لو محتاج تعمل ملايين الإغلاقات في حلقة ساخنة (hot path)، تكلفة تخصيص الذاكرة لكل إغلاق بتبقى ملحوظة؛ في الحالة دي كلاس واحد أو كائن مشترك أوفر. كمان لو الحالة اللي بتخزّنها لازم تتشارك بين أجزاء كتير من التطبيق، الإغلاق بيخبّيها أكتر من اللازم ويصعّب التتبّع؛ استخدم كائن أو موديول صريح. وأخيرًا لو إنت بس محتاج قيمة ثابتة، الإغلاق زيادة مش محتاجينها.
الخطوة التالية
افتح أي كود عندك فيه عدّاد أو حالة متخزّنة في متغيّر عام (global). حوّله لدالة بترجّع إغلاق زي makeCounter فوق، وشيل المتغيّر العام. بعدها افتح DevTools ← تبويب Memory ← Heap snapshot، ودوّر على closure عشان تشوف الذاكرة المحتجزة فعلًا. لو لقيت إغلاق ماسك حاجة كبيرة مش محتاجها، ده أول تسريب هتصلّحه بنفسك.
المصادر
- MDN Web Docs — Closures: توثيق الإغلاقات، البيئة المعجمية، وأمثلة النطاق.
- ECMAScript Language Specification (ECMA-262) — تعريف Lexical Environment وربط
letلكل دورة في الحلقات (منذ ES2015). - V8 Blog — مقالات محرّك V8 حول إدارة الذاكرة وجامع القمامة وكيفية احتجاز الإغلاق للمراجع.
- Chrome DevTools Documentation — تحليل الذاكرة عبر Heap snapshots وتتبّع الأحجام المحتجَزة (Retained Size).