المستوى: متوسط (Intermediate). الشرح مبني على فرضية إنك بتكتب JavaScript وفاهم الدوال والـ scope الأساسي. لو لسه مبتدئ، مثال ماكينة التذاكر تحت هيوصّلك الفكرة قبل ما ندخل في التفاصيل العلمية.
Closures في جافاسكريبت: الدالة اللي بتفتكر
بعد ما تخلص المقال ده هتعرف تبني حالة خاصة (private state) في جافاسكريبت بدون class وبدون متغير عام، وتفهم ليه دالة واحدة تقدر تفتكر متغير اتعرّف من زمان واتفترض إنه اختفى. الفكرة دي اسمها Closure، وهي تحت أغلب الأدوات اللي بتستخدمها كل يوم: debounce، الـ event handlers، والـ React hooks.
المشكلة باختصار
القاعدة اللي اتعلمناها: لما الدالة تخلص، المتغيرات اللي جواها بتموت وبيتحرّر مكانها في الذاكرة. ده بيحصل فعلاً في الحالة العادية. لكن في حالة واحدة المتغير بيفضل عايش حتى بعد ما الدالة الأم رجعت: لما تكون في دالة داخلية لسه ماسكة المتغير ده. الدالة الداخلية مع المتغيرات اللي بتفتكرها هي اللي بنسمّيها Closure.
مثال بسيط: ماكينة التذاكر اللي بتفتكر
تخيّل ماكينة تذاكر في عيادة. كل واحد يضغط الزرار يطلعله رقم أكبر من اللي قبله: 1، 2، 3. الماكينة مش بتسأل اللي قبلك وصل لكام، هي جواها عدّاد صغير محفوظ، بتزوّده وتديك النتيجة. الزرار اللي بتضغطه (الدالة الداخلية) بيوصل للعدّاد المخفي (المتغير في الدالة الأم) من غير ما العدّاد ده يبقى ظاهر لأي حد بره الماكينة.
دلوقتي نرجع للتعريف الدقيق: الـ Closure هو دالة + البيئة المعجمية (lexical environment) اللي اتعرّفت جواها. يعني الدالة بتحتفظ بمرجع للمتغيرات اللي كانت في مداها وقت ما اتكتبت، مش وقت ما اتنادت. النقطة دي بالظبط هي اللي بتخدع الناس: المهم مكان تعريف الدالة، مش مكان استدعائها.
الكود اللي بيوريك إنه بيحصل فعلاً
function makeCounter() {
let count = 0; // متغير خاص جوه الدالة الأم
return function () {
count++; // الدالة الداخلية لسه بتشوف count
return count;
};
}
const next = makeCounter(); // makeCounter اشتغلت وخلصت ورجعت
console.log(next()); // 1
console.log(next()); // 2
console.log(next()); // 3
// makeCounter() خلصت من زمان، ومع ذلك count لسه عايش جوه الـ closure
لاحظ: مفيش طريقة توصل لـ count من بره. مفيش next.count. المتغير مخفي تماماً ومحمي، وده بالظبط اللي بيخلّي الـ closures الأساس العملي للـ encapsulation في جافاسكريبت قبل ما الـ private fields توصل للّغة.
كل closure بيحمل حالته الخاصة
كل مرة تنادي makeCounter() بتتولد بيئة جديدة ومتغير count جديد مستقل. يعني العدّادين ميتلخبطوش في بعض:
const a = makeCounter();
const b = makeCounter();
console.log(a()); // 1
console.log(a()); // 2
console.log(b()); // 1 عدّاد b مستقل تماماً عن a
سيناريو واقعي: debounce لصندوق البحث
لو عندك صندوق بحث بيضرب الـ API في كل ضغطة كيبورد، كلمة من 8 حروف ممكن تطلّع 8 طلبات أو أكتر، وفي الكتابة السريعة بتوصل لحوالي 40 طلب على الفاضي للاستعلام الواحد. الـ debounce بيستنى المستخدم يبطّل كتابة 300 ميلي ثانية وبعدها يبعت طلب واحد بس. الحيلة كلها إننا محتاجين نفتكر الـ timer بين كل نداء، وده شغل الـ closure:
function debounce(fn, ms) {
let timer; // محفوظ بين كل نداء بفضل الـ closure
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
const search = debounce((q) => fetch('/api/search?q=' + q), 300);
// في تجربة على input بـ 40 ضغطة سريعة: الطلبات نزلت من 40 لـ 1
من غير الـ closure كنت هتضطر تحط الـ timer في متغير عام، وده بيكسر أول ما يبقى عندك أكتر من صندوق بحث في نفس الصفحة، لأن كلهم هيتشاركوا نفس المؤقّت.
الفخ: الـ closure ممكن يتحوّل لتسريب ذاكرة
لإن الـ closure بيمسك مرجع للمتغيرات، أي متغير ماسكه بيفضل في الذاكرة طول ما الدالة عايشة. لو المتغير ده حاجة كبيرة، انت بتدفع رام من غير ما تحس:
function holdBigArray() {
const big = new Array(1000000).fill(0); // حوالي 8MB
return () => big.length; // الـ closure ماسك big كله
}
const refs = [];
for (let i = 0; i < 50; i++) refs.push(holdBigArray());
// 50 closure في حوالي 8MB يساوي تقريباً 400MB لسه محجوزة، لأن big مش بيتحرّر
console.log((process.memoryUsage().heapUsed / 1e6).toFixed(0), 'MB');
الـ trade-off هنا واضح: بتكسب حالة خاصة محفوظة ومحمية، بتخسر إن أي حاجة الـ closure ماسكها مش هتتحرر لحد ما الـ closure نفسه يتمسح. لو احتجت big.length بس، خزّن الرقم في متغير صغير وسيب المصفوفة تتحرر، بدل ما تمسك المليون عنصر كلهم.
متى لا تستخدم هذه الطريقة
الـ closures مش الحل في كل مكان. تجنّبها في الحالات دي: لما تكون بتولّد آلاف الـ closures في حلقة وكل واحد ماسك بيانات تقيلة، ده طريق مباشر لتسريب ذاكرة. ولما الحالة لازم تتشارك وتتعدّل من أماكن كتير ومحتاج تختبرها بسهولة، الـ closure بيخبّي الحالة فيصعّب الـ testing، وهنا class أو module بحقول واضحة أنضف. القاعدة: استخدم closure للحالة الخاصة الصغيرة المؤقتة، مش كبديل عن بنية بيانات حقيقية.
الخطوة التالية
افتح أقرب دالة setTimeout أو event handler في كودك ودوّر على متغير بيتقري جواها واتعرّف بره. ده closure شغّال عندك دلوقتي. اسأل نفسك: المتغير ده كبير؟ محتاج يفضل عايش كل العمر ده؟ لو لأ، فكّكه. وجرّب تعيد كتابة أي عدّاد عام عندك بـ makeCounter عشان تشيل المتغير العام.
المصادر
- MDN Web Docs — Closures: developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
- ECMAScript Language Specification — Lexical Environments & Environment Records: tc39.es/ecma262/#sec-environment-records
- Node.js API — process.memoryUsage(): nodejs.org/api/process.html#processmemoryusage
- MDN Web Docs — setTimeout: developer.mozilla.org/en-US/docs/Web/API/setTimeout