وعود JavaScript: مقدمة

تسهّل الوعود العمليات الحسابية المؤجّلة وغير المتزامنة. يمثّل الوعد عملية لم تكتمل بعد.

على المطوّرين الاستعداد للحظة محورية في تاريخ تطوير الويب.

[بدء الطبل الافتتاحي]

أصبحت الوعود متاحة في JavaScript!

[انفجار الألعاب النارية، وتساقط الورق اللامع من الأعلى، وهتاف الجمهور]

في هذه المرحلة، ستندرج ضمن إحدى الفئات التالية:

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

توافُق المتصفّح وPolyfill

Browser Support

  • Chrome: 32.
  • Edge: 12.
  • Firefox: 29.
  • Safari: 8.

Source

لجعل المتصفحات التي لا تتضمّن تنفيذًا كاملاً للوعود متوافقة مع المواصفات، أو لإضافة الوعود إلى متصفحات أخرى وNode.js، يمكنك الاطّلاع على الرمز البرمجي المتوافق مع الإصدارات القديمة (2k مضغوط).

ولعلنا جميعًا نتساءل عن سبب الضجة التي أثارها ذلك التطبيق.

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

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

ربما استخدمت الأحداث وعمليات الاستدعاء لتجنُّب ذلك. في ما يلي الأحداث:

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

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

في المثال أعلاه، من المحتمل أنّ الأحداث وقعت قبل أن نبدأ في الاستماع إليها، لذا علينا إيجاد حلّ بديل باستخدام السمة "complete" للصور:

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

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

الأحداث ليست دائمًا الطريقة الأفضل

تُعدّ الأحداث رائعة بالنسبة إلى الإجراءات التي يمكن أن تحدث عدة مرات على الكائن نفسه، مثل keyup وtouchstart وما إلى ذلك. باستخدام هذه الأحداث، لا يهمّك ما حدث قبل ربط المستمع. ولكن عندما يتعلق الأمر بالنجاح/الفشل غير المتزامن، من المفترض أن يكون لديك ما يلي:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

هذا ما تفعله الوعود، ولكن بأسماء أفضل. إذا كانت عناصر HTML الخاصة بالصور تتضمّن طريقة "جاهزة" تعرض وعدًا، يمكننا إجراء ما يلي:

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

في أبسط أشكالها، تشبه الوعود أدوات معالجة الأحداث، باستثناء ما يلي:

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

وهذا مفيد للغاية في حالات النجاح/الفشل غير المتزامنة، لأنّك لا تهتم كثيرًا بالوقت الدقيق الذي أصبح فيه شيء ما متاحًا، بل يهمّك أكثر التفاعل مع النتيجة.

مصطلحات الوعد

راجع "دومينيك دينيكولا" المسودّة الأولى لهذه المقالة ومنحني التقدير "مقبول" على المصطلحات. لقد عاقبني بالاحتجاز، وأجبرني على نسخ الولايات والمصائر 100 مرة، وكتب رسالة قلق إلى والديّ. ومع ذلك، ما زلت أخلط بين المصطلحات، ولكن إليك الأساسيات:

يمكن أن يكون الوعد:

  • fulfilled: نجح الإجراء المتعلّق بالوعد
  • مرفوضة: تعذّر تنفيذ الإجراء المتعلّق بالوعد
  • في انتظار المراجعة: لم يتمّ تنفيذ الطلب أو رفضه بعد
  • تمت تسويتها: تم تنفيذها أو رفضها

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

أصبحت الوعود متاحة في JavaScript!

تتوفّر الوعود منذ فترة طويلة في شكل مكتبات، مثل:

تتشارك الوعود المذكورة أعلاه ووعود JavaScript في سلوك موحّد ومعياري يُعرف باسم Promises/A+‎. إذا كنت تستخدم jQuery، يتوفّر شيء مشابه يُعرف باسم Deferreds. ومع ذلك، لا تتوافق Deferreds مع Promise/A+، ما يجعلها مختلفة بشكل طفيف وأقل فائدة، لذا يجب الانتباه إلى ذلك. يتضمّن jQuery أيضًا نوع Promise، ولكن هذا النوع هو مجرد مجموعة فرعية من Deferred ويواجه المشاكل نفسها.

