JavaScript بعمق
الإغلاقات والنطاق وthis: الآليات وراء JavaScript
دليل أعمق لنموذج تنفيذ JavaScript — النطاق المعجمي وسلسلة النطاق، والرفع والمنطقة الميتة الزمنية، والإغلاقات والأنماط التي تتيحها (الحالة الخاصّة، المصانع، التخزين المؤقّت)، وقواعد this الأربع، وcall/apply/bind، والدوال السهمية — مع تمارين عملية وحلولها.
الإغلاقات والنطاق وthis هي المفاهيم الثلاثة التي تفصل مَن يستخدم JavaScript عن مَن يفهمها. إنها وراء الحالة الخاصّة، وخطّافات React، ومعالِجات الأحداث، والتطبيق الجزئي، وأكثر أسئلة المقابلات طرحًا. قدّمها مقال الأساسيات؛ وهذا يتعمّق في لماذا تتصرّف هكذا، كي يصير السلوك متوقّعًا بدل مفاجئ.
فكرتان تفتحان كل شيء هنا. النطاق معجميّ (lexical) — وصول الدالّة إلى المتغيّرات يقرّره أين كُتبت، لا أين استُدعيت. لكن
thisديناميكيّ — يقرّره كيف استُدعيت الدالّة، لا أين كُتبت. كل الالتباس تقريبًا يأتي من خلط هذين.
سياق التنفيذ: البيئة الزمنية لكل استدعاء
قبل أن نُعقِّل النطاق وthis، من المفيد أن نعرف أن JavaScript تُنشئ سياق تنفيذ (execution context) كلما شغَّلت شيفرة. تخيَّل أنه إطار تشغيل للتنفيذ الحالي: فكل استدعاء دالّة يحصل على سياق تنفيذ خاص به، والبرنامج العام له سياق تنفيذ عام.
يحتوي كل سياق تنفيذ على الأجزاء التي تهمّنا هنا:
- البيئة المعجمية (Lexical Environment) للمتغيّرات والدوال التي تنتمي إلى ذلك السياق،
- مرجعًا خارجيًّا (سلسلة النطاق) لربطه بالنطاقات الخارجية،
- ربطًا لـ
thisيُحدَّد لهذا الاستدعاء، - ومكانًا في مكدس الاستدعاءات كي يتتبّع المحرك ما هي الدالّة التي تعمل حاليًّا.
يُنشئ المحرك سياق التنفيذ في مرحلتين:
- مرحلة الإنشاء — يُهيَّأ البيئة قبل تشغيل الشيفرة.
- تُهيَّأ تصريحات
functionبالكامل وتكون جاهزة للاستدعاء. - تُنشَأ متغيّرات
varوتُهيَّأ إلىundefined. - تبقى
letوconstفي المنطقة الميتة الزمنية (TDZ) حتى يصل سطر التصريح.
- تُهيَّأ تصريحات
- مرحلة التنفيذ — تُشغَّل الشيفرة سطرًا سطرًا.
مثال واضح يوضّح ذلك:
console.log(a);
console.log(b);
var a = 10;
let b = 20;أثناء مرحلة الإنشاء:
a -> undefined
b -> <uninitialized> (TDZ)
function declarations -> fully initializedأثناء التنفيذ:
console.log(a) // undefined
console.log(b) // ReferenceErrorوهذا هو كيف يرتبط سياق التنفيذ مباشرة بالرفع.
هذا هو سبب أن اللغة قد تبدو متوقّعة ومفاجئة في الوقت نفسه. تستطيع الدالّة أن "ترى" المتغيّرات الخارجية لأن سياق تنفيذها يحمل مرجعًا إلى سلسلة النطاق، ونفس الآلية هي ما يجعل الإغلاقات ممكنة.
function outer() {
let value = 42;
function inner() {
console.log(value);
}
return inner;
}
const fn = outer();
fn(); // 42عندما يُشغَّل outer، يُنشئ JavaScript سياق تنفيذ دالّة جديدًا. تُغلِق الدالّة inner على البيئة المعجمية الخاصة بهذا السياق. عادةً، يُزال هذا السياق عند عودة outer. لكن إذا كانت دالّة أخرى ما زالت تشير إلى هذه البيئة المعجمية، يحتفظ JavaScript بها حيّة. ولهذا تواصل الإغلاقات الوصول إلى تلك المتغيّرات.
مثال صغير على مكدس الاستدعاءات يوضّح الأمر أكثر:
function a() {
b();
}
function b() {
c();
}
function c() {
console.log("done");
}
a();يبدو المكدس هكذا أثناء تشغيل الدوال:
Top
---------
c()
---------
b()
---------
a()
---------
Global
---------
Bottomلكل إطار سياق تنفيذ خاص به، لذلك يمكن أن يختلف this والمتغيّرات الظاهرة من إطار إلى آخر.
مقارنة صغيرة تساعد على تجنّب واحد من أكبر فخاخ المقابلات:
| المفهوم | النطاق | سياق التنفيذ |
|---|---|---|
| ما الذي يجيب عنه | ما المتغيّرات التي تكون مرئية؟ | ما الذي يحدث خلال استدعاء واحد؟ |
| متى يُنشأ | عند كتابة الشيفرة | عند تنفيذ الشيفرة |
| طبيعته | معجمي | زمن تشغيل |
| إعادة الاستخدام | مشترك بين عدة استدعاءات | جديد لكل استدعاء |
يحصل كل سياق تنفيذ أيضًا على ربط خاص له لـ this. ولهذا يمكن لنفس الدالّة أن يكون لها قيمة this مختلفة في استدعاءات مختلفة:
function show() {
console.log(this.name);
}
show.call({ name: "Alice" });
show.call({ name: "Bob" });نفس الدالّة. سياقات تنفيذ مختلفة. this مختلف.
النطاق المعجمي وسلسلة النطاق
النطاق هو مجموعة المتغيّرات التي تستطيع قطعة شيفرة رؤيتها. JavaScript معجمية النطاق: تستطيع دالّة داخلية قراءة متغيّرات الدوال المحيطة بها في الشيفرة المصدرية، صاعدةً للخارج حتى تجد الاسم أو تبلغ النطاق العام. ذلك المسار هو سلسلة النطاق:
const planet = "Earth";
function outer() {
const greeting = "Hello";
function inner() {
console.log(greeting, planet); // يرى كليهما — يصعد السلسلة
}
inner();
}تستطيع inner رؤية greeting (أبوها) وplanet (عام) بسبب أين عُرّفت. لا يهمّ من أين تُستدعى inner لاحقًا — النطاق المعجمي ثابت وقت التأليف.
let وconst نطاقهما كتلة (محصور بأقرب { })؛ وvar نطاقها دالّة، وهذا أحد أسباب تجنّبها:
if (true) {
let a = 1;
var b = 2;
}
console.log(b); // 2 — var تتجاهل الكتلة
console.log(a); // ReferenceError — let تحترمهاالرفع والمنطقة الميتة الزمنية
الرفع (hoisting) يعني أن التصريحات تُعالَج قبل تشغيل الشيفرة. لكن الأنواع الثلاثة تتصرّف مختلفةً:
greet(); // ✅ يعمل — تصريحات الدوال مرفوعة بالكامل
function greet() {
return "hi";
}
console.log(x); // undefined — var مرفوعة لا قيمتها
var x = 5;
console.log(y); // ❌ ReferenceError — TDZ
let y = 5;تصريح function مرفوع بالكامل، فتستطيع استدعاءه قبل سطره. وvar مرفوعة لكنها مُهيّأة إلى undefined. وlet/const مرفوعة أيضًا، لكنها تعيش في المنطقة الميتة الزمنية (TDZ) — الإشارة إليها قبل تصريحها ترمي، ما يلتقط أخطاءً كان undefined الصامت لـ var ليخفيها.
الإغلاقات
الإغلاق (closure) دالّة مغلّفة مع متغيّرات نطاقها المعجمي — "تتذكّرها" حتى بعد عودة الدالّة الخارجية. هذه نتيجة مباشرة للنطاق المعجمي: احتفظت الدالّة الداخلية بمرجعها للمتغيّرات الخارجية، فبقيت حيّة.
function makeCounter() {
let count = 0; // يبقى داخل الإغلاق
return {
increment: () => ++count,
value: () => count,
};
}
const c = makeCounter();
c.increment();
c.increment();
c.value(); // 2 — count بقي، يُبلَغ فقط عبر هذه الدوالcount خاص — لا شيء خارجه يستطيع قراءته أو تغييره إلا عبر الدوال المُرجَعة. هذا أساس ثلاثة أنماط:
// 1. الحالة الخاصّة / إخفاء البيانات (أعلاه)
// 2. دوال المصنع — هيّئ السلوك مسبقًا
function multiplier(factor) {
return (n) => n * factor; // يُغلِق على `factor`
}
const double = multiplier(2);
double(5); // 10
// 3. التخزين المؤقّت — خزّن النتائج في خريطة مُغلَقة عليها
function memoize(fn) {
const cache = new Map();
return (arg) =>
cache.has(arg) ? cache.get(arg) : cache.set(arg, fn(arg)).get(arg);
}فخّ الإغلاق-في-حلقة الكلاسيكي، ولماذا تُصلحه let:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0, 1, 2 — لكل تكرار i خاصّ به
}
// مع `var`، تسجّل الثلاثة 3 — ارتباط واحد مشترك تُغلِق عليه كل ردود النداءthis: يقرّره الاستدعاء
هنا المحور: this ليس معجميًّا. this للدالّة العادية يُضبَط من جديد عند كل استدعاء، بـ كيف يُجرى الاستدعاء. القواعد الأربع، بترتيب الأولوية:
function show() {
return this;
}
// 1. استدعاء تابع — `this` الكائن قبل النقطة
const obj = { name: "Ada", show };
obj.show(); // obj
// 2. استدعاء مجرّد — `this` يساوي undefined (الوضع الصارم) أو الكائن العام
const bare = obj.show;
bare(); // undefined
// 3. صريح — call/apply/bind تضبط `this` بنفسك
show.call({ name: "Grace" }); // { name: "Grace" }
// 4. new — `this` الكائن الجديد قيد البناء
function Person(n) {
this.name = n;
}
new Person("Lin"); // { name: "Lin" }أشيع خطأ: تمرير تابع كردّ نداء مجرّد (setTimeout(obj.show, 0)) يفقد this، لأنه لم يعد يُستدعى كـ obj.show().
call وapply وbind
هذه الثلاثة تتيح التحكّم في this صراحةً:
const greet = function (greeting) {
return `${greeting}, ${this.name}`;
};
const user = { name: "Ada" };
greet.call(user, "Hi"); // "Hi, Ada" — الوسائط مسرودة
greet.apply(user, ["Hi"]); // "Hi, Ada" — الوسائط كمصفوفة
const bound = greet.bind(user); // تُرجِع دالّة جديدة بـ this مضبوط دائمًا
bound("Hey"); // "Hey, Ada"call وapply تستدعيان فورًا (تختلفان فقط في كيفية تمرير الوسائط)؛ وbind تُرجِع دالّة جديدة بـ this مقفول — مثالية لتسليم تابع إلى setTimeout أو مستمع حدث دون فقدان سياقه.
الدوال السهمية بلا this
الدوال السهمية لا تحصل على this خاصّ بها — تستخدم this النطاق المعجمي المحيط. هذا ما يجعلها مثالية لردود النداء داخل التوابع، حيث تريد أن يتدفّق this الخارجي:
const timer = {
seconds: 0,
start() {
setInterval(() => {
this.seconds++; // `this` = timer، موروث من start()
}, 1000);
},
};ردّ نداء بدالّة عادية هناك سيكون this === undefined. ولأن السهمية لا this خاصّ بها، تستخدم this الخاص بـ start بشفافية — ساداً الفجوة بين النطاق المعجمي وthis. (للسبب نفسه، لا تستخدم سهمية كـ تابع كائن يحتاج this أن يكون الكائن.)
الأخطاء الشائعة
- افتراض أن
thisيتبع أين تُعرَّف الدالّة — يتبع كيف تُستدعى. - تمرير
obj.methodكردّ نداء وفقدانthis— استخدم.bind(obj)أو غلاف سهمي. - استخدام دالّة سهمية كتابع يحتاج
thisأن يكون الكائن — لن يكون. - الاعتماد على
varفي حلقة مع إغلاقات والوقوع في خطأ الارتباط المشترك — استخدمlet. - الإشارة إلى
let/constقبل تصريحها والاصطدام بـ TDZ. - الظنّ أن الإغلاقات "تنسخ" المتغيّرات — تحمل مرجعًا حيًّا، فالتغييرات اللاحقة مرئيّة.
- نسيان أن كل استدعاء لمصنع يصنع إغلاقًا جديدًا بحالته الخاصّة.
تمارين
جرّب كلًّا منها قبل فتح الحل.
تمرين 1 — عدّاد خاص
ابنِ عدّادًا بـ inc() وget() حيث لا يمكن لمس العدّ من الخارج.
إظهار الحل
function counter() {
let n = 0;
return { inc: () => ++n, get: () => n };
}n يعيش في الإغلاق — فقط inc وget يُغلِقان عليه، فلا سبيل لبلوغه خارجيًّا.
تمرين 2 — تنبّأ بالخرج
const obj = {
name: "X",
regular() {
return this?.name;
},
arrow: () => this?.name,
};
console.log(obj.regular());
console.log(obj.arrow());إظهار الحل
"X" ثم undefined. regular استُدعيت كـ obj.regular()، فـ this هو obj. وarrow لا this خاصّ بها؛ تستخدم this النطاق المحيط (الوحدة/العام)، حيث name غير معرّف.
تمرين 3 — أصلِح السياق المفقود
const api = {
base: "/v1",
url(path) {
return this.base + path;
},
};
const fn = api.url;
fn("/users"); // ينكسرإظهار الحل
const fn = api.url.bind(api);
fn("/users"); // "/v1/users"سحب api.url إلى fn واستدعاؤه مجرّدًا يفقد this. bind(api) تُرجِع دالّة بـ this مضبوط دائمًا إلى api.
تمرين 4 — اصنع مصنع جامع
اكتب adder(x) يُرجِع دالّة تضيف x إلى وسيطها؛ أظهِر أن جامعَين مستقلّان.
إظهار الحل
function adder(x) {
return (y) => x + y;
}
const add5 = adder(5);
const add10 = adder(10);
add5(1); // 6
add10(1); // 11كل استدعاء لـ adder يصنع إغلاقًا منفصلًا على x الخاص به، فلا يتداخل add5 وadd10.
النموذج الذهني الذي تحتفظ به
أبقِ المحورين منفصلين. النطاق معجميّ: أين تُكتب الدالّة يثبّت أي متغيّرات تراها، صاعدةً سلسلة النطاق — والإغلاق مجرّد دالّة أبقت تلك المتغيّرات حيّة بعد عودة أبيها، وهكذا تحصل على الحالة الخاصّة والمصانع والتخزين المؤقّت. this ديناميكيّ: يُضبَط بـ كيف تُستدعى الدالّة — استدعاء تابع (الكائن)، استدعاء مجرّد (undefined)، صريح (call/apply/bind)، أو new (الكائن الجديد) — عدا الدوال السهمية، التي لا this لها وتستعير المحيط، ما يجعلها مثالية لردود النداء. أبقِ "عُرّف أين" (النطاق) و"استُدعي كيف" (this) منفصلين، فتصير أجزاء JavaScript التي بدت سحرًا أسود قواعد تطبّقها.