بهینه‌سازی تکالیف طولانی

به شما گفته شده است که «رشته اصلی را مسدود نکنید» و «تکالیف طولانی خود را تقسیم کنید»، اما انجام این کارها به چه معناست؟

تاریخ انتشار: ۳۰ سپتامبر ۲۰۲۲، تاریخ آخرین به‌روزرسانی: ۱۹ دسامبر ۲۰۲۴

توصیه رایج برای سریع نگه داشتن برنامه‌های جاوا اسکریپت معمولاً به توصیه زیر خلاصه می‌شود:

  • «رشته اصلی را مسدود نکنید.»
  • «تکالیف طولانی‌تان را تقسیم کنید.»

این توصیه عالی است، اما چه کاری را شامل می‌شود؟ ارسال جاوا اسکریپت کمتر خوب است، اما آیا این به‌طور خودکار با میاناهای کاربر واکنش‌گراتر برابری می‌کند؟ شاید، اما شاید هم نه.

برای درک نحوه بهینه‌سازی تکالیف در جاوا اسکریپت، ابتدا باید بدانید که تکالیف چیست و مرورگر چگونه آن‌ها را مدیریت می‌کند.

تکلیف چیست؟

تکلیف هر بخش مجزایی از کار است که مرورگر انجام می‌دهد. این کار شامل رندر کردن، تجزیه HTML و CSS، اجرای JavaScript، و انواع دیگر کارهایی است که ممکن است کنترل مستقیم روی آن‌ها نداشته باشید. از بین همه این‌ها، جاوا اسکریپتی که می‌نویسید شاید بزرگ‌ترین منبع تکلیف‌ها باشد.

تجسمی از یک تکلیف که در نمایه‌ساز عملکرد Chrome DevTools نشان داده شده است. تکلیف در بالای پشته قرار دارد و یک کنترل‌کننده رویداد کلیک، یک فراخوانی تابع، و موارد بیشتری در زیر آن قرار دارد. این تکلیف همچنین شامل برخی کارهای پردازش در سمت راست است.
تکلیفی که توسط گرداننده رویداد click شروع شده است، در نمایه‌ساز عملکرد Chrome DevTools نشان داده شده است.

تکالیف مرتبط با جاوا اسکریپت به چند روش بر عملکرد تأثیر می‌گذارند:

  • وقتی مرورگر درطول راه‌اندازی فایل جاوا اسکریپت را بارگیری می‌کند، تکالیف را برای تجزیه و کامپایل کردن آن جاوا اسکریپت در صف قرار می‌دهد تا بتواند بعداً آن را اجرا کند.
  • در مواقع دیگر درطول عمر صفحه، وقتی جاوا اسکریپت کارهایی مثل پاسخ دادن به تعامل‌ها ازطریق کنترل‌کننده‌های رویداد، پویانمایی‌های مبتنی بر جاوا اسکریپت، و فعالیت‌های پس‌زمینه مثل جمع‌آوری تجزیه‌وتحلیل را انجام می‌دهد، تکالیف در صف قرار می‌گیرند.

همه این موارد—به‌استثنای وب‌ورکرها و میاناهای برنامه‌سازی کاربردی مشابه—در رشته اصلی اتفاق می‌افتد.

رشته اصلی چیست؟

رشته اصلی جایی است که بیشتر تکالیف در مرورگر اجرا می‌شوند و تقریباً همه جاوا اسکریپتی که می‌نویسید در آنجا اجرا می‌شود.

رشته اصلی در هر زمان فقط می‌تواند یک کار را پردازش کند. هر تکلیفی که بیش‌از ۵۰ میلی‌ثانیه طول بکشد تکلیف طولانی است. برای کارهایی که بیش‌از ۵۰ میلی‌ثانیه طول می‌کشند، کل زمان کار منهای ۵۰ میلی‌ثانیه به‌عنوان دوره مسدودسازی کار شناخته می‌شود.

