دليل شامل لمغلقات جافاسكريبت (JavaScript Closures) مع أمثلة عملية

دقائق القراءة: 8
مقدمة

تُعد مغلقات جافاسكريبت (JavaScript Closures) من المفاهيم الأساسية والمتقدمة التي غالبًا ما يواجهها مطورو JavaScript. على الرغم من أهميتها البالغة، قد تبدو معقدة للوهلة الأولى، مما يقلل من اهتمام البعض بها. في هذا المقال، سنسعى لتبسيط هذا المفهوم وجعله شيقًا ومفهومًا، مع التركيز على قيمته العملية في بناء تطبيقات قوية ومرنة.

قبل الغوص في عالم Closures، من الضروري أن نفهم أولاً مفهوم “النطاق المعجمي” (Lexical Scoping)، فهو حجر الزاوية لفهم كيفية عمل المغلقات. إذا كنت على دراية بهذا المفهوم، يمكنك الانتقال مباشرة إلى قسم المغلقات؛ وإلا، فإن فهمه سيثري استيعابك للموضوع بشكل كبير.

النطاق المعجمي (Lexical Scoping): حجر الزاوية لمغلقات جافاسكريبت

قد تتساءل، “أنا أعرف النطاق المحلي (Local Scope) والنطاق العام (Global Scope)، ولكن ما هو النطاق المعجمي؟” هذا التساؤل مشروع، وهو ما يدفعنا لاستكشافه بعمق. في الواقع، النطاق المعجمي ليس معقدًا كما يبدو، بل هو مفهوم أساسي يحدد كيفية وصول الدوال إلى المتغيرات.

فهم النطاق المعجمي عبر مثال عملي

function greetCustomer ( ) {
  var customerName = "anchal" ;
  function greetingMsg ( ) {
    console .log( "Hi! " + customerName); // Hi! anchal
  }
  greetingMsg();
}
greetCustomer();

من خلال المثال أعلاه، يتضح أن الدالة الداخلية (greetingMsg) يمكنها الوصول إلى المتغير (customerName) المعرف في الدالة الخارجية (greetCustomer). هذا هو جوهر النطاق المعجمي: حيث يتم تحديد نطاق المتغير وقيمته بناءً على مكانه في الشيفرة البرمجية (أي، حيث تم تعريفه).

النطاق المعجمي مقابل النطاق الديناميكي (Dynamic Scoping)

هل تعلم أن النطاق المعجمي يُعرف أيضًا باسم “النطاق الثابت” (Static Scoping)؟ هذا صحيح، إنه مصطلح آخر لنفس المفهوم. ولتعزيز فهمنا، من المفيد أن نلقي نظرة سريعة على مفهوم معاكس وهو “النطاق الديناميكي” (Dynamic Scoping)، والذي تدعمه بعض لغات البرمجة الأخرى. سيساعدنا هذا التباين على استيعاب النطاق المعجمي بشكل أوضح.

function greetingMsg ( ) {
  console .log(customerName); // ReferenceError: customerName is not defined
}
function greetCustomer ( ) {
  var customerName = "anchal" ;
  greetingMsg();
}
greetCustomer();

هل تتوقع نفس النتيجة؟ لا، سيؤدي هذا إلى خطأ مرجعي (ReferenceError). السبب هو أن كلتا الدالتين معرفتان بشكل منفصل، وبالتالي لا يمكن لأي منهما الوصول إلى نطاق الأخرى.

function addNumbers ( number1 ) {
  console .log(number1 + number2);
}
function addNumbersGenerate ( ) {
  var number2 = 10 ;
  addNumbers(number2);
}
addNumbersGenerate();

في لغة برمجة تدعم النطاق الديناميكي، ستكون النتيجة 20. أما في اللغات التي تدعم النطاق المعجمي (مثل JavaScript)، فستحصل على خطأ (ReferenceError: number2 is not defined).

لماذا هذا الاختلاف؟ في النطاق الديناميكي، يتم البحث عن المتغيرات أولاً في الدالة المحلية، ثم ينتقل البحث إلى الدالة التي استدعت تلك الدالة المحلية، وهكذا صعودًا في مكدس الاستدعاءات (Call Stack). اسمه “ديناميكي” يشرح نفسه: نطاق المتغير وقيمته يمكن أن يتغيرا بناءً على مكان استدعاء الدالة، مما يعني أن معنى المتغير يمكن أن يتغير في وقت التشغيل.

إذا استوعبت فكرة النطاق الديناميكي، فما عليك سوى تذكر أن النطاق المعجمي هو عكسه تمامًا. في النطاق المعجمي، يتم البحث عن المتغيرات أولاً في الدالة المحلية، ثم ينتقل البحث إلى الدالة التي تم تعريف الدالة المحلية بداخلها، وهكذا صعودًا في تسلسل التعريف. لذا، فإن النطاق المعجمي (أو الثابت) يعني أن نطاق المتغير وقيمته يتحددان من مكان تعريفه ولا يتغيران.