على الرغم من أنّ عمليات تنفيذ الوعود تتبع سلوكًا موحّدًا، إلا أنّ واجهات برمجة التطبيقات الإجمالية تختلف. تتشابه وعود JavaScript في واجهة برمجة التطبيقات مع RSVP.js. إليك كيفية إنشاء وعد:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

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

كما هو الحال مع throw في JavaScript العادي، من الشائع، ولكن ليس من الضروري، أن يتم الرفض باستخدام كائن Error. تتمثّل فائدة عناصر Error في أنّها تسجّل تتبُّع تسلسل استدعاء الدوال البرمجية، ما يجعل أدوات تصحيح الأخطاء أكثر فائدة.

إليك كيفية استخدام هذا الوعد:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

تتلقّى الدالة then() وسيطتَين، إحداهما لسيناريو النجاح والأخرى لسيناريو الخطأ. وكلاهما اختياريان، لذا يمكنك إضافة دالة ردّ للنجاح أو الفشل فقط.

بدأت الوعود في JavaScript في نموذج العناصر في المستند (DOM) باسم "Futures"، ثم تم تغيير اسمها إلى "Promises"، وأخيرًا تم نقلها إلى JavaScript. ويُعدّ توفّرها في JavaScript بدلاً من DOM أمرًا رائعًا لأنّها ستكون متاحة في سياقات JS غير المتعلّقة بالمتصفّح، مثل Node.js (يبقى السؤال ما إذا كانت ستستفيد منها في واجهات برمجة التطبيقات الأساسية).

على الرغم من أنّها ميزة في JavaScript، إلا أنّ نموذج العناصر في المستند (DOM) لا يتردد في استخدامها. في الواقع، ستستخدم جميع واجهات برمجة التطبيقات الجديدة الخاصة بنموذج المستندات (DOM) التي تتضمّن طرقًا غير متزامنة للنجاح/الفشل الوعود. ويحدث ذلك حاليًا مع إدارة الحصص وأحداث تحميل الخطوط وServiceWorker وWeb MIDI وStreams وغير ذلك.

التوافق مع المكتبات الأخرى

ستتعامل واجهة برمجة التطبيقات JavaScript promises مع أي شيء يتضمّن طريقة then() على أنّه يشبه الوعد (أو thenable في لغة الوعد sigh)، لذا إذا كنت تستخدم مكتبة تعرض وعدًا من Q، فلا بأس في ذلك، ستعمل بشكل جيد مع وعود JavaScript الجديدة.

مع ذلك، كما ذكرتُ، فإنّ Deferreds في jQuery غير مفيدة إلى حدّ ما. لحسن الحظ، يمكنك تحويلها إلى وعود عادية، وهو أمر يستحق القيام به في أقرب وقت ممكن:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

في هذا المثال، تعرض الدالة $.ajax في jQuery عنصر Deferred. بما أنّها تتضمّن طريقة then()، يمكن أن تحوّلها Promise.resolve() إلى وعد JavaScript. ومع ذلك، في بعض الأحيان، تمرّر الكائنات المؤجّلة وسيطات متعدّدة إلى دوال رد الاتصال، على سبيل المثال:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

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

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

لحسن الحظ، هذا هو ما تريده عادةً، أو على الأقل يمنحك إذن الوصول إلى ما تريده. يُرجى أيضًا العِلم أنّ jQuery لا يتّبع قاعدة تمرير عناصر Error إلى عمليات الرفض.

تسهيل كتابة الرموز غير المتزامنة المعقّدة

حسنًا، لنكتب بعض التعليمات البرمجية. لنفترض أنّنا نريد إجراء ما يلي:

  1. بدء مؤشر دوران للإشارة إلى أنّ المحتوى قيد التحميل
  2. استرجاع بعض ملفات JSON الخاصة بقصة، ما يمنحنا العنوان وعناوين URL لكل فصل
  3. إضافة عنوان إلى الصفحة
  4. استرداد كل فصل
  5. إضافة القصة إلى الصفحة
  6. إيقاف أداة الاختيار العشوائي

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

