الفصل 11

البرمجة غير المتزامنة

من يستطيع الانتظار بهدوء حتى يترسّب الطين؟ من يستطيع أن يبقى ساكناً حتى لحظة الفعل؟

— لاو تزو، تاو تي تشينغ

رسم توضيحي يُظهر غرابين على غصن شجرة

الجزء المركزي من الحاسوب، الجزء الذي ينفّذ الخطوات الفردية التي تتكوّن منها برامجنا، يُسمى المعالج (processor). والبرامج التي رأيناها حتى الآن تُبقي المعالج مشغولاً حتى تنتهي من عملها. وتعتمد السرعة التي يمكن بها تنفيذ شيء مثل حلقة تتعامل مع الأعداد اعتماداً شبه كامل على سرعة معالج الحاسوب وذاكرته.

لكن برامج كثيرة تتفاعل مع أشياء خارج المعالج. فقد تتواصل مثلاً عبر شبكة حاسوبية أو تطلب بيانات من القرص الصلب — وهو أبطأ بكثير من جلبها من الذاكرة.

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

اللاتزامنية

في نموذج البرمجة المتزامنة (synchronous)، تحدث الأشياء واحداً تلو الآخر. فعندما تستدعي دالة تنفّذ إجراءً طويل الأمد، لا ترجع إلا بعد انتهاء الإجراء وتمكّنها من إرجاع النتيجة. وهذا يوقف برنامجك طوال المدة التي يستغرقها الإجراء.

أما النموذج غير المتزامن (asynchronous) فيسمح بحدوث أشياء متعددة في الوقت نفسه. فعندما تبدأ إجراءً، يواصل برنامجك العمل. وعندما ينتهي الإجراء، يُبلَّغ البرنامج ويتاح له الوصول إلى النتيجة (مثل البيانات المقروءة من القرص).

يمكننا مقارنة البرمجة المتزامنة وغير المتزامنة بمثال صغير: برنامج يجري طلبين عبر الشبكة ثم يجمع نتيجتيهما.

في بيئة متزامنة، حيث لا ترجع دالة الطلب إلا بعد إنجاز عملها، أسهل طريقة لأداء هذه المهمة هي إجراء الطلبين الواحد تلو الآخر. ويعيب هذا أن الطلب الثاني لن يبدأ إلا بعد انتهاء الأول. وسيكون الوقت الإجمالي المستغرق على الأقل مجموع زمني الاستجابة.

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

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

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

وثمة طريقة أخرى لوصف الفرق: أن انتظار انتهاء الإجراءات ضمني في النموذج المتزامن، بينما هو صريح — تحت سيطرتنا — في النموذج غير المتزامن.

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

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

دوال رد النداء

من مقاربات البرمجة غير المتزامنة أن تجعل الدوال التي تحتاج إلى انتظار شيء ما تأخذ معطى إضافياً هو دالة رد النداء (callback function). فالدالة غير المتزامنة تبدأ عملية، وترتّب الأمور بحيث تُستدعى دالة رد النداء عند انتهاء العملية، ثم ترجع.

ومن الأمثلة على ذلك الدالة setTimeout، المتاحة في Node.js وفي المتصفحات معاً، وهي تنتظر عدداً معطىً من المللي ثانية ثم تستدعي دالة.

setTimeout(() => console.log("Tick"), 500);

ليس الانتظار عموماً عملاً مهماً، لكنه قد يكون مفيداً جداً عندما تحتاج إلى ترتيب حدوث شيء في وقت معيّن أو التحقق مما إذا كان إجراء ما يستغرق وقتاً أطول من المتوقع.

ومن الأمثلة الأخرى على عملية غير متزامنة شائعة قراءة ملف من وحدة تخزين الجهاز. تخيّل أن لديك دالة readTextFile تقرأ محتوى ملف كنص وتمرّره إلى دالة رد نداء.

readTextFile("shopping_list.txt", content => {
  console.log(`Shopping List:\n${content}`);
});
// → Shopping List:
// → Peanut butter
// → Bananas

الدالة readTextFile ليست جزءاً من JavaScript القياسية. وسنرى كيفية قراءة الملفات في المتصفح وفي Node.js في فصول لاحقة.

تنفيذ عدة إجراءات غير متزامنة على التوالي باستخدام دوال رد النداء يعني أن عليك مواصلة تمرير دوال جديدة للتعامل مع استمرار الحساب بعد هذه الإجراءات. والدالة غير المتزامنة التي تقارن ملفين وتُنتج قيمة منطقية تشير إلى ما إذا كان محتواهما متطابقاً قد تبدو هكذا:

function compareFiles(fileA, fileB, callback) {
  readTextFile(fileA, contentA => {
    readTextFile(fileB, contentB => {
      callback(contentA == contentB);
    });
  });
}

هذا الأسلوب من البرمجة قابل للعمل، لكن مستوى الإزاحة يزداد مع كل إجراء غير متزامن لأنك تنتهي داخل دالة أخرى. وقد يصبح فعل أشياء أكثر تعقيداً، مثل تغليف الإجراءات غير المتزامنة في حلقة، مربكاً.

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

الوعود

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

وهذا هو الغرض من الصنف القياسي Promise. فالوعد (promise) إيصال يمثّل قيمة قد لا تكون متاحة بعد. وهو يوفّر طريقة then تتيح لك تسجيل دالة ينبغي استدعاؤها عند انتهاء الإجراء الذي ينتظره. وعندما يُحلّ الوعد (resolved)، أي تصبح قيمته متاحة، تُستدعى هذه الدوال (وقد تكون متعددة) بقيمة النتيجة. ومن الممكن استدعاء then على وعد حُلّ بالفعل — وستُستدعى دالتك رغم ذلك.

