EduFlow
أساسيات JavaScript
⁦0%⁩

الوحدة 2 · المنطق والدوال

النطاق (Scope) والـ Closures

18 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

تتوقّع أين يمكن استخدام متغيّر ما (نطاق الكتلة ونطاق الدالة والنطاق العام)، وتتجنّب مزالق var القديمة، وتستخدم الـ closures لتمنح الدوال حالة خاصة تتذكّرها، مثل عدّاد للمعرّفات.

  1. 1الكتل والدوال والنطاق العام
  2. 2مشكلة var
  3. 3الـ Closures: دوال تتذكّر

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

كل مصروف في Pocket يحتاج إلى معرّف (id) فريد كي تستطيع لاحقًا تعديله هو بالذات أو حذفه. والطريقة البديهية عدّاد في أعلى الملف:

JavaScript
let nextId = 1;
function createId() {
  return `exp-${nextId++}`;
}

هذا يعمل، إلى أن يأتي جزء آخر من البرنامج، ربما ميزة “إعادة التعيين” كُتبت بعد أشهر، فيضبط nextId = 1 من جديد، فتحصل على مصروفين باسم exp-1. العدّاد مرئي في كل مكان، لذلك يستطيع أي شيء أن يُفسده. ولكي تحميه، تحتاج إلى فهم النطاق (scope): قواعد المكان الذي يمكن أن يُرى فيه اسم ما.

الكتل والدوال والنطاق العام

كل زوج من الأقواس المعقوفة يُنشئ كتلة (block)، وأسماء let وconst تعيش في الكتلة التي صُرّح بها فيها. والدوال تُنشئ نطاقًا أيضًا. أما الأسماء المُصرَّح بها خارج كل الكتل والدوال فهي في النطاق العام (global scope)، ومرئية في كل مكان.

JavaScript
const appName = "Pocket";           // global

function describe(amount) {
  const size = amount > 10000 ? "big" : "small";   // function scope
  if (size === "big") {
    const warning = "Think twice";  // block scope
    console.log(appName, size, warning);
  }
  // console.log(warning) here would throw a ReferenceError
}

describe(12000);

حين يصادف المحرّك اسمًا، يبحث عنه في النطاق الحالي أولًا. فإن لم يجده هناك، بحث في النطاق المحيط، ثم في الذي يحيط به، وصولًا إلى النطاق العام. وإن لم يجده بعد ذلك، تحصل على ReferenceError: … is not defined. والبحث يتّجه إلى الخارج فقط: الكود داخل الدالة يستطيع قراءة appName العام، لكن الكود خارج الدالة لا يستطيع أبدًا رؤية size.

ولهذا جُعل سطر warning تعليقًا. فالكتلة التي صرّحت بـ warning انتهت، فاختفى الاسم.

ثلاثة صناديق متداخلة: النطاق العام يحمل appName، ونطاق الدالة describe يحمل amount وsize، وكتلة if تحمل warning. سهم من الكتلة الأعمق يشير إلى الخارج ليوضّح أن البحث يتّجه من النطاقات الداخلية إلى الخارجية. النطاق العام appName function describe amount, size كتلة if warning ابحث إلى الخارج
النطاقات تتداخل، والبحث يتّجه إلى الخارج

إذا صرّح نطاق داخلي باسم موجود أصلًا في الخارج، فإن الاسم الداخلي يحجب (shadows) الخارجي: داخل الكتلة، يعني الاسم المتغيّر الداخلي. هذا مسموح، لكن وجود شيئين مختلفين باسم واحد يجعل الكود صعب القراءة، فاختر أسماء مختلفة.

مشكلة var

قبل عام 2015، لم يكن في JavaScript إلا var، وvar تتجاهل الكتل: إنها مرئية في الدالة التي هي فيها بالكامل. وهذا يُنتج خطأً شهيرًا. هنا تجدول الحلقة ثلاث دوال لتُنفَّذ بعد لحظة باستخدام setTimeout:

JavaScript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var:", i), 0);
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let:", j), 0);
}

حلقة var تطبع 3 ثلاث مرات، لأن هناك i واحدًا فقط للحلقة كلها، وحين تُنفَّذ الدوال يكون قد بلغ 3. أما حلقة let فتطبع 0 و1 و2، لأن let تُنشئ j جديدًا في كل دورة، وكل دالة تحتفظ بنسختها الخاصة. وتذكّر هذه الدوال للمتغيّرات هو بالضبط ما يُسمّى closure.

الـ Closures: دوال تتذكّر

الدالة التي تُنشأ داخل دالة أخرى تستطيع استخدام متغيّرات الدالة الخارجية. والمفاجأة أنها تحتفظ بهذا الوصول بعد أن تنتهي الدالة الخارجية:

JavaScript
function createIdGenerator(prefix) {
  let count = 0;
  return () => {
    count++;
    return `${prefix}-${count}`;
  };
}

const nextExpenseId = createIdGenerator("exp");
const nextCategoryId = createIdGenerator("cat");

console.log(nextExpenseId());
console.log(nextExpenseId());
console.log(nextCategoryId());
console.log(nextExpenseId());