بالطبع، لن تستخدم JavaScript لعرض قصة، لأنّ عرضها بتنسيق HTML أسرع، ولكن هذا النمط شائع جدًا عند التعامل مع واجهات برمجة التطبيقات: يتم جلب بيانات متعددة، ثم يتم تنفيذ إجراء معيّن عند اكتمال عملية الجلب.

لنبدأ بالتعامل مع استرجاع البيانات من الشبكة:

تحويل XMLHttpRequest إلى وعد

سيتم تعديل واجهات برمجة التطبيقات القديمة لاستخدام الوعود، إذا كان ذلك ممكنًا بطريقة متوافقة مع الإصدارات السابقة. ‫XMLHttpRequest هو خيار ممتاز، ولكن في الوقت الحالي، لنكتب دالة بسيطة لإرسال طلب GET:

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

لنستخدِمها الآن:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

يمكننا الآن إرسال طلبات HTTP بدون كتابة XMLHttpRequest يدويًا، وهذا أمر رائع لأنّه كلما قلّت رؤيتي لأسلوب الكتابة المزعج هذا، أصبحت حياتي أفضل.XMLHttpRequest

ربط السلاسل

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

تحويل القيم

يمكنك تحويل القيم ببساطة عن طريق عرض القيمة الجديدة:

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

كمثال عملي، لنرجع إلى:

get('story.json').then(function(response) {
  console.log("Success!", response);
})

الردّ هو JSON، ولكننا نتلقّاه حاليًا كنص عادي. يمكننا تعديل دالة get لاستخدام responseType بتنسيق JSON، ولكن يمكننا أيضًا حلّ المشكلة باستخدام الوعود:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

بما أنّ الدالة JSON.parse() تأخذ وسيطًا واحدًا وتعرض قيمة محوَّلة، يمكننا إنشاء اختصار:

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

في الواقع، يمكننا إنشاء دالة getJSON() بسهولة بالغة:

function getJSON(url) {
  return get(url).then(JSON.parse);
}

لا يزال getJSON() يعرض وعدًا، وهو وعد يجلب عنوان URL ثم يحلّل الاستجابة بتنسيق JSON.

وضع الإجراءات غير المتزامنة في قائمة الانتظار

يمكنك أيضًا ربط thens لتنفيذ الإجراءات غير المتزامنة بالتسلسل.

عند إرجاع قيمة من دالة ردّ نداء في then()، يكون الأمر سحريًا بعض الشيء. إذا عرضت قيمة، سيتم استدعاء then() التالي باستخدام هذه القيمة. ومع ذلك، إذا أرجعت قيمة تشبه الوعد، ستنتظر الدالة then() التالية إلى أن يتم تنفيذ الوعد (بنجاح أو بفشل)، ولن يتم استدعاؤها إلا بعد ذلك. على سبيل المثال:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

في هذا المثال، نرسل طلبًا غير متزامن إلى story.json، ما يمنحنا مجموعة من عناوين URL التي يمكننا طلبها، ثم نطلب العنوان الأول من تلك المجموعة. وهنا تبدأ الميزات التي توفّرها الوعود بالتميّز عن أنماط معاودة الاتصال البسيطة.

يمكنك حتى إنشاء طريقة مختصرة للحصول على الفصول:

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

لا يتم تنزيل story.json إلا عند استدعاء getChapter، ولكن في المرات التالية التي يتم فيها استدعاء getChapter، نعيد استخدام وعد القصة، وبالتالي لا يتم جلب story.json إلا مرة واحدة. ميزة "الوعود"

معالجة الأخطاء

كما رأينا سابقًا، تأخذ الدالة then() وسيطَين، أحدهما للنجاح والآخر للفشل (أو التنفيذ والرفض، بلغة الوعود):

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

يمكنك أيضًا استخدام catch():

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

لا يختلف catch() عن then(undefined, func)، فهو مجرد اختصار له، ولكنّه أسهل للقراءة. يُرجى العِلم أنّ مثالَي الرمز البرمجي أعلاه لا يتصرفان بالطريقة نفسها، فالرمز الأخير يعادل ما يلي:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