مرورگر از وقوع تعاملات درحین اجرای یک تکلیف با هر طولی جلوگیری می‌کند، اما تا زمانی که تکالیف برای مدت طولانی اجرا نشوند، این موضوع برای کاربر قابل‌تشخیص نیست. بااین‌حال، وقتی کاربر تلاش می‌کند با صفحه‌ای تعامل داشته باشد که کارهای طولانی زیادی دارد، واسط کاربر غیرپاسخگو به‌نظر می‌رسد و حتی ممکن است درصورت مسدود شدن رشته اصلی برای مدت‌های بسیار طولانی، خراب به‌نظر برسد.

یک تکلیف طولانی در نمایه‌ساز عملکرد DevTools در Chrome. بخش مسدودکننده تکلیف (بیشتر از ۵۰ میلی‌ثانیه) با الگویی از نوارهای مورب قرمز نشان داده می‌شود.
یک تکلیف طولانی همان‌طور که در نمایه‌ساز عملکرد Chrome نشان داده شده است. تکالیف طولانی با مثلثی قرمز در گوشه تکلیف نشان داده می‌شوند و بخش مسدودکننده تکلیف با الگویی از نوارهای قرمز مورب پر شده است.

برای جلوگیری از مسدود شدن رشته اصلی برای مدت طولانی، می‌توانید یک تکلیف طولانی را به چند تکلیف کوچک‌تر تقسیم کنید.

یک تکلیف طولانی درمقابل همان تکلیف که به تکالیف کوتاه‌تر تقسیم شده است. تکلیف طولانی یک مستطیل بزرگ است، درحالی‌که تکلیف تکه‌تکه‌شده پنج جعبه کوچک‌تر است که مجموعاً همان عرض تکلیف طولانی را دارند.
دیداری‌سازی یک تکلیف طولانی درمقابل همان تکلیف که به پنج تکلیف کوتاه‌تر تقسیم شده است.

این موضوع مهم است زیرا وقتی تکالیف تقسیم می‌شوند، مرورگر می‌تواند خیلی زودتر به کارهای با اولویت بالاتر پاسخ دهد—ازجمله تعاملات کاربر. سپس، کارهای باقی‌مانده تا تکمیل شدن اجرا می‌شوند تا اطمینان حاصل شود کاری که در ابتدا در صف قرار داده‌اید انجام می‌شود.

تصویری از اینکه چگونه تقسیم کردن یک تکلیف می‌تواند تعامل کاربر را تسهیل کند. در بالا، یک تکلیف طولانی مانع از اجرای یک کنترل‌کننده رویداد می‌شود تا زمانی که تکلیف به پایان برسد. در پایین، تکلیف تکه‌تکه‌شده به کنترل‌کننده رویداد اجازه می‌دهد زودتر از آنچه درغیراین‌صورت ممکن بود اجرا شود.
تجسمی از آنچه برای تعاملات اتفاق می‌افتد وقتی‌که تکالیف خیلی طولانی هستند و مرورگر نمی‌تواند به‌سرعت کافی به تعاملات پاسخ دهد، درمقابل زمانی که تکالیف طولانی‌تر به تکالیف کوچک‌تر تقسیم می‌شوند.

در بالای شکل قبلی، یک کنترل‌کننده رویداد که توسط تعامل کاربر در صف قرار گرفته بود، باید قبل‌از شروع، منتظر یک تکلیف طولانی می‌ماند. این امر باعث تأخیر در انجام تعامل می‌شود. در این سناریو، ممکن است کاربر متوجه تأخیر شده باشد. در پایین، گرداننده رویداد می‌تواند زودتر شروع به اجرا کند و تعامل ممکن است فوری احساس شود.

اکنون که می‌دانید چرا تقسیم کردن تکالیف مهم است، می‌توانید با نحوه انجام این کار در جاوا اسکریپت آشنا شوید.

استراتژی‌های مدیریت تکلیف

یک توصیه رایج در معماری نرم‌افزار این است که کار خود را به توابع کوچک‌تر تقسیم کنید:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

