كن مطوّر واجهات أمامية محترفا
7 دقيقة قراءة

وعود JavaScript: ترويض الشيفرة غير المتزامنة

دليل عملي كامل للوعود (Promises) — ما هي وحالاتها الثلاث، وإنشاؤها واستهلاكها، والتسلسل بـ then/catch/finally، والمجمّعات (all وallSettled وrace وany)، وانتشار الأخطاء، وتوقيت المهام الدقيقة، والأخطاء الشائعة — مع تمارين عملية وحلولها.

الوعد (Promise) هو جواب JavaScript عن "هذه القيمة لا توجد بعد، لكنها ستوجد". قبل الوعود، كانت الشيفرة غير المتزامنة تعني ردود نداء متداخلة تنجرف خارج الحافّة اليمنى للشاشة — "جحيم ردود النداء" الشهير. الوعود تسطّح ذلك إلى سلسلة مقروءة وتمنح الأخطاء مكانًا واحدًا تحطّ فيه. وهي أيضًا الأساس الذي بُني عليه async/await، ففهمها جيّدًا يجعل كل ما يليه واضحًا. (هذا يبني على حلقة الأحداث في أساسيات JavaScript.)

الوعد كائن يمثّل قيمة مستقبلية. يكون في واحدة بالضبط من ثلاث حالات — معلّق (pending)، أو مُنجَز (fulfilled)، أو مرفوض (rejected) — وحالما يستقرّ (مُنجَز أو مرفوض)، يتجمّد هناك للأبد. وكل ما عدا ذلك هو التفاعل مع ذلك الاستقرار.

الحالات الثلاث

يبدأ الوعد معلّقًا. ثم يستقرّ مرّة واحدة بالضبط، في إحدى حالتين نهائيتين:

  • مُنجَز — نجحت العملية، والوعد يحمل قيمة.
  • مرفوض — فشلت العملية، والوعد يحمل سببًا (خطأ).

حالما يستقرّ لا يتغيّر مجدّدًا أبدًا — وعد مُنجَز لا يستطيع الرفض لاحقًا. هذه الثباتية هي ما يجعل الوعود متوقّعة.

const p = fetch("/api/data");  // معلّق الآن...
// ...لاحقًا يستقرّ: مُنجَز بـ Response، أو مرفوض بخطأ

استهلاك الوعد

تتفاعل مع وعد مستقرّ بثلاثة توابع:

fetch("/api/user")
  .then(response => response.json())   // يعمل عند الإنجاز، يتلقّى القيمة
  .then(user => console.log(user))     // كل then يمرّر نتيجته للتالي
  .catch(error => console.error(error)) // يعمل عند أي رفض أعلاه
  .finally(() => hideSpinner());        // يعمل دائمًا، استقرّ بأي حال
  • .then(onFulfilled) — يعمل حين يُنجَز الوعد، متلقّيًا قيمته. وأيًّا كان ما يُرجِعه يصير قيمة الحلقة التالية.
  • .catch(onRejected) — يعمل حين يُرفَض أي وعد سابق في السلسلة.
  • .finally(fn) — يعمل بصرف النظر عن النتيجة؛ مثالي للتنظيف كإخفاء مؤشّر تحميل.

التسلسل: الهدف كلّه

السحر أن .then تُرجِع وعدًا جديدًا، فتتسلسل الاستدعاءات إلى تتابع مسطّح بدل التداخل. وإن أرجع .then وعدًا، فإن السلسلة تنتظره قبل المتابعة:

fetch("/api/user/1")
  .then(res => res.json())
  .then(user => fetch(`/api/posts?author=${user.id}`)) // يُرجِع وعدًا...
  .then(res => res.json())                              // ...السلسلة تنتظره
  .then(posts => console.log(posts))
  .catch(err => console.error("فشل شيء ما:", err));

قارِن ذلك بنسخة ردود النداء المتداخلة لنفس المنطق، التي ستزحف عدّة مسافات بادئة إلى اليمين. السلسلة تُقرأ من الأعلى للأسفل، و**.catch واحد في النهاية يعالج فشلًا من أي خطوة** — تنتشر الأخطاء نزولًا في السلسلة حتى يلتقطها شيء.

إنشاء وعد