والفرق بسيط، ولكنه مفيد للغاية. يتم تخطّي عمليات رفض الوعد إلى then() التالي باستخدام دالة ردّ الاتصال الخاصة بالرفض (أو catch()، لأنّهما متكافئان). سيتم استدعاء then(func1, func2) أو func1 أو func2، ولكن ليس كليهما. ولكن باستخدام then(func1).catch(func2)، سيتم استدعاء كليهما إذا تم رفض func1، لأنّهما خطوتان منفصلتان في السلسلة. اتّبِع الخطوات التالية:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

يشبه التسلسل أعلاه إلى حد كبير عملية try/catch العادية في JavaScript، إذ تنتقل الأخطاء التي تحدث ضمن عبارة "try" مباشرةً إلى كتلة catch(). في ما يلي الخطوات المذكورة أعلاه في شكل مخطط انسيابي (لأنني أحب المخططات الانسيابية):

اتّبِع الخطوط الزرقاء للوعود التي يتم تنفيذها، أو الخطوط الحمراء للوعود التي يتم رفضها.

استثناءات JavaScript والوعود

تحدث حالات الرفض عندما يتم رفض وعد بشكل صريح، ولكن أيضًا بشكل ضمني إذا تم طرح خطأ في معاودة الاتصال الخاصة بالدالة الإنشائية:

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

وهذا يعني أنّه من المفيد تنفيذ جميع المهام ذات الصلة بالوعد داخل دالة رد الاتصال الخاصة بإنشاء الوعد، حتى يتم رصد الأخطاء تلقائيًا وتصبح حالات رفض.

وينطبق الأمر نفسه على الأخطاء التي يتم عرضها في عمليات معاودة الاتصال then().

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

معالجة الأخطاء في الممارسة

باستخدام القصة والفصول، يمكننا استخدام catch لعرض خطأ للمستخدم:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

في حال تعذُّر جلب story.chapterUrls[0] (مثلاً، ظهور الخطأ http 500 أو عدم توفّر اتصال بالإنترنت)، سيتم تخطّي جميع عمليات معاودة الاتصال الناجحة التالية، بما في ذلك العملية الواردة في getJSON() التي تحاول تحليل الاستجابة بتنسيق JSON، كما سيتم تخطّي عملية معاودة الاتصال التي تضيف chapter1.html إلى الصفحة. بدلاً من ذلك، يتم الانتقال إلى دالة الاستدعاء catch. نتيجةً لذلك، ستتم إضافة "تعذّر عرض الفصل" إلى الصفحة في حال تعذّر تنفيذ أي من الإجراءات السابقة.

وكما هو الحال في try/catch في JavaScript، يتم رصد الخطأ ويستمر تنفيذ الرمز البرمجي اللاحق، وبالتالي يتم إخفاء مؤشر التحميل دائمًا، وهو ما نريده. يصبح ما ورد أعلاه إصدارًا غير متزامن لا يحظر العمليات الأخرى من:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

قد تحتاج إلى ذلك catch() لأغراض التسجيل فقط، بدون الحاجة إلى استرداد البيانات من الخطأ. لإجراء ذلك، ما عليك سوى إعادة طرح الخطأ. يمكننا إجراء ذلك في طريقة getJSON() على النحو التالي:

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

لقد تمكّنا من جلب فصل واحد، ولكننا نريد جلب جميع الفصول. دعنا نحقق ذلك.

التوازي والتسلسل: الاستفادة من مزايا كليهما

ليس من السهل التفكير بشكل غير متزامن. إذا كنت تواجه صعوبة في البدء، جرِّب كتابة الرمز كما لو كان متزامنًا. في هذه الحالة:

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

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

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

ولكن كيف يمكننا تكرار عناوين URL الخاصة بالفصول واسترجاعها بالترتيب؟ لا يمكن إجراء ما يلي:

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

لا يتوافق forEach مع عمليات التحميل غير المتزامنة، لذا ستظهر الفصول بأي ترتيب يتم تنزيلها به، وهو ما يشبه طريقة كتابة فيلم Pulp Fiction. هذه ليست Pulp Fiction، لذا لنحاول إصلاح هذه المشكلة.

إنشاء تسلسل

نريد تحويل مصفوفة chapterUrls إلى سلسلة من الوعود. يمكننا إجراء ذلك باستخدام then():

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

هذه هي المرة الأولى التي نرى فيها Promise.resolve()، وهي تنشئ وعدًا يتم تنفيذه وفقًا للقيمة التي تحدّدها. إذا مرّرت إليه مثيلاً من Promise، سيعرضه ببساطة (ملاحظة: هذا تغيير في المواصفات لم يتم تطبيقه بعد في بعض الحالات). إذا مرّرت إليها شيئًا يشبه الوعد (يتضمّن طريقة then())، فإنّها تنشئ Promise حقيقيًا يتم تنفيذه/رفضه بالطريقة نفسها. إذا مرّرت أي قيمة أخرى، مثل Promise.resolve('Hello')، يتم إنشاء وعد يتم تنفيذه باستخدام هذه القيمة. إذا طلبتها بدون قيمة، كما هو موضّح أعلاه، سيتم عرض القيمة "undefined".

هناك أيضًا Promise.reject(val)، التي تنشئ وعدًا مرفوضًا بالقيمة التي تقدّمها (أو قيمة غير محدّدة).

يمكننا ترتيب الرمز البرمجي أعلاه باستخدام array.reduce:

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

يؤدي هذا الإجراء الوظيفة نفسها التي يؤديها المثال السابق، ولكنّه لا يحتاج إلى المتغيّر "sequence" المنفصل. يتم استدعاء دالة رد الاتصال الخاصة بالتقليل لكل عنصر في المصفوفة. تكون قيمة "sequence" هي Promise.resolve() في المرة الأولى، ولكن في بقية المكالمات، تكون قيمة "sequence" هي القيمة التي تم إرجاعها من المكالمة السابقة. تُعدّ الدالة array.reduce مفيدة جدًا لتقليل حجم مصفوفة إلى قيمة واحدة، وهي في هذه الحالة عبارة عن وعد.

لنضع كل ذلك معًا:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

وهكذا، أصبح لدينا نسخة غير متزامنة بالكامل من النسخة المتزامنة. ولكن يمكننا تقديم أداء أفضل. في الوقت الحالي، يتم تنزيل صفحتنا على النحو التالي:

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

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

تأخذ الدالة Promise.all مجموعة من الوعود وتنشئ وعدًا يتم تنفيذه عند اكتمال جميع الوعود بنجاح. ستحصل على مجموعة من النتائج (أيًا كان ما تم تنفيذه من الوعود) بالترتيب نفسه الذي تم تمرير الوعود به.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

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

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

لإجراء ذلك، نسترد ملف JSON لجميع الفصول في الوقت نفسه، ثم ننشئ تسلسلاً لإضافتها إلى المستند:

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

وهكذا نكون قد جمعنا بين أفضل ما يقدّمه كلّ من التطبيقين. يستغرق عرض كل المحتوى الوقت نفسه، ولكن يحصل المستخدم على الجزء الأول من المحتوى بشكل أسرع.

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

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

جولة إضافية: إمكانات موسّعة

منذ أن كتبت هذه المقالة في الأصل، توسّعت إمكانية استخدام Promises بشكل كبير. منذ الإصدار 55 من Chrome، أتاحت الدوال غير المتزامنة كتابة الرموز البرمجية المستندة إلى الوعود كما لو كانت متزامنة، ولكن بدون حظر سلسلة التعليمات الرئيسية. يمكنك الاطّلاع على مزيد من المعلومات حول هذا الموضوع في مقالة حول الدوال غير المتزامنة. تتوافق المتصفحات الرئيسية على نطاق واسع مع كل من Promises والدالات غير المتزامنة. يمكنك العثور على التفاصيل في مرجع Promise ومرجع async function على شبكة مطوّلي Mozilla.

نشكر كلّ من "آن فان كيستيرين" و"دومينيك دينيكولا" و"توم آش وورث" و"ريمي شارب" و"آدي أوسماني" و"آرثر إيفانز" و"يوتاكا هيرانو" على مراجعة هذا المستند وتقديم التصحيحات والاقتراحات.

نشكر أيضًا Mathias Bynens على تعديل أجزاء مختلفة من المقالة.