استخدام الكائن Promise في JavaScript

من موسوعة حسوب

يُعرّف الكائن Promise أو «الوعد» على أنّه كائنُ يمثّلُ النّتيجة النّهائيّة من إكمالٍ أو فشلٍ لعمليّةٍ غير متزامنة. ولمّا كان أغلب المُطورين يستخدمون الوعود الجاهزة (المنشأة مسبقًا)، فسيشرح هذا الدّليل استخدام الوعود المُعادة قبل التّطرّق إلى كيفيّة إنشائها.

يمكننا القول أنّ «الوعد» (promise) في جوهره ما هو إلّا كائنٌ مُعادٌ تقوم بإرفاق ردود النّداء (Callbacks) معه عوضًا عن تمريرها إلى الدّالّة.

تخيّل أنّ لدينا الدّالّة createAudioFileAsync()‎ والتي تُولّد بطريقةٍ غير متزامنةٍ ملفًّا صوتيًّا انطلاقًا من ضبط الملف الصوتي ودالّتي رد نداء تُستدعَى الأولى عند نجاح إنشاء الملف الصوتي والثّانية عند حدوث أخطاء. إليك شيفرة تستخدم الدّالّة createAudioFileAsync()‎:

function successCallback(result) {
  console.log("Audio file ready at URL: " + result);
}

function failureCallback(error) {
  console.log("Error generating audio file: " + error);
}

createAudioFileAsync(audioSettings, successCallback, failureCallback);

تُعيد الدّوالُّ الحديثة وعدًا يمكنك إرفاق ردود النّداء معه عوضًا عن ذلك. إذا ما أُعيدت كتابة الدّالّة createAudioFileAsync()‎ لتُعيدَ وعدًا، يصبح استخدامها بهذه البساطة:

createAudioFileAsync(audioSettings).then(successCallback, failureCallback);

يُمثّل ما سبق اختزالًا لما يلي:

const promise = createAudioFileAsync(audioSettings); 
promise.then(successCallback, failureCallback);

نُطلق على هذه الحالة استدعاء دالّة استدعاءً غير متزامن. يمنحنا هذا الاصطلاح عددًا من الميّزات سنتطّرق لكل منها على حدة.

الضمانات

على نقيض ردود النّداء المُمَرّرة ذات النمط القديم، يأتي Promise مع بعض الضمانات:

  • لن تُطلَب ردود النّداء قبل إتمام التنفيذ الحالي لحلقة الأحداث في JavaScript.
  • تًطلب ردود النداء المضافة باستخدام then()‎ حتى بعد نجاح أو فشل العملية غير المتزامنة وفق ما ذكر أعلاه.
  • يُمكن إضافة عدة ردود نداء من خلال استدعاء then()‎ عدة مرات. تُنفّذ جميع ردود النداء واحدًا تلو الآخر بنفس الترتيب الذي أدرجت به.

يُقدم استخدام الوعود العديد من الميزات الرائعة، نذكرالآن منها استخدام السّلسَلة (chaining).

السّلسَلة (الاستدعاء المتسلسل)

يُعدُّ التنفيذ التّعاقبيُّ لعمليّتَين غير متزامنتَين أو أكثر حاجةً مشتركةً لدى العديد من المُطوّرين، حين تقتضي الضّرورة أن تبدأ العمليّة اللّاحقة فور نجاح سابقتها ومعتمدةً على النّتيجة من تلك الأخيرة. نُنجز هذا من خلال إنشاء سلسلة من الوعود (promise chain).

هنا يكمن الإبداع: تٌعيد الدّالّة then()‎ كائن Promise جديد مختلفًا عن الأصليّ:

const promise = doSomething();
const promise2 = promise.then(successCallback, failureCallback);

أو:

const promise2 = doSomething().then(successCallback, failureCallback);

