الوحدة 4 · JavaScript في المتصفّح
JavaScript غير المتزامنة: الـ Promises وasync/await وfetch
22 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
تشرح لماذا تنتظر JavaScript دون أن تتجمّد، وتقرأ وتكتب كودًا مبنيًا على الـ promises باستخدام then وasync/await، وتستدعي fetch مع معالجة سليمة للأخطاء، وتتوقّع ترتيب المخرجات اعتمادًا على حلقة الأحداث (event loop).
- 1الـ Callbacks: “اتصل بي حين تنتهي”
- 2الـ Promises: قيمة تصل لاحقًا
- 3async وawait: promises تُقرأ مثل الكود العادي
- 4fetch الحقيقية
- 5حلقة الأحداث
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
سيكون Pocket أنفع لو استطاع أن يعرض إنفاقك باليورو أيضًا، وهذا يعني أن يطلب من خادم أسعار صرف اليوم. قد يستغرق هذا الطلب 50 ميلي ثانية أو خمس ثوانٍ. ولو توقّفت JavaScript ببساطة وانتظرت، لتجمّدت الصفحة كلها: لا تمرير، ولا كتابة، ولا نقرات. ولأن كودك يتشارك خيطًا (thread) واحدًا مع الصفحة نفسها، فهو يحتاج إلى طريقة يبدأ بها العمل البطيء، ويواصل، ثم يلتقط النتيجة لاحقًا. وهذا هو الكود غير المتزامن (asynchronous).
الـ Callbacks: “اتصل بي حين تنتهي”
أقدم أشكال “لاحقًا” هو الـ callback. تطلب setTimeout من المتصفّح أن يستدعي دالة بعد مهلة:
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().
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:
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 الخاصة بالمتصفّح، ويبقى شكل الكود كما هو:
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)، التي تُنجَز بمصفوفة من النتائج، أو تُرفض بمجرّد أن يفشل أيّ منهما:
const [rates, categories] = await Promise.all([
getJson("/api/rates"),
getJson("/api/categories"),
]);حلقة الأحداث
كيف يستطيع خيط واحد فعل كل هذا؟ ينفّذ المحرّك كودك على مكدّس الاستدعاءات (call stack)، دالة واحدة في كل مرة. أما الأشياء البطيئة، مثل المؤقّتات وطلبات الشبكة، فيتولّاها المتصفّح خارج المكدّس. وحين ينتهي أحدها، يوضع الـ callback الخاص به في طابور. وكلما فرغ المكدّس، تأخذ حلقة الأحداث (event loop) الـ callback التالي من طابور وتنفّذه.
هناك طابوران، والترتيب بينهما يفسّر ألغازًا كثيرة. callbacks الـ promises تذهب إلى طابور المهام الدقيقة (microtask queue)؛ والمؤقّتات والأحداث تذهب إلى طابور المهام (task queue). وفي كل مرة يفرغ فيها المكدّس، تنفّذ الحلقة أولًا كل مهمة دقيقة منتظرة، ثم مهمة واحدة، ثم تفحص المهام الدقيقة من جديد:
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 المنتظرة قبل المؤقّت أو الحدث التالي.