EduFlow
أساسيات JavaScript
⁦0%⁩

الوحدة 4 · JavaScript في المتصفّح

JavaScript غير المتزامنة: الـ Promises وasync/await وfetch

22 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

تشرح لماذا تنتظر JavaScript دون أن تتجمّد، وتقرأ وتكتب كودًا مبنيًا على الـ promises باستخدام then وasync/await، وتستدعي fetch مع معالجة سليمة للأخطاء، وتتوقّع ترتيب المخرجات اعتمادًا على حلقة الأحداث (event loop).

  1. 1الـ Callbacks: “اتصل بي حين تنتهي”
  2. 2الـ Promises: قيمة تصل لاحقًا
  3. 3async وawait: promises تُقرأ مثل الكود العادي
  4. 4fetch الحقيقية
  5. 5حلقة الأحداث

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

سيكون Pocket أنفع لو استطاع أن يعرض إنفاقك باليورو أيضًا، وهذا يعني أن يطلب من خادم أسعار صرف اليوم. قد يستغرق هذا الطلب 50 ميلي ثانية أو خمس ثوانٍ. ولو توقّفت JavaScript ببساطة وانتظرت، لتجمّدت الصفحة كلها: لا تمرير، ولا كتابة، ولا نقرات. ولأن كودك يتشارك خيطًا (thread) واحدًا مع الصفحة نفسها، فهو يحتاج إلى طريقة يبدأ بها العمل البطيء، ويواصل، ثم يلتقط النتيجة لاحقًا. وهذا هو الكود غير المتزامن (asynchronous).

الـ Callbacks: “اتصل بي حين تنتهي”

أقدم أشكال “لاحقًا” هو الـ callback. تطلب setTimeout من المتصفّح أن يستدعي دالة بعد مهلة:

JavaScript
console.log("Requesting rates…");
setTimeout(() => {
  console.log("Rates arrived");
}, 100);
console.log("Still responsive");

يُطبع “Still responsive” قبل “Rates arrived”. setTimeout لا توقف شيئًا؛ إنها تسلّم الدالة إلى المتصفّح وتعود فورًا. الـ callbacks تعمل، لكن حين تحتاج الخطوة الثانية إلى نتيجة الأولى، والثالثة إلى نتيجة الثانية، فإنها تتداخل أعمق فأعمق، ويجب تكرار معالجة الأخطاء في كل مستوى. وقد أُضيفت الـ promises لإصلاح ذلك.

الـ Promises: قيمة تصل لاحقًا

الـ promise (الوعد) كائن يمثّل نتيجة ليست جاهزة بعد. يبدأ معلّقًا (pending)، ويستقرّ مرة واحدة بالضبط: إمّا مُنجَزًا (fulfilled) بقيمة، أو مرفوضًا (rejected) بخطأ. وتُلحق ما يجب أن يحدث بعد ذلك باستخدام .then (عند النجاح) و.catch (عند الفشل).

البيئة المعزولة لهذه الدورة لا شبكة فيها، لذلك تستخدم الأمثلة fakeFetch، وهي بديل يتصرّف مثل fetch الحقيقية: تُرجع promise لكائن استجابة فيه ok وstatus ودالة json().

JavaScript
function fakeFetch(url) {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (url === "/api/rates") {
        resolve({ ok: true, status: 200, json: async () => ({ EUR: 0.86, GBP: 0.75 }) });
      } else {
        resolve({ ok: false, status: 404, json: async () => ({ error: "Not found" }) });
      }
    }, 50);
  });
}

fakeFetch("/api/rates")
  .then((response) => response.json())
  .then((rates) => console.log("EUR rate:", rates.EUR))
  .catch((error) => console.log("Failed:", error.message));

كل .then تُرجع promise جديدًا، لذلك تتسلسل الخطوات في خط مسطّح بدلًا من أن تتداخل، و.catch واحدة في النهاية تتعامل مع الفشل في أي خطوة. نادرًا ما ستُنشئ promises بنفسك باستخدام new Promise؛ فالمكتبات وواجهات المتصفّح البرمجية تسلّمها إليك، ومهمتك أن تستهلكها.

async وawait: promises تُقرأ مثل الكود العادي

async/await طريقة أوضح لكتابة الشيء نفسه. داخل دالة موسومة بـ async، توقف await promise تلك الدالة مؤقتًا حتى يستقرّ الـ promise، ثم تعطيك قيمته. وتصبح الأخطاء استثناءات عادية تلتقطها بـ try/catch:

