دليل شامل لـ Async/Await في JavaScript: فهم انتظار الدوال غير المتزامنة

دقائق القراءة: 11

مقدمة إلى البرمجة غير المتزامنة في JavaScript

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

ما هو الكود غير المتزامن (Asynchronous Code)؟

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

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

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

من يقوم بالعمل الإضافي؟

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

const logName = () => {
  console.log("Han")
}

setTimeout(logName, 0)

console.log("Hi there")

تشغيل هذا الكود يؤدي إلى المخرجات التالية في الـ console:

// in console
Hi there
Han

حسنًا. ما الذي يحدث؟ يتضح أن الطريقة التي نوكل بها العمل في JavaScript هي استخدام دوال وواجهات برمجة تطبيقات (APIs) خاصة بالبيئة. وهذا مصدر ارتباك كبير في JavaScript. تعمل JavaScript دائمًا في بيئة. غالبًا ما تكون هذه البيئة هي المتصفح (browser). ولكن يمكن أن تكون أيضًا على الخادم باستخدام Node.js.

ولكن ما هو الفرق؟ الفرق – وهذا مهم – هو أن المتصفح والخادم (Node.js)، من حيث الوظائف، ليسا متكافئين. غالبًا ما يكونان متشابهين، لكنهما ليسا متماثلين. دعنا نوضح هذا بمثال.

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

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

لذا، إذا كنت قد تساءلت يومًا لماذا يمكنك استخدام fetch في JavaScript عندما تشغلها في المتصفح (ولكن تحتاج إلى تثبيت حزمة عندما تشغلها في Node.js)، فهذا هو السبب. اعتقد أحدهم أن fetch كانت فكرة جيدة، وبناها كأداة لبيئة Node.js. هل الأمر محير؟ نعم! ولكن الآن يمكننا أخيرًا فهم ما يتولى العمل من JavaScript، وكيف يتم توظيفه. يتضح أن البيئة هي التي تتولى العمل، والطريقة لجعل البيئة تقوم بهذا العمل هي استخدام وظائف تنتمي إلى البيئة. على سبيل المثال fetch أو setTimeout في بيئة المتصفح.

ماذا يحدث للعمل المفوض؟

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

كما يتضح، هناك قواعد صارمة تحيط بمتى يمكن لـ JavaScript استلام العمل المفوض. تحكم هذه القواعد حلقة الأحداث (Event Loop) وتتضمن قائمة انتظار المهام الصغيرة (microtask queue) وقائمة انتظار المهام الكبيرة (macrotask queue). نعم، أعلم. إنه كثير. ولكن تحمل معي.

رسم توضيحي لحلقة الأحداث (Event Loop) في JavaScript، يوضح كيفية معالجة المهام غير المتزامنة.

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

سيأخذ المتصفح العمل، يقوم به، ثم يضع النتيجة في إحدى قائمتي الانتظار بناءً على نوع العمل الذي يتلقاه. الـ Promises، على سبيل المثال، توضع في قائمة انتظار المهام الصغيرة (microtask queue) ولها أولوية أعلى. الأحداث و setTimeout هي أمثلة على العمل الذي يوضع في قائمة انتظار المهام الكبيرة (macrotask queue)، ولها أولوية أقل.

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

لذا دعنا نلقي نظرة على مثال:

setTimeout(() => console.log("hello"), 0)

fetch("https://someapi/data")
  .then(response => response.json())
  .then(data => console.log(data))

console.log("What soup?")

ماذا سيكون الترتيب هنا؟

  1. أولاً، يتم تفويض setTimeout إلى المتصفح، الذي يقوم بالعمل ويضع الدالة الناتجة في قائمة انتظار المهام الكبيرة (macrotask queue).
  2. ثانيًا، يتم تفويض fetch إلى المتصفح، الذي يتولى العمل. يسترد البيانات من نقطة النهاية ويضع الدوال الناتجة في قائمة انتظار المهام الصغيرة (microtask queue).
  3. تسجل JavaScript في الـ console عبارة "What soup?".
  4. تتحقق حلقة الأحداث مما إذا كانت JavaScript جاهزة لاستقبال النتائج من العمل الموجود في قائمة الانتظار. عندما ينتهي console.log، تكون JavaScript جاهزة.
  5. تختار حلقة الأحداث الدوال الموجودة في قائمة انتظار المهام الصغيرة (microtask queue)، والتي لها أولوية أعلى، وتعيدها إلى JavaScript لتنفيذها (بيانات الـ API).
  6. بعد أن تصبح قائمة انتظار المهام الصغيرة فارغة، يتم إخراج دالة الاستدعاء (callback) الخاصة بـ setTimeout من قائمة انتظار المهام الكبيرة (macrotask queue) وإعادتها إلى JavaScript لتنفيذها.
