frontend

JavaScript توضيح مرئي لتنفيذ الـ Promise

تم التحديث: ٢٧ مارس ٢٠٢٦

Javascript Visualized Promise Execution

ملخص

يتم تنفيذ وعود (Promises) JavaScript بشكل غير متزامن عبر طابور المهام الدقيقة (microtask queue) — حيث يتم تشغيل كل معالج .then() بعد اكتمال المهمة الحالية ولكن قبل المهمة الكبيرة (macrotask) التالية، مما يعطي الوعود (Promises) أولوية على setTimeout وعمليات الإدخال والإخراج (I/O) الأخرى.

تعد الوعود (Promises) أساسية في JavaScript الحديثة، ومع ذلك فإن نموذج تنفيذها يربك العديد من المطورين. لماذا يتم تشغيل معالج .then() قبل استدعاء setTimeout، حتى لو كانت المهلة 0 مللي ثانية؟ كيف يختلف async/await عن الوعود (Promises) في الكواليس؟ يوضح هذا المنشور آلية تنفيذ الوعود من خلال تصور حلقة الأحداث (event loop)، وطابور المهام الدقيقة (microtask queue)، وطابور المهام الكبيرة (macrotask queue) — وهي الآليات التي تمنح الوعود قوتها وسلوكها.

حلقة الأحداث: المهام الكبيرة والمهام الدقيقة

تعتمد JavaScript على خيط تنفيذ واحد (single-threaded) وتستخدم حلقة أحداث لإدارة العمليات غير المتزامنة. تعالج الحلقة نوعين من العمل:

المهام الكبيرة (Macrotasks - طابور المهام): السكريبت الأولي، setTimeout، setInterval، عمليات الإدخال والإخراج (I/O)، وأحداث واجهة المستخدم (UI events). المهام الدقيقة (Microtasks - طابور المهام الدقيقة): ردود أفعال الوعود (Promise reactions)، و queueMicrotask()، و MutationObserver.

وفقًا لـ مواصفات HTML، تقوم كل دورة من حلقة الأحداث بتشغيل مهمة واحدة، وتفريغ طابور المهام الدقيقة بالكامل، ثم إجراء أي تحديث للعرض (rendering) إذا كانت هناك فرصة لذلك، ثم تعيد الدورة. ينطبق تحديث واجهة المستخدم فقط في المتصفحات — أما Node.js فلا توجد به خطوة عرض.

تصوير مرئي: ترتيب تنفيذ حلقة الأحداث

┌─────────────────────────────────────────────────────┐
│ 1. Run one task                                     │
│    (initial script, setTimeout cb, I/O cb, etc.)    │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 2. Drain the microtask queue                        │
│    (Promise reactions, queueMicrotask)              │
│    Includes microtasks queued by other microtasks.  │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 3. Render (browsers only, if there's an opportunity)│
│    (style, layout, paint — may be skipped)          │
└─────────────────────────────────────────────────────┘
│ [Loop back to step 1 with the next task]

قد يتخطى المتصفح العرض في دورة معينة إذا لم يتغير شيء مرئي، أو إذا قرر دمج الإطارات (frames). ومع ذلك، يتم دائمًا تفريغ طابور المهام الدقيقة حتى يصبح فارغًا قبل أن تستمر الحلقة.

مثال كود: ترتيب التنفيذ

console.log('1. Sync start');

setTimeout(() => console.log('2. setTimeout'), 0);

Promise.resolve()
  .then(() => console.log('3. Promise 1'))
  .then(() => console.log('4. Promise 2'));

queueMicrotask(() => console.log('5. queueMicrotask'));

console.log('6. Sync end');

// Output:
// 1. Sync start
// 6. Sync end
// 3. Promise 1
// 5. queueMicrotask
// 4. Promise 2
// 2. setTimeout

// Why? Synchronous code runs first, then microtasks (Promises, queueMicrotask),
// then macrotasks (setTimeout).

حالات الوعود وانتقالاتها

يبدأ الوعد (Promise) بحالة pending (معلق) وينتقل إما إلى fulfilled (متحقق) أو rejected (مرفوض)، بشكل لا رجعة فيه.

// Creating and resolving a Promise
const promise = new Promise((resolve reject) => {
  console.log('A. Promise constructor runs immediately');

  setTimeout(() => {
    console.log('B. Inside setTimeout');
    resolve('success'); // Transition to fulfilled
  } 0);
});

console.log('C. After Promise declaration');

promise.then(
  result => console.log('D. .then handler:' result)
  error => console.log('Error handler:' error)
);

console.log('E. After .then');

// Output:
// A. Promise constructor runs immediately
// C. After Promise declaration
// E. After .then
// B. Inside setTimeout
// D. .then handler: success

// Explanation:
// 1. Promise constructor (A) runs synchronously
// 2. setTimeout callback is scheduled as macrotask
// 3. .then is registered but doesn't execute immediately
// 4. After sync code (C, E), event loop processes microtasks
// 5. setTimeout executes (B), which resolves the Promise
// 6. Promise resolution queues the .then handler (D) as microtask

تدفق تنفيذ Promise.then()

كل استدعاء لـ .then() يعيد وعدًا (Promise) جديدًا. تصبح القيمة التي يعيدها المعالج هي قيمة التحقق للوعد التالي.

const p = Promise.resolve(1)
  .then(x => {
    console.log('Handler 1:' x);
    return x + 1; // New Promise resolves with 2
  })
  .then(x => {
    console.log('Handler 2:' x);
    return x + 1; // New Promise resolves with 3
  })
  .then(x => {
    console.log('Handler 3:' x);
  });

// Output:
// Handler 1: 1
// Handler 2: 2
// Handler 3: 3

// Visual: Promise Chain
//
// Promise { 1 }
//     ↓ .then(handler1)
// [queue handler1 as microtask]
//     ↓ (after sync code)
// Handler 1: 1 (returns 2)
//     ↓ (new Promise { 2 })
// [queue handler2 as microtask]
//     ↓ (after handler1 completes)
// Handler 2: 2 (returns 3)
//     ↓ (new Promise { 3 })
// [queue handler3 as microtask]
//     ↓ (after handler2 completes)
// Handler 3: 3

Promise.withResolvers() (ES2024)

قبل ES2024، كان إنشاء وعد (Promise) مع إمكانية تسويته خارجيًا يتطلب تخزين مراجع resolve و reject. يبسط Promise.withResolvers() هذه العملية.

// Traditional approach (verbose)
let resolve reject;
const promise = new Promise((res rej) => {
  resolve = res;
  reject = rej;
});

// ES2024 approach (clean)
const { promise resolve reject } = Promise.withResolvers();

// Using it: external control
const { promise: p resolve: r } = Promise.withResolvers();

document.getElementById('button').addEventListener('click' () => {
  r('Button clicked!');
});

const result = await p; // Waits for button click
console.log(result); // 'Button clicked!'

// Under the hood: Promise.withResolvers() is roughly equivalent to
// ┌──────────────────────────────────────────┐
// │ let resolve, reject;                     │
// │ const promise = new Promise((res, rej) =>│
// │   { resolve = res; reject = rej; });     │
// │ return { promise, resolve, reject };     │
// └──────────────────────────────────────────┘
//
// Browser support: all evergreen browsers since early 2024.
// Node.js: v22+ enables it by default (v21.7+ behind a flag).

Async/Await في الكواليس

يعتبر async/await مجرد "سكر برمجي" (syntactic sugar) فوق الوعود (Promises) والمهام الدقيقة. تعيد الدالة async دائمًا وعدًا (Promise)، وكل await توقف الدالة مؤقتًا وتستأنفها كمهمة دقيقة بمجرد تسوية القيمة المنتظرة.

// Async function
async function fetchUser(id) {
  const response = await fetch(`/API/users/${id}`);
  const user = await response.json();
  return user;
}

// Roughly equivalent Promise chain (rejections propagate through .then)
function fetchUserPromise(id) {
  return fetch(`/API/users/${id}`)
    .then(response => response.json());
}
// (The trailing `return user` simply forwards the value, so no extra .then is needed.)

// Execution order: await schedules continuation as a microtask
async function example() {
  console.log('1. Sync before await');

  const result = await Promise.resolve('async');
  console.log('2. After await:' result);

  return result;
}

example().then(() => console.log('3. After function'));

console.log('4. Sync after function call');

// Output:
// 1. Sync before await
// 4. Sync after function call
// 2. After await: async
// 3. After function

يتم تشغيل الجزء المتزامن من دالة async (كل شيء قبل أول await) فورًا عند استدعاء الدالة. عند أول await، تعود السيطرة إلى المستدعي — ويستأنف باقي جسم الدالة لاحقًا كمهمة دقيقة. قام محرك V8 بتحسين هذا في عام 2018 بحيث يكلف انتظار وعد (Promise) تم حله بالفعل تكة واحدة من المهام الدقيقة بدلاً من ثلاث1.

معالجة الأخطاء في سلاسل الوعود

تنتقل الأخطاء عبر سلاسل الوعود، وتعتبر معالجات .catch() أيضًا مهامًا دقيقة.

Promise.resolve()
  .then(() => {
    console.log('Handler 1');
    throw new Error('Oops!');
  })
  .then(() => {
    console.log('Handler 2 (skipped)');
  })
  .catch(error => {
    console.log('Caught:' error.message);
    return 'recovered';
  })
  .then(result => {
    console.log('Handler 3:' result);
  });

// Output:
// Handler 1
// Caught: Oops!
// Handler 3: recovered

// Visualization: Error in Promise Chain
//
// Promise.resolve()
//     ↓
// [Handler 1 queued as microtask]
//     ↓ (execute)
// Handler 1 → throw Error
//     ↓ (state: rejected)
// [skip Handler 2]
//     ↓
// [.catch handler queued as microtask]
//     ↓ (execute)
// Caught: Oops! → return 'recovered'
//     ↓ (new Promise: fulfilled)
// [Handler 3 queued as microtask]
//     ↓ (execute)
// Handler 3: recovered

Promise.allSettled() مقابل Promise.all()

يرفض Promise.all() بمجرد رفض أي وعد مدخل (فشل سريع). أما Promise.allSettled() فينتظر جميع الوعود ويبلغ عن حالة كل تسوية.

function makePromises() {
  return [
    Promise.resolve('a')
    Promise.reject('b')
    Promise.resolve('c')
  ];
}

// Promise.all: fails fast
Promise.all(makePromises()).catch(error => {
  console.log('Failed:' error); // 'b'
});

// Promise.allSettled: waits for all
Promise.allSettled(makePromises()).then(results => {
  console.log('Results:' results);
  // [
  //   { status: 'fulfilled', value: 'a' },
  //   { status: 'rejected', reason: 'b' },
  //   { status: 'fulfilled', value: 'c' }
  // ]
});

يتم تسوية Promise.race() مع أول مدخل يتم تسويته (سواء تحقق أو رفض). أما Promise.any() (ES2021) فهو عكس Promise.all() — حيث يتحقق مع أول قيمة محققة ويرفض فقط إذا رُفضت جميع المدخلات (باستخدام AggregateError).

رفض الوعود غير المعالج

إذا تم رفض وعد (Promise) ولم يتم إرفاق معالج .catch()، فإن JavaScript تطلق حدث خطأ.

// Unhandled rejection
Promise.reject('Oops!');

// Global handler (browser)
window.addEventListener('unhandledrejection' event => {
  console.error('Unhandled rejection:' event.reason);
  event.preventDefault(); // Prevent crash
});

// Global handler (Node.js)
process.on('unhandledRejection' (reason promise) => {
  console.error('Unhandled rejection:' reason);
});

// Attaching handler after delay avoids the error
setTimeout(() => {
  Promise.reject('Later error').catch(e => console.log(e));
} 0);

نمط عملي: إعادة المحاولة مع تراجع أسي

فهم المهام الدقيقة وتنفيذ الوعود (Promises) يتيح بناء أنماط معقدة.

async function retryWithBackoff(fn maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;

      const delay = Math.pow(2) * 100; // 100ms, 200ms, 400ms
      console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms`);

      // Wait using Promise (microtask) or setTimeout (macrotask)
      await new Promise(resolve => setTimeout(resolve delay));
    }
  }
}

// Usage
retryWithBackoff(async () => {
  const response = await fetch('/API/data');
  if (!response.ok) throw new Error('HTTP error');
  return response.json();
}).then(data => console.log('Success:' data));

خاتمة

يتم التحكم في تنفيذ الوعود (Promises) من خلال طابور المهام الدقيقة (microtask queue) الخاص بحلقة الأحداث. يتم تشغيل كل معالج .then() و .catch() و .finally() كمهمة دقيقة، ويتم تفريغ الطابور بالكامل بين المهام — لذا فإن المهمة الدقيقة التي توضع في الطابور خلال المهمة الحالية تعمل دائمًا قبل استدعاء setTimeout التالي. يتم استئناف async/await من خلال نفس آلية المهام الدقيقة، لذا ينطبق نفس الترتيب. أتقن هذا النموذج، وستفهم ظروف السباق (race conditions)، وتصحح مشكلات التوقيت، وتكتب كودًا غير متزامن موثوقًا.

Footnotes

  1. V8 team, "Faster async functions and promises".