لا يُمثّل الكائن promise2 إتمام doSomething()‎ فحسب، إنّما يشمل ما قمت بتمريره سواء successCallback أو failureCallback؛ الأمر الذي قد يُمثّل دوالًا غير متزامنة أخرى تقوم بدورها بإرجاع وعد. في هذه الحالة، تُوضع أيّة ردود نداء مضافة إلى promise2 في طابور وذلك خلف الوعد المُعاد إمّا من قبل successCallback أو failureCallback.

يُمثّل كل وعدٍ بصفةِ أساسيّةٍ إتمام خطوة غير متزامنة أخرى في السّلسلة.

أدى تنفيذ عدّة عمليّات غير متزامنة على التّسلسل في السّابق إلى حالة هرم الموت (pyramid of doom):

doSomething(function(result) {
  doSomethingElse(result, function(newResult) {
    doThirdThing(newResult, function(finalResult) {
      console.log('Got the final result: ' + finalResult);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

نُرفق كحلّ بديل في الدّوال الحديثة ردود النداء مع الوعود المُعادة مما يُشكّل سلسلة وعود:

doSomething()
.then(function(result) {
  return doSomethingElse(result);
})
.then(function(newResult) {
  return doThirdThing(newResult);
})
.then(function(finalResult) {
  console.log('Got the final result: ' + finalResult);
})
.catch(failureCallback);

إضافة المُتغيّرات إلى then أمرٌ غير إلزاميّ، أمّا catch(failureCallback)‎ فهي اختزال للدّالّة then(null, failureCallback)‎ وقد تُصادِف في المقابل هذا الأمر مُمثّلًا بدوال سهميّة.

doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
  console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);

ملاحظة مهمة: قُم دائمًا بإرجاع نتائج وإلّا لن تقوم ردود النداء بالتقاط نتيجة الوعد السّابق (تُمثّل في الدّوال السّهميّة ‎() => x اختزالًا للتّركيب ‎() => { return x; }‎).

استخدام السّلاسل بعد catch

يُمكن استخدام السّلاسل بعد الفشل في حالة catch، إذ يفيد هذا في إنجاز إجراءات جديدة حتى بعد فشل إجراء في سلسلة الاستدعاء. إليك المثال التّالي:

new Promise((resolve, reject) => {
    console.log('Initial');

    resolve();
})
.then(() => {
    throw new Error('Something failed');
        
    console.log('Do this');
})
.catch(() => {
    console.log('Do that');
})
.then(() => {
    console.log('Do this, no matter what happened before');
});

تُخرج الشّيفرة السّابقة النّص التّالي:

Initial
Do that
Do this, no matter what happened before

ملاحظة: لا يُعرض النص "Do this" لأن الخطاً "Something failed" تسبّب بحدوث رفض.

انتشار الخطأ

قد تعود إلى ذهنك رؤية الدّالّة failureCallback ثلاث مرّاتٍ في هرم الموت أعلاه بالمقارنة مع ورودها مّرةً واحدة في نهاية سلسلة وعود:

doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => console.log(`Got the final result: ${finalResult}`))
.catch(failureCallback);

تتوقّف سلسلة الوعود عند وجود استثناء لتبحث عوض ذلك أدناه عن معالجات catch. نُمذج هذا الأمر على نحوٍ مماثل للغاية لعمل الشّيفرة البرمجيّة المتزامنة.

try {
  const result = syncDoSomething();
  const newResult = syncDoSomethingElse(result);
  const finalResult = syncDoThirdThing(newResult);
  console.log(`Got the final result: ${finalResult}`);
} catch(error) {
  failureCallback(error);
}

يبلغُ هذا التماثل ذروته عند استخدام التجميل اللّغوي async/await في ECMAScript 2017:

async function foo() {
  try {
    const result = await doSomething();
    const newResult = await doSomethingElse(result);
    const finalResult = await doThirdThing(newResult);
    console.log(`Got the final result: ${finalResult}`);
  } catch(error) {
    failureCallback(error);
  }
}

يعتمد الأمر تمامًا على الوعود، على سبيل المثال doSomething()‎ هي نفسها الدّالّة السّابقة. إذا أردت، يمكنك قراءة المزيد حول هذه الصّياغة.

تُقدّم الوعود حلًّا لخللٍ جوهريٍّ في استدعاء هرم الموت، ذلك بالتقاط (catch) جميعَ الأخطاء حتى الاستثناءات المرميّة منها وأخطاء البرمجة. هذا الأمر في غاية الأهميّة للتركيب الوظيفيّ للعمليات غير المتزامنة.

أحداث رفض الوعود

عند رفض وعدٍ، يُرسَل أحد حدثين اثنين إلى النّطاق العمومي (يكون هذا النّطاق غالباً إما النّافذة window أو عند الاستخدام في عامل وِيب، العامل Worker أو واجهة أخرى مبنيّة على عامل). الحدثان المذكوران أعلاه هما:

rejectionhandled

يُرسل عندما يُرفض الوعد بعد أن يعالج الخطأ من قبل دالّة reject الخاصّة بالمنفّذ.

unhandledrejection

يُرسل عندما يُرفض الوعد ولا يتوفّر هناك معالج رفض.

في كلتي الحالتين، يتوفّر الحدث من النّوع PromiseRejectionEvent على promise كعضوٍ للإشارة إلى الوعد المرفوض وعلى واصفة reason لتبيان السّبب وراء رفض هذا الوعد.

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

للوقوف على إحدى الفوائد الهامّة لما ذكر أعلاه نعرض التّالي: من الشّائع عند كتابة شيفرة برمجية لبيئة Node.js أن تكون الوحدات التي تُضمّنها في مشروعك ذات وعودٍ مرفوضة وغير مدَاوَلة. تُسجّل هذه الوعود في الكونسول من قبل مُشغِّل Node الآني (Node runtime). يمكنك التقاطها في شيفرتك البرمجيّة للتّحليل والمعالجة أو لتفادي بعثرتها في خرج شيفرتك وذلك ومن خلال إضافة معالج للحدث unhandledrejection كما يلي:

window.addEventListener("unhandledrejection", event => {
 /* يُمكنك أن تبدأ هنا بإضافة الشّيفرة البرمجيّة
event.promise لفحص الوعد الذي يُحدّده  
event.reason والسّبب في 
 */
  event.preventDefault();
}, false);

باستدعاء تابع preventDefault()‎ الخاصّة بالحدث، تقوم بتوجيه مُشغل JavaScript الآني (JavaScript runtime) إلى عدم اتّخاذ إجراءه الافتراضي عندما لا تُعالج الوعود المرفوضة. عادةً ما يتضمن الإجراء الافتراضي هذا تسجيل الخطأ في الطرفية وهذه هي الحالة فعلًا في Node.

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

إنشاء الوعد حول واجهة برمجة تطبيقات API ذات ردِّ نداء قديم

يمكن إنشاء وعدٍ من الصّفر باستخدام بانيه. نحتاج هذا الأمر عند تغليف واجهات برمجة تطبيقات قديمة.

تُعيد جميع الدّوال غير المتزامنة وعودًا في الحالة المثلى. لكن لسوء الحظّ، لا تزال بعض واجهات برمجة التطبيقات تنتظر تمرير نتيجة الاستدعاء من نجاح و/أو فشل وفق الطّريقة المتّبعة قديمًا. يتجلّى هذا بأوضح صوره في الدّالة setTimeout()‎.

setTimeout(() => saySomething("10 seconds passed"), 10000);

يتسبب المزج بين ردود النداء ذات النمط القديم والوعود بالكثير من المشاكل. لا يوجد ما يلتقط الخطأ إذا ما فشل saySomething()‎ أو احتوى على خطأ برمجي وتقع المسؤولية في هذا على عاتق setTimeout . لحسن الحظ، يمكننا تغليف setTimeout ضمن الوعد. الممارسة المثلى في هذه الحالة هي تغليف الدوال الإشكالية في أدنى مستوى ممكن ومن ثم عدم استدعائها مباشرة بعد ذلك:

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

wait(10000).then(() => saySomething("10 seconds")).catch(failureCallback);

يأخذ باني الوعد دالّة تنفيذية تسمح لنا بحل أو رفض كائن الوعد يدويًّا. لما كان فشل الدالة setTimeout()‎ مستبعدًا، فقد أهملنا وضع رفضٍ في هذه الحالة.

التركيب

يُعدّ Promise.resolve()‎ و Promise.reject()‎ اختصارين لإنشاء وعدٍ محلولٍ بالفعل أو مرفوضٍ بالفعل على التّرتيب يدويًا. نجد هذا مفيدًا جدًا في بعض الأوقات.

يُعرّف Promise.all()‎ و Promise.race()‎ على أنّهما أداتا تركيب لتنفيذ العمليات غير المتزامنة على التّوازي.

يمكننا بدء العمليات على التوازي وانتظار انتهائها كما يلي:

Promise.all([func1(), func2(), func3()])
.then(([result1, result2, result3]) => { /* use result1, result2 and result3 */ });

يمكن القيام بالتّركيب التًسلسلي بالاستخدام الحذق للغة JavaScript:

[func1, func2, func3].reduce((p, f) => p.then(f), Promise.resolve())
.then(result3 => { /* use result3 */ });

نختزل مصفوفة الدّوال غير المتزامنة إلى سلسلة وعود مكافئة للتّالي:

Promise.resolve().then(func1).then(func2).then(func3);

يمكن تحويل هذا إلى دالة تركيب قابلة لإعادة الاستخدام. هذا الأمر شائع في البرمجة الوظيفيّة.

const applyAsync = (acc,val) => acc.then(val);
const composeAsync = (...funcs) => x => funcs.reduce(applyAsync, Promise.resolve(x));

ستقبل الدّالة composeAsync()‎ أيّ عدد من الدّوال كمتغيّرات وستُعيد دالة جديدة تقبل تمرير القيمة الأوليّة عبر أنبوب (Pipeline).

const transformData = composeAsync(func1, func2, func3);
const result3 = transformData(data);

يمكن القيام بالتّركيب التّسلسلي بسهولةٍ أكبر من خلال async/await في ECMAScript 2017.

let result;
for (const f of [func1, func2, func3]) {
  result = await f(result);
}
/* (result3 استخدم آخر نتيجة (أي */

التوقيت

لا تُستدعى الدّوال الممرّرة إلى ()then على نحو تزامنيّ مطلقاُ حتى بوجود وعدٍ محلول بالفعل وذلك لتفادي النّتائج غير المتوقّعة.

Promise.resolve().then(() => console.log(2));
console.log(1); // 1, 2

تُوضع الدّالة الممرّرة في طابور مهام مُصغّرة microtask queue عوضًا عن تنفيذها مباشرةً، هذا يعني أنّها تُنفّذ لاحقًأ عندما يفرغ الطّابور في نهاية التّنفيذ الحالي لحلقة أحداث JavaScript. لا يستغرق هذا الأمر وقتًا يذكر.

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

wait().then(() => console.log(4));
Promise.resolve().then(() => console.log(2)).then(() => console.log(3));
console.log(1); // 1, 2, 3, 4

التداخل

يُفضّل الإبقاء على سلاسل الوعود البسيطة مسطّحةً بدون تداخل على اعتبار أنّ التّداخل قد يكون نتيجة لتركيب غير متقن. يُمكنك الاطلاع على بعض الأخطاء الشّائعة أدناه.

يُعرّف التّداخل على أنّه بنية تحكميّة للحدّ من نطاق عبارات catch. بكلمات أدقّ، تلتقط catch المتداخلة الأخطاء الواردة في نطاقها وما دونه فقط، ولا تلتقط الأخطاء الواردة في نطاق أعلى في السّلسلة خارج النطاق المتداخل. يمنح التدّاخل عند استثماره على نحو صحيح دقّةً أكبر في التصحيح بعد الخطأ.

doSomethingCritical()
.then(result => doSomethingOptional()
  .then(optionalResult => doSomethingExtraNice(optionalResult))
  .catch(e => {})) // .وتابع doSomethingOptional() تجاهل هذا إذا ما فشل تنفيذ
.then(() => moreCriticalStuff())
.catch(e => console.log("Critical failure: " + e.message));

لاحظ أنّ الخطوات الاختياريّة هنا متداخلة؛ لا علاقة للأمر بالإزاحة إنّما بالتّموضع المُتقلقل لكلٍّ من ) و ( الخارجيّة حولها. تلتقط عبارة catch الدّاخليّة المُحيّدة حالات الفشل من doSomethingOptional()‎ و doSomethingExtraNice()‎ فقط. تُتابع الشّيفرة البرمجيّة بعد ذلك مع moreCriticalStuff()‎. من المهم الانتباه إلى أنّه عند فشل doSomethingCritical()‎ فإن catch الخارجيّة النهائيّة فقط هي من يلتقط الخطأ.

أخطاء شائعة

تجنّب الوقوع في الأخطاء الشائعة عند تركيب سلسلة وعود. يظهر عدد من هذه الأخطاء في المثال التّالي:

// مثال خاطئ! حاول اكتشاف الأخطاء فيه.

doSomething().then(function(result) {
  doSomethingElse(result) // إغفال إعادة الوعد من السلسلة الداخليّة + تداخل غير ضروري
  .then(newResult => doThirdThing(newResult));
}).then(() => doFourthThing());
// Catch إغفال إنهاء السّلسلة باستخدام

الخطأ الأوّل هو عدم سلسلة الأشياء معًا بشكلٍ سليم. يحدث هذا عندما نقوم بإنشاء وعدٍ جديد لكننا ننسى إرجاعه. يترتّب على هذا كسرُ السلسلة، أو بكلمات أدق، يصبح لدينا سلسلتان مستقلّتان تتسابقان في التّنفيذ. هذا يعني أنّ الدّالةdoFourthThing()‎ لن تنتظر انتهاء doSomethingElse()‎ أو doThirdThing()‎ بل ستُنفّذ على التوازي معهما، هذا الأمر غير مرغوب به غالباَ. تتوّفر السلاسل المنفصلة على معالجات أخطاء منفصلة ممّا يؤدّي إلى أخطاء غير مُلتقَطة.

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

يُعتبر النّموذج المضاد للباني (promise constructor anti-pattern) والذي يجمع بين التّداخل والاستخدام الفائض لباني الوعد لتغليف الشّيفرة البرمجيّة التي تستخدم الوعود بالفعل أحد أشكال هذا الخطأ.

الخطأ الثّالث هو إغفال إنهاء السلاسل بواسطة catch. تُؤدي سلاسل الوعود غير المنتهية إلى حالات رفض وعودٍ غير مُلتقَطة في معظم المتصفّحات.

يُستحسَن دومًا كقاعدة عامّة إما الإرجاع أو إنهاء سلاسل الوعود. قُم بإرجاع أيّ وعدٍ جديد حال حصولك عليه. للتوضيح:

doSomething()
.then(function(result) {
  return doSomethingElse(result);
})
.then(newResult => doThirdThing(newResult))
.then(() => doFourthThing())
.catch(error => console.log(error));

لاحظ أنّ ‎() => x يُمثّل اختزالًا للتركيب ‎() => { return x; }‎.

أصبح لدينا الآن سلسلةٌ وحيدة حتميّة ذات معالجة أخطاء سليمة. تُعالج معظم، إن لم تكن جميع، هذه المشاكل باستخدام async/await مع مراعاة تذكّر الكلمة المفتاحية await في هذه الصّياغة.

المصادر