لقد تمّ إبلاغك بأنّه "يجب عدم حظر سلسلة التعليمات الرئيسية" و"تقسيم المهام الطويلة"، ولكن ما المقصود بتنفيذ هذه الإجراءات؟
تتلخّص النصائح الشائعة للحفاظ على سرعة تطبيقات JavaScript في النصائح التالية:
- "لا تحظر سلسلة المحادثات الرئيسية."
- "تقسيم المهام الطويلة"
هذه نصيحة رائعة، ولكن ما هو العمل الذي ينطوي عليه ذلك؟ من الجيد استخدام قدر أقل من JavaScript، ولكن هل يعني ذلك تلقائيًا أنّ واجهة المستخدم ستكون أكثر استجابةً؟ ربما، ولكن ربما لا.
لفهم كيفية تحسين المهام في JavaScript، عليك أولاً معرفة المهام وكيفية تعامل المتصفّح معها.
ما هي المهمة؟
المهمة هي أي عمل منفصل يؤديه المتصفّح. ويشمل هذا العمل عرض HTML وCSS وتحليلهما وتشغيل JavaScript وأنواع أخرى من العمل التي قد لا يكون لديك سيطرة مباشرة عليها. من بين كل هذه المهام، يُعدّ JavaScript الذي تكتبه هو المصدر الأكبر للمهام.
تؤثر المهام المرتبطة بلغة JavaScript في الأداء بطريقتَين:
- عندما ينزِّل المتصفّح ملف JavaScript أثناء بدء التشغيل، يضيف المهام إلى قائمة الانتظار لتحليل JavaScript هذا وتجميعه حتى يمكن تنفيذه لاحقًا.
- في الأوقات الأخرى أثناء عرض الصفحة، يتم وضع المهام في قائمة الانتظار عندما يعمل JavaScript، مثل زيادة التفاعلات من خلال معالجات الأحداث والرسوم المتحرّكة المستندة إلى JavaScript والأنشطة التي تعمل في الخلفية، مثل جمع الإحصاءات.
تحدث كل هذه الإجراءات في سلسلة المهام الرئيسية، باستثناء عمال الويب وواجهات برمجة التطبيقات المشابهة.
ما هو الموضوع الرئيسي؟
الخيط الرئيسي هو المكان الذي يتم فيه تنفيذ معظم المهام في المتصفّح، وحيث يتم تنفيذ كل مقتطفات JavaScript التي تكتبها تقريبًا.
لا يمكن لسلسلة التعليمات الرئيسية معالجة أكثر من مهمة واحدة في كل مرة. أي مهمة تستغرق أكثر من 50 ملي ثانية هي مهمة طويلة. بالنسبة إلى المهام التي تتجاوز 50 ملي ثانية، يُعرف إجمالي وقت المهمة مطروحًا منه 50 ملي ثانية باسم فترة الحظر للمهمة.
يحظر المتصفّح حدوث التفاعلات أثناء تنفيذ مهمة بأي مدة، ولكن لا يلاحظ المستخدم ذلك ما دامت المهام لا يتم تنفيذها لفترة طويلة جدًا. عندما يحاول المستخدم التفاعل مع صفحة عندما تكون هناك العديد من المهام الطويلة، ستبدو واجهة المستخدم غير متجاوبة، وقد تتعطل في حال حظر سلسلة المحادثات الرئيسية لفترات طويلة جدًا.
لمنع حظر سلسلة التعليمات الرئيسية لفترة طويلة جدًا، يمكنك تقسيم مهمة طويلة إلى عدة مهام أصغر.
وهذا مهم لأنّه عند تقسيم المهام، يمكن للمتصفّح الاستجابة للعمل ذو الأولوية الأعلى في وقت أقرب بكثير، بما في ذلك تفاعلات المستخدمين. بعد ذلك، يتم تنفيذ المهام المتبقية إلى أن تكتمل، ما يضمن إنجاز العمل الذي وضعته في "قائمة الانتظار" في البداية.
في أعلى الشكل السابق، كان على معالِج الأحداث الذي تم وضعه في "قائمة الانتظار" من خلال تفاعل المستخدِم الانتظار حتى تكتمل مهمة واحدة طويلة قبل أن يبدأ، ما يؤخّر حدوث التفاعل. في هذا السيناريو، قد يلاحظ المستخدم تأخُّرًا في التحميل. في أسفل الصفحة، يمكن أن يبدأ معالِج الأحداث في العمل بشكل أسرع، وقد يبدو التفاعل فوريًا.
الآن بعد أن عرفت مدى أهمية تقسيم المهام، يمكنك التعرّف على كيفية إجراء ذلك في JavaScript.
استراتيجيات إدارة المهام
من النصائح الشائعة في هندسة البرامج تقسيم عملك إلى وظائف أصغر:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
في هذا المثال، هناك دالة باسم saveSettings()
تستدعي خمس دوال للتحقّق من صحة نموذج وعرض مؤشر تقدم ونقل البيانات إلى الخلفية في التطبيق وتعديل واجهة المستخدم وإرسال الإحصاءات.
من الناحية النظرية، تم تصميم saveSettings()
بشكل جيد. إذا كنت بحاجة إلى تصحيح أخطاء إحدى هذه الدوالّ، يمكنك التنقّل في شجرة المشروع لمعرفة ما تفعله كلّ وظيفة. من خلال تقسيم العمل بهذه الطريقة، يصبح من الأسهل التنقّل في المشاريع والحفاظ عليها.
ومع ذلك، هناك مشكلة محتملة هنا وهي أنّ JavaScript لا تُنفِّذ كلّ دالة من هذه الدوالّ كمهام منفصلة لأنّها يتم تنفيذها ضمن دالة saveSettings()
. هذا يعني أنّه سيتم تنفيذ جميع الدوالّ الخمس كمهمة واحدة.
في أفضل السيناريوهات، يمكن أن تساهم وظيفة واحدة فقط من هذه الدوال في زيادة إجمالي مدة المهمة بمقدار 50 ملي ثانية أو أكثر. وفي أسوأ الحالات، يمكن تنفيذ المزيد من هذه المهام لفترة أطول، خاصةً على الأجهزة التي تتضمّن موارد محدودة.
تأجيل تنفيذ الرمز يدويًا
من الطرق التي استخدمها المطوّرون لتقسيم المهام إلى مهام أصغر هي 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()
ليست الأداة المناسبة لهذه المهمة، على الأقل عند استخدامها بهذه الطريقة.
استخدِم async
/await
لإنشاء نقاط العائد
للتأكّد من تنفيذ المهام المهمة الموجّهة للمستخدم قبل المهام ذات الأولوية الأقل، يمكنك الاستسلام للسلسلة الرئيسية من خلال إيقاف قائمة المهام مؤقتًا لمنح المتصفّح فرصًا لتنفيذ مهام أكثر أهمية.
كما شرحنا سابقًا، يمكن استخدام setTimeout
للسماح لسلسلة المهام الرئيسية بالتقدّم. ومع ذلك، للتيسير وسهولة القراءة، يمكنك استدعاء setTimeout
ضمن Promise
وضبط أسلوب resolve
كطريقة للردّ.
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
تتمثل فائدة دالة yieldToMain()
في أنّه يمكنك await
ها في أيّ دالة async
. استنادًا إلى المثال السابق، يمكنك إنشاء صفيف من الدوالّ لتشغيلها، والتسليم إلى الخيط الرئيسي بعد تنفيذ كلّ منها:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
والنتيجة هي أنّ المهمة التي كانت متكاملة تم تقسيمها الآن إلى مهام منفصلة.
واجهة برمجة تطبيقات مخصّصة لجدولة المهام
setTimeout
هي طريقة فعّالة لتقسيم المهام، ولكن يمكن أن يكون لها عيب: عند التنازل عن سلسلة المهام الرئيسية من خلال تأجيل الرمز لتشغيله في مهمة لاحقة، تتم إضافة هذه المهمة إلى نهاية قائمة الانتظار.
إذا كنت تتحكّم في كل الرموز البرمجية على صفحتك، من الممكن إنشاء جدول زمني خاص بك مع إمكانية تحديد أولويات المهام، ولكن لن تستخدم النصوص البرمجية التابعة لجهات خارجية جدولك الزمني. وبالتالي، لا يمكنك تحديد أولويات العمل في هذه البيئات. يمكنك تقسيمها فقط أو التفاعل مع المستخدمين بشكل صريح.
توفّر واجهة برمجة التطبيقات Scheduler API الدالة postTask()
التي تتيح جدولة المهام بدقة أكبر، وهي إحدى الطرق لمساعدة المتصفّح في تحديد أولويات العمل بحيث تُعطى المهام ذات الأولوية المنخفضة الأولوية للسلسلة الرئيسية. يستخدم postTask()
الوعود، ويقبل أحد إعدادات priority
الثلاثة التالية:
-
'background'
للمهام ذات الأولوية الأدنى -
'user-visible'
للمهام ذات الأولوية المتوسطة هذا هو الإعداد التلقائي في حال عدم ضبطpriority
. 'user-blocking'
للمهام الحرجة التي يجب تنفيذها بأولوية عالية.
خذ الرمز البرمجي التالي كمثال، حيث يتم استخدام واجهة برمجة التطبيقات postTask()
لتشغيل ثلاث مهام بأعلى أولوية ممكنة، والمهمتَين المتبقيتَين بأدنى أولوية ممكنة.
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
في هذه الحالة، يتم جدولة أولوية المهام بطريقة تتيح للمهام التي يمنح المتصفّح لها الأولوية، مثل تفاعلات المستخدمين، أن تُنفَّذ بين المهام الأخرى حسب الحاجة.
في ما يلي مثال بسيط على كيفية استخدام postTask()
. من الممكن إنشاء عناصر TaskController
مختلفة يمكنها مشاركة الأولويات بين المهام، بما في ذلك إمكانية تغيير الأولويات لمثيلات TaskController
المختلفة حسب الحاجة.
العائد المضمّن مع المتابعة باستخدام واجهة برمجة التطبيقات scheduler.yield()
scheduler.yield()
هي واجهة برمجة تطبيقات مصمّمة خصيصًا للسماح لسلسلة المحادثات الرئيسية في المتصفّح بالعمل. يشبه استخدامها استخدام الدالة yieldToMain()
الموضّحة سابقًا في هذا الدليل:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
هذا الرمز مألوف إلى حد كبير، ولكن بدلاً من استخدام yieldToMain()
، يتم استخدام
await scheduler.yield()
.
تتمثل فائدة scheduler.yield()
في الاستمرار، ما يعني أنّه في حال إيقاف المعالجة في منتصف مجموعة من المهام، ستستمر المهام المُجدوَلة الأخرى بالترتيب نفسه بعد نقطة الإيقاف. يؤدي ذلك إلى منع الرموز البرمجية من النصوص البرمجية التابعة لجهات خارجية من مقاطعة ترتيب تنفيذ الرمز البرمجي.
عدم استخدام isInputPending()
توفّر واجهة برمجة التطبيقات isInputPending()
طريقة للتحقّق مما إذا حاول أحد المستخدِمين التفاعل مع صفحة، ولا تُعرِض أيّ نتائج إلا إذا كانت هناك بيانات في انتظار المراجعة.
يتيح ذلك لـ JavaScript مواصلة العمل في حال عدم توفّر أي إدخالات معلّقة، بدلاً من التوقّف والانتقال إلى نهاية قائمة المهام. ويمكن أن يؤدّي ذلك إلى تحسينات مُبهرة في الأداء، كما هو موضّح بالتفصيل في Intent to Ship، وذلك للمواقع الإلكترونية التي قد لا تعود إلى سلسلة المحادثات الرئيسية بخلاف ذلك.
ومع ذلك، منذ إطلاق واجهة برمجة التطبيقات هذه، زاد فهمنا للأداء، لا سيما مع طرح نموذج INP. لم نعُد نقترح استخدام واجهة برمجة التطبيقات هذه، وننصحك بدلاً من ذلك بتقديم البيانات بغض النظر عمّا إذا كانت البيانات في انتظار المراجعة أم لا لعدة أسباب:
- قد يعرض
isInputPending()
خطأً القيمةfalse
على الرغم من تفاعل المستخدِم في بعض الحالات. - الإدخال ليس الحالة الوحيدة التي يجب أن تؤدي فيها المهام إلى نتيجة. يمكن أن تكون الصور المتحركة وغيرها من التعديلات العادية على واجهة المستخدم مهمة بنفس القدر لتوفير صفحة ويب متجاوبة.
- ومنذ ذلك الحين، تمّت إضافة واجهات برمجة تطبيقات أكثر شمولية تعالج المخاوف المتعلّقة بتحقيق الربح، مثل
scheduler.postTask()
وscheduler.yield()
.
الخاتمة
إنّ إدارة المهام أمر صعب، ولكنّ ذلك يضمن استجابة صفحتك بشكل أسرع لتفاعلات المستخدمين. لا تتوفّر نصيحة واحدة لإدارة المهام وتحديد أولوياتها، بل هناك عدد من الأساليب المختلفة. للتذكير، إليك النقاط الرئيسية التي يجب مراعاتها عند إدارة المهام:
- يجب أن تتخلّى عن السلسلة الرئيسية للمهام المهمة الموجَّهة للمستخدمين.
- تحديد أولويات المهام باستخدام
postTask()
- ننصحك بتجربة
scheduler.yield()
. - أخيرًا، ابذل أقل قدر ممكن من الجهد في دوالّك.
باستخدام أداة واحدة أو أكثر من هذه الأدوات، من المفترض أن تتمكّن من تنظيم العمل في تطبيقك بحيث يمنح الأولوية لاحتياجات المستخدم، مع ضمان إنجاز العمل الأقل أهمية. سيؤدي ذلك إلى توفير تجربة مستخدم أفضل وأكثر سلاسة وممتعة.
نشكر فيليب والتون على الفحص الفني لهذا الدليل.
صورة مصغّرة مصدرها Unsplash، من إبداع Amirali Mirhashemian.