تحسين المهام الطويلة

لقد تمّ إبلاغك بأنّه "يجب عدم حظر سلسلة التعليمات الرئيسية" و"تقسيم المهام الطويلة"، ولكن ما المقصود بتنفيذ هذه الإجراءات؟

تاريخ النشر: 30 أيلول (سبتمبر) 2022، تاريخ آخر تعديل: 19 كانون الأول (ديسمبر) 2024

تتلخّص النصائح الشائعة للحفاظ على سرعة تطبيقات JavaScript في ما يلي:

  • "لا تحظر سلسلة المحادثات الرئيسية."
  • "تقسيم المهام الطويلة"

هذه نصيحة رائعة، ولكن ما هو العمل الذي ينطوي عليه ذلك؟ من الجيد استخدام قدر أقل من JavaScript، ولكن هل يعني ذلك تلقائيًا أنّ واجهات المستخدم ستكون أكثر استجابةً؟ ربما، ولكن ربما لا.

لفهم كيفية تحسين المهام في JavaScript، عليك أولاً معرفة المهام وكيفية تعامل المتصفّح معها.

ما هي المهمة؟

المهمة هي أي عمل منفصل يؤديه المتصفّح. ويشمل هذا العمل عرض HTML وCSS وتحليلهما وتشغيل JavaScript وأنواع أخرى من العمل التي قد لا يكون لديك سيطرة مباشرة عليها. من بين كل هذه المهام، يُعدّ JavaScript الذي تكتبه هو المصدر الأكبر للمهام.

تمثيل مرئي لمهمة كما هو موضّح في أداة تحليل الأداء في "أدوات مطوّري البرامج في Chrome" تكون المهمة في أعلى حزمة، مع معالِج حدث النقرة واستدعاء دالة ومزيد من العناصر تحتها. تتضمّن المهمة أيضًا بعض أعمال التقديم على الجانب الأيمن.
مهمة بدأها معالِج أحداث click في، معروضة في أداة تحليل الأداء في "أدوات مطوّري البرامج في Chrome".

تؤثر المهام المرتبطة بلغة JavaScript في الأداء بطريقتَين:

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

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

ما هو الموضوع الرئيسي؟

الخيط الرئيسي هو المكان الذي يتم فيه تنفيذ معظم المهام في المتصفّح، وحيث يتم تنفيذ كل مقتطفات JavaScript التي تكتبها تقريبًا.

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

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

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

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

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

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

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

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

الآن بعد أن عرفت مدى أهمية تقسيم المهام، يمكنك التعرّف على كيفية إجراء ذلك في JavaScript.

استراتيجيات إدارة المهام

من النصائح الشائعة في هندسة البرامج تقسيم عملك إلى وظائف أصغر:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

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

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

ومع ذلك، هناك مشكلة محتملة هنا وهي أنّ JavaScript لا تُنفِّذ كلّ دالة من هذه الدوالّ كمهام منفصلة لأنّها يتم تنفيذها ضمن دالة saveSettings(). هذا يعني أنّه سيتم تنفيذ جميع الدوالّ الخمس كمهمة واحدة.

دالة saveSettings كما هو موضّح في أداة تحليل الأداء في Chrome في حين أنّ الدالة ذات المستوى الأعلى تستدعي خمس دوالّ أخرى، يتم تنفيذ كل العمل في مهمة طويلة واحدة تجعل النتيجة المرئية للمستخدم من تشغيل الدالة غير مرئية إلى أن تكتمل جميعها.
دالة واحدة saveSettings() تستدعي خمس دوال. يتم تنفيذ العمل كجزء من مهمة واحدة طويلة ومتكاملة، ما يؤدي إلى حظر أي استجابة مرئية إلى أن تكتمل جميع الوظائف الخمس.

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

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

تأجيل تنفيذ الرمز يدويًا

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

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

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

يُعرف ذلك باسم القيمة المعروضة، وهو يعمل بشكل أفضل مع سلسلة من الدوالّ التي يجب تشغيلها بشكل تسلسلي.

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

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