In console:
// What soup?
// the data from the api
// hello

الوعود (Promises)

الآن يجب أن يكون لديك قدر كبير من المعرفة حول كيفية تعامل JavaScript وبيئة المتصفح مع الكود غير المتزامن. لذا دعنا نتحدث عن الوعود (Promises). الـ Promise هي بنية في JavaScript تمثل قيمة مستقبلية غير معروفة. من الناحية المفاهيمية، الـ Promise هي مجرد JavaScript تعد بإرجاع قيمة. يمكن أن تكون النتيجة من استدعاء API، أو يمكن أن تكون كائن خطأ من طلب شبكة فاشل. أنت مضمون أن تحصل على شيء.

const promise = new Promise((resolve, reject) => {
  // Make a network request
  if (response.status === 200) {
    resolve(response.body)
  } else {
    const error = { ... }
    reject(error)
  }
})

promise.then(res => {
  console.log(res)
}).catch(err => {
  console.log(err)
})

يمكن أن يكون للـ Promise الحالات التالية:

  • fulfilled: تم إكمال الإجراء بنجاح.
  • rejected: فشل الإجراء.
  • pending: لم يتم إكمال أي من الإجراءين.
  • settled: تم إكماله أو رفضه (أي ليس في حالة pending).

يتلقى الـ Promise دالتي resolve و reject يمكن استدعاؤهما لتشغيل إحدى هذه الحالات. إحدى نقاط البيع الكبيرة للـ Promises هي أنه يمكننا ربط الدوال التي نريد تشغيلها عند النجاح (resolve) أو الفشل (reject):

  • لتسجيل دالة للتشغيل عند النجاح، نستخدم .then().
  • لتسجيل دالة للتشغيل عند الفشل، نستخدم .catch().
// Fetch returns a promise
fetch("https://swapi.dev/api/people/1")
  .then((res) => console.log("This function is run when the request succeeds", res))
  .catch(err => console.log("This function is run when the request fails", err))

// Chaining multiple functions
fetch("https://swapi.dev/api/people/1")
  .then((res) => doSomethingWithResult(res))
  .then((finalResult) => console.log(finalResult))
  .catch((err => doSomethingWithErr(err)))

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

const fetch = (url, options) => {
  // simplified
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    // ... make request
    xhr.onload = () => {
      const options = {
        status: xhr.status,
        statusText: xhr.statusText
        // ...
      }
      resolve(new Response(xhr.response, options))
    }
    xhr.onerror = () => {
      reject(new TypeError("Request failed"))
    }
  })
}

fetch("https://swapi.dev/api/people/1")
  // Register handleResponse to run when promise resolves
  .then(handleResponse)
  .catch(handleError)

// conceptually, the promise looks like this now:
// { status: "pending", onsuccess: [handleResponse], onfailure: [handleError] }

const handleResponse = (response) => {
  // handleResponse will automatically receive the response,
  // because the promise resolves with a value and automatically injects into the function
  console.log(response)
}

const handleError = (response) => {
  // handleError will automatically receive the error,
  // because the promise resolves with a value and automatically injects into the function
  console.log(response)
}

// the promise will either resolve or reject causing it to run all of the registered functions in the respective arrays
// injecting the value. Let's inspect the happy path:
// 1. XHR event listener fires
// 2. If the request was successfull, the onload event listener triggers
// 3. The onload fires the resolve(VALUE) function with given value
// 4. Resolve triggers and schedules the functions registered with .then

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

عندما نستخدم الـ Promises، فإننا نربط دوالنا بالـ Promise للتعامل مع السيناريوهات المختلفة. هذا يعمل، لكننا لا نزال بحاجة إلى التعامل مع منطقنا داخل دوال الاستدعاء (callbacks) (الدوال المتداخلة) بمجرد استعادة النتائج. ماذا لو تمكنا من استخدام الـ Promises ولكن نكتب كودًا يبدو متزامنًا؟ يتضح أننا نستطيع.