وأسهل طريقة لإنشاء وعد هي استدعاء Promise.resolve. تضمن هذه الدالة أن القيمة التي تعطيها إياها ملفوفة في وعد. فإن كانت القيمة وعداً بالفعل، أُعيدت كما هي. وإلا حصلت على وعد جديد يُحلّ فوراً بقيمتك كنتيجة له.

let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15

ولإنشاء وعد لا يُحلّ فوراً، يمكنك استخدام Promise كبانية. ولها واجهة غريبة بعض الشيء: إذ تتوقع البانية دالة كمعطى، تستدعيها فوراً وتمرّر إليها دالة يمكنها استخدامها لحلّ الوعد.

وعلى سبيل المثال، هكذا يمكنك إنشاء واجهة قائمة على الوعود للدالة readTextFile:

function textFile(filename) {
  return new Promise(resolve => {
    readTextFile(filename, text => resolve(text));
  });
}

textFile("plans.txt").then(console.log);

لاحظ كيف أن هذه الدالة غير المتزامنة، خلافاً للدوال بأسلوب رد النداء، تُرجع قيمة ذات معنى — وعداً بأن يعطيك محتويات الملف في وقت ما في المستقبل.

من الأمور المفيدة في طريقة then أنها هي نفسها تُرجع وعداً آخر. وهذا الوعد يُحلّ إلى القيمة التي أرجعتها دالة رد النداء، أو، إن كانت القيمة المُرجَعة وعداً، إلى القيمة التي يُحلّ إليها ذلك الوعد. وهكذا يمكنك «تسلسل» استدعاءات متعددة لـthen لبناء سلسلة من الإجراءات غير المتزامنة.

هذه الدالة، التي تقرأ ملفاً مليئاً بأسماء ملفات وتُرجع محتوى ملف عشوائي من تلك القائمة، تُظهر هذا النوع من خط أنابيب الوعود غير المتزامن:

function randomFile(listFile) {
  return textFile(listFile)
    .then(content => content.trim().split("\n"))
    .then(ls => ls[Math.floor(Math.random() * ls.length)])
    .then(filename => textFile(filename));
}

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

في هذه الشيفرة، تُرجع الدالتان المستخدمتان في استدعاءَي then الأولين قيمة عادية ستُمرَّر فوراً إلى الوعد الذي يُرجعه then عندما ترجع الدالة. أما استدعاء then الأخير فيُرجع وعداً (textFile(filename)) مما يجعله خطوة غير متزامنة حقيقية.

كان من الممكن أيضاً تنفيذ كل هذه الخطوات داخل دالة رد نداء واحدة لـthen، لأن الخطوة الأخيرة وحدها غير متزامنة فعلاً. لكن هذا النوع من أغلفة then التي لا تفعل سوى بعض التحويل المتزامن للبيانات مفيد غالباً، كما حين تريد إرجاع وعد يُنتج نسخة معالَجة من نتيجة غير متزامنة ما.

function jsonFile(filename) {
  return textFile(filename).then(JSON.parse);
}

jsonFile("package.json").then(console.log);

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

الفشل

قد تفشل حسابات JavaScript العادية برمي استثناء. وكثيراً ما تحتاج الحسابات غير المتزامنة إلى شيء من هذا القبيل. فقد يفشل طلب شبكي، أو لا يوجد ملف، أو قد ترمي شيفرة ما جزءٌ من الحساب غير المتزامن استثناءً.

من أكثر المشكلات إلحاحاً في أسلوب رد النداء في البرمجة غير المتزامنة أنه يجعل ضمان الإبلاغ الصحيح عن حالات الفشل إلى دوال رد النداء أمراً بالغ الصعوبة.

ومن الاصطلاحات الشائعة استخدام المعطى الأول لدالة رد النداء للإشارة إلى فشل الإجراء، والثاني لتمرير القيمة التي أنتجها الإجراء عند نجاحه.

someAsyncFunction((error, value) => {
  if (error) handleError(error);
  else processValue(value);
});

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

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

وكما يوفّر حلّ الوعد قيمة، يوفّر رفضه أيضاً قيمة، تُسمى عادة سبب الرفض. وعندما يتسبب استثناء في دالة معالج في الرفض، تُستخدم قيمة الاستثناء كسبب. وبالمثل، عندما يُرجع معالج وعداً مرفوضاً، ينتقل ذلك الرفض إلى الوعد التالي. وهناك دالة Promise.reject تنشئ وعداً جديداً مرفوضاً فوراً.

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

وكتعبير مختصر، تقبل then أيضاً معالج رفض كمعطى ثانٍ، فيمكنك تثبيت نوعي المعالجات في استدعاء طريقة واحد: .then(acceptHandler, rejectHandler).

والدالة التي تُمرَّر إلى بانية Promise تتلقى معطى ثانياً إلى جانب دالة الحل، يمكنها استخدامه لرفض الوعد الجديد.

عندما تصادف دالتنا readTextFile مشكلة، تمرّر الخطأ إلى دالة رد النداء الخاصة بها كمعطى ثانٍ. وينبغي أن يتحقق غلافنا textFile فعلاً من ذلك المعطى حتى يؤدي الفشل إلى رفض الوعد الذي يُرجعه.

function textFile(filename) {
  return new Promise((resolve, reject) => {
    readTextFile(filename, (text, error) => {
      if (error) reject(error);
      else resolve(text);
    });
  });
}

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

new Promise((_, reject) => reject(new Error("Fail")))
  .then(value => console.log("Handler 1:", value))
  .catch(reason => {
    console.log("Caught failure " + reason);
    return "nothing";
  })
  .then(value => console.log("Handler 2:", value));
// → Caught failure Error: Fail
// → Handler 2: nothing

لا تُستدعى دالة معالج then الأولى لأن الوعد يحمل عند تلك النقطة من خط الأنابيب رفضاً. ويتعامل معالج catch مع ذلك الرفض ويُرجع قيمة تُعطى لدالة معالج then الثانية.

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

كارلا

إنه يوم مشمس في برلين. يعجّ مدرج المطار القديم المهجور بالدرّاجين ومتزلجين على العجلات. وفي العشب قرب حاوية قمامة، يتجمّع سرب من الغربان في صخب، محاولاً إقناع مجموعة من السياح بالتنازل عن شطائرهم.

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

وخلافاً لبقية السرب، الذين يبدون سعداء بقضاء اليوم في العبث هنا، تبدو الغرابة الكبيرة هادفة. فتحمل غنيمتها وتطير مباشرة نحو سقف مبنى الحظيرة، لتختفي في فتحة تهوية.

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

تُعرف هذه الغرابة بين أقرانها باسم «كااو-كرو». لكن لأن تلك الأصوات غير ملائمة للأوتار الصوتية البشرية، سنشير إليها باسم كارلا.

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

الاختراق

تحب كارلا الإنترنت. ومن المزعج أن الهاتف الذي تعمل عليه على وشك أن تنفد منه بيانات الدفع المسبق. فالمبنى فيه شبكة لاسلكية، لكنها تتطلب رمزاً للوصول.

لحسن الحظ، أجهزة التوجيه اللاسلكية في المبنى عمرها 20 عاماً وأمنها ضعيف. وبعد بعض البحث، تكتشف كارلا أن آلية المصادقة على الشبكة فيها ثغرة يمكنها استغلالها. فعند الانضمام إلى الشبكة، يجب أن يرسل الجهاز رمز المرور الصحيح المكوّن من ستة أرقام. وسيردّ نقطة الوصول برسالة نجاح أو فشل حسب ما إذا قُدّم الرمز الصحيح. غير أن إرسال رمز جزئي (ثلاثة أرقام فقط، مثلاً) يعطي استجابة مختلفة حسب ما إذا كانت تلك الأرقام هي البداية الصحيحة للرمز أم لا. فإرسال أرقام خاطئة يُرجع رسالة فشل فوراً. أما إرسال الأرقام الصحيحة فيجعل نقطة الوصول تنتظر مزيداً من الأرقام.

وهذا يجعل من الممكن تسريع تخمين الرمز تسريعاً كبيراً. فتستطيع كارلا إيجاد الرقم الأول بتجربة كل رقم بدوره، حتى تجد رقماً لا يُرجع الفشل فوراً. وبعد معرفة رقم واحد، تستطيع إيجاد الرقم الثاني بالطريقة نفسها، وهكذا دواليك، حتى تعرف رمز المرور كاملاً.

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

function withTimeout(promise, time) {
  return new Promise((resolve, reject) => {
    promise.then(resolve, reject);
    setTimeout(() => reject("Timed out"), time);
  });
}

وهذا يستفيد من كون الوعد لا يمكن حلّه أو رفضه إلا مرة واحدة. فإن حُلّ الوعد المعطى كوسيط أو رُفض أولاً، كانت تلك النتيجة نتيجة الوعد الذي يُرجعه withTimeout. أما إن انطلق setTimeout أولاً ورفض الوعد، تُتجاهل أي استدعاءات أخرى للحل أو الرفض.

ولإيجاد رمز المرور كاملاً، يحتاج البرنامج إلى البحث مراراً عن الرقم التالي بتجربة كل رقم. فإن نجحت المصادقة، علمنا أننا وجدنا ما نبحث عنه. وإن فشلت فوراً، علمنا أن ذلك الرقم خاطئ وعلينا تجربة الرقم التالي. وإن انتهت مهلة الطلب، فقد وجدنا رقماً صحيحاً آخر وعلينا المتابعة بإضافة رقم آخر.

ولأنك لا تستطيع انتظار وعد داخل حلقة for، تستخدم كارلا دالة تعاودية لقيادة هذه العملية. في كل استدعاء، تتلقى هذه الدالة الرمز كما نعرفه حتى الآن، وكذلك الرقم التالي المراد تجربته. وتبعاً لما يحدث، قد تُرجع رمزاً مكتملاً أو تستدعي نفسها، إما لبدء كسر الموضع التالي في الرمز أو لتجربة رقم آخر.

function crackPasscode(networkID) {
  function nextDigit(code, digit) {
    let newCode = code + digit;
    return withTimeout(joinWifi(networkID, newCode), 50)
      .then(() => newCode)
      .catch(failure => {
        if (failure == "Timed out") {
          return nextDigit(newCode, 0);
        } else if (digit < 9) {
          return nextDigit(code, digit + 1);
        } else {
          throw failure;
        }
      });
  }
  return nextDigit("", 0);
}

تميل نقطة الوصول إلى الاستجابة لطلبات المصادقة الخاطئة خلال نحو 20 مللي ثانية، لذا وللاحتياط، تنتظر هذه الدالة 50 مللي ثانية قبل أن تنتهي مهلة الطلب.

crackPasscode("HANGAR 2").then(console.log);
// → 555555

تُميل كارلا رأسها وتتنهد. كان هذا سيكون أكثر إرضاءً لو كان الرمز أصعب قليلاً في التخمين.

دوال async

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

ما تفعله دالة الكسر فعلاً خطّي تماماً — فهي تنتظر دائماً اكتمال الإجراء السابق قبل بدء التالي. وكان التعبير عنه في نموذج برمجة متزامن سيكون أبسط.

والخبر السار أن JavaScript تتيح لك كتابة شيفرة شبه متزامنة لوصف حساب غير متزامن. فدالة async تُرجع ضمناً وعداً، وتستطيع في جسمها أن تنتظر (await) وعوداً أخرى بطريقة تبدو متزامنة.

يمكننا إعادة كتابة crackPasscode هكذا:

async function crackPasscode(networkID) {
  for (let code = "";;) {
    for (let digit = 0;; digit++) {
      let newCode = code + digit;
      try {
        await withTimeout(joinWifi(networkID, newCode), 50);
        return newCode;
      } catch (failure) {
        if (failure == "Timed out") {
          code = newCode;
          break;
        } else if (digit == 9) {
          throw failure;
        }
      }
    }
  }
}

تُظهر هذه النسخة بوضوح أكبر بنية الحلقتين في الدالة (الحلقة الداخلية تجرّب الأرقام من 0 إلى 9، والحلقة الخارجية تضيف أرقاماً إلى رمز المرور).

تُوسم دالة async بالكلمة async قبل الكلمة المفتاحية function. ويمكن أيضاً جعل الطرائق async بكتابة async قبل اسمها. وعند استدعاء دالة أو طريقة كهذه، فإنها تُرجع وعداً. وبمجرد أن تُرجع الدالة شيئاً، يُحلّ ذلك الوعد. وإن رمى جسمها استثناءً، يُرفض الوعد.

وداخل دالة async، يمكن وضع الكلمة await أمام تعبير للانتظار حتى يُحلّ وعد، وبعدها فقط يواصل تنفيذ الدالة. وإن رُفض الوعد، يُرفع استثناء عند موضع await.

ولم تعد دالة كهذه تعمل من البداية إلى النهاية دفعة واحدة مثل دالة JavaScript عادية. بل يمكن تجميدها عند أي نقطة فيها await، واستئنافها في وقت لاحق.

ولمعظم الشيفرة غير المتزامنة، هذه الصيغة أكثر ملاءمة من استخدام الوعود مباشرة. لكنك ما زلت بحاجة إلى فهم الوعود، لأنك في حالات كثيرة ستتعامل معها مباشرة. غير أن دوال async أجمل عموماً في الكتابة من سلاسل استدعاءات then عندما يتعلق الأمر بربطها معاً.

الدوال المولّدة

ليست قدرة الدوال على التوقف ثم الاستئناف حكراً على دوال async. فلJavaScript أيضاً سمة تُسمى الدوال المولّدة (generator functions). وهي مشابهة، لكن من دون الوعود.

وعندما تعرّف دالة بـfunction* (بوضع نجمة بعد كلمة function)، تصبح مولّداً. وعندما تستدعي مولّداً، فإنه يُرجع مكرِّراً (iterator)، وقد رأيناه من قبل في الفصل 6.

function* powers(n) {
  for (let current = n;; current *= n) {
    yield current;
  }
}

for (let power of powers(3)) {
  if (power > 50) break;
  console.log(power);
}
// → 3
// → 9
// → 27

في البداية، عندما تستدعي powers، تُجمَّد الدالة عند بدايتها. وفي كل مرة تستدعي فيها next على المكرِّر، تعمل الدالة حتى تصل إلى تعبير yield، الذي يوقفها مؤقتاً ويجعل القيمة المُسلَّمة هي القيمة التالية التي ينتجها المكرِّر. وعندما ترجع الدالة (ودالة المثال لا ترجع أبداً)، يكون المكرِّر قد انتهى.

غالباً ما تكون كتابة المكرِّرات أسهل بكثير عند استخدام دوال مولّدة. ويمكن كتابة المكرِّر الخاص بصنف Group (من تمرين الفصل 6) بهذا المولّد:

Group.prototype[Symbol.iterator] = function*() {
  for (let i = 0; i < this.members.length; i++) {
    yield this.members[i];
  }
};

ولم تعد هناك حاجة إلى إنشاء كائن لحفظ حالة التكرار — فالمولّدات تحفظ حالتها المحلية تلقائياً في كل مرة تُسلّم فيها.

ولا يجوز أن تظهر تعبيرات yield هذه إلا مباشرة في دالة المولّد نفسها، لا في دالة داخلية تعرّفها بداخلها. والحالة التي يحفظها المولّد عند التسليم هي بيئته المحلية فقط والموضع الذي سلّم عنده.

ودالة async نوع خاص من المولّدات. فهي تُنتج وعداً عند استدعائها، يُحلّ عند إرجاعها (انتهائها) ويُرفض عند رميها استثناءً. وكلما سلّمت (انتظرت) وعداً، كانت نتيجة ذلك الوعد (قيمة أو استثناء مرفوع) هي نتيجة تعبير await.

مشروع فني غرابي

في صباح أحد الأيام، تستيقظ كارلا على ضجيج غير مألوف قادم من أرض المطار خارج حظيرتها. فتقفز إلى حافة السطح وترى البشر يجهّزون شيئاً ما. هناك الكثير من الكابلات الكهربائية، ومنصة، ونوع من جدار أسود كبير يجري بناؤه.

ولأنها غرابة فضولية، تُلقي كارلا نظرة أقرب على الجدار. يبدو أنه يتكوّن من عدد من الأجهزة الكبيرة ذات الواجهات الزجاجية الموصولة بالكابلات. وعلى ظهر الأجهزة كتابة «LedTec SIG-5030».

وبحث سريع على الإنترنت يُظهر دليل مستخدم لهذه الأجهزة. يبدو أنها لافتات مرور، بمصفوفة قابلة للبرمجة من أضواء LED كهرمانية. والأرجح أن البشر يريدون عرض نوع من المعلومات عليها خلال فعاليتهم. ومن المثير للاهتمام أن الشاشات يمكن برمجتها عبر شبكة لاسلكية. فهل يمكن أن تكون متصلة بالشبكة المحلية للمبنى؟

ويحصل كل جهاز على الشبكة على عنوان IP، يمكن للأجهزة الأخرى استخدامه لإرسال رسائل إليه. وسنتحدث عن ذلك أكثر في الفصل 13. وتلاحظ كارلا أن هواتفها كلها تحصل على عناوين مثل 10.0.0.20 أو 10.0.0.33. وقد يستحق الأمر محاولة إرسال رسائل إلى كل هذه العناوين ورؤية ما إذا كان أي منها يستجيب للواجهة الموصوفة في دليل اللافتات.

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

ووفقاً للدليل، يمكنك تغيير ما يُعرض على لافتة SIG-5030 بإرسال رسالة بمحتوى مثل {"command": "display", "data": [0, 0, 3, …]}، حيث يحمل data عدداً واحداً لكل نقطة LED، يحدّد سطوعها — 0 يعني إطفاءً، و3 يعني أقصى سطوع. وكل لافتة عرضها 50 ضوءاً وارتفاعها 30 ضوءاً، لذا ينبغي أن يرسل أمر التحديث 1,500 عدد.

ترسل هذه الشيفرة رسالة تحديث عرض إلى كل العناوين على الشبكة المحلية، لنرى ما سيعلق. ويمكن لكل عدد في عنوان IP أن يتراوح من 0 إلى 255. وفي البيانات التي ترسلها، تُشعل عدداً من الأضواء يوافق العدد الأخير في عنوان الشبكة.

for (let addr = 1; addr < 256; addr++) {
  let data = [];
  for (let n = 0; n < 1500; n++) {
    data.push(n < addr ? 3 : 0);
  }
  let ip = `10.0.0.${addr}`;
  request(ip, {command: "display", data})
    .then(() => console.log(`Request to ${ip} accepted`))
    .catch(() => {});
}

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

وبعد أن أطلقت مسحها الشبكي، تعود كارلا إلى الخارج لترى النتيجة. ولبهجتها، تُظهر كل الشاشات الآن شريطاً من الضوء في زواياها العلوية اليسرى. فهي بالفعل على الشبكة المحلية، وبالفعل تقبل الأوامر. وتسجّل سريعاً الأرقام المعروضة على كل شاشة. هناك تسع شاشات، مرتّبة في ثلاثة صفوف وثلاثة أعمدة. ولها عناوين الشبكة التالية:

const screenAddresses = [
  "10.0.0.44", "10.0.0.45", "10.0.0.41",
  "10.0.0.31", "10.0.0.40", "10.0.0.42",
  "10.0.0.48", "10.0.0.47", "10.0.0.46"
];

وهذا يفتح الآن إمكانات لشتى أنواع المشاغبات. فيمكنها أن تعرض على الجدار «الغربان تسود، والبشر يسيل لعابهم» بحروف عملاقة. لكن ذلك يبدو فظاً بعض الشيء. وبدلاً من ذلك، تخطّط لعرض فيديو لغراب طائر يغطي كل الشاشات في الليل.

تجد كارلا مقطع فيديو مناسباً، يمكن تكرار ثانية ونصف منه لإنشاء فيديو معادة حلقته يُظهر رفرفة جناح غراب. وملاءمةً للشاشات التسع (التي تستطيع كل منها عرض 50×30 بكسل)، تقصّ كارلا الفيديو وتغيّر مقاسه للحصول على سلسلة من الصور بمقاس 150×90، عشر صور في الثانية. ثم تُقطَّع كل منها إلى تسعة مستطيلات، وتُعالَج بحيث تُظهر البقع الداكنة في الفيديو (حيث يوجد الغراب) ضوءاً ساطعاً، وتُترك البقع الفاتحة (حيث لا غراب) داكنة، ما ينبغي أن يخلق تأثير غراب كهرماني يطير على خلفية سوداء.

وقد جهّزت ارتباط clipImages ليحمل مصفوفة من الإطارات، حيث يمثّل كل إطار بمصفوفة من تسع مجموعات من البكسلات — واحدة لكل شاشة — بالصيغة التي تتوقعها اللافتات.

ولعرض إطار واحد من الفيديو، تحتاج كارلا إلى إرسال طلب إلى كل الشاشات دفعة واحدة. لكنها تحتاج أيضاً إلى انتظار نتيجة هذه الطلبات، وذلك لكي لا تبدأ إرسال الإطار التالي قبل إرسال الإطار الحالي إرسالاً سليماً، ولكي تلاحظ عندما تفشل الطلبات.