دعنا نعود إلى المثال السابق ونحاول تخمين النتيجة، مع تغيير بسيط: سنعلن عن المتغير number2 في النطاق العام.

var number2 = 2 ;
function addNumbers ( number1 ) {
  console .log(number1 + number2);
}
function addNumbersGenerate ( ) {
  var number2 = 10 ;
  addNumbers(number2);
}
addNumbersGenerate();

هل تعرف ما ستكون النتيجة؟ صحيح – ستكون 12 في اللغات ذات النطاق المعجمي. يحدث هذا لأن الدالة addNumbers تبحث أولاً ضمن نطاقها الداخلي، ثم تنتقل للبحث في النطاق الذي تم تعريفها فيه. وبمجرد أن تجد المتغير number2 (الذي قيمته 2)، تتوقف عن البحث، وبالتالي تكون النتيجة 12.

قد تتساءل لماذا أمضينا كل هذا الوقت في شرح النطاق المعجمي في مقال عن Closures. الإجابة بسيطة: بدون فهم النطاق المعجمي، لن تتمكن من استيعاب Closures بشكل كامل. ستتضح هذه العلاقة بشكل أكبر عندما ننتقل إلى تعريف Closures.

ما هي مغلقات جافاسكريبت (JavaScript Closures)؟

لنبدأ بتعريف Closure:

“تنشأ Closure عندما تتمكن دالة داخلية من الوصول إلى متغيرات الدالة الخارجية ومعاملاتها.”

بعبارة أخرى، الدالة الداخلية لديها القدرة على الوصول إلى:

  • متغيراتها الخاصة.
  • متغيرات ومعاملات الدالة الخارجية.
  • المتغيرات العامة (Global Variables).

لحظة! ألا يبدو هذا التعريف مشابهًا لتعريف النطاق المعجمي؟ هذا التشابه هو السبب في أننا خصصنا جزءًا كبيرًا من المقال لشرح النطاق المعجمي، فالمغلقات مرتبطة ارتباطًا وثيقًا به.

لكي نميز Closures عن النطاق المعجمي، دعنا نلقي نظرة على تعريف آخر أكثر دقة:

“تحدث Closure عندما تتمكن دالة من الوصول إلى نطاقها المعجمي، حتى عندما يتم تنفيذ تلك الدالة خارج نطاقها المعجمي الأصلي.”

أو بصيغة أخرى:

“يمكن للدوال الداخلية الوصول إلى نطاقها الأبوي، حتى بعد انتهاء تنفيذ الدالة الأبوية بالفعل.”

إذا كنت تشعر ببعض الارتباك، فلا تقلق. الأمثلة العملية التالية ستوضح الفكرة بشكل كامل. لنعدّل المثال الأول الذي استخدمناه لشرح النطاق المعجمي.

function greetCustomer ( ) {
  const customerName = "anchal" ;
  function greetingMsg ( ) {
    console .log( "Hi! " + customerName);
  }
  return greetingMsg;
}
const callGreetCustomer = greetCustomer();
callGreetCustomer(); // output – Hi! anchal

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

ولكن هنا، الوضع مختلف تمامًا. بعد انتهاء تنفيذ الدالة الأبوية (greetCustomer)، لا تزال الدالة الداخلية (الدالة المرتجعة) قادرة على الوصول إلى متغيرات الدالة الأبوية. نعم، لقد خمنت صحيحًا: هذا بفضل Closures.

تقوم الدالة الداخلية بالحفاظ على نطاقها المعجمي عند تنفيذ الدالة الأبوية، وبالتالي يمكنها لاحقًا الوصول إلى تلك المتغيرات.

للحصول على فهم أعمق، دعنا نستخدم الدالة dir() من console لفحص قائمة خصائص callGreetCustomer:

console .dir(callGreetCustomer);

صورة توضح كيفية احتفاظ الدالة الداخلية بالنطاق المعجمي للدالة الأبوية في JavaScript Closures.

من الصورة أعلاه، يمكنك ملاحظة كيف تحتفظ الدالة الداخلية بنطاقها الأبوي (المتغير customerName) عند تنفيذ greetCustomer(). ولاحقًا، استخدمت customerName عندما تم تنفيذ callGreetCustomer(). آمل أن يكون هذا المثال قد ساعدك على فهم التعريف السابق لـ Closure بشكل أفضل، وربما أصبحت الآن تجدها أكثر إثارة للاهتمام.

أمثلة متقدمة لمغلقات جافاسكريبت (Closures) في العمل

لننتقل الآن إلى أمثلة عملية ومتنوعة لتوضيح قوة ومرونة Closures في JavaScript.

مثال 1: عداد بسيط باستخدام Closure

function counter ( ) {
  let count = 0 ;
  return function ( ) {
    return count++;
  };
}
const countValue = counter();
console.log(countValue()); // 0
console.log(countValue()); // 1
console.log(countValue()); // 2

