وعود JavaScript: مقدمة

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

جيك أرشيبالد
جيك أرشيبالد

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

[بدء الطبل]

التعهدات متوفّرة بلغة JavaScript

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

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

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

دعم المتصفح وpolyfill

التوافق مع المتصفح

  • 32
  • 12
  • 29
  • 8

المصدر

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

ما هو كل هذا العناء؟

فلغة 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 إيقاف التنفيذ حتى يتم استدعاء إحدى هؤلاء المستمعين.

لسوء الحظ، في المثال أعلاه، من المحتمل أن الأحداث قد وقعت قبل أن نبدأ في الاستماع إليها، لذلك علينا التغلب على ذلك باستخدام الخاصية "الكاملة" للصور:

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
});

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

قد لا تكون الأحداث دائمًا هي أفضل طريقة

تُعدّ الأحداث مناسبة جدًا للفعاليات التي يمكن أن تتكرّر عدّة مرات على الموضوع نفسه، "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
});

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

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

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

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

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

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

  • تمّ التنفيذ - تم تنفيذ الإجراء المرتبط بالوعد بنجاح
  • مرفوض - تعذَّر تنفيذ الإجراء المرتبط بالوعد.
  • Pending - لم يتم تنفيذ الطلب أو رفضه حتى الآن.
  • settled - تمّ التنفيذ أو الرفض.

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

التعهدات تصل إلى JavaScript

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

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

على الرغم من أن عمليات تنفيذ الوعد تتبع سلوكًا موحدًا، فإن واجهات برمجة التطبيقات العامة تختلف بشكل عام. وتتشابه وعود 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"));
  }
});

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

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

إليك كيفية الوفاء بهذا الوعد:

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

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

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

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

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

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

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

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

هنا، تعرض دالة jQuery قيمة $.ajax مؤجلة. وبما أنّ هذه الطريقة تتضمّن 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 لا يتبع اصطلاح تمرير كائنات الخطأ إلى عمليات الرفض.

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

حسنًا، لنقم بترميز بعض الأشياء. لنفترض أننا نريد:

  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() ليست نهاية القصة، ويمكنك ربط then معًا لتحويل القيم أو تنفيذ إجراءات إضافية غير متزامنة واحدة تلو الأخرى.

تحويل القيم

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

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 لاستخدام JSON responseType، ولكن يمكننا أيضًا حلها في شكل وعود:

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.

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

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

عند عرض محتوى من معاودة الاتصال على "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!");
})

يشبه المسار أعلاه إلى حد كبير عملية محاولة/التقاط محتوى JavaScript العادي، فالأخطاء التي تحدث ضمن "محاولة" تنتقل فورًا إلى كتلة 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 إلى الصفحة. وبدلاً من ذلك ينتقل إلى معاودة الاستدعاء للصيد. نتيجةً لذلك، ستتم إضافة عبارة "تعذّر عرض الفصل" إلى الصفحة في حال تعذّر عرض أي من الإجراءات السابقة.

ومثل محاولة/التقاط محتوى 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 Fantasy. هذا ليس فيلم Pulp Fantasy، لذلك دعنا نصلحه.

إنشاء تسلسل

نريد تحويل مصفوفة 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')، فهذا يقطع وعودًا يفي بهذه القيمة. إذا استدعيت بدون قيمة، كما أشرنا سابقًا، فيكون يفي بـ "غير محدد".

هناك أيضًا السمة 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())

وينطبق ذلك مثل المثال السابق، ولكن لا يحتاج إلى متغير "التسلسل" المنفصل. يتم استدعاء معاودة الاتصال "التقليل" الخاصة بنا لكل عنصر في المصفوفة. يكون "التسلسل" Promise.resolve() في المرة الأولى، ولكن بالنسبة لبقية الاستدعاءات، يكون "التسلسل" هو كل ما عرضناه من الاستدعاء السابق. إنّ 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 الأخرى، فإنها تصبح أسهل أكثر.

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

منذ أن كتبت هذه المقالة في الأصل، توسعت القدرة على استخدام الوعود بشكل كبير. منذ إصدار 55 من Chrome، سمحت الدوال غير المتزامنة بكتابة التعليمات البرمجية المستندة إلى الوعد كما لو كانت متزامنة، ولكن بدون حظر سلسلة التعليمات الرئيسية. يمكنك قراءة المزيد عن هذا الموضوع في my async functions article. هناك دعم واسع النطاق لكلّ من وظائف الوعود والوظائف غير المتزامنة في المتصفحات الرئيسية. يمكنك العثور على التفاصيل في مرجع وعد ودالة غير متزامنة لـ MDN.

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

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