وعود 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
});

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

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

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

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

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

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

  • fulful - تم تنفيذ الإجراء المتعلق بالوعد بنجاح
  • مرفوضة: تعذّر تنفيذ الإجراء المرتبط بالوعد
  • في انتظار المراجعة: لم يتمّ استيفاء الطلب أو رفضه بعد
  • 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 عنصرًا من النوع 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 لا يتبع اصطلاح تمرير كائنات الخطأ إلى حالات الرفض.

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

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

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

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

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

مثل المحاولة أو الالتقاط في 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" المنفصل. يتم استدعاء تقليل استدعاء الدالة لكل عنصر في الصفيفة. "التسلسل" هو 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 بشكل كبير. منذ استخدام Chrome 55، أتاحت الوظائف غير المتزامنة كتابة الرموز البرمجية المستندة إلى الوعد كما لو كانت متزامنة، ولكن بدون حظر سلسلة التعليمات الرئيسية. يمكنك قراءة المزيد من المعلومات حول ذلك في مقالتي حول الدوال غير المتزامنة. وهناك توافق على نطاق واسع لكل من الدوال غير المتزامنة والوعود في المتصفحات الرئيسية. يمكنك العثور على التفاصيل في مرجع Promise وasync function في MDN.

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

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