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

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

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

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

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

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

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

  • fulful - تم تنفيذ الإجراء المتعلق بالوعد بنجاح
  • مرفوض - تعذّر الإجراء المتعلق بالوعد
  • في انتظار الموافقة - لم يتمّ توصيلها أو رفضها بعد
  • settled - تم استيفاء الطلب أو رفضه.

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

وصول وعود باستخدام JavaScript

وظلت وعود هذه متاحة منذ فترة في شكل مكتبات، مثل:

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

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

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

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

ستتعامل واجهة برمجة التطبيقات في 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) {
  // ...
})

بينما تتعهد JS بتجاهل الكل باستثناء الأول:

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

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

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

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

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

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

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

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

طلب XMLHttp واعد

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

حدث خطأ أثناء المعالجة

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

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 Cinema. هذا ليس من أعمال "ألب" الخيالي، لذا دعنا نصلحه.

إنشاء تسلسل

نريد تحويل مصفوفة 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 الأخرى، تصبح أكثر سهولة.

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

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

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

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