غالبًا تستهلك الوعود من واجهات مثل fetch. وحين تحتاج لتغليف شيء قائم على ردود النداء (مؤقّت، واجهة قديمة)، استخدم الباني — يسلّمك resolve وreject:

function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);   // أنجِز بعد ms مللي ثانية
  });
}

await delay(1000);  // توقّف ثانية

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);          // أنجِز بالصورة
    img.onerror = () => reject(new Error(`فشل تحميل ${src}`)); // ارفض
    img.src = src;
  });
}

استدعِ resolve(value) للإنجاز، وreject(reason) للفشل. واختصاران جاهزان موجودان للقيم المعروفة سلفًا:

Promise.resolve(42);              // وعد مُنجَز سلفًا
Promise.reject(new Error("لا")); // وعد مرفوض سلفًا

المجمّعات

حين تملك عدّة وعود، أربعة توابع ساكنة تجمعها — ومعرفة أيّها تستخدم علامة على الإتقان:

// انتظر إنجاز الكلّ؛ ارفض حالما يُرفَض أيّ واحد.
const [a, b, c] = await Promise.all([fetchA(), fetchB(), fetchC()]);

// انتظر استقرار الكلّ؛ لا يرفض أبدًا — تفحص كل نتيجة.
const results = await Promise.allSettled([fetchA(), fetchB()]);
// → [{status:"fulfilled", value}, {status:"rejected", reason}]

// استقرّ حالما يستقرّ الأول (إنجازًا أو رفضًا).
const fastest = await Promise.race([fetchData(), timeout(5000)]);

// أنجِز بأول نجاح؛ ارفض فقط إن رُفِض الكلّ.
const firstOk = await Promise.any([mirror1(), mirror2(), mirror3()]);
  • Promise.all — "أحتاجها كلها." يفشل بسرعة إن فشل أيّ منها. الخيار للجلب المتوازي الذي تعتمد عليه كلّه.
  • Promise.allSettled — "شغّل الكلّ، وأخبرني كيف سار كلٌّ." لا شيء يجعله يرفض؛ مثالي حين يكون الفشل الجزئي مقبولًا.
  • Promise.race — أول مستقرّ يفوز. استخدام كلاسيكي: سباق طلب مع timeout لفرض مهلة.
  • Promise.any — أول نجاح يفوز. جيّد لتجربة مصادر متكرّرة.

التشغيل بالتوازي مقابل التتابع

نقطة أداء دقيقة لكنها مهمّة: الوعود متلهّفة — يبدأ العمل لحظة إنشائها. فـكيف تنتظرها يقرّر إن كانت تعمل معًا أم واحدة تلو الأخرى:

// ❌ تتابعي — B لا يبدأ حتى تنتهي A
const a = await getA();
const b = await getB();   // الوقت الكلّي = A + B

// ✅ متوازٍ — كلاهما يبدأ فورًا، ثم تنتظر كليهما
const [a2, b2] = await Promise.all([getA(), getB()]); // الوقت الكلّي = max(A, B)

إن لم تعتمد العمليتان إحداهما على الأخرى، فقد يقلّل Promise.all الانتظار إلى النصف تقريبًا.

معالجة الأخطاء وانتشارها

الرفض يتخطّى كل .then حتى يصطدم بـ .catch. والخطأ المرميّ داخل .then يصير أيضًا رفضًا — فتتجمّع الإخفاقات المتزامنة وغير المتزامنة في المكان نفسه:

fetch("/api/data")
  .then(res => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // يصير رفضًا
    return res.json();
  })
  .then(data => process(data))
  .catch(err => {
    // يلتقط خطأ HTTP، أو فشل تحليل JSON، أو رميًا في process()
    console.error(err);
  });

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

توقيت المهام الدقيقة

ردود نداء الوعود تعمل كـ مهام دقيقة (microtasks) — لها أولوية على setTimeout (مهمّة كبيرة) وتعمل بعد انتهاء الشيفرة المتزامنة الحالية:

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// الخرج: 1, 4, 3, 2  — الوعد (3) يقفز أمام المؤقّت (2)

لهذا لا يعمل .then أبدًا "في منتصف" شيفرتك المتزامنة، ولهذا يُجدوَل عمل الوعود باستمرار أمام المؤقّتات.

