الـ Scope والـ Closures من أكثر المفاهيم التي تُدرَّس في جافاسكريبت، وأكثرها يُساء فهمًا في الوقت نفسه. ليس لأنها معقدة، بل لأن معظم الشروحات تقدم تعريفًا نظريًا صحيحًا ثم تنتقل مباشرة إلى الأمثلة دون شرح آلية التنفيذ الفعلية التي تجعل الأخطاء تحدث. النتيجة أن المطور يحفظ القاعدة، ثم يقع في الخطأ نفسه في الإنتاج.
هذا الدليل يفكك المفهومين من الداخل: كيف يبني محرك جافاسكريبت البيئة المعجمية، وكيف تُربط المتغيرات بالنطاقات، ولماذا تنتج الحلقات والعمليات غير المتزامنة وthis أخطاءً متكررة. ثم يربط كل ذلك بمشكلات حقيقية: تسريب الذاكرة، وأداء الصفحة، وسلوك مستمعي الأحداث. الهدف ليس أن تعرف التعريف، بل أن تتوقف عن الوقوع في الأخطاء الستة الشائعة التي يقع فيها معظم المطورين.
نظرة سريعة: خريطة الـ Scope في جافاسكريبت
الجدول التالي يلخص أنواع النطاقات في جافاسكريبت، والمكان الذي ينشئها، والكلمات التي تعمل داخلها:
| النوع | يُنشأ بواسطة | الكلمات العاملة | مثال |
|---|---|---|---|
| Global Scope | الملف أو النافذة | var، let، const، function | متغير خارج أي دالة |
| Function Scope | دالة عادية أو سهم | var، let، const، function | متغير داخل function |
| Block Scope | أقواس معقوفة | let، const (var لا) | داخل if أو for أو try |
| Module Scope | ملف ES Module | كل المتغيرات | var داخل ملف module |
| Lexical / Closure | دالة داخل دالة | ما تصل إليه الدالة الداخلية | الدالة الداخلية + بيئتها |
ما هو الـ Scope؟ ولماذا هو مشكلة أصلًا؟
الـ Scope هو القاعدة التي تحدد أين يمكن الوصول إلى
متغير. عند تنفيذ سطر مثل
console.log(user.name)، لا يبحث المحرك عن
user في كل مكان. يبحث في نطاق محدد، وفق سلسلة
مرتبة، ويتوقف عند أول تطابق. هذه السلسلة هي ما يحدد ما إذا
كان الكود سينجح أم يرمي ReferenceError.
المشكلة أن جافاسكريبت لا تُخبرك بصريًا بنطاق كل متغير. أنت ترى المتغير، لكنك لا ترى الحدود التي يعيش داخلها. في لغة مثل Python، المسافات البادئة ترسم النطاق أمام عينيك. في جافاسكريبت، عليك أن تعرف القواعد ذهنيًا. وعندما تكون هذه القواعد غائبة عن الذهن، تظهر الأخطاء في وقت التشغيل، لا في وقت الكتابة.
ثم يأتي الـ Closure ليزيد الأمر تعقيدًا: الدالة الداخلية لا تحتفظ فقط بالمكان الذي كُتبت فيه، بل تحتفظ بـالبيئة كاملة التي كانت موجودة وقت تعريفها، حتى لو اختفى النطاق الخارجي من التنفيذ. هذا يعني أن المتغيرات قد تبقى حية في الذاكرة سنوات بعد انتهاء الدالة التي أنشأتها، وهذا في حد ذاته أرضية خصبة للأخطاء.
أنواع الـ Scope: Global وFunction وBlock وModule
Global Scope
المتغيرات التي تُعرَّف خارج أي دالة متاحة في كل مكان. في
المتصفح تُربط بـwindow إذا استُخدم
var، لكنها في ES Modules تكون معزولة في نطاق
الملف. هذه العزلة أحد أسباب تحول المشاريع الحديثة إلى
الوحدات بدل الملفات المتسلسلة.
Function Scope
كل دالة تُنشئ نطاقًا خاصًا. المتغيرات المعرفة داخلها
لا تُرى من الخارج. لكن المهم هنا: var يتصرف
كما لو كان داخل النطاق الوظيفي بأكمله، حتى لو كُتب داخل
كتلة if. هذا هو أول مصدر للأخطاء.
function test() {
if (true) {
var hidden = "I leak out of the block";
let confined = "I stay inside the block";
}
console.log(hidden); // "I leak out of the block"
console.log(confined); // ReferenceError
} Block Scope
الكتل () بعد ES6 تُنشئ نطاقًا عند استخدام
let وconst. هذا يعني أن المتغير
يختفي بمجرد إغلاق القوس. القاعدة بسيطة، لكن أثرها على
الحلقات والclosures هائل، كما سنرى.
Module Scope
كل ملف ES Module له نطاق خاص به، لا يُشارك فيه إلا ما
تصدّره صراحة بـexport. هذا يحل جزئيًا مشكلة
التلوث العالمي، لكنه يخلق مشكلة جديدة: بعض المطورين
يعتقدون أن المتغيرات مشتركة بين الوحدات، ثم يتفاجأون
بأن كل وحدة معزولة.
Lexical Scope: النطاق يُحدد وقت الكتابة لا وقت التنفيذ
جافاسكريبت تتبع Lexical Scoping، أي أن النطاق يُحدد حسب مكان كتابة الدالة في الملف، لا حسب المكان الذي تُستدعى فيه. هذا التمييز يبدو أكاديميًا حتى ترى أثره:
const value = "outer";
function outer() {
const value = "inner";
return function inner() {
console.log(value);
};
}
const fn = outer();
fn(); // "inner" — لأن inner كُتبت داخل outer، لا لأنها استُدعيت منه لو كانت جافاسكريبت تتبع Dynamic Scoping (كما في بعض لغات Lisp القديمة)، لكان السلوك مختلفًا تمامًا. لكن لأنها Lexical، فإن البيئة التي تحملها الدالة معها تُحدد من سطر الكتابة. هذه الحقيقة هي أساس فهم الـ Closures كلها.
var مقابل let وconst: الفروق التي تصنع الأخطاء
كثير من المطورين يعرفون أن let وconst
بديلان حديثان، لكن قليلين يعرفون أن الفروق بينها وبين
var ليست تجميلية، بل سلوكية.
الجدول التالي يوضح الفروق التي تسبب الأخطاء فعلًا:
| السلوك | var | let | const |
|---|---|---|---|
| النطاق | دالة | كتلة | كتلة |
| Hoisting | نعم، بقيمة undefined | نعم، لكن غير مهيأ (TDZ) | نعم، لكن غير مهيأ (TDZ) |
| إعادة التعيين | نعم | نعم | لا |
| إعادة التعريف في نفس النطاق | نعم | لا | لا |
| الالتقاط في الـ Closure | قيمة واحدة مشتركة | قيمة لكل تكرار | قيمة لكل تكرار |
السطر الأخير هو أخطر ما في الجدول. عند استخدام
var في حلقة مع closures، تُلتقط
نفس المتغير في كل التكرارات، وتكون قيمته
النهائية هي آخر قيمة بعد انتهاء الحلقة. مع
let، تُنشأ بيئة جديدة لكل تكرار، فيلتقط كل
closure قيمته الخاصة.
Hoisting وTemporal Dead Zone: لماذا يرمي let خطأ؟
الـ Hoisting ليس نقلًا فعليًا للكود إلى الأعلى. إنه سلوك مكافئ: تصريحات المتغيرات والدوال تُعالج قبل تنفيذ الكود. لكن المعالجة نفسها تختلف حسب الكلمة:
- var: يُرفع ويُهيأ بقيمة
undefined. يمكنك قراءته قبل التصريح وستحصل علىundefined، لا خطأ. - let وconst: يُرفعان لكن
دون تهيئة. الفترة بين بداية النطاق
وسطر التصريح تُسمى Temporal Dead Zone
(TDZ). أي محاولة للوصول إلى المتغير فيها
ترمي
ReferenceError. - function declarations: تُرفع بالكامل، فتصبح قابلة للاستدعاء قبل تعريفها في الكود.
- function expressions وarrow functions: تُعامل كمتغيرات const، فتدخل TDZ إذا استُدعيت قبل تعريفها.
// var: يعمل بلا خطأ
console.log(a); // undefined
var a = 10;
// let: يرمي خطأ داخل TDZ
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;
// function declaration: تعمل حتى قبل تعريفها
sayHi(); // "hi"
function sayHi() { console.log("hi"); }
كثير من أخطاء الإنتاج تأتي من الاعتقاد بأن let
«مجرد var أحدث». الفرق في TDZ يعني أن إعادة ترتيب
الكود قد تنجح مع var وتفشل مع
let، وهذا يجعل الانتقال بينهما غير آمن إذا
لم تفهم السبب.
سلسلة النطاق (Scope Chain): كيف تبحث جافاسكريبت عن المتغير؟
عند قراءة متغير، يبحث المحرك في النطاق الحالي
أولًا. إن لم يجده، ينتقل إلى النطاق الأب، ثم
الأب الأعلى، حتى يصل إلى النطاق العالمي. هذه هي سلسلة
النطاق. إن لم يجد شيئًا، يرمي ReferenceError
(إذا كان القراءة) أو ينشئ متغيرًا عالميًا (إذا كان
الكتابة بـvar في الوضع غير الصارم).
const global = "G";
function outer() {
const outerVar = "O";
function inner() {
const innerVar = "I";
console.log(innerVar, outerVar, global); // "I O G"
}
inner();
}
outer(); المهم هنا: البحث يحدث وقت التنفيذ، لكن السلسلة نفسها تُحدد وقت الكتابة. أي أن الدالة الداخلية لا تبحث في النطاق الذي استُدعيت منه، بل في النطاق الذي عُرِّفت فيه. هذا فرق جوهري بين فهم نظري وفهم عملي: من لا يفهمه سيقع في الخطأ التالي.
const x = "global";
function a() {
console.log(x);
}
function b() {
const x = "local";
a(); // تطبع "global" لا "local"
}
b(); ما هو الـ Closure تحديدًا؟
الـ Closure ليس ميزة في اللغة تُستدعى بكلمة مفتاحية. إنه سلوك ناتج عن الجمع بين الدالة والنطاق. عندما تُعرّف دالة داخل دالة، فإن الدالة الداخلية تحتفظ بمرجع إلى بيئتها المعجمية، حتى لو انتهى تنفيذ الدالة الخارجية. هذه البيئة المحفوظة هي الـ Closure.
function createCounter() {
let count = 0;
return function increment() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// count لا يمكن الوصول إليه من الخارج
console.log(typeof count); // "undefined"
ما حدث هنا أن increment احتفظت بـ
count بعد أن انتهى تنفيذ
createCounter. المتغير count
لم يعد جزءًا من نطاق التنفيذ، لكنه لم يُحذف من الذاكرة،
لأن الدالة الداخلية ما زالت تشير إليه.
هذا هو الفرق بين النطاق والـ Closure: النطاق هو مكان المتغير. الـ Closure هو ما يحدث عندما يبقى المتغير حيًا بعد انتهاء النطاق، لأن دالة أخرى تحتاجه. كل closure يحتوي على نطاق، لكن ليس كل نطاق ينتج closure.
استخدامات عملية للـ Closures
الـ Closures ليست مفهومًا أكاديميًا. إنها موجودة في كل مكان في كود جافاسكريبت الحديث:
1. البيانات الخاصة (Private State)
قبل أن تصل حقول الفئات الخاصة (#field) في
ES2022، كان الـ Closure هو الطريقة الوحيدة لإنشاء بيانات
خاصة فعلًا في جافاسكريبت. وما زال خيارًا خفيفًا.
2. دوال المصنع (Factory Functions)
كل استدعاء لدالة المصنع يُنشئ closure مستقلًا. هذه الآلية تُستخدم في مكتبات مثل Redux لإنشاء action creators، وفي React لبناء custom hooks.
3. التخزين المؤقت (Memoization)
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
} 4. معالجة الأحداث وإلغاء التهيئة
عندما تحتاج إلى تمرير بيانات إضافية إلى مستمع حدث، تُنشئ closure يلتقط تلك البيانات. هذه الآلية نفسها قد تصبح مصدرًا لتسريب الذاكرة إن لم يُزل المستمع عند الحاجة.
الخطأ الأشهر: Closures داخل الحلقات
هذا هو الخطأ الذي يسأل عنه كل مقابلة عمل، ويرتكبه كل مطور
مرة على الأقل. الكود التالي يفشل للسبب الذي شرحناه في جدول
var مقابل let:
// الخطأ
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// النتيجة: 3, 3, 3 — لا 0, 1, 2
السبب أن var ينتج متغيرًا واحدًا مشتركًا في
نطاق الدالة. الحلقة تنتهي وقيمة i أصبحت 3،
ثم تنفذ المؤقتات الثلاثة وتقرأ نفس المتغير، فتطبع 3.
الإصلاح الأول هو استخدام let، لأن كل تكرار
يُنشئ نسخة جديدة من i:
// الإصلاح 1: let
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// النتيجة: 0, 1, 2 الإصلاح الثاني هو إنشاء IIFE لكل تكرار، وهي الطريقة القديمة قبل ES6:
// الإصلاح 2: IIFE
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 0);
})(i);
}
المشكلة أن كثيرًا من المطورين يحفظون «استخدم let»، ثم
يقعون في الخطأ نفسه بصيغة مختلفة حين يستخدمون
forEach أو map مع closure
يعتمد على متغير خارجي يتغير لاحقًا. القاعدة العامة:
أي closure يعتمد على متغير يُعدَّل لاحقًا قد
يلتقط القيمة الخاطئة.
this والـ Closures: الخطأ الذي يطارد المبتدئين
this في جافاسكريبت يُحدد وقت
الاستدعاء، لا وقت التعريف. لكن الـ Closures
تحتفظ بالبيئة المعجمية للمتغيرات، لا بـ
this. وهذا هو مصدر الالتباس الأكبر:
const user = {
name: "Ali",
greetLater() {
setTimeout(function () {
console.log(this.name); // undefined
}, 100);
}
};
user.greetLater();
الدالة العادية داخل setTimeout لها
this خاص بها (في المتصفح هو
window أو undefined في الوضع
الصارم)، ولا ترث this من
greetLater. الـ Closure التقطت
name كمرجع للنطاق، لكنها لم تلتقط
this.
الحلول ثلاثة، ولكل منها سياقه:
- الدالة السهمية: ترث
thisمن النطاق الخارجي، فتصبح الأبسط في معظم الحالات. - ربط this يدويًا: باستخدام
.bind(this)أو متغير مثلconst self = this. - استخدام معامل صريح: تمرير
الكائن كوسيط بدل الاعتماد على
this.
const user = {
name: "Ali",
greetLater() {
setTimeout(() => {
console.log(this.name); // "Ali"
}, 100);
}
}; تسريب الذاكرة: كيف تسبب الـ Closures مشكلة حقيقية؟
الـ Closure يُبقي المتغيرات حية ما دامت الدالة حية. هذا يعني أن كل متغير يشير إليه closure يبقى في الذاكرة، حتى لو لم يعد هناك من يحتاجه فعليًا. إذا نُسي المستمع أو المؤقت أو الـ closure نفسه، تبقى الذاكرة محتجزة، وهذا هو التسريب.
الأنماط الثلاثة الأكثر شيوعًا للتسريب:
1. مستمعو الأحداث الذين لا يُزالون
function attach(element, data) {
element.addEventListener("click", () => {
console.log(data);
});
// لا يوجد removeEventListener
}
كل استدعاء لـattach يُنشئ closure جديدًا
يحتفظ بـdata. إذا نُسي العنصر أو لم يُزل
المستمع، يبقى data في الذاكرة. الحل هو
الاحتفاظ بمرجع الدالة وإزالتها عند التدمير.
2. المؤقتات التي لا تُلغى
setInterval مع closure يحتفظ بمتغيرات كثيرة
هو تسريب كلاسيكي. كل مؤقت يعمل ويحتفظ ببيئته، حتى لو
اختفى العنصر من الصفحة. الحل: clearInterval
دائمًا عند إخفاء المكوّن أو إزالته.
3. الـ Closures في بنى البيانات
تخزين دوال في مصفوفات أو كائنات مع closure يشير إلى نطاق
كبير (مثل كائن DOM ضخم) يبقي الكائن كله حيًا. الحل هو فصل
البيانات عن الدالة، أو استخدام
WeakMap وWeakRef للبيانات التي
يجب ألا تعيق جمع القمامة.
IIFE ونمط الوحدة (Module Pattern)
قبل ظهور ES Modules، كان الـ IIFE (Immediately Invoked Function Expression) هو الأداة الأساسية لعزل الكود. الفكرة بسيطة: دالة تُعرَّف وتُستدعى فورًا، فتنشئ نطاقًا خاصًا يختفي بعد التنفيذ.
const Counter = (function () {
let count = 0;
return {
increment: () => ++count,
reset: () => (count = 0),
get: () => count,
};
})();
Counter.increment();
Counter.increment();
console.log(Counter.get()); // 2 هذا النمط هو الأساس الذي قامت عليه معظم مكتبات جافاسكريبت في العقد الأول من الألفية. ES Modules حلّت محل IIFE في المشاريع الحديثة، لكن فهم IIFE يبقى ضروريًا لأنك ستقابله في كود قديم كثير، ولأنه يوضح فكرة أن النطاق يمكن أن يُنشأ صناعيًا بلا دالة ظاهرة.
الـ Closures والعمليات غير المتزامنة
أكثر مكان تظهر فيه مشاكل الـ Closure اليوم ليس الحلقات التقليدية، بل الكود غير المتزامن. المشكلة أن الـ closure يحتفظ بالبيئة، لكن البيئة قد تتغير قبل أن ينفَّذ callback. مثال:
let userId = null;
async function load() {
userId = 1;
await fetchUser(userId);
userId = 2;
await fetchUser(userId);
// أي طلب انتهى متأخرًا قد يقرأ userId مختلفًا عما توقعت
}
الحل ليس استخدام let أو const.
الحل هو تمرير القيمة المطلوبة كوسيط
بدلًا من قراءتها من نطاق خارجي مشترك. هذا هو الفرق بين
closure «يقرأ متغيرًا» وclosure «يحمل قيمة»:
async function load() {
await fetchUser(1);
await fetchUser(2);
}
// أو، إذا كان لا بد من closure:
function makeFetcher(id) {
return () => fetchUser(id); // كل closure يحمل id الخاص به
}
نفس المبدأ ينطبق على Promises وasync/await
داخل الحلقات: إذا كنت تقرأ متغيرًا من الحلقة داخل
await، تأكد أن المتغير لا يُعاد تعيينه قبل
أن يقرأه الـ closure.
ممارسات عملية لتجنب أخطاء الـ Scope والـ Closures
- استخدم const افتراضيًا. لا تستخدم
letإلا عندما تحتاج إعادة التعيين، ولا تستخدمvarإلا في كود قديم لا يمكنك تغييره. - مرّر القيم بدل قراءتها من نطاق خارجي. الدوال التي تعتمد على متغيرات خارجية تصبح هشة مع تغير الزمن. الدوال التي تستقبل كل ما تحتاجه كوسائط تصبح قابلة للاختبار والتنبؤ.
- تجنب الـ closures في الحلقات إن أمكن.
إذا كنت تحتاج إلى ربط بيانات بكل تكرار، استخدم
for...ofمعlet، أو دوال مصنع تُعيد closure جديدًا لكل قيمة. - أزل مستمعي الأحداث والمؤقتات.
كل
addEventListenerيحتاجremoveEventListenerعند التدمير. كلsetIntervalيحتاجclearInterval. - افهم this قبل استخدام closures.
إذا كنت داخل دالة عادية،
thisليس ما في الخارج. استخدم arrow functions عندما تريد وراثةthis، وتجنبها عندما تحتاجthisديناميكيًا. - استخدم أدوات تحليل ثابت. أدوات
مثل ESLint مع قواعد مثل
no-loop-funcوno-varوprefer-constتكشف كثيرًا من هذه الأخطاء قبل التشغيل.
قائمة تحقق نهائية
-
هل تستخدم
varفي كود جديد؟ ← استبدله بـlet أو const - هل لديك closure داخل حلقة يقرأ متغير الحلقة؟ ← استخدم let أو دالة مصنع
- هل تستدعي متغيرًا قبل تعريفه بـlet؟ ← ستقع في TDZ، أعد ترتيب الكود
- هل تستخدم دالة عادية داخل setTimeout مع this؟ ← استخدم arrow function أو bind
- هل لديك مستمعو أحداث لا يُزالون؟ ← أضف removeEventListener في التنظيف
- هل لديك setInterval بلا clearInterval؟ ← أضف إلغاءً عند إخفاء المكوّن
- هل تقرأ متغيرًا خارجيًا داخل await؟ ← مرّر القيمة كوسيط
- هل تخلط بين النطاق والـ closure؟ ← النطاق مكان المتغير، الـ closure ما يبقيه حيًا
روابط ومصادر مفيدة
فيما يلي روابط رسمية ومراجع موثوقة يمكنك الرجوع إليها لتعميق فهمك:
- MDN — JavaScript Guide: Grammar and types (Scope)
- MDN — Closures
- MDN — Hoisting
- ECMAScript Language Specification
- Exploring JavaScript — Deep JavaScript
- ESLint — no-loop-func Rule
- ESLint — prefer-const Rule
- You Don't Know JS — Scope & Closures
أسئلة شائعة
هل let أفضل من var دائمًا؟
في الكود الجديد، نعم في الغالب. let يحدد
النطاق بدقة ويمنع تسرب الكتلة. لكن var
ليس «سيئًا» بطبيعته؛ إنه مختلف. المشكلة تأتي من استخدامه
في سياقات تتوقع فيها نطاق كتلة. في الكود القديم، قد
تكون إعادة كتابته إلى let خطرة إذا لم
تفهم أين يعتمد الكود على تسرب النطاق.
هل الـ Closure نفسه يسبب تسريب الذاكرة؟
لا. الـ Closure أداة طبيعية في اللغة. التسريب يحدث عندما يُحتفظ بالـ closure أكثر مما يجب، أو عندما يشير إلى بيانات كبيرة لا تُستخدم. الحل ليس تجنب الـ closures، بل إدارة دورة حياته: إزالة المستمعين، وإلغاء المؤقتات، وعدم تخزين مراجع غير ضرورية.
لماذا تطبع الحلقة 3, 3, 3 مع var؟
لأن var يُنشئ متغيرًا واحدًا على مستوى
الدالة، لا متغيرًا لكل تكرار. الـ closures الثلاثة
تشير إلى نفس المتغير، وقيمته عند التنفيذ هي 3. استخدام
let يُنشئ متغيرًا جديدًا لكل تكرار، فتحمل
كل closure قيمتها.
هل يمكن أن يحدث TDZ مع var؟
لا. var يُرفع ويُهيأ بـ
undefined، فلا يوجد نطاق ميت. الوصول إليه
قبل التصريح يعطيك undefined لا خطأ.
TDZ خاص بـlet وconst
وclass.
ما الفرق بين Function Scope وLexical Scope؟
Function Scope هو نطاق الدالة نفسها. Lexical Scope هو القاعدة التي تحدد أن الدالة الداخلية تصل إلى متغيرات النطاق الذي كُتبت فيه، لا النطاق الذي استُدعيت منه. الأولى نوع نطاق، والثانية آلية تحديده.
هل arrow functions تغني عن bind دائمًا؟
لا. Arrow functions ترث this من النطاق
الخارجي، وهذا ما تريده غالبًا. لكن إذا كنت تحتاج إلى
this ديناميكي (مثل طريقة في كائن تُستدعى
بطرق مختلفة)، فالدالة العادية أنسب. الاختيار يعتمد على
ما تريده من this، لا على ما إذا كانت
السهمية «أحدث».