لقد قيل لك "لا تحظر سلسلة التعليمات الرئيسية" و"قسِّم المهام الطويلة"، ولكن ماذا يعني ذلك؟
تاريخ النشر: 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، وهي مقدَّمة من أميرعلي ميرهاشميان.