تتبّع المحرّك. استدعاء createIdGenerator("exp") يُنشئ prefix وcount جديدين، ويبني الدالة السهمية، ويُرجعها. في العادة تختفي متغيّرات الدالة حين تنتهي، لكن الدالة السهمية ما زالت تشير إلى count وprefix، فيبقيان حيّين، ملتصقين بها. وهذا الاقتران بين دالة والمتغيّرات التي وُلدت بجوارها هو ما يُسمّى closure.

كل استدعاء لـ createIdGenerator يُنشئ مجموعة جديدة منفصلة من المتغيّرات. ولهذا يبدأ nextCategoryId() من 1، وتستمر معرّفات المصاريف من حيث توقّفت.

والآن انظر إلى الأمان الذي كسبته. لا يوجد count عام. لا يستطيع أي كود آخر قراءته، ولا تصفيره، ولا ضبطه على قيمة مكرّرة. والطريقة الوحيدة للتأثير فيه هي استدعاء الدالة التي أُعطيت لك، وهي لا تستطيع إلا أن تدفعه إلى الأمام. هذه هي الطريقة المعتمدة لمنح دالة حالة خاصة (private state) في JavaScript.

يستطيع الـ closure أن يحمي أكثر من مجرّد عدّاد. هذه ميزانية لا يمكن أن تنقص إلا عبر الدالة التي تسلّمها:

JavaScript
function createBudget(limitCents) {
  let remaining = limitCents;
  return (spentCents) => {
    remaining -= spentCents;
    return remaining;
  };
}

const spend = createBudget(2000);
console.log(spend(450));
console.log(spend(1220));

اكتمل القسم 2: تستطيع الآن اتخاذ القرارات، وتكرار العمل، وحزمه في دوال لها حالتها الخاصة. وينتقل القسم 3 إلى البيانات، بدءًا بالقائمة التي كنت تمرّ عليها: المصفوفة.

أهم النقاط

  • النطاق (scope) هو المكان الذي يمكن فيه استخدام اسم ما: let وconst تعيشان في أقرب كتلة { }، والبحث عن الأسماء يتّجه إلى الخارج، لا إلى الداخل أبدًا.
  • الاسم الداخلي الذي يحمل اسم متغيّر خارجي “يحجبه” (shadowing) داخل النطاق الداخلي، وهذا مسموح لكن من السهل أن يُساء فهمه.
  • var تتجاهل الكتل ويمتد نطاقها إلى الدالة كلها، وهذا يسبّب أخطاء كلاسيكية؛ استخدم let وconst.
  • الـ closure دالة تحتفظ بإمكانية الوصول إلى متغيّرات النطاق الذي أُنشئت فيه، حتى بعد أن تكون الدالة الخارجية قد انتهت.
  • كل استدعاء للدالة الخارجية يُنشئ مجموعة جديدة من المتغيّرات، لذلك يملك كل closure تُرجعه حالته الخاصة.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

ماذا يحدث حين يُنفَّذ هذا الكود؟

JavaScript
function check(amount) {
  if (amount > 100) {
    const label = "big";
  }
  return label;
}
console.log(check(500));
السؤال 2 من 3

ماذا يطبع هذا الكود؟

JavaScript
function makeCounter() {
  let count = 0;
  return () => {
    count++;
    return count;
  };
}
const a = makeCounter();
const b = makeCounter();
a(); a();
console.log(a(), b());
السؤال 3 من 3

لماذا يُعدّ مولّد المعرّفات المبني على closure أكثر أمانًا من let nextId = 1 عام؟

تدريب

بيئة تجريب الكود

عدّادات خاصة باستخدام الـ closures

مولّد المعرّفات في كود البداية يتشارك count عامًا واحدًا، فيتداخل مولّدان معًا ويستطيع أي كود تصفيره. أعِد كتابة createIdGenerator(prefix) بحيث يملك كل مولّد عدّاده الخاص، ولا يبقى أي count عام: أول معرّف من أي مولّد جديد هو دائمًا prefix-1.

ثم اكتب createBudget(limitCents)، التي تُرجع دالة spend(cents). كل استدعاء يطرح cents من مبلغ متبقٍّ خاص (يبدأ من limitCents) ويُرجع ما تبقّى. ويمكن أن يصبح سالبًا.

// A global counter: any code can break it. Move it into a closure.
let count = 0;

function createIdGenerator(prefix) {
  return () => {
    count++;
    return `${prefix}-${count}`;
  };
}

// Return a function spend(cents) that subtracts from a private
// remaining amount and returns what is left
function createBudget(limitCents) {
}

const nextId = createIdGenerator("exp");
console.log(nextId(), nextId());
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • المولّد يُرجع prefix-1 ثم prefix-2 ثم prefix-3…
  • مولّدان يحتفظ كلٌّ منهما بعدّه المنفصل
  • لم يعد هناك متغيّر count عام
  • spend تطرح وتُرجع ما تبقّى
  • ميزانيتان تحتفظ كلٌّ منهما بمبلغها المتبقّي المنفصل