كان من المفترض أن تنقذنا صيغة async/await في JavaScript من "جحيم الـ callbacks". ولكن بدلاً من ذلك، قدمت مشكلة أكثر هدوءاً وخبثاً: كود يبدو صحيحاً ولكنه يتصرف بشكل غير متوقع. ترى await مستقرة هناك في جسم الدالة وتفترض أن كل شيء يتوقف بأدب، سطراً بسطر. غالباً لا يحدث ذلك. الحلقات (loops) تندفع للأمام. مجموعات كاملة تنهار بسبب طلب واحد فاشل. ملفات الدخول (entry files) تنبت أغلفة async قبيحة بلا سبب. إذا واجهت أيًا من هذه المشكلات، فإن هذه الأنماط الثلاثة ستصلحها.
توقف عن استخدام await داخل forEach
إليك خطأ شائع يبدو غير ضار للوهلة الأولى:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
قم بتشغيل هذا، وستجد أن 'All done!' تُطبع قبل وصول أي استجابة. لماذا؟ تقوم forEach بتنفيذ الـ callback لكل عنصر فوراً. هي لا تنتظر الـ promise الموجود داخل كل تكرار. كلمة async تحول كل callback إلى promise تتجاهله forEach على الفور. تنتهي الحلقة الخاصة بك في أجزاء من الثانية؛ بينما تذهب طلبات الشبكة في مساراتها الخاصة. إذا كنت بحاجة إلى معالجة الأخطاء بالترتيب، أو إذا كنت بحاجة إلى ضمان انتهاء طلب واحد قبل بدء الطلب التالي، فإن هذا النمط يكسر كلا الضمانين بصمت.
استبدلها بحلقة for...of:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
الآن تتوقف الحلقة فعلياً عند كل await. الطلب الثاني ينتظر الأول. تُطبع 'All done!' فقط بعد استقرار كل شيء.
استخدم for...of عندما يكون الترتيب مهماً — مثل رفع الملفات واحداً تلو الآخر لاحترام حدود المعدل (rate limits)، أو كتابة صفوف قاعدة البيانات بترتيب معين، أو تسلسل استدعاءات API حيث يحتاج الطلب التالي إلى بيانات من الاستجابة السابقة. إذا كنت تريد بالفعل تنفيذاً متوازياً، فلا تعبث بـ forEach. استخدم Promise.all بشكل صريح ليكون قصدك واضحاً للمطور التالي. لكن لا تخلط أبداً بين await و forEach متوقعاً سلوكاً متزامناً (synchronous). لن يحدث ذلك.
استخدم Promise.allSettled عندما لا يكون "الصفر" هو الإجابة
Promise.all صادقة من الناحية الدلالية. أعطها مصفوفة من الـ promises، وستعيد لك مصفوفة من النتائج. المشكلة هي أنه في اللحظة التي يرفض (reject) فيها أي promise واحد، يرفض الأمر برمته فوراً. تُترك جميع الـ promises المعلقة الأخرى لتنتهي بمفردها، لكنك تفقد القدرة على الوصول إلى نتائجها. في بيئة الإنتاج (production)، يكون سلوك "الكل أو لا شيء" هذا مؤلماً.
تخيل أن تطبيقك يجلب أدوات (widgets) لوحة التحكم من أربع خدمات مستقلة: تحليلات حركة المرور، بيانات الإيرادات، ملاحظات المستخدمين، وصحة الخادم. واجهة برمجة تطبيقات (API) الإيرادات تواجه مهلة زمنية (timeout) قصيرة. في ظل استخدام Promise.all ، ستظهر لوحة التحكم بالكامل خطأً. وتختفي الاستجابات الثلاث السليمة في الفراغ. يرى المستخدم أيقونة تحميل (spinner)، ثم شاشة فشل، لأن ربع البيانات فقط لم يعمل بشكل صحيح.
تمنحك Promise.allSettled عقداً أكثر منطقية. فهي تنتظر حتى ينتهي كل promise، بغض النظر عن نتيجته. القيمة المستلمة (resolved value) هي مصفوفة من الكائنات التي تصف كل نتيجة:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
لا يتم التخلص من أي استجابة. يمكنك عرض ما يمكنك عرضه وعزل الفشل. هذا النمط مهم كلما كنت تتعامل مع عمليات غير مرتبطة ببعضها البعض — مثل الإشعارات الجماعية، أو إرسال الـ webhooks من طرف ثالث، أو استيراد السجلات من تدفقات CSV متعددة. لا تزال بحاجة إلى تتبع مركزي للأخطاء، ولكن تطبيقك سيحافظ على استقراره.
ملاحظة عملية واحدة: تعيد allSettled المجموعة الكاملة، لذا لا تزال بحاجة إلى فحص النتائج وتحديد ما يعنيه "النجاح الجزئي" لميزتك. لا تعامل المصفوفة المعادة كبيانات ناجحة بالكامل. تحقق من حقول الحالة (status fields) تلك قبل دفع أي شيء إلى طبقة الحالة (state layer) الخاصة بك.
استخدم top-level await وتخلص من غلاف الـ IIFE
لسنوات، إذا كنت تريد انتظار (await) شيء ما في جذر الملف، كنت تغلفه في دالة async مستدعاة فوراً (IIFE):
(async () => {
const config = await loadConfig();
startServer(config);
})();
هذا يعمل، ولكنه مجرد ضجيج زائد. الـ top-level await المدمج في ES modules يتيح لك التخلص من الأكواد الروتينية (boilerplate):
const config = await loadConfig();
startServer(config);
استخدم هذا في نقطة الدخول (entry point) لتطبيقك أو في وحدات التكوين (configuration modules) المخصصة حيث يجب أن تكتمل عملية التهيئة قبل تشغيل أي شيء آخر. تحميل ملفات البيئة (environment files)، أو إنشاء مجمع اتصالات قاعدة البيانات (database connection pool)، أو جلب أعلام الميزات (feature flags) عن بُعد، كلها حالات مناسبة تماماً. ولأن الـ top-level await يوقف تنفيذ مخطط الوحدة (module graph) — حيث ستنتظر الملفات الأخرى التي تستورد هذا الملف حتى يتم حل الـ promise الخاص بك — فستحصل على حالة مضمونة. يمكن لبقية الكود الخاص بك استيراد db ومعرفة أن الاتصال نشط بالفعل.
هناك عقبتان. أولاً، يجب أن يدعم وقت التشغيل (runtime) أو أداة التجميع (bundler) الخاصة بك وحدات ES (ES modules). في Node.js، يعني هذا إما استخدام امتدادات .mjs أو تعيين "type": "module" في ملف package.json. ثانياً، نظرًا لأن الانتظار على مستوى الوحدة (module level) يؤخر كل مستورد (importer)، اجعل العمل الذي يتم انتظاره مركزاً. عمليات الجلب المتسلسلة الثقيلة في الجزء العلوي من ملف أدوات (utility file) يتم استيراده بشكل متكرر ستؤدي إلى إبطاء عملية التشغيل البارد (cold start) لتطبيقك بالكامل. احتفظ بـ top-level await لمهام التمهيد (bootstrap tasks) الحقيقية التي تعتمد عليها الوحدات الأخرى فعلياً.
ما الذي يتغير فعلياً عند اعتماد هذه الأنماط
القدرة على التنبؤ هي أول مكسب. عندما تقرأ حلقة for...of ، ستعرف بالضبط متى تنتهي الكتلة البرمجية التي تليها. لا توجد وعود (promises) شبحية تتسابق خلف الكواليس، ولا دوال استدعاء (callbacks) في foreach تنفصل عن معالجات الأخطاء الخاصة بك. تدفق التحكم الخاص بك يتطابق مع شكل الكود الظاهر على الشاشة.
تأتي المرونة بعد ذلك. تجبرك Promise.allSettled على التفكير في الفشل الجزئي بدلاً من الأمل في أن يظل كل نظام خارجي مثالياً. البرمجيات في بيئة الإنتاج ليست ثنائية. بعض نقاط النهاية (endpoints) ستتعثر. وبعض عمليات قراءة الملفات ستواجه أخطاء في الأذونات. التصميم لمواجهة واقع الإخفاقات المتفرقة يحافظ على استقرار تطبيقك دون تجاهل البيانات المشروعة.
الوضوح يربط كل شيء معاً. حلقة for...of تُقرأ كتسلسل لغوي بسيط. تعبر allSettled عن غرضها من خلال اسمها. يزيل top-level await أغلفة IIFE الغامضة، بحيث تبدأ ملفات الدخول الخاصة بك بمنطق العمل (business logic) بدلاً من البهلوانيات النحوية. المهندس التالي الذي سيتعامل مع الملف — سواء كنت أنت بعد ستة أشهر أو زميل لك يعمل تحت ضغط المواعيد النهائية — سيشكرك.
خلاصة عملية
لا تعامل async/await كحل شامل تنثره فوق الكود الحالي. قم بمراجعة مشاريعك الحالية بحثاً عن هذه الأنماط المضادة (anti-patterns) الثلاثة المحددة. ابحث عن await داخل كتل forEach واستبدلها بـ for...of أو بـ Promise.all مقصود. راجع كل Promise.all يتواصل مع خدمات خارجية واسأل نفسك ما إذا كان فشل واحد يجب أن يدمر العملية برمتها؛ إذا لم يكن الأمر كذلك، فاستخدم Promise.allSettled وقم بمعالجة النتائج المختلطة. أخيراً، تخلص من الـ async IIFEs من نقاط دخول وحدات ES الخاصة بك، واترك top-level await يتولى تسلسل التمهيد (bootstrap sequence) مباشرة. هذه تغييرات ميكانيكية صغيرة، لكنها معاً تحول السكربتات غير المتزامنة الهشة إلى كود يمكنك الوثوق به حقاً.