Async/Await: تبسيط البرمجة غير المتزامنة

Async/Await هي طريقة لكتابة الـ Promises تسمح لنا بكتابة كود غير متزامن بطريقة متزامنة. دعنا نلقي نظرة.

const getData = async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
  const data = await response.json()
  console.log(data)
}

getData()

لم يتغير شيء تحت الغطاء هنا. لا نزال نستخدم الـ Promises لجلب البيانات، لكنها الآن تبدو متزامنة، ولم يعد لدينا كتل .then() و .catch(). Async/Await هو في الواقع مجرد syntactic sugar (تسهيل نحوي) يوفر طريقة لإنشاء كود أسهل في الفهم، دون تغيير الديناميكية الأساسية.

دعنا نلقي نظرة على كيفية عمله. يسمح لنا Async/Await باستخدام المولدات (generators) لإيقاف تنفيذ دالة مؤقتًا. عندما نستخدم async/await، فإننا لا نقوم بالـ blocking لأن الدالة تعيد التحكم إلى البرنامج الرئيسي. ثم عندما يتم حل الـ Promise، نستخدم المولد لإعادة التحكم إلى الدالة غير المتزامنة مع القيمة من الـ Promise الذي تم حله. يمكنك قراءة المزيد هنا للحصول على نظرة عامة رائعة على المولدات والكود غير المتزامن.

في الواقع، يمكننا الآن كتابة كود غير متزامن يبدو ككود متزامن. مما يعني أنه أسهل في الفهم، ويمكننا استخدام أدوات متزامنة لمعالجة الأخطاء مثل try/catch:

const getData = async () => {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
    const data = await response.json()
    console.log(data)
  } catch (err) {
    console.log(err)
  }
}

getData()

حسنًا. فكيف نستخدمه؟ من أجل استخدام async/await، نحتاج إلى إضافة الكلمة المفتاحية async قبل الدالة. هذا لا يجعلها دالة غير متزامنة، بل يسمح لنا فقط باستخدام await بداخلها. سيؤدي الفشل في توفير الكلمة المفتاحية async إلى خطأ في بناء الجملة (syntax error) عند محاولة استخدام await داخل دالة عادية.

const getData = async () => {
  console.log("We can use await in this function")
}

بسبب هذا، لا يمكننا استخدام async/await في الكود ذي المستوى الأعلى (top-level code). ولكن async و await لا يزالان مجرد syntactic sugar فوق الـ Promises. لذا يمكننا التعامل مع حالات المستوى الأعلى باستخدام سلاسل الـ Promises:

async function getData() {
  let response = await fetch('http://apiurl.com');
  // ...
}

// getData is a promise
getData().then(res => console.log(res)).catch(err => console.log(err));

يكشف هذا حقيقة أخرى مثيرة للاهتمام حول async/await. عند تعريف دالة على أنها async، فإنها ستعيد دائمًا Promise. قد يبدو استخدام async/await كالسحر في البداية. ولكن مثل أي سحر، إنها مجرد تقنية متقدمة بما فيه الكفاية تطورت على مر السنين. نأمل أن يكون لديك الآن فهم قوي للأساسيات، ويمكنك استخدام async/await بثقة.

الخلاصة التقنية

إذا وصلت إلى هنا، تهانينا! لقد أضفت للتو قطعة أساسية من المعرفة حول JavaScript وكيف تعمل مع بيئاتها إلى صندوق أدواتك. هذا الموضوع محير بالتأكيد، والخطوط الفاصلة ليست واضحة دائمًا. ولكن الآن نأمل أن يكون لديك فهم لكيفية عمل JavaScript مع الكود غير المتزامن في المتصفح، وفهم أقوى لكل من الـ Promises و Async/Await. إن إتقان هذه المفاهيم يمثل حجر الزاوية في بناء تطبيقات ويب حديثة وفعالة، حيث يتيح للمطورين كتابة كود نظيف وقابل للصيانة، مع الحفاظ على تجربة مستخدم سلسة وغير متقطعة.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *