دليل تعلم جافاسكربت مقدمة إلى ردود النداء callbacks في جافاسكربت


صفا الفليج

ملاحظة ابتدائية: لتوضيح طريقة استخدام ردود النداء callbacks والوعود promises والمفاهيم المجردة سنستخدم بعض توابِع المتصفح، تحديدًا سكربتات التحميل loading scripts وأدوات التلاعب بالمستندات البسيطة.

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

وبالرغم من ذلك سنحاول توضيح الأمور بكل الأحوال ولن يكون هنالك شيء معقد في المتصفح.

أغلب الإجراءات في جافاسكربت هي إجراءات غير متزامنة، أي أنّنا نشغّلها الآن ولكنّها تنتهي في وقت لاحق.

مثال على ذلك هو حين نُجدول تلك الإجراءات باستعمال setTimeout.

يُعدّ تحميل السكربتات والوحدات (سنشرحها في الفصول القادمة) أمثلة فعلية عن الإجراءات غير المتزامنة.

لاحظ مثلًا الدالة loadScript(src)‎ أسفله، إذ تُحمّل سكربتًا من العنوان src الممرّر:

function loadScript(src) {
// ‫أنشئ وسم <script> وأضفه للصفحة
// ‫فسيؤدي ذلك إلى بدء تحميل السكربت ذي الخاصية src ثم تنفيذه عند الاكتمال
  let script = document.createElement('script');
  script.src = src;
  document.head.append(script);
}

تُضيف الدالة إلى المستند الوسم <script src=‎"…"‎> الجديد الذي وُلّد ديناميكيًا. حين يعمل المتصفّح ينفّذ الدالة.

يمكننا استعمال هذه الدالة هكذا:

// حمّل ونفّذ السكربت في هذا المكان
loadScript('/my/script.js');

يُنفّذ هذا السكربت ”بلا تزامن“ إذ تحميله يبدأ الآن أمّا تشغيله فيكون لاحقًا متى انتهى الدالة.

ولو كانت هناك شيفرة أسفل الدالة loadScript(…)‎ فلن ننتظر انتهاء تحميل السكربت.

loadScript('/my/script.js');
// الشيفرة أسفل ‫loadScript
// لا تنتظر انتهاء تحميل السكربت
// ...

فنقل بأنّنا نريد استعمال السكربت متى انتهى تحميله (فهو مثلًا يُصرّح عن دوال جديدة ونريد تشغيلها).

ولكن لو حدث ذلك مباشرةً بعد استدعاء loadScript(…)‎، فلن تعمل الشيفرة:

// في السكربت الدالة ‫"function newFunction() {…}‎"
loadScript('/my/script.js'); 

newFunction(); // ما من دالة بهذا الاسم!

الطبيعي هو أنّ ليس على المتصفّح انتظار مدّة من الزمن حتّى ينتهي تحميل السكربت. كما نرى فحاليًا لا تقدّم لنا الدالة loadScript أيّ طريقة لنعرف وقت اكتمال التحميل، بل يُحمّل السكربت ويُشغّل متى اكتمل، وفقط. ولكن ما نريده هو معرفة وقت ذلك كي نستعمل الدوال والمتغيرات الجديدة فيه.

لنُضيف دالة ردّ نداء callback لتكون الوسيط الثاني لدالة loadScript، كي تُنفّذ متى انتهى تحميل السكربت:

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  script.onload = () => callback(script);

  document.head.append(script);
}

الآن متى أردنا استدعاء الدوال الجديدة في السكربت، نكتبها في دالة ردّ النداء تلك:

loadScript('/my/script.js', function() {
  // يعمل ردّ النداء بعد تحميل السكربت
  newFunction(); // الآن تعمل
  ...
});

هذه الفكرة تمامًا: يُعدّ الوسيط الثاني دالةً (عادةً ما تكون مجهولة) تعمل متى اكتمل الإجراء.

وإليك مثالًا حقيقيًا قابل للتشغيل:

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  script.onload = () => callback(script);
  document.head.append(script);
}

*!*
loadScript('https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.2.0/lodash.js', script => {
  alert(`Cool, the script ${script.src} is loaded`);
  alert( _ ); // التابع معرف في السكربت المُحمّل
});
*/!*

يُسمّى هذا الأسلوب في البرمجة غير المتزامنة ”الأسلوب المبني على ردود النداء“ (callback-based)، إذ على الدوال التي تنفّذ أمور غير متزامنة تقديمَ وسيط ردّ نداء callback نضع فيه الدالة التي ستُشغّل متى اكتملت تلك الأمور.

ولقد فعلناها في loadScript ولكن بكلّ الأحوال هذا نهج عام.

رد النداء داخل رد النداء

وكيف نُحمّل سكربتين واحدًا تلو الآخر: يعمل الأول وبعدها حين ينتهي يعمل الثاني؟

الحلّ الطبيعي الذي سيفكّر به الجميع هو وضع استدعاء loadScript الثاني داخل ردّ النداء، هكذا:

loadScript('/my/script.js', function(script) {

  // جميل، اكتمل تحميل كذا، هيًا نُحمّل الآخر
  alert(`Cool, the ${script.src} is loaded, let's load one more`);

  loadScript('/my/script2.js', function(script) {
    // جميل، اكتمل تحميل السكربت الثاني أيضًا
    alert(`Cool, the second script is loaded`);
  });

});

وبعدما تكتمل الدالة الخارجية loadScript، يُشغّل ردّ النداء الدالة الداخلية.

ولكن ماذا لو أردنا سكربتًا آخر، أيضًا…؟

loadScript('/my/script.js', function(script) {

  loadScript('/my/script2.js', function(script) {

    loadScript('/my/script3.js', function(script) {
      // ...نواصل متى اكتملت كلّ السكربتات
    });

  })

});

هكذا نضع كلّ إجراء جديد داخل ردّ نداء. لو كانت الإجراءات قليلة فليست مشكلة، ولكن لو زادت فستصير كارثية. سنرى ذلك قريبًا.

التعامل مع الأخطاء

لم نضع الأخطاء في الحسبان في هذه الأمثلة. ماذا لو فشل تحميل السكربت؟ يفترض أن تتفاعل دالة ردّ النداء بناءً على الخطأ.

إليك نسخة محسّنة من الدالة loadScript تتعقّب أخطاء التحميل:

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;

  // هنا
  script.onload = () => callback(null, script);
  // خطأ في تحميل السكربت كذا
  script.onerror = () => callback(new Error(`Script load error for ${src}`));

  document.head.append(script);
}

هنا نستدعي callback(null, script)‎ لو اكتمل التحميل، ونستدعي callback(error)‎ لو لم يكتمل.

طريقة الاستعمال:

loadScript('/my/script.js', function(error, script) {
  if (error) {
    // نتعامل مع الخطأ
  } else {
    // تحمّل السكربت بنجاح
  }
});

نُعيد، ما استعملناه هنا للدالة loadScript هي طريقة مشهورة جدًا، تُدعى بأسلوب ”ردّ نداء الأخطاء أولًا“.

إليك ما اصطُلح عليه:

  1. يُحجز الوسيط الأوّل من دالة callback للخطأ إن حدث، ويُستدعى callback(err)‎.
  2. يكون الوسيط الثاني (وغيرها إن دعت الحاجة) للنتيجة الصحيحة،

هكذا نستعمل الدالة callback فقط للأمرين: الإبلاغ عن الأخطاء وتمرير النتيجة.

هرم العذابات (Pyramid of Doom)

يمكن أن نرى أنّ البرمجة بنمط اللاتزامن مفيد جدًا، وهذا جليّ من النظرة الأولى. فحين نكون أمام استدعاء أو استدعاءين فقط، فأمورنا ممتازة.

ولكن لو تتابعت الإجراءات غير المتزامنة واحدة تلو الأخرى، سنرى هذا:

loadScript('1.js', function(error, script) {

  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', function(error, script) {
      if (error) {
        handleError(error);
      } else {
        // ...
        loadScript('3.js', function(error, script) {
          if (error) {
            handleError(error);
          } else {
            // ‫...نُواصل بعد اكتمال تحميل كل السكربتات (*)
          }
        });

      }
    })
  }
});

إليك ما في الشيفرة أعلاه:

  1. نُحمّل ‎1.js ونرى لو لم تحدث أخطاء.
  2. نُحمّل ‎2.js ونرى لو لم تحدث أخطاء.
  3. نُحمّل ‎3.js، ولو لم تحدث أخطاء نفّذنا شيئًا (*).

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

يُسمّون هذا أحيانًا ”بجحيم ردود النداء“ (callback hell) أو ”هرم العذابات“.

callback-hell.png

بزيادة الإجراءات غير المتزامنة واحدًا بعد آخر، يتشعّب ”هرم“ الاستدعاءات المتداخلة إلى اليمين أكثر فأكثر، ولن يمضي من الوقت الكثير حتى دخلتْ في دوّامة حلزونية محال تنظيمها.

بهذا تُعدّ طريقة البرمجة هذه سيّئة.

يمكننا في محاولة يائسة لتقليل وقع المشكلة تحويل كلّ إجراء إلى دالة منفردة، هكذا:

loadScript('1.js', step1);

function step1(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('2.js', step2);
  }
}

function step2(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ...
    loadScript('3.js', step3);
  }
}

function step3(error, script) {
  if (error) {
    handleError(error);
  } else {
    // ‫...نواصل بعد اكتمال تحميل كل السكربتات (*)
  }
};

رأيت الفكرة؟ مبدأ الشيفرة واحد وليس هناك تداخلات متشعّبة إذ حوّلنا كلّ إجراء إلى دالة

لا مشكلة في الشيفرة إذ هي تعمل، ولكنّها فعليًا مقطّعة إربًا إربًا، ويصعُب على الشخص قراءتها وغالبًا تنتقل بنظرك بين أجزاء الشيفرة وأنت تقرأها. ليس هذا بالأمر الجيد خصوصًا لو لم يكن قارئ الشيفرة يعلم بما تفعله أو إلى أين ينقل نظره.

كما وأنّ الدوال بأسماء الخطوات step* هي لاستعمال واحد ولم نصنعها إلّا لتفادي ”هرم العذابات“. لن يأتي أحد لاحقًا ويُعيد استعمالها في أيّ شيء عدا سلسلة الإجراءات هذه. بهذا ”نلوّث“ فضاء الأسماء، إن جاز التعبير.

نريد بالطبع ما هو أفضل من هذا الحل.

ولحسن حظنا (كالعادة)، فهناك طرق أخرى نتجنّب بها الأهرام هذه، أفضلها هي استعمال ”الوعود“ وسنشرحها في الفصل القادم.

تمارين

دائرة تتحرك ولها رد نداء

نرى في التمرين Animated circle دائرة يكبُر مقاسها في حركة جميلة.

لنقل بأنّا لا نريد الدائرة فقط، بل أيضًا عرض رسالة فيها. يجب أن تظهر الرسالة بعدما ينتهي التحريك (أي تكون الدائرة بمقاسها الكبير الكامل)، وإلّا بدا النص قبيح المظهر.

في حلّ ذاك التمرين، نرى الدالة showCircle(cx, cy, radius)‎ ترسم الدائرة ولكن لا تُقدّم لنا أيّ طريقة نعرف بها انتهاء الرسم.

أضِف وسيط ردّ نداء showCircle(cx, cy, radius, callback)‎ يُستدعى متى اكتمل التحريك. على المُعامل callback استلام كائن <div> للدائرة وسيطًا له.

إليك مثالًا:

showCircle(150, 150, 100, div => {
  div.classList.add('message-ball');
  div.append("Hello, world!");
});

تجربة حية:

يمكنك اعتماد حل هذا التمرين منطلقًا للحل.

الحل

يمكنك معاينة الحل من خلال المثال الحي.

ترجمة -وبتصرف- للفصل Introduction: callbacks من كتاب The JavaScript language





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن