وصلتك عبارة "لا تحظر سلسلة التعليمات الرئيسية" أو "تقسّم مهامك الطويلة"، ولكن ماذا يعني تنفيذ هذه الإجراءات؟
إذا كنت تقرأ الكثير من الأشياء حول أداء الويب، فإن النصيحة المتعلقة بالحفاظ على سرعة تطبيقات JavaScript تميل إلى إشراك بعض هذه الأشياء الحاسمة:
- "عدم حظر سلسلة التعليمات الرئيسية"
- "تقسيم المهام الطويلة".
ماذا يعني أي من ذلك؟ من المفيد توفير شحن أقل من JavaScript، ولكن هل يعادل ذلك تلقائيًا واجهات مستخدم أكثر سرعة على مدار مراحل نشاط الصفحة؟ ربما لكن ربما لا.
للتعرّف على سبب أهمية تحسين المهام في JavaScript، عليك فهم دور المهام وكيفية تعامل المتصفح معها، ويبدأ ذلك بفهم ماهية المهمة.
ما المهمة؟
المهمة هي أي عمل منفصل ينفّذه المتصفّح. تتضمن المهام أعمالاً مثل العرض، وتحليل HTML وCSS، وتشغيل رمز JavaScript الذي تكتبه، وأشياء أخرى قد لا يكون لديك تحكّم مباشر فيها. من بين كل ذلك، تُعدّ لغة JavaScript التي تكتبها وتنشرها على الويب مصدرًا رئيسيًا للمهام.
تؤثر المهام على الأداء بطريقتين. على سبيل المثال، عندما ينزِّل متصفّح ملف JavaScript أثناء بدء التشغيل، يضع المهام في قائمة انتظار لتحليل JavaScript وتجميعها بحيث يمكن تنفيذها. في وقت لاحق من دورة حياة الصفحة، تبدأ المهام عند عمل JavaScript، مثل توجيه التفاعلات من خلال معالجات الأحداث، والرسوم المتحركة المستندة إلى JavaScript، ونشاط الخلفية، مثل مجموعة "إحصاءات Google". تحدث كل هذه الأمور في سلسلة التعليمات الرئيسية باستثناء العاملين على الويب وواجهات برمجة التطبيقات المشابهة.
ما هي سلسلة التعليمات الرئيسية؟
تشير سلسلة التعليمات الرئيسية إلى المكان الذي يتم فيه تشغيل معظم المهام في المتصفِّح. ويُطلق عليها اسم سلسلة التعليمات الرئيسية لسبب ما: إنها السلسلة الوحيدة تقريبًا التي تؤدي فيها كل رموز JavaScript التي تكتبها.
يمكن لسلسلة التعليمات الرئيسية معالجة مهمة واحدة فقط في كل مرة. عندما تتجاوز المهام نقطة تتجاوز نقطة معيّنة - 50 مللي ثانية على وجه التحديد - يتم تصنيفها على أنها مهام طويلة. إذا كان المستخدم يحاول التفاعل مع الصفحة أثناء تشغيل مهمة طويلة - أو في حالة الحاجة إلى إجراء تحديث مهم للعرض - سيتأخر المتصفح في التعامل مع هذا العمل. وينتج عن ذلك وقت استجابة التفاعل أو العرض.
تحتاج إلى تقسيم المهام. وهذا يعني تخصيص مهمة واحدة طويلة وتقسيمها إلى مهام أصغر تستغرق وقتًا أقل لتشغيلها بشكل فردي.
وهذا مهم لأنّه عند تقسيم المهام، يحصل المتصفّح على فرص أكبر للاستجابة للأعمال ذات الأولوية الأعلى، ويشمل ذلك تفاعلات المستخدمين.
في الجزء العلوي من الشكل السابق، كان على معالج الحدث في قائمة الانتظار من خلال تفاعل المستخدم أن ينتظر مهمة طويلة قبل أن يتم تشغيلها، وهذا يؤخر التفاعل. في الجزء السفلي، لدى معالج الحدث فرصة للتشغيل بشكل أسرع. نظرًا لأن معالج الحدث لديه فرصة للتشغيل بين المهام الأصغر، يتم تشغيله في وقت أقرب مما لو كان عليه الانتظار حتى تنتهي مهمة طويلة. في المثال العلوي، ربما لاحظ المستخدم تأخُّرًا، وفي الجزء السفلي، ربما بدا التفاعل فوريًا.
ولكن تكمن المشكلة في أن النصيحة "تقسيم المهام الطويلة" و"عدم حظر سلسلة التعليمات الرئيسية" ليست محددة بما يكفي ما لم تكن تعرف كيفية تنفيذ هذه الإجراءات. هذا ما سيشرحه هذا الدليل.
استراتيجيات إدارة المهام
من النصائح الشائعة في بنية البرامج تقسيم عملك إلى وظائف أصغر. يمنحك هذا فوائد سهولة قراءة التعليمة البرمجية بشكل أفضل، وإمكانية صيانة المشروع. وهذا أيضًا يجعل كتابة الاختبارات أسهل.
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
في هذا المثال، هناك دالة تسمى 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()
الأداة المناسبة للمهمة، على الأقل ليس عند استخدامها بهذه الطريقة.
بالإضافة إلى setTimeout()
، هناك بعض واجهات برمجة التطبيقات الأخرى التي تتيح لك تأجيل تنفيذ الرمز إلى مهمة لاحقة. ويتطلّب أحدها استخدام postMessage()
للحصول على مهلات أسرع. يمكنك أيضًا تقسيم العمل باستخدام "requestIdleCallback()
"، ولكن يُرجى الانتباه! حيث تعمل ميزة "requestIdleCallback()
" على جدولة المهام بأقل أولوية ممكنة، وذلك فقط أثناء وقت عدم نشاط المتصفِّح. عندما تكون سلسلة المحادثات الرئيسية مزدحمة، قد لا يتم مطلقًا تنفيذ المهام المُجدوَلة باستخدام "requestIdleCallback()
".
استخدِم async
/await
لإنشاء نقاط العائدات.
هناك عبارة ستظهر في بقية هذا الدليل "خاضعة لسلسلة التعليمات الرئيسية"، ولكن ماذا يعني ذلك؟ لماذا يجب عليك القيام بذلك؟ متى يجب إجراء ذلك؟
عندما يتم تقسيم المهام، يمكن منح الأولوية للمهام الأخرى بشكل أفضل من خلال مخطط تحديد الأولويات الداخلي للمتصفّح. من بين الطرق التي يمكن اتّباعها لتقديم سلسلة التعليمات الرئيسية استخدام مزيج من Promise
التي يتم حلّها من خلال الطلب setTimeout()
:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
في الدالة saveSettings()
، يمكنك تقديم سلسلة التعليمات الرئيسية بعد كل جزء من العمل في حال await
باستخدام الدالة 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:
await yieldToMain();
}
}
والنتيجة هي أن المهمة المترابطة في السابق يتم تقسيمها الآن إلى مهام منفصلة.
تتمثل فائدة استخدام منهج يعتمد على الوعد في تحقيق الأرباح بدلاً من الاستخدام اليدوي لـ setTimeout()
في توفير هندسة أفضل. تصبح نقاط المحصلة بيانات وصفية، وبالتالي تكون أسهل في الكتابة والقراءة والفهم.
الكمية الناتجة عند الضرورة فقط
ماذا لو كان لديك مجموعة من المهام، ولكنك لا تريد الاستسلام إلا إذا حاول المستخدم التفاعل مع الصفحة؟ هذا هو النوع من الأشياء التي تم إنشاء isInputPending()
من أجلها.
isInputPending()
هي دالة يمكنك تشغيلها في أي وقت لتحديد ما إذا كان المستخدم يحاول التفاعل مع عنصر صفحة: سيؤدي استدعاء isInputPending()
إلى عرض true
. وتعرض false
في الحالات الأخرى.
لنفترض أن لديك قائمة انتظار للمهام التي تحتاج إلى تشغيلها، لكنك لا تريد إعاقة أي إدخالات. يضمن هذا الرمز، الذي يستخدم كلاً من isInputPending()
ودالة yieldToMain()
المخصّصة، عدم تأخير الإدخال أثناء محاولة المستخدم التفاعل مع الصفحة:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
while (tasks.length > 0) {
// Yield to a pending user input:
if (navigator.scheduling.isInputPending()) {
// There's a pending user input. Yield here:
await yieldToMain();
} else {
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
}
أثناء تشغيل saveSettings()
، سيتم تكرار المهام في قائمة الانتظار. وإذا عرضت isInputPending()
القيمة true
أثناء التكرار الحلقي، ستستدعي saveSettings()
الدالة yieldToMain()
بحيث يمكن التعامل مع البيانات التي أدخلها المستخدم. خلاف ذلك، سينقل المهمة التالية من مقدمة قائمة الانتظار ويشغلها بشكل مستمر. سيؤدي ذلك إلى إجراء ذلك حتى يتم إيقاف المزيد من المهام.
ويُعدّ استخدام isInputPending()
إلى جانب آلية للحصول على النتائج وسيلة رائعة لإيقاف أي مهام يعالجها المتصفّح، ما يتيح له الاستجابة لتفاعلات المستخدمين المهمّة. ويمكن أن يساعد ذلك في تحسين قدرة صفحتك على الاستجابة للمستخدم في العديد من الحالات عندما تكون هناك العديد من المهام أثناء الطيران.
هناك طريقة أخرى لاستخدام isInputPending()
، خاصةً إذا كنت تريد توفير عنصر احتياطي للمتصفّحات غير المتوافقة، وهي استخدام منهج يستند إلى الوقت إلى جانب عامل تشغيل التسلسل الاختياري:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
let deadline = performance.now() + 50;
while (tasks.length > 0) {
// Optional chaining operator used here helps to avoid
// errors in browsers that don't support `isInputPending`:
if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
// There's a pending user input, or the
// deadline has been reached. Yield here:
await yieldToMain();
// Extend the deadline:
deadline = performance.now() + 50;
// Stop the execution of the current loop and
// move onto the next iteration:
continue;
}
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
بهذه الطريقة، يمكنك اتخاذ إجراء احتياطي للمتصفّحات التي لا تتوافق مع isInputPending()
، وذلك من خلال اتّباع منهج يستند إلى الوقت يستخدم (ويعدِّل) موعدًا نهائيًا حتى يتم تقسيم العمل عند الضرورة، سواء من خلال إبداء الموافقة على ملاحظات المستخدم أو عند وقت معيّن.
الثغرات في واجهات برمجة التطبيقات الحالية
يمكن أن تساعدك واجهات برمجة التطبيقات المذكورة حتى الآن في تقسيم المهام، ولكن لها جانب سلبي كبير: عندما تستسلم لسلسلة التعليمات الرئيسية عن طريق تأجيل الكود لتشغيله في مهمة لاحقة، تتم إضافة هذا الرمز إلى نهاية قائمة انتظار المهام.
إذا كنت تتحكم في كل التعليمات البرمجية الموجودة على صفحتك، فمن الممكن إنشاء برنامج الجدولة الخاص بك مع إمكانية تحديد أولويات المهام، ولكن لن تستخدم النصوص البرمجية للجهات الخارجية أداة الجدولة. وبالتالي، لا يمكنك وضع أولوية للعمل في مثل هذه البيئات. ويمكنك فقط تقسيمها أو الاستجابة بشكل صريح لتفاعلات المستخدم.
لحسن الحظ، هناك واجهة برمجة تطبيقات مخصصة لنظام الجدولة قيد التطوير حاليًا تعالج هذه المشكلات.
واجهة برمجة تطبيقات مخصّصة لنظام الجدولة
توفر واجهة برمجة التطبيقات لنظام جدولة المهام حاليًا الوظيفة postTask()
، والتي تتوفر وقت كتابة هذا التقرير في متصفّحات Chromium، وفي متصفّح Firefox خلف علامة. تتيح postTask()
جدولة المهام بشكل أكثر دقة، وهي إحدى الطرق التي تساعد المتصفِّح في تحديد أولويات العمل، بحيث يتم حل المهام ذات الأولوية المنخفضة في سلسلة التعليمات الرئيسية. يستخدم postTask()
الوعود ويقبل إعداد priority
.
تتضمّن واجهة برمجة التطبيقات postTask()
ثلاث أولويات يمكنك استخدامها:
'background'
للمهام ذات الأولوية الأقل'user-visible'
للمهام ذات الأولوية المتوسطة. وهذا هو الخيار التلقائي في حال لم يتم ضبطpriority
.'user-blocking'
للمهام المهمة التي يجب تشغيلها بأولوية عالية.
على سبيل المثال، يمكنك استخدام الرمز البرمجي التالي، حيث يتم استخدام واجهة برمجة التطبيقات postTask()
API لتنفيذ ثلاث مهام بأعلى أولوية ممكنة، والمهمة المتبقية بأقل أولوية ممكنة.
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()
لعرض سلسلة التعليمات الرئيسية عندما يحاول المستخدم التفاعل مع الصفحة. - يمكنك منح الأولوية للمهام باستخدام "
postTask()
". - أخيرًا، نفِّذ أقل قدر ممكن من العمل في الدوال.
باستخدام واحدة أو أكثر من هذه الأدوات، يجب أن تكون قادرًا على تنظيم العمل في تطبيقك بحيث يعطي الأولوية لاحتياجات المستخدم، مع ضمان إنجاز العمل الأقل أهمية. سيؤدي ذلك إلى توفير تجربة مستخدم أفضل وأكثر استجابة وأكثر متعة للاستخدام.
شكر خاص لفيليب والتون على التدقيق التقني في هذه المقالة.
صورة رئيسية مصدرها Unsplash، مقدّمة من أميرالي ميرهاشيميان