يشكّل استخدام setTimeout() هنا مشكلة بسبب ملاءمة المطوّرين، وبعد خمس جولات من setTimeout() المُدمَجة، سيبدأ المتصفّح في فرض تأخير لا يقل عن 5 مللي ثانية لكل setTimeout() إضافي.

هناك عيب آخر في setTimeout أيضًا عند التخلي عن المعالجة: عند التخلي عن المعالجة إلى سلسلة المعالجة الرئيسية من خلال تأجيل الرمز البرمجي لتشغيله في مهمة لاحقة باستخدام setTimeout، تتم إضافة هذه المهمة إلى نهاية قائمة الانتظار. إذا كانت هناك مهام أخرى في انتظار التنفيذ، سيتم تنفيذها قبل الرمز المؤجَّل.

واجهة برمجة تطبيقات مخصّصة لزيادة الأرباح: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not supported.
  • Safari: not supported.

Source

scheduler.yield() هي واجهة برمجة تطبيقات مصمّمة خصيصًا للسماح لسلسلة المحادثات الرئيسية في المتصفّح بالعمل.

وهي ليست بنية خاصة أو بنية على مستوى اللغة، بل هي مجرد دالة تعرض Promise سيتم حلّها في مهمة مستقبلية.scheduler.yield() سيتم بعد ذلك تنفيذ أي رمز برمجي مرتبط بتنفيذه بعد حلّ هذا Promise (إما في سلسلة .then() صريحة أو بعد awaitه في دالة غير متزامنة) في هذه المهمة المستقبلية.

في الممارسة العملية: أدخِل await scheduler.yield() وستؤدّي الدالة إلى إيقاف التنفيذ مؤقتًا في تلك المرحلة وستتوقّف عن العمل لتتيح لسلسلة التعليمات الرئيسية تنفيذ المهام. سيتم جدولة تنفيذ بقية الدالة، والتي تُعرف باسم متابعة الدالة، لتنفيذها في مهمة جديدة لحلقة الأحداث. عند بدء هذه المهمة، سيتم حلّ الوعد المتوقّع، وستستمر الدالة في التنفيذ من حيث توقفت.

async function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
وظيفة saveSettings كما هو موضّح في أداة تحليل الأداء في Chrome، تم تقسيمها الآن إلى مهمتَين. تستدعي المهمة الأولى دالّتَين، ثم تُعطى، ما يسمح بتنفيذ عمل التنسيق والرسم وتقديم استجابة مرئية للمستخدم. ونتيجةً لذلك، يتم الانتهاء من حدث النقر في 64 ملي ثانية بشكل أسرع بكثير. تستدعي المهمة الثانية الدوالّ الثلاث الأخيرة.
تم تقسيم تنفيذ الدالة saveSettings() الآن على مَهمتَين. ونتيجةً لذلك، يمكن تنفيذ التنسيق والرسم بين المهام، ما يمنح المستخدم استجابة مرئية أسرع، كما يتم قياس ذلك من خلال تفاعل المؤشر الأقصر الآن بكثير.

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

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

ثلاثة مخطّطات بيانية تصوّر المهام بدون تسليم، وتسليم، وتسليم ومتابعة. بدون التنازل، هناك مهام طويلة. باستخدام وضع "الاستسلام"، تكون هناك المزيد من المهام الأقصر، ولكن قد تقاطعها مهام أخرى غير ذات صلة. من خلال التوقّف المؤقت والمتابعة، تصبح المهام أقصر، ولكن يتم الحفاظ على ترتيب تنفيذها.
عند استخدام scheduler.yield()، تتابع المهام من حيث توقفت قبل الانتقال إلى مهام أخرى.

التوافق مع جميع المتصفّحات

لا تتوفّر scheduler.yield() بعد في جميع المتصفّحات، لذا يجب توفير بديل.

يتمثل أحد الحلول في إسقاط scheduler-polyfill في الإصدار، ثم يمكن استخدام scheduler.yield() مباشرةً، وسيتولى العنصر التكميلي الرجوع إلى دوال أخرى لجدولة المهام حتى يعمل بشكل مشابه في جميع المتصفّحات.