في كل مرة تستدعي فيها countValue، تزداد قيمة المتغير count بمقدار 1. قد تتساءل، “ألم تكن قيمة count صفرًا عند إنشاء الدالة؟” هذا خطأ شائع، فـ Closure لا تعمل مع القيمة مباشرة، بل تخزن “مرجع” المتغير. لهذا السبب، عندما نقوم بتحديث القيمة، ينعكس التغيير في الاستدعاءات اللاحقة، لأن Closure تحتفظ بالمرجع.

مثال 2: دوال عداد مستقلة

function counter ( ) {
  let count = 0 ;
  return function ( ) {
    return count++;
  };
}
const countValue1 = counter();
const countValue2 = counter();
console.log(countValue1()); // 0
console.log(countValue1()); // 1
console.log(countValue2()); // 0
console.log(countValue2()); // 1

آمل أن تكون قد خمنت الإجابة الصحيحة. إذا لم يكن كذلك، فالسبب هو أن كل من countValue1 و countValue2 تحتفظان بنطاقها المعجمي الخاص بها بشكل مستقل. كل واحدة منهما لديها بيئة معجمية منفصلة تمامًا عن الأخرى. يمكنك استخدام console.dir() للتحقق من قيمة [[scopes]] في كلتا الحالتين.

مثال 3: دالة إضافة جزئية (Currying)

في هذا المثال، نريد كتابة دالة تحقق النتيجة التالية:

const addNumberCall = addNumber( 7 );
addNumberCall( 8 ) // 15
addNumberCall( 6 ) // 13

الحل بسيط باستخدام معرفتك الجديدة بـ Closures:

function addNumber ( number1 ) {
  return function ( number2 ) {
    return number1 + number2;
  };
}

مثال 4: تحدي فهم Closure مع الحلقات التكرارية

لنلقِ نظرة على مثال قد يكون مخادعًا بعض الشيء:

function countTheNumber ( ) {
  var arrToStore = [];
  for ( var x = 0 ; x < 9 ; x++) {
    arrToStore[x] = function ( ) {
      return x;
    };
  }
  return arrToStore;
}
const callInnerFunctions = countTheNumber();
console.log(callInnerFunctions[ 0 ]()); // 9
console.log(callInnerFunctions[ 1 ]()); // 9

كل عنصر في المصفوفة الذي يخزن دالة سيعطيك الناتج 9. هل خمنت هذا بشكل صحيح؟ السبب يكمن في سلوك Closure. فـ Closure تخزن “مرجع” المتغير x، وليس قيمته في لحظة التكرار. في كل مرة يتم فيها تشغيل الحلقة، تتغير قيمة x، وفي النهاية، ستكون قيمة x النهائية هي 9. لذا، فإن استدعاء callInnerFunctions[0]() يعطي الناتج 9.

ولكن ماذا لو أردت الحصول على ناتج من 0 إلى 8؟ الحل بسيط أيضًا باستخدام Closure بشكل صحيح. فكر في الأمر قبل رؤية الحل أدناه:

function callTheNumber ( ) {
  function getAllNumbers ( number ) {
    return function ( ) {
      return number;
    };
  }
  var arrToStore = [];
  for ( var x = 0 ; x < 9 ; x++) {
    arrToStore[x] = getAllNumbers(x);
  }
  return arrToStore;
}
const callInnerFunctions = callTheNumber();
console .log(callInnerFunctions[ 0 ]()); // 0
console .log(callInnerFunctions[ 1 ]()); // 1

هنا، قمنا بإنشاء نطاق منفصل لكل تكرار. الدالة getAllNumbers(x) يتم استدعاؤها في كل تكرار، وتُنشئ Closure جديدة لكل قيمة من قيم x. هذا يضمن أن كل دالة مخزنة في المصفوفة تحتفظ بقيمة x الخاصة بها في وقت الإنشاء. يمكنك استخدام console.dir(arrToStore) للتحقق من قيمة x في [[scopes]] لعناصر المصفوفة المختلفة.

الخلاصة التقنية

نأمل أن يكون هذا الدليل قد أزال الغموض عن مفهوم مغلقات جافاسكريبت (Closures) وجعله أكثر وضوحًا وإثارة للاهتمام. لقد رأينا كيف أن فهم النطاق المعجمي (Lexical Scoping) هو المفتاح لاستيعاب Closures، وكيف تمكننا هذه الأخيرة من بناء دوال تحتفظ بحالتها وتتفاعل مع بيئتها الأصلية حتى بعد انتهاء تنفيذها. هذه القدرة تجعل Closures أداة قوية ومرنة في JavaScript، وتُستخدم على نطاق واسع في أنماط تصميم مثل (Module Pattern) و (Currying) وغيرها، مما يعزز من قوة وفعالية الشيفرة البرمجية ويساهم في كتابة تطبيقات أكثر تنظيمًا وقابلية للصيانة.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *