وعود JavaScript: مقدمة

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

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

[يبدأ صوت الطبل]

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

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

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

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

التوافق مع المتصفّحات واستخدام polyfill

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

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

المصدر

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

ما هو سرّ الضجة التي أثارها ذلك التطبيق؟

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

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

تُعدّ الأحداث رائعة للأشياء التي يمكن أن تحدث عدة مرات على العنصر نفسه، مثل 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
});

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

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

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

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

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

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

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

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

بدء استخدام ميزة "الوعد" في JavaScript

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

تشترك الوعود أعلاه وJavaScript في سلوك موحّد شائع يُعرف باسم الوعود/A+. إذا كنت من مستخدمي jQuery، ستجد ميزة مشابهة تُعرف باسم Deferreds. ومع ذلك، فإنّ وظائف Deferred ليست متوافقة مع 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 إذا نجحت كل الإجراءات، وإلا رفض المكالمة.

مثل throw في JavaScript العادي القديم، من المعتاد، ولكن ليس مطلوبًا، استخدام throw مع كائن 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 أمر رائع لأنّه سيكون متاحًا في سياقات JavaScript غير المخصّصة للمتصفّح، مثل Node.js (واستخدامها في واجهات برمجة التطبيقات الأساسية هو سؤال آخر).

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

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

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

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

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

في هذا المثال، يعرض $.ajax من jQuery عنصرًا مؤجّلاً. بما أنّه يحتوي على طريقة 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 بتنسيق camel-casing المثير للانزعاج، زادت سعادتي.

التسلسل

لا تنتهي القصة عند 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!");
})

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

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

استثناءات JavaScript ووعود 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);
})

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

وينطبق الشيء نفسه على الأخطاء التي يتم طرحها في 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] (مثل الخطأ 500 http أو إذا لم يكن المستخدم متصلاً بالإنترنت)، سيتم تخطّي جميع عمليات الاستدعاء التالية للنجاح، بما في ذلك عملية الاستدعاء في getJSON() التي تحاول تحليل الاستجابة بتنسيق JSON، كما سيتم تخطّي عملية الاستدعاء التي تضيف chapter1.html إلى الصفحة. بدلاً من ذلك، يتم الانتقال إلى catch callback. نتيجةً لذلك، ستتم إضافة "تعذّر عرض الفصل" إلى الصفحة إذا تعذّر تنفيذ أيّ من الإجراءات السابقة.

مثل أسلوب 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. لنصلح هذه المشكلة.

إنشاء تسلسل

نريد تحويل صفيف 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())

يؤدي هذا الإجراء إلى الإجراء نفسه الذي يؤديه المثال السابق، ولكنّه لا يحتاج إلى المتغيّر المختلف "sequence". يتمّ استدعاء دالة الاستدعاء reduce لكلّ عنصر في الصفيف. "التسلسل" هو 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';
})

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

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

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

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

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

نشكر Anne van Kesteren وDomenic Denicola وTom Ashworth وRemy Sharp و Addy Osmani وArthur Evans وYutaka Hirano على مراجعة هذه المقالة وإجراء تعديلات وتقديم اقتراحات.

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