قد تكون سمعت عبارات مثل "لا تحظر سلسلة التعليمات الرئيسية" و"قسِّم المهام التي تستغرق وقتًا طويلاً"، ولكن ما معنى ذلك؟
تاريخ النشر: 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.
باستخدام واحدة أو أكثر من هذه الأدوات، من المفترض أن تتمكّن من تنظيم العمل في تطبيقك بحيث يمنح الأولوية لاحتياجات المستخدم، مع ضمان إنجاز المهام الأقل أهمية. سيؤدي ذلك إلى إنشاء تجربة مستخدم أفضل وأكثر استجابة ومتعة في الاستخدام.
نتوجّه بالشكر إلى فيليب والتون على مراجعته الفنية لهذا الدليل.