وللصنف Promise طريقة ساكنة (static method) هي all يمكن استخدامها لتحويل مصفوفة من الوعود إلى وعد واحد يُحلّ إلى مصفوفة من النتائج. وهذا يوفّر طريقة ملائمة لجعل بعض الإجراءات غير المتزامنة تحدث جنباً إلى جنب، والانتظار حتى تنتهي كلها، ثم فعل شيء بنتائجها (أو على الأقل الانتظار للتأكد من أنها لا تفشل).

function displayFrame(frame) {
  return Promise.all(frame.map((data, i) => {
    return request(screenAddresses[i], {
      command: "display",
      data
    });
  }));
}

تُجري هذه الدالة map على الصور في frame (وهي مصفوفة من مصفوفات بيانات العرض) لإنشاء مصفوفة من وعود الطلبات. ثم تُرجع وعداً يجمعها كلها.

ولكي يكون ممكناً إيقاف فيديو قيد التشغيل، تُغلَّف العملية في صنف. وللصنف طريقة play غير متزامنة تُرجع وعداً لا يُحلّ إلا عند إيقاف التشغيل مرة أخرى عبر الطريقة stop.

function wait(time) {
  return new Promise(accept => setTimeout(accept, time));
}

class VideoPlayer {
  constructor(frames, frameTime) {
    this.frames = frames;
    this.frameTime = frameTime;
    this.stopped = true;
  }

  async play() {
    this.stopped = false;
    for (let i = 0; !this.stopped; i++) {
      let nextFrame = wait(this.frameTime);
      await displayFrame(this.frames[i % this.frames.length]);
      await nextFrame;
    }
  }

  stop() {
    this.stopped = true;
  }
}

تغلّف الدالة wait الدالة setTimeout في وعد يُحلّ بعد عدد المللي ثانية المعطى. وهذا مفيد للتحكم في سرعة التشغيل.

let video = new VideoPlayer(clipImages, 100);
video.play().catch(e => {
  console.log("Playback failed: " + e);
});
setTimeout(() => video.stop(), 15000);

وطوال الأسبوع الذي يبقى فيه جدار الشاشات قائماً، في كل مساء، عندما يحلّ الظلام، يظهر عليه بشكل غامض طائر برتقالي متوهج ضخم.

حلقة الأحداث

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

إذن لا تُستدعى دوال رد النداء مباشرة من الشيفرة التي جدولتها. فإذا استدعيت setTimeout من داخل دالة، تكون تلك الدالة قد رجعت بحلول وقت استدعاء دالة رد النداء. وعندما ترجع دالة رد النداء، لا يعود التحكّم إلى الدالة التي جدولتها.

يحدث السلوك غير المتزامن على مكدّس استدعاء دوال فارغ خاص به. وهذا أحد أسباب صعوبة إدارة الاستثناءات عبر الشيفرة غير المتزامنة بدون الوعود. فلأن كل دالة رد نداء تبدأ بمكدّس فارغ في معظمه، لن تكون معالجات catch الخاصة بك على المكدّس عندما ترمي استثناءً.

try {
  setTimeout(() => {
    throw new Error("Woosh");
  }, 20);
} catch (e) {
  // لن يعمل هذا
  console.log("Caught", e);
}

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

يضبط هذا المثال مهلة لكنه يتمهّل بعد ذلك إلى ما بعد الوقت المقصود للمهلة، ما يجعل المهلة متأخرة.

let start = Date.now();
setTimeout(() => {
  console.log("Timeout ran at", Date.now() - start);
}, 20);
while (Date.now() < start + 50) {}
console.log("Wasted time until", Date.now() - start);
// → Wasted time until 50
// → Timeout ran at 55

تُحلّ الوعود أو تُرفض دائماً كحدث جديد. وحتى إذا كان الوعد محلولاً بالفعل، فإن انتظاره سيجعل دالة رد النداء الخاصة بك تعمل بعد انتهاء النص الحالي، لا فوراً.

Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done

وفي فصول لاحقة سنرى أنواعاً أخرى متنوعة من الأحداث التي تعمل على حلقة الأحداث.

العلل غير المتزامنة

عندما يعمل برنامجك بشكل متزامن، دفعة واحدة، لا تحدث تغيّرات في الحالة إلا تلك التي يُجريها البرنامج نفسه. أما البرامج غير المتزامنة فالأمر فيها مختلف — فقد يكون في تنفيذها فجوات يمكن لشيفرة أخرى أن تعمل خلالها.

لننظر إلى مثال. هذه دالة تحاول الإبلاغ عن حجم كل ملف في مصفوفة ملفات، مع الحرص على قراءتها كلها في الوقت نفسه لا بالتتابع.

async function fileSizes(files) {
  let list = "";
  await Promise.all(files.map(async fileName => {
    list += fileName + ": " +
      (await textFile(fileName)).length + "\n";
  }));
  return list;
}

يوضح الجزء async fileName => كيف يمكن جعل دوال السهم async أيضاً بوضع الكلمة async أمامها.

لا تبدو الشيفرة مثيرة للشك فوراً... فهي تُجري map للدالة السهمية async على مصفوفة الأسماء، منشئة مصفوفة من الوعود، ثم تستخدم Promise.all للانتظار حتى تكتمل كلها قبل إرجاع القائمة التي تبنينها.

لكن هذا البرنامج معطوب تماماً. فسيُرجع دائماً سطراً واحداً فقط من المخرجات، يسرد الملف الذي استغرق أطول وقت في القراءة.

fileSizes(["plans.txt", "shopping_list.txt"])
  .then(console.log);

هل تستطيع أن تكتشف السبب؟

تكمن المشكلة في المعامل +=، فهو يأخذ القيمة الحالية لـlist عند بدء تنفيذ الجملة، ثم، عند انتهاء await، يضبط الارتباط list على تلك القيمة مضافاً إليها النص المضاف.

لكن بين وقت بدء تنفيذ الجملة ووقت انتهائها توجد فجوة غير متزامنة. فتعبير map يعمل قبل أن يُضاف أي شيء إلى القائمة، لذا يبدأ كل معامل من معاملات += من نص فارغ، وينتهي، عند انتهاء جلب التخزين الخاص به، إلى ضبط list على نتيجة إضافة سطره إلى النص الفارغ.

كان يمكن تجنّب هذا بسهولة بإرجاع الأسطر من الوعود المعيَّنة عبر map واستدعاء join على نتيجة Promise.all، بدلاً من بناء القائمة بتغيير ارتباط. وكالعادة، حساب قيم جديدة أقل عرضة للأخطاء من تغيير قيم موجودة.

async function fileSizes(files) {
  let lines = files.map(async fileName => {
    return fileName + ": " +
      (await textFile(fileName)).length;
  });
  return (await Promise.all(lines)).join("\n");
}

من السهل ارتكاب أخطاء كهذه، خصوصاً عند استخدام await، وينبغي أن تكون واعياً بمواضع الفجوات في شيفرتك. ومن مزايا اللاتزامنية الصريحة في JavaScript (سواء عبر دوال رد النداء أو الوعود أو await) أن ملاحظة هذه الفجوات سهلة نسبياً.

الملخص

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

وتسهّل الوعود، وهي كائنات تمثّل إجراءات قد تكتمل في المستقبل، البرمجة غير المتزامنة، وكذلك دوال async التي تتيح لك كتابة برنامج غير متزامن كما لو كان متزامناً.

التمارين

أوقات الهدوء

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

وقد كانت أيضاً تسجّل أوقات تفعّل الكاميرا منذ مدة، وتريد استخدام هذه المعلومات لتصوّر أي الأوقات، في أسبوع متوسط، تميل إلى الهدوء وأيها تميل إلى الازدحام. ويُخزَّن السجل في ملفات تحمل عدداً واحداً للطابع الزمني (كما تُرجعه Date.now()) في كل سطر.

1695709940692
1695701068331
1695701189163

يحمل الملف "camera_logs.txt" قائمة بملفات السجل. اكتب دالة غير متزامنة activityTable(day) تُرجع، ليوم معيّن من الأسبوع، مصفوفة من 24 عدداً، عدداً لكل ساعة من ساعات اليوم، تحمل عدد ملاحظات حركة شبكة الكاميرا المسجلة في تلك الساعة من اليوم. وتُعرَّف الأيام بالأرقام وفق النظام الذي تستخدمه Date.getDay، حيث الأحد 0 والسبت 6.

وتلخّص الدالة activityGraph، التي يوفّرها صندوق الرمل (sandbox)، جدولاً كهذا في نص.

ولقراءة الملفات، استخدم الدالة textFile المعرَّفة سابقاً — بإعطائها اسم ملف، تُرجع وعداً يُحلّ إلى محتوى الملف. وتذكّر أن new Date(timestamp) ينشئ كائن Date لذلك الوقت، وله طريقتان هما getDay وgetHours تُرجعان يوم الأسبوع وساعة اليوم.

وكلا نوعي الملفات — قائمة ملفات السجل وملفات السجل نفسها — فيه كل قطعة بيانات في سطرها الخاص، مفصولة بمحارف سطر جديد ("\n").

async function activityTable(day) {
  let logFileList = await textFile("camera_logs.txt");
  // اكتب شيفرتك هنا
}

activityTable(1)
  .then(table => console.log(activityGraph(table)));
إظهار التلميح

ستحتاج إلى تحويل محتوى هذه الملفات إلى مصفوفة. وأسهل طريقة لذلك هي استخدام طريقة split على النص الذي تُنتجه textFile. ولاحظ أن ذلك سيعطيك، في ملفات السجل، مصفوفة من النصوص، وعليك تحويلها إلى أعداد قبل تمريرها إلى new Date.

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

احرص على استخدام await على نتيجة الدوال غير المتزامنة قبل فعل أي شيء بها، وإلا انتهيت بـPromise حيث كنت تتوقع نصاً.

وعود حقيقية

أعد كتابة الدالة من التمرين السابق بدون async/await، باستخدام طرائق Promise العادية.

function activityTable(day) {
  // اكتب شيفرتك هنا
}

activityTable(6)
  .then(table => console.log(activityGraph(table)));

في هذا الأسلوب، سيكون استخدام Promise.all أكثر ملاءمة من محاولة نمذجة حلقة على ملفات السجل. أما في دالة async، فمجرد استخدام await في حلقة أبسط. وإذا استغرقت قراءة ملف بعض الوقت، فأي هذين المقاربتين سيستغرق أقل وقت للتشغيل؟

إذا كان في أحد الملفات المدرجة في قائمة الملفات خطأ مطبعي، وفشلت قراءته، فكيف ينتهي ذلك الفشل في كائن Promise الذي تُرجعه دالتك؟

إظهار التلميح

أبسط مقاربة لكتابة هذه الدالة هي استخدام سلسلة من استدعاءات then. يُنتج الوعد الأول بقراءة قائمة ملفات السجل. وتستطيع دالة رد النداء الأولى تقسيم هذه القائمة وإجراء map للدالة textFile عليها للحصول على مصفوفة من الوعود تُمرَّر إلى Promise.all. ويمكنها إرجاع الكائن الذي يُرجعه Promise.all، بحيث تصبح أي قيمة يُرجعها هي نتيجة القيمة المُرجَعة لهذا الاستدعاء الأول لـthen.

صار لدينا الآن وعد يُرجع مصفوفة من ملفات السجل. ويمكننا استدعاء then مرة أخرى عليه، ووضع منطق عدّ الطوابع الزمنية هناك. شيء من هذا القبيل:

function activityTable(day) {
  return textFile("camera_logs.txt").then(files => {
    return Promise.all(files.split("\n").map(textFile));
  }).then(logs => {
    // حلّل...
  });
}

أو يمكنك، لجدولة عمل أفضل، وضع تحليل كل ملف داخل Promise.all، بحيث يمكن بدء ذلك العمل لأول ملف يعود من القرص، حتى قبل عودة الملفات الأخرى.

function activityTable(day) {
  let table = []; // تهيئة...
  return textFile("camera_logs.txt").then(files => {
    return Promise.all(files.split("\n").map(name => {
      return textFile(name).then(log => {
        // حلّل...
      });
    }));
  }).then(() => table);
}

يوضح هذا أن طريقة بناء وعودك يمكن أن يكون لها أثر حقيقي في طريقة جدولة العمل. فحلقة بسيطة فيها await ستجعل العملية خطّية تماماً — إذ تنتظر تحميل كل ملف قبل المتابعة. أما Promise.all فيتيح من حيث المبدأ العمل على مهام متعددة في الوقت نفسه، مما يسمح لها بالتقدم أثناء تحميل الملفات. وقد يكون هذا أسرع، لكنه يجعل أيضاً ترتيب حدوث الأشياء أقل قابلية للتنبؤ. وفي هذه الحالة، لن نفعل سوى زيادة أعداد في جدول، وهذا ليس صعباً فعله بطريقة آمنة. أما في أنواع أخرى من المشكلات، فقد يكون أصعب بكثير.

عندما لا يوجد ملف من القائمة، يُرفض الوعد الذي تُرجعه textFile. ولأن Promise.all يُرفض إذا فشل أي من الوعود المعطاة له، فإن القيمة المُرجَعة لدالة رد النداء المعطاة لاستدعاء then الأول ستكون أيضاً وعداً مرفوضاً. وهذا يجعل الوعد الذي يُرجعه then يفشل، فلا تُستدعى دالة رد النداء المعطاة لاستدعاء then الثاني أصلاً، ويُرجَع وعد مرفوض من الدالة.

بناء Promise.all

كما رأينا، عند إعطائه مصفوفة من الوعود، يُرجع Promise.all وعداً ينتظر حتى تنتهي كل الوعود في المصفوفة. ثم ينجح، مُنتجاً مصفوفة من قيم النتائج. وإن فشل وعد في المصفوفة، يفشل الوعد الذي يُرجعه all أيضاً، ناقلاً سبب الفشل من الوعد الفاشل.

نفّذ شيئاً كهذا بنفسك كدالة عادية تُسمى Promise_all.

تذكّر أن الوعد بعد نجاحه أو فشله لا يمكن أن ينجح أو يفشل مرة أخرى، وتُتجاهل أي استدعاءات أخرى للدوال التي تحلّه. وهذا قد يبسّط طريقة تعاملك مع فشل وعدك.

function Promise_all(promises) {
  return new Promise((resolve, reject) => {
    // اكتب شيفرتك هنا.
  });
}

// شيفرة اختبار.
Promise_all([]).then(array => {
  console.log("This should be []:", array);
});
function soon(val) {
  return new Promise(resolve => {
    setTimeout(() => resolve(val), Math.random() * 500);
  });
}
Promise_all([soon(1), soon(2), soon(3)]).then(array => {
  console.log("This should be [1, 2, 3]:", array);
});
Promise_all([soon(1), Promise.reject("X"), soon(3)])
  .then(array => {
    console.log("We should not get here");
  })
  .catch(error => {
    if (error != "X") {
      console.log("Unexpected failure:", error);
    }
  });
إظهار التلميح

سيتعيّن على الدالة التي تُمرَّر إلى بانية Promise استدعاء then على كل وعد في المصفوفة المعطاة. وعندما ينجح أحدها، يجب أن يحدث أمران: تخزين القيمة الناتجة في الموضع الصحيح من مصفوفة النتائج، والتحقق مما إذا كان هذا آخر وعد معلّق وإنهاء وعدنا الخاص إن كان كذلك.

ويمكن فعل الأمر الثاني بعدّاد يُهيّأ على طول المصفوفة المُدخَلة ونطرح منه 1 في كل مرة ينجح فيها وعد. وعندما يصل إلى 0 نكون قد انتهينا. واحرص على أن تأخذ في الحسبان حالة كون المصفوفة المُدخَلة فارغة (وبالتالي لن يُحلّ أي وعد أبداً).

تتطلب معالجة الفشل بعض التفكير لكنها تبيّن أنها بالغة البساطة. ما عليك سوى تمرير دالة reject الخاصة بالوعد الغلاف إلى كل وعد في المصفوفة كمعالج catch أو كمعطى ثانٍ لـthen، بحيث يؤدي فشل أحدها إلى رفض وعد الغلاف كله.