JavaScript
function fakeFetch(url) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const ok = url === "/api/rates";
      const body = ok ? { EUR: 0.86 } : { error: "Not found" };
      resolve({ ok, status: ok ? 200 : 404, json: async () => body });
    }, 50);
  });
}

async function getJson(url) {
  const response = await fakeFetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} for ${url}`);
  }
  return response.json();
}

async function showRates() {
  try {
    const rates = await getJson("/api/rates");
    console.log("EUR:", rates.EUR);
    await getJson("/api/missing");
  } catch (error) {
    console.log("Could not load:", error.message);
  }
}

showRates();

لاحظ ما لا تفعله await: إنها لا تجمّد الصفحة. فبينما تنتظر showRates، يواصل المتصفّح التعامل مع النقرات ورسم الإطارات. هذه الدالة وحدها هي المتوقّفة مؤقتًا.

الدالة async تُرجع دائمًا promise، حتى حين تُرجع بـ return قيمة عادية. لذلك يجب على من يستدعيها أن يستخدم await أيضًا، أو .then.

fetch الحقيقية

في صفحة حقيقية، تستبدل fakeFetch بـ fetch الخاصة بالمتصفّح، ويبقى شكل الكود كما هو:

rates.js
async function getJson(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} for ${url}`);
  }
  return response.json();
}

فحص response.ok ضروري، وكثير من الشروحات تُغفله. fetch لا تُرفض إلا حين يتعذّر حدوث الطلب أصلًا، مثلًا حين يكون المستخدم غير متّصل. أما الـ 404 أو الـ 500 فاستجابة HTTP سليمة تمامًا، لذلك يُنجَز الـ promise، وok تساوي false. ودون هذا الفحص، سيحاول كودك قراءة أسعار الصرف من صفحة خطأ.

وحين لا يعتمد طلبان أحدهما على الآخر، ابدأهما معًا وانتظر مرة واحدة باستخدام Promise.all (داخل دالة async، كأي await)، التي تُنجَز بمصفوفة من النتائج، أو تُرفض بمجرّد أن يفشل أيّ منهما:

rates.js
const [rates, categories] = await Promise.all([
  getJson("/api/rates"),
  getJson("/api/categories"),
]);

حلقة الأحداث

كيف يستطيع خيط واحد فعل كل هذا؟ ينفّذ المحرّك كودك على مكدّس الاستدعاءات (call stack)، دالة واحدة في كل مرة. أما الأشياء البطيئة، مثل المؤقّتات وطلبات الشبكة، فيتولّاها المتصفّح خارج المكدّس. وحين ينتهي أحدها، يوضع الـ callback الخاص به في طابور. وكلما فرغ المكدّس، تأخذ حلقة الأحداث (event loop) الـ callback التالي من طابور وتنفّذه.

يُنفَّذ كودك على مكدّس الاستدعاءات. يتولّى المتصفّح المؤقّتات وfetch، ويضع الـ callbacks المنتهية في طوابير. callbacks الـ promises تذهب إلى طابور المهام الدقيقة، والمؤقّتات والأحداث إلى طابور المهام. وحين يفرغ المكدّس، تنفّذ حلقة الأحداث أولًا كل المهام الدقيقة، ثم مهمة واحدة. مكدّس الاستدعاءات showRates() main script المتصفّح المؤقّتات، fetch المهام الدقيقة callbacks الـ promises المهام المؤقّتات، النقرات 1. كل المهام الدقيقة 2. ثم مهمة واحدة
حلقة الأحداث تنفّذ الـ callbacks المنتظرة حين يفرغ المكدّس

هناك طابوران، والترتيب بينهما يفسّر ألغازًا كثيرة. callbacks الـ promises تذهب إلى طابور المهام الدقيقة (microtask queue)؛ والمؤقّتات والأحداث تذهب إلى طابور المهام (task queue). وفي كل مرة يفرغ فيها المكدّس، تنفّذ الحلقة أولًا كل مهمة دقيقة منتظرة، ثم مهمة واحدة، ثم تفحص المهام الدقيقة من جديد:

JavaScript
console.log("1: script");
setTimeout(() => console.log("4: timeout task"), 0);
Promise.resolve().then(() => console.log("3: promise microtask"));
console.log("2: script");

مهلة 0 ميلي ثانية لا تعني “الآن”؛ بل تعني “على شكل مهمة، بمجرّد أن يفرغ المكدّس وتنتهي المهام الدقيقة”. والنموذج نفسه يفسّر لماذا تجمّد حلقة تكرار طويلة الصفحة: فما دام كودك يشغل المكدّس، لا يستطيع أي معالج نقرات أو مؤقّت أو إعادة رسم أن يُنفَّذ.

