به شما گفته شده است که «نخ اصلی را مسدود نکنید» و «وظایف طولانی خود را تقسیم کنید»، اما انجام این کارها به چه معناست؟
منتشر شده: ۳۰ سپتامبر ۲۰۲۲، آخرین بهروزرسانی: ۱۹ دسامبر ۲۰۲۴
توصیههای رایج برای حفظ سرعت برنامههای جاوا اسکریپت به توصیههای زیر خلاصه میشود:
- «موضوع اصلی را مسدود نکنید.»
- «کارهای طولانیمدت خود را به بخشهای کوچکتر تقسیم کنید.»
این توصیه خیلی خوبی است، اما چه کاری را شامل میشود؟ کاهش حجم جاوا اسکریپت خوب است، اما آیا این به طور خودکار به معنای رابط کاربری واکنشگراتر است؟ شاید، اما شاید هم نه.
برای درک چگونگی بهینهسازی وظایف در جاوا اسکریپت، ابتدا باید بدانید وظایف چیستند و مرورگر چگونه آنها را مدیریت میکند.
وظیفه چیست؟
یک وظیفه (task) هر بخش مجزایی از کار است که مرورگر انجام میدهد. این کار شامل رندر کردن، تجزیه HTML و CSS، اجرای جاوا اسکریپت و سایر کارهایی است که ممکن است کنترل مستقیمی روی آنها نداشته باشید. از بین همه اینها، جاوا اسکریپتی که مینویسید شاید بزرگترین منبع وظایف باشد.

click در آغاز شده است، که در نمایهساز عملکرد Chrome DevTools نشان داده شده است.وظایف مرتبط با جاوا اسکریپت از چند طریق بر عملکرد تأثیر میگذارند:
- وقتی یک مرورگر هنگام راهاندازی، یک فایل جاوا اسکریپت را دانلود میکند، وظایفی را برای تجزیه و کامپایل آن جاوا اسکریپت در صف قرار میدهد تا بعداً بتواند اجرا شود.
- در مواقع دیگر در طول عمر صفحه، وظایفی که جاوا اسکریپت انجام میدهد، مانند پاسخ به تعاملات از طریق event handlerها، انیمیشنهای مبتنی بر جاوا اسکریپت و فعالیتهای پسزمینه مانند جمعآوری دادههای تحلیلی، در صف قرار میگیرند.
همه این موارد - به استثنای web workerها و APIهای مشابه - در thread اصلی اتفاق میافتد.
رشته اصلی چیه؟
نخ اصلی جایی است که بیشتر وظایف در مرورگر اجرا میشوند و تقریباً تمام جاوا اسکریپتی که مینویسید در آنجا اجرا میشود.
نخ اصلی فقط میتواند یک وظیفه را در یک زمان پردازش کند. هر وظیفهای که بیش از ۵۰ میلیثانیه طول بکشد، یک وظیفه طولانی است. برای وظایفی که بیش از ۵۰ میلیثانیه طول میکشند، کل زمان وظیفه منهای ۵۰ میلیثانیه به عنوان دوره انسداد وظیفه شناخته میشود.
مرورگر از وقوع تعاملات در حین اجرای یک وظیفه با هر طولی جلوگیری میکند، اما این موضوع تا زمانی که وظایف برای مدت طولانی اجرا نشوند، برای کاربر قابل درک نیست. با این حال، هنگامی که کاربر سعی میکند با صفحهای که وظایف طولانی زیادی دارد تعامل داشته باشد، رابط کاربری احساس عدم پاسخگویی و حتی اگر رشته اصلی برای مدت زمان بسیار طولانی مسدود شود، ممکن است خراب به نظر برسد.

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

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

در بالای شکل قبلی، یک کنترلکننده رویداد که توسط تعامل کاربر در صف قرار گرفته بود، قبل از شروع، باید منتظر یک کار طولانی میبود. این باعث تأخیر در انجام تعامل میشود. در این سناریو، کاربر ممکن است متوجه تأخیر شده باشد. در پایین، کنترلکننده رویداد میتواند زودتر شروع به اجرا کند و تعامل ممکن است فوری به نظر برسد.
حالا که میدانید چرا تقسیم وظایف مهم است، میتوانید یاد بگیرید که چگونه این کار را در جاوا اسکریپت انجام دهید.
استراتژیهای مدیریت وظایف
یک توصیه رایج در معماری نرمافزار این است که کار خود را به توابع کوچکتر تقسیم کنید:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
در این مثال، تابعی به نام saveSettings() وجود دارد که پنج تابع را برای اعتبارسنجی فرم، نمایش یک spinner، ارسال داده به backend برنامه، بهروزرسانی رابط کاربری و ارسال تجزیه و تحلیل فراخوانی میکند.
از نظر مفهومی، 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()
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() اکنون به دو وظیفه تقسیم شده است. در نتیجه، طرحبندی و رنگآمیزی میتوانند بین وظایف اجرا شوند و به کاربر پاسخ بصری سریعتری بدهند، که با تعامل بسیار کوتاهتر اشارهگر سنجیده میشود. مزیت واقعی تابع 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) واگذار میشود.

از isInputPending() استفاده نکنید.
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 ، با اجازه امیرعلی میرهاشمیان گرفته شده است.