الأخطاء في الكود غير المتزامن لا تتصرّف مثل الأخطاء العادية
في جافا سكريبت المتزامنة، عندما يُرمى خطأ فإنه يصعد في مكدّس الاستدعاءات حتى يلتقطه try/catch ما، أو يتعطّل البرنامج بالكامل. أما الكود غير المتزامن فيكسر هذا النموذج تماماً؛ فبحلول الوقت الذي يفشل فيه طلب الشبكة، تكون الدالة التي أطلقته قد انتهت وأعادت قيمتها بالفعل، ولم يعد هناك أي مكدّس استدعاءات ليصعد إليه الخطأ.
الـ Promises تحلّ هذه المشكلة بمنح الأخطاء قناة خاصة بها. فالـ promise إما أن يُحقَّق (fulfill) بقيمة، أو يُرفَض (reject) لسبب ما. والرفض هنا هو المكافئ غير المتزامن لعملية الرمي (throw). كل ما سنتناوله في هذه الصفحة يدور حول فكرة واحدة: كيف تضمن أن تصل حالات الرفض إلى مكان تتحكّم فيه أنت، بدلاً من أن تضيع في الهواء.
تنتهي كتلة try/catch وتخرج بسلام، ثم يقع الرفض بعد 50 مللي ثانية، أي بعد أن تكون كتلة try قد انتهت منذ وقت طويل. لا شيء يلتقط هذا الخطأ. هذا هو الفخ بعينه.
عودة try/catch إلى العمل مع await
بمجرد أن تضع await أمام الوعد، يتحوّل الرفض إلى خطأ مرميّ داخل الدالة غير المتزامنة (async function). عندها تلتقطه كتلة try/catch المحيطة تمامًا كما تلتقط أي throw متزامن:
هذا هو النمط الذي يجب أن تلجأ إليه أولاً. فكلمة await هي الجسر الذي يعيدك من عالم الـ async إلى صيغة try/catch المألوفة. ضع استدعاءات await التي قد تفشل داخل try، ثم تعامل مع الأخطاء في catch.
لكن انتبه إلى تفصيلة مهمة: الحماية تشمل فقط الاستدعاء الذي تنتظره بـ await. أما إذا أطلقت promise دون انتظاره، فإن الأخطاء تفلت من بين يديك.
الخطأ الأشهر: نسيان await
حين تستدعي دالة async بدون await (ودون إعادة الـ promise الخاص بها)، فإن الـ rejections تتسرّب وتتجاوز try/catch المحيط:
ينتهي تنفيذ بلوك try بنجاح، ثم يحدث الـ rejection في التِك التالي دون وجود أي شيء يلتقطه. النتيجة؟ ستظهر لك رسالة تحذير "unhandled promise rejection" في الـ console.
الحل بسيط وثابت دائمًا: إمّا أن تستخدم await مع الاستدعاء، أو أن تُرجِع الـ promise عبر return ليتمكّن المستدعي من انتظاره بـ await.
.catch() هو الوجه الآخر للعملة نفسها
تقدر تتعامل مع حالات الرفض (rejections) بدون الحاجة إلى async/await عن طريق ربط .catch() بالسلسلة:
.catch(fn) هي اختصار لـ .then(undefined, fn)، وتلتقط أي رفض (rejection) حصل في أي مرحلة سابقة من السلسلة. وضع .catch() في نهاية السلسلة يُعتبر المكافئ غير المتزامن لـ try/catch على المستوى الأعلى — أي خط الدفاع الأخير قبل أن يتحوّل الرفض إلى "unhandled".
لا مشكلة في المزج بين الأسلوبين. من الأنماط الشائعة استخدام async/await داخل الدالة، وترك المستدعي يُرفق .catch() بنفسه:
دالة fetch لا ترفض الـ Promise عند أخطاء HTTP
هذي النقطة توقع الجميع مرة على الأقل. دالة fetch ما ترفض الـ Promise إلا عند أخطاء الشبكة على المستوى المنخفض — فشل الـ DNS، أو رفض الاتصال، أو إلغاء الطلب. أما الاستجابة بحالة 404 أو 500 فتُعتبر طلب fetch ناجح؛ الـ Promise يتم حلّه (resolve) بشكل طبيعي، لكن بكائن استجابة قيمة ok فيه false.
إذا كنت تريد أن تُلتقط أخطاء HTTP داخل كتلة catch، فتحقق من res.ok وارمِ الخطأ صراحةً باستخدام throw:
ده كود متكرر يستاهل تستخرجه في دالة مساعدة أول ما تلاقي نفسك بتكتبه مرتين.
الفرق بين Promise.all و Promise.allSettled
Promise.all بياخد مصفوفة من الـ promises ويرجّع مصفوفة بالنتائج — إلا لو واحد منهم رفض (rejected)، وساعتها بيرفض على طول بنفس الخطأ. باقي الـ promises بتفضل شغالة، لكن نتايجها بترمى في الزبالة.
الأسلوب fail-fast هو التصرف الصحيح حين تحتاج إلى كل النتائج، ويكون فشل واحد فقط كافياً لإلغاء معنى العملية بأكملها. أما إذا أردت معرفة مآل كل عملية على حدة — من نوع "جرّب هذه الرفعات الخمس، وأخبرني أيها نجح وأيها فشل" — فاللجوء إلى Promise.allSettled هو الخيار الأمثل:
allSettled لا تُرفَض أبدًا. كل عنصر في النتيجة يكون إمّا {status: "fulfilled", value} أو {status: "rejected", reason}.
إعادة رمي الأخطاء والتقاطها بشكل مُحدَّد
ليست كل الأخطاء تستحق نفس المعالجة. من الأنماط الشائعة أن تلتقط الخطأ، تفحصه، ثم تُعيد رميه إذا لم يكن من النوع الذي تتوقّعه:
ابتلاع كل الأخطاء بكتابة catch (err) {} فارغة يُخفي أخطاء حقيقية في الكود. التقط فقط ما تستطيع التعامل معه فعلاً بشكل مفيد، وأعد رمي الباقي.
التعامل مع unhandledrejection كشبكة أمان أخيرة
مهما كان الكود مُتقناً، لا بد أن يفلت خطأ ما في النهاية. لحسن الحظ، توفّر كلٌّ من Node.js والمتصفحات خطّافاً عامّاً (global hook) لالتقاط أي Promise مرفوض لم يلتقطه أحد:
// المتصفح
window.addEventListener("unhandledrejection", event => {
console.error("غير مُعالَج:", event.reason);
event.preventDefault(); // منع تحذير وحدة التحكم الافتراضي
});
// Node.js
process.on("unhandledRejection", reason => {
console.error("غير مُعالَج:", reason);
});
هذا ليس بديلاً عن المعالجة السليمة — إنما هو مجرد خط دفاع أخير للتسجيل أو لربطه بأدوات المراقبة (telemetry). في إصدارات Node.js الحديثة، أي rejection غير معالج يتسبب افتراضياً في انهيار العملية (process)، وهذا في الغالب ما تريده فعلاً في بيئة الإنتاج. سجّل الخطأ، ثم دع العملية تنهار وتُعاد تشغيلها من جديد بحالة نظيفة.
قائمة تحقق عملية
كلما كتبت دالة async تتعامل مع أي شيء قابل للفشل، اسأل نفسك:
- هل كل
awaitمحفوف بالمخاطر موضوع داخلtry/catch، أم أن الـ promise الراجع يعالجه المستدعي بواسطة.catch()؟ - هل أنا فعلاً أستعمل
awaitمع الاستدعاء، أم أنني أطلقته ونسيته (fire-and-forget) عن طريق الخطأ؟ - بالنسبة لـ
fetchتحديداً، هل أتحقق منres.okقبل الوثوق بالاستجابة؟ - عند تشغيل العمليات بالتوازي، هل
Promise.allهو الخيار المناسب، أم أن الأنسب هوPromise.allSettled؟ - هل يوجد
.catch()على المستوى الأعلى أو مُعالجunhandledrejectionحتى لا تختفي الأخطاء في صمت؟
اضبط هذه النقاط الخمس، وسيتوقف الكود غير المتزامن عن مفاجأتك بأخطاء تبتلعها حلقة الأحداث (event loop).
التالي: وحدات ES Modules
بهذا نكون قد أكملنا فصل البرمجة غير المتزامنة مع معالجة الأخطاء. في الجزء القادم، ننتقل إلى كيفية تنظيم كود JavaScript عبر الملفات — import وexport، ونظام الوحدات الذي يقوم عليه كل مشروع حديث.
الأسئلة الشائعة
كيف نتعامل مع الأخطاء داخل دالة async؟
ضع استدعاءات await داخل كتلة try/catch. أي رفض (rejection) لوعد يتم انتظاره بـ await يتحول إلى خطأ مرمي (thrown) يلتقطه catch. بدلًا من ذلك يمكنك ترك الخطأ ينتشر والتعامل معه من مكان الاستدعاء عبر .catch() على الـ promise المُرجَع.
ليش الـ try/catch ما يلتقط الخطأ عندي؟
في الغالب لأن الخطأ يحدث في كود لم يتم انتظاره. إذا استدعيت دالة async بدون await (أو بدون إرجاع الـ promise الخاص بها)، فإن أي rejection سيهرب من كتلة try/catch المحيطة. لازم دائمًا تستخدم await أو return للـ promise الذي تريد التقاط أخطائه.
ماذا يحدث لو رُفض promise ولم يلتقطه أحد؟
تحصل على unhandled rejection. في Node.js يُطلَق حدث unhandledRejection، وفي الإصدارات الحديثة يتعطل البرنامج افتراضيًا. أما في المتصفحات فيُطلَق window.onunhandledrejection ويُسجَّل تحذير في الـ console. في الحالتين، الحل هو إضافة .catch() أو التعامل مع الخطأ داخل try/catch حول await.
كيف تتعامل Promise.all مع الأخطاء؟
دالة Promise.all تُرفَض فور رفض أي promise من المدخلات، وبقية الـ promises تستمر في التنفيذ لكن نتائجها تُهمَل. إذا كنت تريد كل النتائج بغض النظر عن الفشل، استخدم Promise.allSettled — فهي تُرجع مصفوفة من الكائنات بالشكل {status, value} أو {status, reason}.