تُبسِّط الوعود العمليات الحسابية المؤجَّلة وغير المتزامنة. يمثّل الوعد عملية لم تكتمل بعد.
استعدّوا للحظة محورية في تاريخ تطوير الويب.
[يبدأ قرع الطبول]
أصبحت الوعود متاحة في JavaScript.
[تنفجر الألعاب النارية، وتتساقط قصاصات الورق اللامعة من الأعلى، ويهتف الجمهور بحماس]
في هذه المرحلة، تندرج ضمن إحدى الفئات التالية:
- يهتف الناس من حولك، لكنك لست متأكدًا من سبب كل هذه الضجة. قد لا تكون متأكدًا حتى من معنى "الوعد". قد تهز كتفيك، ولكن ثقل الورق اللامع يثقل كاهلك. إذا كان الأمر كذلك، فلا تقلق بشأن ذلك، فقد استغرق الأمر مني وقتًا طويلاً لمعرفة سبب اهتمامي بهذه الأشياء. من الأفضل أن تبدأ من البداية.
- توجّه لكمة في الهواء. أخيرًا، أليس كذلك؟ لقد استخدمت كائنات Promise هذه من قبل، ولكن يزعجك أنّ جميع عمليات التنفيذ لها واجهة برمجة تطبيقات مختلفة قليلاً. ما هي واجهة برمجة التطبيقات لإصدار JavaScript الرسمي؟ من الأفضل البدء بالمصطلحات.
- كنت تعرف ذلك بالفعل وتسخر من أولئك الذين يقفزون لأعلى ولأسفل وكأنها أخبار لهم. يمكنك الاستمتاع بهذا الإنجاز، ثم الانتقال مباشرةً إلى مرجع واجهة برمجة التطبيقات.
توافق المتصفّح وpolyfill
لجعل المتصفحات التي تفتقر إلى تنفيذ كامل للوعود متوافقة مع المواصفات، أو لإضافة وعود إلى متصفحات أخرى و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 عن التنفيذ حتى يتم استدعاء أحد هؤلاء المستمعين.
في المثال أعلاه، من المحتمل أن تكون الأحداث قد وقعت قبل أن نبدأ في الاستماع إليها، لذلك نحتاج إلى حل هذه المشكلة باستخدام السمة "complete" للصور:
var img1 = document.querySelector('.img-1');
function loaded() {
// woo yey image loaded
}
if (img1.complete) {
loaded();
}
else {
img1.addEventListener('load', loaded);
}
img1.addEventListener('error', function() {
// argh everything's broken
});
لا يرصد هذا الصور التي حدث بها خطأ قبل أن تتاح لنا فرصة الاستماع إليها، وللأسف لا يمنحنا نموذج العناصر في المستند (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
});
في أبسط حالاتها، تشبه العمليات غير المكتملة إلى حد ما متتبِّعات الأحداث باستثناء ما يلي:
- يمكن أن ينجح الوعد أو يفشل مرة واحدة فقط. ولا يمكن أن تنجح أو تفشل مرتين، ولا يمكنها التبديل من النجاح إلى الفشل أو العكس.
- إذا نجح الوعد أو فشل وأضفت لاحقًا دالة رد اتصال للنجاح/الفشل، فسيتم استدعاء دالة رد الاتصال الصحيحة، على الرغم من أن الحدث قد حدث في وقت سابق.
هذا مفيد للغاية للنجاح/الفشل غير المتزامن، لأنك أقل اهتمامًا بالوقت المحدد الذي أصبح فيه شيء ما متاحًا، وأكثر اهتمامًا بالتفاعل مع النتيجة.
مصطلحات Promise
قرأ دومينيك دينيكولا المسودة الأولى لهذه المقالة وصحّحها، ومنحني درجة "F" في المصطلحات. احتجزني في المدرسة، وأجبرني على نسخ States and Fates 100 مرة، وكتب رسالة قلقة إلى والديّ. ومع ذلك، ما زلت أخلط بين الكثير من المصطلحات، ولكن إليك الأساسيات:
يمكن أن يكون الوعد:
- fulfilled: نجح الإجراء المتعلّق بالوعد
- مرفوض: تعذّر تنفيذ الإجراء المتعلّق بالوعد
- pending: لم يتم تنفيذ الطلب أو رفضه بعد
- settled: تم تنفيذ الطلب أو رفضه
تستخدم المواصفات أيضًا المصطلح thenable لوصف عنصر يشبه الوعد، من حيث أنّه يتضمّن طريقة then. يذكّرني هذا المصطلح بمدير كرة القدم الإنجليزي السابق تيري فينابلز، لذا سأستخدمه بأقل قدر ممكن.
وصول الوعود في JavaScript!
تتوفّر الوعود منذ فترة في شكل مكتبات، مثل:
تتشارك الوعود المذكورة أعلاه ووعود JavaScript في سلوك موحّد وشائع يُعرف باسم Promises/A+. إذا كنت من مستخدمي jQuery، ستجد أنّ لديهم شيئًا مشابهًا يُعرف باسم Deferreds. ومع ذلك، لا تتوافق Deferreds مع Promise/A+، ما يجعلها مختلفة بشكل طفيف وأقل فائدة، لذا احذر. لدى jQuery أيضًا نوع Promise، ولكن هذا مجرد مجموعة فرعية من Deferred ولديه نفس المشاكل.
على الرغم من أنّ عمليات تنفيذ الوعد تتبع سلوكًا موحدًا، إلا أنّ واجهات برمجة التطبيقات العامة تختلف. تتشابه وعود JavaScript في واجهة برمجة التطبيقات مع RSVP.js. إليك كيفية إنشاء وعد:
var promise = new Promise(function(resolve, reject) {
// do a thing, possibly async, then…
if (/* everything turned out fine */) {
resolve("Stuff worked!");
}
else {
reject(Error("It broke"));
}
});
تأخذ الدالة الإنشائية للوعد وسيطة واحدة، وهي دالة رد اتصال بمعاملين، وهما resolve وreject. Do something within the callback, perhaps async, then call resolve if everything worked, otherwise call reject.
مثل throw في JavaScript القديمة العادية، من المعتاد، ولكن ليس من الضروري،
الرفض باستخدام كائن خطأ. تتمثل فائدة كائنات الخطأ في أنها تلتقط
تتبع تسلسل استدعاء الدوال البرمجية، مما يجعل أدوات تصحيح الأخطاء أكثر فائدة.
إليك كيفية استخدام هذا الوعد:
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 promises مع أي شيء باستخدام طريقة then() على أنّه
يشبه الوعد (أو thenable في لغة الوعد sigh)، لذا إذا كنت تستخدم مكتبة
تعرض وعدًا Q، فلا بأس، ستعمل بشكل جيد مع وعود
JavaScript الجديدة.
ومع ذلك، كما ذكرت، فإنّ كائنات Deferreds في jQuery غير مفيدة بعض الشيء. لحسن الحظ، يمكنك تحويلها إلى وعود عادية، وهو أمر يستحق القيام به في أقرب وقت ممكن:
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
هنا، تعرض دالة $.ajax في jQuery كائنًا مؤجلاً. بما أنّها تتضمّن طريقة then()،
يمكن لـ Promise.resolve() تحويلها إلى JavaScript promise. ومع ذلك،
في بعض الأحيان، تمرر المؤجلات وسيطات متعددة إلى عمليات الاستدعاء الخاصة بها، على سبيل المثال:
var jqDeferred = $.ajax('/whatever.json');
jqDeferred.then(function(response, statusText, xhrObj) {
// ...
}, function(xhrObj, textStatus, err) {
// ...
})
بينما تتجاهل وعود JS كل شيء باستثناء الأول:
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
لحسن الحظ، هذا عادة ما تريده، أو على الأقل يمنحك الوصول إلى ما تريد. يُرجى العِلم أيضًا أنّ jQuery لا تتبع اصطلاح تمرير كائنات الخطأ إلى عمليات الرفض.
تسهيل كتابة الرموز غير المتزامنة المعقّدة
حسنًا، لنكتب بعض الرموز. لنفترض أنّنا نريد:
- بدء مؤشر دوّار للإشارة إلى التحميل
- جلب بعض ملفات JSON لقصة، والتي تعطينا العنوان وعناوين URL لكل فصل
- إضافة عنوان إلى الصفحة
- جلب كل فصل
- إضافة القصة إلى الصفحة
- إيقاف العجلة الدوّارة
… ولكن يجب أيضًا إعلام المستخدم في حال حدوث خطأ أثناء تنفيذ الإجراء. سنريد أيضًا إيقاف مؤشر التحميل عند هذه النقطة، وإلا سيستمر في الدوران، ويصاب بالدوار، ويتعطل في واجهة مستخدم أخرى.
بالطبع، لن تستخدم JavaScript لعرض قصة، لأنّ العرض بتنسيق HTML أسرع، ولكن هذا النمط شائع جدًا عند التعامل مع واجهات برمجة التطبيقات: يتم جلب بيانات متعددة، ثم يتم تنفيذ إجراء ما عند الانتهاء من ذلك.
لنبدأ بجلب البيانات من الشبكة:
تحويل XMLHttpRequest إلى Promise
سيتم تعديل واجهات برمجة التطبيقات القديمة لاستخدام الوعود، إذا كان ذلك ممكنًا بطريقة متوافقة مع الأنظمة القديمة. 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 يدويًا، وهو أمر رائع، لأنّه كلما قلّت حاجتي إلى رؤية تنسيق Camel Case المزعج في XMLHttpRequest، زادت سعادتي.
Chaining
لا يقتصر استخدام 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،
ولكن يمكننا أيضًا حلّ المشكلة في promises:
get('story.json').then(function(response) {
return JSON.parse(response);
}).then(function(response) {
console.log("Yey JSON!", response);
})
بما أنّ الدالة JSON.parse() تأخذ وسيطة واحدة وتعرض قيمة محوَّلة،
يمكننا إنشاء اختصار:
get('story.json').then(JSON.parse).then(function(response) {
console.log("Yey JSON!", response);
})
في الواقع، يمكننا إنشاء دالة getJSON() بسهولة تامة:
function getJSON(url) {
return get(url).then(JSON.parse);
}
لا تزال الدالة getJSON() تعرض وعدًا، وهو وعد يجلب عنوان URL ثم يحلّل
الاستجابة بتنسيق JSON.
وضع الإجراءات غير المتزامنة في قائمة الانتظار
يمكنك أيضًا ربط thens لتنفيذ إجراءات غير متزامنة بالتسلسل.
عند عرض شيء من دالة رد اتصال then()، يكون الأمر سحريًا بعض الشيء.
إذا عرضت قيمة، يتم استدعاء دالة then() التالية بهذه القيمة. ومع ذلك،
إذا عرضت شيئًا يشبه الوعد، فإن then() التالي ينتظره، ولا يتم
استدعاؤه إلا عند تسوية هذا الوعد (نجاح/فشل). على سبيل المثال:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
console.log("Got chapter 1!", chapter1);
})
هنا، نرسل طلبًا غير متزامن إلى story.json، ما يمنحنا مجموعة من عناوين URL لطلبها، ثم نطلب أول عنوان 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 مرة واحدة فقط. وعود Yay
معالجة الأخطاء
كما رأينا سابقًا، تأخذ الدالة then() وسيطتَين، واحدة للنجاح والأخرى للفشل (أو التنفيذ والرفض، في لغة الوعود):
get('story.json').then(function(response) {
console.log("Success!", response);
}, function(error) {
console.log("Failed!", error);
})
يمكنك أيضًا استخدام catch():
get('story.json').then(function(response) {
console.log("Success!", response);
}).catch(function(error) {
console.log("Failed!", error);
})
لا يوجد شيء مميز في catch()، فهو مجرد اختصار لـ then(undefined, func)، ولكنه أكثر قابلية للقراءة. يُرجى العِلم أنّ مثالَي الرمز البرمجي أعلاه لا يعملان بالطريقة نفسها، فالمثال الأخير يعادل ما يلي:
get('story.json').then(function(response) {
console.log("Success!", response);
}).then(undefined, function(error) {
console.log("Failed!", error);
})
الفرق دقيق، ولكنه مفيد للغاية. يتم تخطّي حالات رفض الوعد
والانتقال إلى then() التالي مع استدعاء الرفض (أو catch()، لأنّه
مكافئ). باستخدام then(func1, func2)، سيتم استدعاء func1 أو func2، وليس كليهما. ولكن مع then(func1).catch(func2)، سيتم استدعاء كليهما إذا رفضت func1، لأنهما خطوتان منفصلتان في السلسلة. اتّبِع
الخطوات التالية:
asyncThing1().then(function() {
return asyncThing2();
}).then(function() {
return asyncThing3();
}).catch(function(err) {
return asyncRecovery1();
}).then(function() {
return asyncThing4();
}, function(err) {
return asyncRecovery2();
}).catch(function(err) {
console.log("Don't worry about it");
}).then(function() {
console.log("All done!");
})
يشبه المسار أعلاه إلى حد كبير مسار try/catch العادي في JavaScript، فالأخطاء التي تحدث ضمن "try" تنتقل فورًا إلى مجموعة catch(). في ما يلي
المعلومات أعلاه على شكل مخطط انسيابي (لأنّني أحب المخططات الانسيابية):
اتّبِع الخطوط الزرقاء للوعود التي يتم الوفاء بها، أو الخطوط الحمراء للوعود التي يتم رفضها.
استثناءات JavaScript والوعود
تحدث حالات الرفض عندما يتم رفض الوعد صراحةً، ولكن أيضًا بشكل ضمني إذا تم طرح خطأ في دالة رد الاتصال الخاصة بالمنشئ:
var jsonPromise = new Promise(function(resolve, reject) {
// JSON.parse throws an error if you feed it some
// invalid JSON, so this implicitly rejects:
resolve(JSON.parse("This ain't JSON"));
});
jsonPromise.then(function(data) {
// This never happens:
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
هذا يعني أنّه من المفيد تنفيذ جميع الأعمال المتعلّقة بالوعد داخل دالة الاستدعاء الخاصة بمنشئ الوعد، بحيث يتم رصد الأخطاء تلقائيًا وتصبح حالات رفض.
ينطبق الأمر نفسه على الأخطاء التي تحدث في عمليات الاستدعاء في then().
get('/').then(JSON.parse).then(function() {
// This never happens, '/' is an HTML page, not JSON
// so JSON.parse throws
console.log("It worked!", data);
}).catch(function(err) {
// Instead, this happens:
console.log("It failed!", err);
})
معالجة الأخطاء في الممارسة العملية
باستخدام القصة والفصول، يمكننا استخدام catch لعرض خطأ للمستخدم:
getJSON('story.json').then(function(story) {
return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
addHtmlToPage(chapter1.html);
}).catch(function() {
addTextToPage("Failed to show chapter");
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
إذا تعذّر جلب story.chapterUrls[0] (على سبيل المثال، خطأ http 500 أو كان المستخدم غير متصل بالإنترنت)،
سيتم تخطي جميع عمليات الاستدعاء الناجحة التالية، بما في ذلك العملية في
getJSON() التي تحاول تحليل الاستجابة كملف JSON، كما يتم تخطي
عملية الاستدعاء التي تضيف chapter1.html إلى الصفحة. بدلاً من ذلك، ينتقل إلى دالة الاستدعاء
catch. نتيجةً لذلك، ستتم إضافة "تعذّر عرض الفصل" إلى الصفحة في حال
تعذّر تنفيذ أي من الإجراءات السابقة.
على غرار try/catch في JavaScript، يتم رصد الخطأ ويستمر تنفيذ الرمز اللاحق، وبالتالي يتم إخفاء مؤشر التحميل دائمًا، وهذا ما نريده. يصبح ما سبق إصدارًا غير متزامن وغير محظور من:
try {
var story = getJSONSync('story.json');
var chapter1 = getJSONSync(story.chapterUrls[0]);
addHtmlToPage(chapter1.html);
}
catch (e) {
addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'
قد تحتاج إلى catch() ببساطة لأغراض التسجيل، بدون الاسترداد
من الخطأ. لإجراء ذلك، ما عليك سوى إعادة طرح الخطأ. يمكننا إجراء ذلك في طريقة getJSON():
function getJSON(url) {
return get(url).then(JSON.parse).catch(function(err) {
console.log("getJSON failed for", url, err);
throw err;
});
}
لقد تمكّنا من جلب فصل واحد، ولكننا نريد جلبها جميعًا. دعنا نحقق ذلك.
التوازي والتسلسل: الحصول على أفضل ما في كليهما
التفكير في العمل غير المتزامن ليس سهلاً. إذا كنت تواجه صعوبة في البدء، جرِّب كتابة الرمز كما لو كان متزامنًا. في هذه الحالة:
try {
var story = getJSONSync('story.json');
addHtmlToPage(story.heading);
story.chapterUrls.forEach(function(chapterUrl) {
var chapter = getJSONSync(chapterUrl);
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}
catch (err) {
addTextToPage("Argh, broken: " + err.message);
}
document.querySelector('.spinner').style.display = 'none'
هذا مناسب. ولكنها متزامنة وتغلق المتصفح أثناء تنزيل الأشياء. لجعل هذا العمل غير متزامن، نستخدم then() لإنجاز المهام واحدة تلو الأخرى.
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
ولكن كيف يمكننا تكرار عناوين URL للفصول وجلبها بالترتيب؟ لا تعمل هذه الميزة في الحالات التالية:
story.chapterUrls.forEach(function(chapterUrl) {
// Fetch chapter
getJSON(chapterUrl).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
})
forEach لا يدرك عدم التزامن، لذا ستظهر فصولنا بأي ترتيب
يتم تنزيلها، وهو في الأساس كيفية كتابة Pulp Fiction. هذا ليس فيلم "بالب فيكشن"،
لذا لنصلح هذا الأمر.
إنشاء تسلسل
نريد تحويل مصفوفة chapterUrls إلى تسلسل من الوعود. يمكننا إجراء ذلك باستخدام then():
// Start off with a promise that always resolves
var sequence = Promise.resolve();
// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
// Add these actions to the end of the sequence
sequence = sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
})
هذه هي المرة الأولى التي نرى فيها Promise.resolve()، والذي ينشئ
وعدًا يتم حله لأي قيمة تمنحها له. إذا تم تمرير مثيل من Promise، سيتم عرضه ببساطة (ملاحظة: هذا تغيير في المواصفات لا تتبعه بعض عمليات التنفيذ حتى الآن). إذا مرّرت إليها كائنًا يشبه الوعد (يحتوي على طريقة then())، فإنّها تنشئ كائن Promise حقيقيًا يتم تنفيذه أو رفضه بالطريقة نفسها. إذا تم تمرير أي قيمة أخرى، مثل Promise.resolve('Hello')، يتم إنشاء وعد يتم الوفاء به بهذه القيمة. إذا استدعيتها بدون قيمة، كما هو موضّح أعلاه،
سيتم تنفيذها باستخدام "undefined".
هناك أيضًا Promise.reject(val)، الذي ينشئ وعدًا يرفض
القيمة التي تمنحها له (أو غير محددة).
يمكننا ترتيب الرمز أعلاه باستخدام
array.reduce:
// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Add these actions to the end of the sequence
return sequence.then(function() {
return getJSON(chapterUrl);
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve())
يؤدي هذا الإجراء نفس ما تم في المثال السابق، ولكنه لا يحتاج إلى متغير
"sequence" منفصل. يتم استدعاء دالة الاستدعاء reduce لكل عنصر في المصفوفة.
تكون قيمة "sequence" هي Promise.resolve() في المرة الأولى، ولكن بالنسبة إلى بقية
الطلبات، تكون قيمة "sequence" هي أي قيمة تم عرضها من الطلب السابق. array.reduce
مفيدة جدًا لتقليص مصفوفة إلى قيمة واحدة، وهي في هذه الحالة
وعد.
لنجمع كل هذه العناصر معًا:
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
return story.chapterUrls.reduce(function(sequence, chapterUrl) {
// Once the last chapter's promise is done…
return sequence.then(function() {
// …fetch the next chapter
return getJSON(chapterUrl);
}).then(function(chapter) {
// and add it to the page
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
// And we're all done!
addTextToPage("All done");
}).catch(function(err) {
// Catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
// Always hide the spinner
document.querySelector('.spinner').style.display = 'none';
})
وهذا كل ما في الأمر، إصدار غير متزامن بالكامل من الإصدار المتزامن. ولكن يمكننا تقديم ما هو أفضل. في الوقت الحالي، يتم تنزيل صفحتنا على النحو التالي:
تتميّز المتصفّحات بقدرتها على تنزيل ملفات متعددة في الوقت نفسه، لذا فإنّ تنزيل الفصول واحدًا تلو الآخر يؤدي إلى انخفاض الأداء. ما نريد فعله هو تنزيلها جميعًا في الوقت نفسه، ثم معالجتها عندما تصل جميعًا. لحسن الحظ، هناك واجهة برمجة تطبيقات لهذا الغرض:
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})
تأخذ الدالة Promise.all مصفوفة من الوعود وتنشئ وعدًا يتم الوفاء به
عند اكتمالها جميعًا بنجاح. ستحصل على مصفوفة من النتائج (أيًا كانت
الوعود التي تم الوفاء بها) بنفس ترتيب الوعود التي أدخلتها.
getJSON('story.json').then(function(story) {
addHtmlToPage(story.heading);
// Take an array of promises and wait on them all
return Promise.all(
// Map our array of chapter urls to
// an array of chapter json promises
story.chapterUrls.map(getJSON)
);
}).then(function(chapters) {
// Now we have the chapters jsons in order! Loop through…
chapters.forEach(function(chapter) {
// …and add to the page
addHtmlToPage(chapter.html);
});
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened so far
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
بناءً على الاتصال، يمكن أن يكون هذا أسرع بثوانٍ من التحميل واحدًا تلو الآخر، وهو رمز أقل من محاولتنا الأولى. يمكن تنزيل الفصول بأي ترتيب، ولكنها تظهر على الشاشة بالترتيب الصحيح.
ومع ذلك، لا يزال بإمكاننا تحسين الأداء الملحوظ. عندما يصل الفصل الأول، يجب أن نضيفه إلى الصفحة. يتيح ذلك للمستخدم بدء القراءة قبل وصول بقية الفصول. عندما يصل الفصل الثالث، لن نضيفه إلى الصفحة لأن المستخدم قد لا يدرك أن الفصل الثاني مفقود. عندما يصل الفصل الثاني، يمكننا إضافة الفصلين الثاني والثالث، وما إلى ذلك.
لإجراء ذلك، نُحضر ملف JSON لجميع الفصول في الوقت نفسه، ثم ننشئ تسلسلاً لإضافتها إلى المستند:
getJSON('story.json')
.then(function(story) {
addHtmlToPage(story.heading);
// Map our array of chapter urls to
// an array of chapter json promises.
// This makes sure they all download in parallel.
return story.chapterUrls.map(getJSON)
.reduce(function(sequence, chapterPromise) {
// Use reduce to chain the promises together,
// adding content to the page for each chapter
return sequence
.then(function() {
// Wait for everything in the sequence so far,
// then wait for this chapter to arrive.
return chapterPromise;
}).then(function(chapter) {
addHtmlToPage(chapter.html);
});
}, Promise.resolve());
}).then(function() {
addTextToPage("All done");
}).catch(function(err) {
// catch any error that happened along the way
addTextToPage("Argh, broken: " + err.message);
}).then(function() {
document.querySelector('.spinner').style.display = 'none';
})
وهكذا، نكون قد حصلنا على أفضل ما في الاثنين. يستغرق تسليم كل المحتوى نفس القدر من الوقت، ولكن يحصل المستخدم على الجزء الأول من المحتوى في وقت أقرب.
في هذا المثال البسيط، تصل جميع الفصول في الوقت نفسه تقريبًا، ولكن ستظهر فائدة عرض فصل واحد في كل مرة بشكل أكبر مع زيادة عدد الفصول وحجمها.
إنّ تنفيذ ما سبق باستخدام عمليات الاستدعاء أو الأحداث بأسلوب Node.js يتطلّب ضعف الرمز تقريبًا، ولكن الأهم من ذلك أنّه ليس من السهل تتبُّعه. ومع ذلك، هذه ليست نهاية قصة الوعود، فعند دمجها مع ميزات ES6 الأخرى تصبح أسهل.
جولة إضافية: إمكانات موسّعة
منذ أن كتبت هذه المقالة في الأصل، توسعت القدرة على استخدام الوعود بشكل كبير. منذ الإصدار 55 من Chrome، سمحت الدوال غير المتزامنة بكتابة الرموز البرمجية المستندة إلى الوعود كما لو كانت متزامنة، ولكن بدون حظر سلسلة التعليمات الرئيسية. يمكنك الاطّلاع على مزيد من المعلومات حول ذلك في مقالة الدوال غير المتزامنة. هناك دعم واسع النطاق لكل من دوال Promises وasync في المتصفحات الرئيسية. يمكنك العثور على التفاصيل في مرجع Promise وasync function على شبكة مطوّري Mozilla (MDN).
شكرًا جزيلاً لـ "آن فان كيستيرين" و"دومينيك دينيكولا" و"توم آشورث" و"ريمي شارب" و"أدي عثماني" و"آرثر إيفانز" و"يوتاكا هيرانو" الذين راجعوا هذا النص وأجروا التصحيحات/التقديمات.
نشكر أيضًا ماتياس بايننز على تعديل أجزاء مختلفة من المقالة.