الأخطاء الشائعة

  • نسيان الإرجاع داخل .then — تتلقّى الحلقة التالية undefined وتنكسر السلسلة بصمت.
  • عدم إرجاع/انتظار وعد متداخل، فلا تنتظره السلسلة ويختلّ الترتيب.
  • حذف .catch — رفض غير مُعالَج يحذّر في الطرفية وقد يُعطّل Node.
  • await داخل حلقة لعمل مستقلّ بدل Promise.all — بطء تتابعي بلا داعٍ.
  • استخدام Promise.all حين لا ينبغي لفشل واحد أن يقتل الدُّفعة — الجأ إلى allSettled.
  • خلط ردود النداء والوعود في الباني واستدعاء resolve أكثر من مرّة (الأول فقط يفوز، بصمت).
  • افتراض أن .then يعمل متزامنًا — إنه دائمًا مهمّة دقيقة، لا فوريًّا.

تمارين

جرّب كلًّا منها قبل فتح الحل.

تمرين 1 — تنبّأ بالترتيب

console.log("A");
Promise.resolve().then(() => console.log("B"));
console.log("C");
إظهار الحل

A, C, B. الأسطر المتزامنة (A، C) تعمل أولًا؛ وردّ نداء .then (B) مهمّة دقيقة تعمل فقط بعد اكتمال الشيفرة المتزامنة الحالية.

تمرين 2 — حوّل مؤقّتًا إلى وعد

اكتب delay(ms) يُرجِع وعدًا يُنجَز بعد ms مللي ثانية، ثم استخدمه.

إظهار الحل
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

delay(500).then(() => console.log("بعد نصف ثانية"));

setTimeout يستدعي resolve بعد التأخير؛ لا يُمرَّر شيء، فيُنجَز الوعد بـ undefined — وهذا جيّد حين يهمّك التوقيت فقط.

تمرين 3 — جلب متوازٍ

تحتاج مستخدمًا وإعداداته من نقطتين مستقلّتين. اجلب كليهما بأسرع ما يمكن.

إظهار الحل
const [user, settings] = await Promise.all([
  fetch("/api/user").then(r => r.json()),
  fetch("/api/settings").then(r => r.json()),
]);

كلا الطلبين يبدأ فورًا؛ وPromise.all ينتظر كليهما ويُحَلّ إلى مصفوفة نتائج. الوقت الكلّي هو الأبطأ منهما، لا مجموعهما.

تمرين 4 — امنح مهلة لطلب بطيء

ارفض إن استغرق fetchData() أطول من 3 ثوانٍ.

إظهار الحل
const timeout = (ms) =>
  new Promise((_, reject) => setTimeout(() => reject(new Error("انتهت المهلة")), ms));

const data = await Promise.race([fetchData(), timeout(3000)]);

Promise.race يستقرّ بأيّما ينتهي أولًا. إن كان fetchData أبطأ من المهلة، يفوز رفض المهلة وتحصل على خطأ واضح.

تمرين 5 — انجُ من فشل جزئي

اجلب ثلاث نقاط وسجّل بيانات الناجحة منها، متجاهلًا الفاشلة.

إظهار الحل
const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
const ok = results.filter(r => r.status === "fulfilled").map(r => r.value);
console.log(ok);

allSettled لا يرفض أبدًا، فلا تُغرِق نقطة فاشلة الأخريات — ترشّح لـ "fulfilled" وتُبقي القيم التي وصلت.

النموذج الذهني الذي تحتفظ به

الوعد صندوق لقطة واحدة لقيمة مستقبلية: معلّق حتى يستقرّ، ثم مُنجَز دائمًا (قيمة) أو مرفوض (خطأ). استهلكه بـ .then/.catch/.finally، وتذكّر أن .then تُرجِع وعدًا جديدًا، فإرجاع قيمة أو وعد آخر يتسلسل — تتابع مسطّح بـ .catch واحد في النهاية يلتقط أي فشل. اجمع المتعدّد بالأداة الصحيحة: all (تحتاج كلًّا)، allSettled (تتحمّل الفشل)، race (أول مستقرّ، كالمهل)، any (أول نجاح). ولأن الوعود متلهّفة وردود نداءها تعمل كـ مهام دقيقة، ابدأ العمل المستقلّ معًا وانتظر بـ Promise.all للتوازي. وحالما يرسخ هذا، يصير async/await مجرّد صياغة أجمل فوق هذه الآليات بالضبط.