JavaScript بعمق
الإغلاقات والنطاق وthis: الآليات وراء JavaScript
دليل أعمق لنموذج تنفيذ JavaScript — النطاق المعجمي وسلسلة النطاق، والرفع والمنطقة الميتة الزمنية، والإغلاقات والأنماط التي تتيحها (الحالة الخاصّة، المصانع، التخزين المؤقّت)، وقواعد this الأربع، وcall/apply/bind، والدوال السهمية — مع تمارين عملية وحلولها.
الإغلاقات والنطاق وthis هي المفاهيم الثلاثة التي تفصل مَن يستخدم JavaScript عن مَن يفهمها. إنها وراء الحالة الخاصّة، وخطّافات React، ومعالِجات الأحداث، والتطبيق الجزئي، وأكثر أسئلة المقابلات طرحًا. قدّمها مقال الأساسيات؛ وهذا يتعمّق في لماذا تتصرّف هكذا، كي يصير السلوك متوقّعًا بدل مفاجئ.
فكرتان تفتحان كل شيء هنا. النطاق معجميّ (lexical) — وصول الدالّة إلى المتغيّرات يقرّره أين كُتبت، لا أين استُدعيت. لكن
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 التي بدت سحرًا أسود قواعد تطبّقها.