لقد قيل لك "لا تحظر سلسلة التعليمات الرئيسية" و"قسِّم المهام الطويلة"، ولكن ماذا يعني تنفيذ هذه الإجراءات؟
تاريخ النشر: 30 سبتمبر 2022، تاريخ آخر تعديل: 19 ديسمبر 2024
تتضمّن النصائح الشائعة للحفاظ على سرعة تطبيقات JavaScript ما يلي:
- "عدم حظر سلسلة التعليمات الرئيسية"
- "تقسيم المهام الطويلة"
هذه نصيحة رائعة، ولكن ما هي المهام التي تتضمّنها؟ إنّ إرسال كمية أقلّ من JavaScript أمر جيد، ولكن هل يؤدي ذلك تلقائيًا إلى توفير واجهات مستخدم أكثر استجابةً؟ ربما، ولكن ربما لا.
لفهم كيفية تحسين المهام في JavaScript، عليك أولاً معرفة ماهية المهام وكيفية تعامل المتصفح معها.
ما هي المهمة؟
المهمة هي أي جزء منفصل من العمل الذي ينفّذه المتصفّح. ويشمل ذلك العرض والتحليل لملفات HTML وCSS وتشغيل JavaScript وأنواعًا أخرى من العمليات التي قد لا يكون لديك تحكّم مباشر فيها. من بين كلّ ذلك، قد يكون JavaScript الذي تكتبه هو أكبر مصدر للمهام.
click، كما هو موضّح في أداة تحليل الأداء في "أدوات مطوّري البرامج في Chrome".
تؤثّر المهام المرتبطة بلغة JavaScript في الأداء بطريقتَين:
- عندما ينزّل المتصفّح ملف JavaScript أثناء بدء التشغيل، يضع المهام في قائمة انتظار لتحليل JavaScript وتجميعه حتى يمكن تنفيذه لاحقًا.
- في أوقات أخرى خلال عمر الصفحة، يتم وضع المهام في قائمة الانتظار عندما ينفّذ JavaScript عملاً، مثل الاستجابة للتفاعلات من خلال معالجات الأحداث، والرسوم المتحركة المستندة إلى JavaScript، والنشاط في الخلفية، مثل جمع الإحصاءات.
يحدث كل ذلك في السلسلة الرئيسية، باستثناء عامل الويب وواجهات برمجة التطبيقات المشابهة.
ما هي سلسلة المحادثات الرئيسية؟
سلسلة التعليمات الرئيسية هي المكان الذي يتم فيه تنفيذ معظم المهام في المتصفّح، ويتم فيه تنفيذ كل مقتطفات JavaScript التي تكتبها تقريبًا.
يمكن لسلسلة التعليمات الرئيسية معالجة مهمة واحدة فقط في كل مرة. أي مهمة تستغرق أكثر من 50 ملي ثانية تُعدّ مهمة يستغرق تنفيذها وقتًا طويلاً. بالنسبة إلى المهام التي تتجاوز 50 ملي ثانية، يُعرف إجمالي وقت المهمة مطروحًا منه 50 ملي ثانية باسم فترة الحظر للمهمة.
يحظر المتصفّح حدوث التفاعلات أثناء تنفيذ مهمة بأي مدة، ولكن لا يمكن للمستخدم ملاحظة ذلك طالما أنّ المهام لا تستغرق وقتًا طويلاً. عندما يحاول المستخدم التفاعل مع صفحة تتضمّن العديد من المهام الطويلة، سيشعر بأنّ واجهة المستخدم لا تستجيب، وقد تبدو معطّلة إذا تم حظر سلسلة التعليمات الرئيسية لفترات طويلة جدًا.
لمنع حظر سلسلة التعليمات الرئيسية لفترة طويلة جدًا، يمكنك تقسيم مهمة يستغرق تنفيذها وقتًا طويلاً إلى عدة مهام أصغر.
هذا الأمر مهم لأنّه عند تقسيم المهام، يمكن للمتصفّح الاستجابة للمهام ذات الأولوية الأعلى بشكل أسرع بكثير، بما في ذلك تفاعلات المستخدمين. بعد ذلك، يتم تنفيذ المهام المتبقية حتى اكتمالها، ما يضمن إنجاز العمل الذي أضفته إلى قائمة الانتظار في البداية.
في أعلى الشكل السابق، كان على معالج الأحداث الذي تم وضعه في صفّ الانتظار من خلال تفاعل المستخدم أن ينتظر مهمة يستغرق تنفيذها وقتًا طويلاً قبل أن يتمكّن من البدء، ما يؤخّر حدوث التفاعل. في هذا السيناريو، من المحتمل أنّ المستخدم لاحظ تأخيرًا. في أسفل الصفحة، يمكن أن يبدأ معالج الأحداث في العمل بشكل أسرع، وقد يبدو التفاعل فوريًا.
بعد أن تعرّفت على أهمية تقسيم المهام، يمكنك التعرّف على كيفية إجراء ذلك في JavaScript.
استراتيجيات إدارة المهام
من النصائح الشائعة في تصميم البرامج تقسيم العمل إلى وظائف أصغر:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
في هذا المثال، هناك دالة باسم saveSettings() تستدعي خمس دوال للتحقّق من صحة نموذج، وعرض أداة تحميل، وإرسال البيانات إلى الخلفية البرمجية للتطبيق، وتعديل واجهة المستخدم، وإرسال الإحصاءات.
من الناحية النظرية، تم تصميم saveSettings() بشكل جيد. إذا كنت بحاجة إلى تصحيح خطأ في إحدى هذه الدوال، يمكنك الانتقال إلى شجرة المشروع لمعرفة وظيفة كل دالة. يؤدي تقسيم العمل بهذه الطريقة إلى تسهيل التنقّل في المشاريع وصيانتها.
مع ذلك، تكمن المشكلة المحتملة هنا في أنّ JavaScript لا ينفّذ كلّ دالة من هذه الدوال كمهام منفصلة لأنّها تُنفَّذ ضمن الدالة saveSettings(). هذا يعني أنّ جميع الدوال الخمس سيتم تشغيلها كمهمة واحدة.
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()
scheduler.yield() هي واجهة برمجة تطبيقات مصمّمة خصيصًا للسماح بتنفيذ سلسلة التعليمات الرئيسية في المتصفّح.
إنّها ليست بنية على مستوى اللغة أو بنية خاصة، بل scheduler.yield() هي مجرد دالة تعرض Promise سيتم حلّها في مهمة مستقبلية. سيتم تنفيذ أي رمز مرتبط ليتم تشغيله بعد حلّ 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() على مهمتَين. نتيجةً لذلك، يمكن تنفيذ التنسيق والرسم بين المهام، ما يمنح المستخدم استجابة مرئية أسرع، كما يقيسها تفاعل المؤشر الأقصر بكثير الآن.
مع ذلك، فإنّ الفائدة الحقيقية من scheduler.yield() مقارنةً بأساليب التنازل الأخرى هي أنّ استمرارها له الأولوية، ما يعني أنّه في حال التنازل في منتصف مهمة، سيتم تنفيذ استمرار المهمة الحالية قبل بدء أي مهام أخرى مماثلة.
يؤدي ذلك إلى تجنُّب مقاطعة ترتيب تنفيذ الرمز من مصادر مهام أخرى، مثل المهام من النصوص البرمجية التابعة لجهات خارجية.
scheduler.yield()، تستأنف المحادثة من حيث توقفت قبل الانتقال إلى مهام أخرى.
التوافق مع المتصفّحات المختلفة
لا تتوافق السمة scheduler.yield() مع جميع المتصفحات بعد، لذا يجب توفير بديل.
أحد الحلول هو إضافة scheduler-polyfill إلى الإصدار، وبعد ذلك يمكن استخدام scheduler.yield() مباشرةً. سيتولّى رمز polyfill الرجوع إلى دوال أخرى لجدولة المهام، وبالتالي سيعمل بشكل مشابه على جميع المتصفحات.
بدلاً من ذلك، يمكن كتابة نسخة أقل تعقيدًا في بضعة أسطر، باستخدام 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 ملي ثانية تقريبًا.
عدم استخدام isInputPending()
توفّر واجهة برمجة التطبيقات isInputPending() طريقة للتحقّق مما إذا كان المستخدم قد حاول التفاعل مع صفحة، ولا تعرض النتيجة إلا إذا كان هناك إدخال معلّق.
يتيح ذلك استمرار JavaScript إذا لم تكن هناك أي مدخلات معلّقة، بدلاً من التوقف والانتهاء في آخر قائمة انتظار المهام. يمكن أن يؤدي ذلك إلى تحسينات كبيرة في الأداء، كما هو موضّح بالتفصيل في Intent to Ship، وذلك للمواقع الإلكترونية التي قد لا تعود إلى سلسلة التعليمات الرئيسية في الحالات العادية.
ومع ذلك، منذ إطلاق واجهة برمجة التطبيقات هذه، زادت معرفتنا بمفهوم "الاستجابة"، لا سيما مع طرح مقياس INP. لم نعد ننصح باستخدام واجهة برمجة التطبيقات هذه، بل ننصح بدلاً من ذلك بالتخلي عن السيطرة على سلسلة التعليمات الرئيسية بغض النظر عمّا إذا كان الإدخال معلّقًا أم لا لعدة أسباب:
- قد تعرض الدالة
isInputPending()القيمةfalseبشكل غير صحيح على الرغم من تفاعل المستخدم في بعض الحالات. - الإدخال ليس الحالة الوحيدة التي يجب فيها إيقاف المهام مؤقتًا. يمكن أن تكون الصور المتحركة وتعديلات واجهة المستخدم العادية الأخرى مهمة بنفس القدر لتوفير صفحة ويب متجاوبة.
- تم منذ ذلك الحين طرح واجهات برمجة تطبيقات أكثر شمولاً لتحسين العائد تعالج مخاوف تحسين العائد، مثل
scheduler.postTask()وscheduler.yield().
الخاتمة
قد يكون من الصعب إدارة المهام، ولكنّ ذلك يضمن استجابة صفحتك بشكل أسرع لتفاعلات المستخدمين. لا توجد نصيحة واحدة لإدارة المهام وتحديد أولوياتها، بل هناك عدد من الأساليب المختلفة. للتأكيد، إليك الأمور الرئيسية التي يجب أخذها في الاعتبار عند إدارة المهام:
- إتاحة سلسلة المحادثات الرئيسية للمهام المهمة التي يراها المستخدم
- استخدِم
scheduler.yield()(مع خيار احتياطي متوافق مع جميع المتصفحات) لتحديد الأولوية في عرض النتائج والحصول على استجابات ذات أولوية - أخيرًا، حاوِل إنجاز أقل قدر ممكن من العمل في الدوال.
لمزيد من المعلومات عن scheduler.yield()، وعن scheduler.postTask() ذات الصلة بجدولة المهام الصريحة، وعن تحديد أولويات المهام، يُرجى الاطّلاع على مستندات Prioritized Task Scheduling API.
باستخدام واحدة أو أكثر من هذه الأدوات، يجب أن تتمكّن من تنظيم العمل في تطبيقك بطريقة تعطي الأولوية لاحتياجات المستخدم، مع ضمان إنجاز المهام الأقل أهمية أيضًا. سيؤدي ذلك إلى تحسين تجربة المستخدم وجعلها أكثر استجابة وأكثر متعة.
نتوجّه بالشكر الخاص إلى فيليب والتون على مراجعته الفنية لهذا الدليل.
الصورة المصغّرة مأخوذة من Unsplash، وهي مقدَّمة من أميرالي ميرهاشميان.