بدلاً من ذلك، يمكن كتابة إصدار أقل تعقيدًا في بضعة أسطر، باستخدام setTimeout فقط المُغلف في Promise كخيار احتياطي في حال عدم توفّر scheduler.yield().

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

على الرغم من أنّ المتصفحات التي لا تتيح استخدام scheduler.yield() لن تحصل على الميزة "مواصلة مُعطاة الأولوية"، سيظلّ بإمكانها الاستمرار في الاستجابة للمستخدِم.

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

يمكن تنفيذ ذلك من خلال رصد الميزات والرجوع إلى انتظار مهمة صغيرة واحدة في سطر واحد مفيد:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

تقسيم المهام التي تستغرق وقتًا طويلاً باستخدام scheduler.yield()

تتمثل فائدة استخدام أي من طرق استخدام scheduler.yield() هذه في أنّه يمكنك await في أي دالة async.

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

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

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

ومع ذلك، هذا ليس استخدامًا فعّالاً للعوائد. إنّ scheduler.yield() سريع وفعّال، ولكنّه يتطلّب بعض الوقت. إذا كانت بعض المهام في jobQueue قصيرة جدًا، يمكن أن تتراكم النفقات العامة بسرعة لتصبح أكثر من الوقت الذي يُستغرَق في الاستراحة واستئناف العمل الفعلي.

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

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

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

سلسلة من وظائف المهام، التي تظهر في لوحة الأداء في "أدوات مطوّري البرامج في Chrome"، مع تقسيم تنفيذها على مهام متعددة
تم تجميع الوظائف في مهام متعددة.

عدم استخدام isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

توفّر واجهة برمجة التطبيقات isInputPending() طريقة للتحقّق مما إذا حاول أحد المستخدِمين التفاعل مع صفحة، ولا تقدّم أيّ نتائج إلا إذا كانت هناك بيانات في انتظار المراجعة.

يتيح ذلك لـ JavaScript مواصلة العمل في حال عدم توفّر أي إدخالات معلّقة، بدلاً من التوقّف والانتقال إلى نهاية قائمة المهام. ويمكن أن يؤدّي ذلك إلى تحسينات مُبهرة في الأداء، كما هو موضّح بالتفصيل في Intent to Ship، وذلك للمواقع الإلكترونية التي قد لا تعود إلى سلسلة المحادثات الرئيسية بخلاف ذلك.

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

  • قد يعرض isInputPending() خطأً القيمة false على الرغم من تفاعل المستخدِم في بعض الحالات.
  • الإدخال ليس الحالة الوحيدة التي يجب أن تؤدي فيها المهام إلى نتيجة. يمكن أن تكون الصور المتحركة وغيرها من التعديلات العادية على واجهة المستخدم مهمة بنفس القدر لتوفير صفحة ويب متجاوبة.
  • ومنذ ذلك الحين، تمّت إضافة واجهات برمجة تطبيقات أكثر شمولية تعالج المخاوف المتعلّقة بتحقيق الربح، مثل scheduler.postTask() وscheduler.yield().

الخاتمة

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

  • يجب أن تتخلّى عن السلسلة الرئيسية للمهام المهمة الموجَّهة للمستخدمين.
  • استخدِم scheduler.yield() (مع عنصر احتياطي متوافق مع جميع المتصفحات) للخروج من التفاعلات بشكل مريح والحصول على عمليات متابعة مُعطاة الأولوية.
  • أخيرًا، ابذل أقل قدر ممكن من الجهد في دوالّك.

للتعرّف على مزيد من المعلومات عن scheduler.yield() وواجهة برمجة التطبيقات ذات الصلة scheduler.postTask() لتحديد المهام ذات الأولوية، يُرجى الاطّلاع على مستندات Prioritized Task Scheduling API.

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

نشكر فيليب والتون على الفحص الفني لهذا الدليل.

صورة مصغّرة مصدرها Unsplash، من إبداع Amirali Mirhashemian.