کارهای طولانی را بهینه کنید

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

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

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

  • «موضوع اصلی را مسدود نکنید.»
  • «کارهای طولانی‌مدت خود را به بخش‌های کوچک‌تر تقسیم کنید.»

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

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

وظیفه چیست؟

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

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

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

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

همه این موارد - به استثنای web workerها و APIهای مشابه - در thread اصلی اتفاق می‌افتد.

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

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

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

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

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

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

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

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

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

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

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

استراتژی‌های مدیریت وظایف

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

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

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

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

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

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

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

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

اجرای کد را به صورت دستی به تعویق بیندازید

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

یکی از روش‌هایی که توسعه‌دهندگان برای تقسیم وظایف به وظایف کوچک‌تر استفاده کرده‌اند، setTimeout() است. با این تکنیک، تابع را به setTimeout() ارسال می‌کنید. این کار اجرای تابع فراخوانی را به یک وظیفه جداگانه موکول می‌کند، حتی اگر timeout را 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);
}

این به عنوان yielding شناخته می‌شود و برای مجموعه‌ای از توابع که باید به صورت متوالی اجرا شوند، بهترین عملکرد را دارد.

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

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

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

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

یک API اختصاصی yielding: scheduler.yield()

Browser Support

  • کروم: ۱۲۹.
  • لبه: ۱۲۹.
  • فایرفاکس: ۱۴۲.
  • سافاری: پشتیبانی نمی‌شود.

Source

scheduler.yield() یک API است که به طور خاص برای تسلیم شدن به نخ اصلی در مرورگر طراحی شده است.

این یک سینتکس سطح زبان یا یک ساختار خاص نیست؛ scheduler.yield() فقط تابعی است که یک Promise برمی‌گرداند که در یک وظیفه آینده حل خواهد شد. هر کدی که برای اجرا پس از حل شدن آن Promise زنجیره‌بندی شده باشد (چه در یک زنجیره صریح .then() یا پس از await برای آن در یک تابع async) در آن وظیفه آینده اجرا خواهد شد.

در عمل: یک await scheduler.yield() وارد کنید و تابع در آن نقطه اجرا را متوقف کرده و به نخ اصلی (main thread) واگذار می‌کند. اجرای بقیه تابع - که ادامه تابع نامیده می‌شود - طوری برنامه‌ریزی می‌شود که در یک وظیفه حلقه رویداد جدید اجرا شود. وقتی آن وظیفه شروع می‌شود، promise مورد انتظار (await promise) حل می‌شود و تابع از جایی که متوقف شده بود، به اجرای خود ادامه می‌دهد.

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

مزیت واقعی تابع scheduler.yield() نسبت به سایر رویکردهای yielding این است که ادامه آن اولویت‌بندی می‌شود، به این معنی که اگر در وسط یک کار 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() پشتیبانی نمی‌کنند، ادامه‌ی اولویت‌بندی‌شده را دریافت نمی‌کنند، اما همچنان برای اینکه مرورگر پاسخگو بماند، yield می‌کنند.

در نهایت، ممکن است مواردی وجود داشته باشد که کد شما نتواند تسلیم نخ اصلی شود اگر ادامه آن در اولویت قرار نگرفته باشد (برای مثال، یک صفحه شلوغ که تسلیم شدن در آن خطر عدم تکمیل کار را برای مدتی دارد). در این صورت، scheduler.yield() می‌تواند به عنوان نوعی بهبود تدریجی در نظر گرفته شود: yield در مرورگرهایی که scheduler.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() اولویت‌بندی خواهد شد، اما همچنان اجازه می‌دهد کارهایی با اولویت بالاتر مانند پاسخ بصری به ورودی کاربر اجرا شوند، نه اینکه مجبور باشند منتظر تمام شدن لیست طولانی بالقوه‌ی کارها بمانند.

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

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

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

نتیجه این است که کارها به بخش‌های کوچکتری تقسیم می‌شوند تا اجرای آنها خیلی طول نکشد، اما اجراکننده (runner) تقریباً هر ۵۰ میلی‌ثانیه به نخ اصلی (main thread) واگذار می‌شود.

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

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

Browser Support

  • کروم: ۸۷.
  • لبه: ۸۷.
  • فایرفاکس: پشتیبانی نمی‌شود.
  • سافاری: پشتیبانی نمی‌شود.

Source

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

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

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

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

نتیجه‌گیری

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

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

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

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

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

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