در این مثال، تابعی به‌نام saveSettings() وجود دارد که پنج تابع را برای اعتبارسنجی فرم، نمایش نشانگر چرخان، ارسال داده به پسای برنامه، به‌روزرسانی میانای کاربر، و ارسال تجزیه‌وتحلیل فراخوانی می‌کند.

ازنظر مفهومی، saveSettings() معماری خوبی دارد. اگر لازم است یکی از این تابع‌ها را اشکال‌زدایی کنید، می‌توانید درخت پروژه را پیمایش کنید تا متوجه شوید هر تابع چه کاری انجام می‌دهد. تقسیم کردن کار به این صورت باعث می‌شود پیمایش و نگهداری پروژه‌ها آسان‌تر شود.

اما مشکل احتمالی در اینجا این است که جاوا اسکریپت هریک از این توابع را به‌عنوان تکالیف جداگانه اجرا نمی‌کند زیرا آن‌ها در تابع saveSettings() اجرا می‌شوند. این یعنی هر پنج تابع به‌عنوان یک تکلیف اجرا خواهند شد.

تابع saveSettings همان‌طور که در نمایه‌ساز عملکرد Chrome نشان داده شده است. درحالی‌که تابع سطح بالا پنج تابع دیگر را فراخوانی می‌کند، همه کارها در یک تکلیف طولانی انجام می‌شود که باعث می‌شود نتیجه قابل‌مشاهده برای کاربر از اجرای تابع تا زمانی که همه کامل نشوند، قابل‌مشاهده نباشد.
یک تابع تکی saveSettings() که پنج تابع را فراخوانی می‌کند. کار به‌عنوان بخشی از یک تکلیف یکپارچه طولانی اجرا می‌شود و هرگونه پاسخ دیداری را تا زمانی که هر پنج تابع تکمیل شوند مسدود می‌کند.

در بهترین حالت، حتی یکی از این توابع می‌تواند ۵۰ میلی‌ثانیه یا بیشتر به کل طول تکلیف اضافه کند. در بدترین حالت، تعداد بیشتری از این تکالیف ممکن است بسیار طولانی‌تر اجرا شوند—به‌ویژه در دستگاه‌هایی با منابع محدود.

در این مورد، 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() تودرتو، مرورگر شروع به اعمال حداقل ۵ میلی‌ثانیه تأخیر برای هر setTimeout() اضافی می‌کند.

setTimeout همچنین یک اشکال دیگر در زمینه واگذاری دارد: وقتی با به‌تعویق انداختن کد برای اجرا در تکلیف بعدی بااستفاده از setTimeout به رشته اصلی واگذار می‌کنید، آن تکلیف به انتهای صف اضافه می‌شود. اگر تکالیف دیگری در انتظار باشند، قبل‌از کد به‌تعویق‌افتاده شما اجرا خواهند شد.

یک API واگذاری اختصاصی: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: 142.
  • Safari: not supported.

Source

‫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 همان‌طور که در نمایه‌ساز عملکرد Chrome نشان داده شده است، اکنون به دو تکلیف تقسیم شده است. تکلیف اول دو تابع را فراخوانی می‌کند، سپس تسلیم می‌شود و اجازه می‌دهد کار چیدمان و رنگ‌آمیزی انجام شود و پاسخ قابل‌مشاهده‌ای به کاربر ارائه دهد. درنتیجه، رویداد کلیک در مدت بسیار سریع‌تر ۶۴ میلی‌ثانیه به‌پایان می‌رسد. تکلیف دوم سه تابع آخر را فراخوانی می‌کند.
اجرای تابع saveSettings() اکنون به دو تکلیف تقسیم شده است. درنتیجه، چیدمان و نقاشی می‌تواند بین تکالیف اجرا شود و پاسخ بصری سریع‌تری به کاربر بدهد، همان‌طور که با تعامل اشاره‌گر بسیار کوتاه‌تر اندازه‌گیری می‌شود.

بااین‌حال، مزیت واقعی scheduler.yield() نسبت‌به سایر رویکردهای واگذاری این است که ادامه آن در اولویت قرار دارد، به این معنی که اگر در وسط یک تکلیف واگذار کنید، ادامه تکلیف فعلی قبل‌از شروع هر تکلیف مشابه دیگری اجرا خواهد شد.

این کار از تداخل کد منابع تکلیف دیگر با ترتیب اجرای کد شما جلوگیری می‌کند، مثلاً تکالیف مربوط به اسکریپت‌های شخص ثالث.

سه نمودار که تکالیف را بدون واگذاری، با واگذاری، و با واگذاری و ادامه نشان می‌دهد. بدون واگذاری، کارهای طولانی وجود دارد. با واگذاری، تکالیف بیشتری وجود دارد که کوتاه‌تر هستند، اما ممکن است با تکالیف نامرتبط دیگر قطع شوند. با واگذاری و ادامه، تکالیف بیشتری وجود دارد که کوتاه‌تر هستند، اما ترتیب اجرای آن‌ها حفظ می‌شود.
وقتی از scheduler.yield() استفاده می‌کنید، ادامه از جایی که متوقف شده بود شروع می‌شود و سپس به کارهای دیگر می‌پردازد.

پشتیبانی از مرورگرهای مختلف

scheduler.yield() هنوز در همه مرورگرها پشتیبانی نمی‌شود، بنابراین به یک جایگزین نیاز است.

یک راه‌حل این است که scheduler-polyfill را در ساخت خود رها کنید و سپس scheduler.yield() می‌تواند مستقیماً استفاده شود؛ شبیه‌ساز مرورگر بازگشت به سایر توابع زمان‌بندی وظیفه را مدیریت خواهد کرد تا به‌طور مشابه در مرورگرها کار کند.

یا اینکه می‌توان نسخه‌ای با پیچیدگی کمتر را در چند خط نوشت، بااستفاده از فقط 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 کنید، درغیراین‌صورت ادامه دهید.

این کار را می‌توان هم با تشخیص ویژگی و هم با بازگشت به انتظار برای یک ریزوظیفه در یک خط کد ساده انجام داد:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

تقسیم کردن کارهای طولانی با scheduler.yield()

مزیت استفاده از هریک از این روش‌های استفاده از scheduler.yield() این است که می‌توانید آن را در هر تابع async await کنید.

برای مثال، اگر آرایه‌ای از کارهای اجرایی دارید که اغلب به یک تکلیف طولانی تبدیل می‌شوند، می‌توانید برای تقسیم تکلیف، yield درج کنید.

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

ادامه runJobs() در اولویت قرار می‌گیرد، اما همچنان به کارهای با اولویت بالاتر مانند پاسخ بصری به ورودی کاربر اجازه اجرا می‌دهد و نیازی نیست منتظر بماند تا فهرست طولانی احتمالی کارها تمام شود.

بااین‌حال، این استفاده کارآمدی از واگذاری نیست. scheduler.yield() سریع و کارآمد است، اما مقداری سربار دارد. اگر برخی‌از کارهای jobQueue بسیار کوتاه باشند، ممکن است سربار به‌سرعت به زمان بیشتری برای واگذاری و ازسرگیری نسبت‌به اجرای کار واقعی اضافه شود.

یک رویکرد این است که کارها را دسته‌بندی کنید و فقط زمانی بین آن‌ها وقفه ایجاد کنید که از آخرین وقفه زمان کافی گذشته باشد. مهلت معمول ۵۰ میلی‌ثانیه است تا از تبدیل شدن تکالیف به تکالیف طولانی جلوگیری شود، اما می‌توان آن را به‌عنوان مصالحه‌ای بین پاسخ‌گویی و زمان تکمیل صف کار تنظیم کرد.

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();
    }
  }
}

نتیجه این است که کارها تقسیم می‌شوند تا اجرای آن‌ها هرگز بیش‌ازحد طول نکشد، اما اجراکننده فقط هر ۵۰ میلی‌ثانیه یک‌بار به رشته اصلی واگذار می‌کند.

مجموعه‌ای از عملکردهای شغلی که در پانل عملکرد Chrome DevTools نشان داده شده است و اجرای آن‌ها به چندین تکلیف تقسیم شده است
کارها در چند تکلیف دسته‌بندی شده‌اند.

استفاده نکردن از isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

isInputPending() API راهی برای بررسی اینکه آیا کاربر تلاش کرده است با صفحه‌ای تعامل داشته باشد یا نه ارائه می‌دهد و فقط درصورتی‌که ورودی معلق باشد نتیجه می‌دهد.

این کار به جاوا اسکریپت اجازه می‌دهد درصورت عدم وجود ورودی‌های معلق، به‌جای تسلیم شدن و قرار گرفتن در انتهای صف وظایف، به کار خود ادامه دهد. این امر می‌تواند منجر به بهبودهای چشمگیر در عملکرد شود، همان‌طور که در Intent to Ship به‌تفصیل شرح داده شده است، برای سایت‌هایی که درغیراین‌صورت ممکن است به رشته اصلی بازنگردند.

بااین‌حال، از زمان راه‌اندازی آن API، درک ما از واگذاری افزایش یافته است، به‌ویژه با معرفی INP. دیگر استفاده از این میانای برنامه‌سازی کاربردی را توصیه نمی‌کنیم و درعوض توصیه می‌کنیم صرف‌نظر از اینکه ورودی معلق است یا نه، به‌دلایل زیر واگذار کنید:

  • isInputPending() ممکن است در برخی شرایط، با وجود تعامل کاربر، به‌اشتباه false را برگرداند.
  • ورودی تنها موردی نیست که در آن تکالیف باید تسلیم شوند. پویانمایی‌ها و دیگر به‌روزرسانی‌های معمول میانای کاربر می‌توانند به همان اندازه برای ارائه صفحه وب واکنش‌گرا مهم باشند.
  • از آن زمان، میاناهای برنامه‌سازی کاربردی جامع‌تری برای واگذاری معرفی شده‌اند که نگرانی‌های مربوط به واگذاری را برطرف می‌کنند، مانند scheduler.postTask() و scheduler.yield().

نتیجه‌گیری

مدیریت کردن تکالیف چالش‌برانگیز است، اما انجام این کار باعث می‌شود صفحه‌تان سریع‌تر به تعامل‌های کاربر پاسخ دهد. هیچ توصیه واحدی برای مدیریت و اولویت‌بندی تکالیف وجود ندارد، بلکه تعدادی تکنیک مختلف وجود دارد. برای تکرار، این‌ها موارد اصلی هستند که باید هنگام مدیریت تکالیف درنظر بگیرید:

  • برای کارهای مهم سَمت کاربر، به رشته اصلی واگذار کنید.
  • از scheduler.yield() (با بازگشت بین مرورگرها) استفاده کنید تا به‌طور ارگونومیک تسلیم شوید و ادامه اولویت‌بندی‌شده را دریافت کنید
  • درنهایت، تا حد امکان کار کمتری در توابع خود انجام دهید.

برای کسب اطلاعات بیشتر درباره scheduler.yield()، scheduler.postTask() مرتبط با زمان‌بندی صریح تکلیف، و اولویت‌بندی تکلیف، به اسناد Prioritized Task Scheduling API مراجعه کنید.

بااستفاده از یک یا چند مورد از این ابزارها، باید بتوانید کار را در برنامه‌تان ساختاربندی کنید تا نیازهای کاربر را در اولویت قرار دهد و درعین‌حال مطمئن شوید که کارهای با اهمیت کمتر نیز همچنان انجام می‌شوند. این کار تجربه کاربری بهتری ایجاد می‌کند که پاسخگوتر است و استفاده از آن لذت‌بخش‌تر است.

با تشکر ویژه از فیلیپ والتون برای بررسی فنی این راهنما.