اكتمل القسم 4: يعيش Pocket في صفحة، ويتفاعل مع النقرات، ويستقبل المدخلات، ويحفظ البيانات، ويستطيع التحدّث إلى خادم. ويحوّل القسم 5 هذه الكومة المتنامية من الكود إلى برنامج منظّم جيدًا.

أهم النقاط

  • تنفّذ JavaScript شيئًا واحدًا في كل مرة؛ أما العمل البطيء مثل طلبات الشبكة والمؤقّتات فيجري خارج كودك، ثم يُستدعى كودك راجعًا حين ينتهي.
  • الـ promise كائن يمثّل قيمة ستصل لاحقًا؛ وينتهي إمّا مُنجَزًا (fulfilled) بقيمة أو مرفوضًا (rejected) بخطأ.
  • await توقف مؤقتًا الدالة async المحيطة بها فقط حتى يستقرّ الـ promise؛ ضع الـ awaits داخل try/catch للتعامل مع حالات الرفض.
  • fetch لا تُرفض إلا حين تفشل الشبكة؛ أما مع أخطاء HTTP مثل 404 فتُنجَز وresponse.ok تساوي false، لذلك افحصها دائمًا.
  • بعد كل قطعة كود متزامن، تنفّذ حلقة الأحداث كل callbacks الـ promises المنتظرة قبل المؤقّت أو الحدث التالي.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

بأي ترتيب تُطبع الأسطر؟

JavaScript
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
السؤال 2 من 4

const data = getRates(); console.log(data.EUR); حيث getRates دالة async. ماذا يُطبع؟

السؤال 3 من 4

يُرجع خادمك 404 Not Found للطلب fetch("/api/rates"). ماذا يفعل الـ promise الخاص بـ fetch؟

السؤال 4 من 4

تحتاج إلى أسعار الصرف وقائمة الفئات، من رابطين مستقلّين، قبل العرض. أيّها الأسرع؟

تدريب

بيئة تجريب الكود

حمّل البيانات بشكل غير متزامن

البيئة المعزولة لا شبكة فيها، لذلك يعطيك كود البداية fakeFetch(url)، التي تتصرّف مثل fetch: تُرجع promise لاستجابة فيها ok وstatus ودالة json() غير متزامنة. اكتب أربع دوال async:

  • getJson(url) تُرجع جسم الاستجابة بعد تحليله، وترمي Error تتضمّن رسالته رمز الحالة (مثل 404) حين تكون response.ok تساوي false.
  • loadCategoryNames() تُرجع ["food", "travel", "fun"]، أي الخاصية name لكل عنصر من /api/categories.
  • toEuroCents(cents) تُرجع المبلغ محوّلًا بسعر EUR من /api/rates، مقرّبًا إلى سنتات صحيحة.
  • safeGetJson(url, fallback) تُرجع الـ JSON من url، أو fallback إن فشل التحميل.
// A stand-in for fetch: the sandbox has no network. It returns a promise
// of a response with ok, status and json(), after a short delay.
function fakeFetch(url) {
  const routes = {
    "/api/rates": { EUR: 0.86, GBP: 0.75 },
    "/api/categories": [{ name: "food" }, { name: "travel" }, { name: "fun" }],
  };
  return new Promise((resolve) => {
    setTimeout(() => {
      const ok = url in routes;
      const body = ok ? routes[url] : { error: "Not found" };
      resolve({ ok, status: ok ? 200 : 404, json: async () => body });
    }, 30);
  });
}

// Return the parsed JSON body, or throw an Error mentioning the status
async function getJson(url) {
  const response = fakeFetch(url);
  return response.json();
}

// ["food", "travel", "fun"], from /api/categories
async function loadCategoryNames() {
  const categories = getJson("/api/categories");
  return categories.map((c) => c.name);
}

// The amount converted to euro cents (rounded), using /api/rates
async function toEuroCents(cents) {
}

// The JSON from url, or `fallback` if anything goes wrong
async function safeGetJson(url, fallback) {
  return getJson(url);
}
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • getJson تُرجع الجسم بعد تحليله
  • getJson ترمي Error تذكر 404 مع الرابط المفقود
  • loadCategoryNames تُرجع أسماء الفئات
  • toEuroCents تحوّل بسعر EUR وتقرّب
  • safeGetJson تُرجع القيمة البديلة حين يفشل التحميل