حلقهٔ رویداد در جاوااسکریپت: تسکها، میکروتسکها و رندر
چرا یک پرامیسِ resolveشده همیشه زودتر از setTimeout(fn, 0) اجرا میشود؟ دقیقاً ببینید حلقهٔ رویداد تسکها، میکروتسکها و رندر صفحه را چطور زمانبندی میکند و چطور کارهای طولانی را طوری اجرا کنید که صفحه قفل نشود.
در این صفحه
حتماً میدانید که await تابع را متوقف میکند بیآنکه صفحه قفل شود. اما آن تابع دقیقاً کِی ادامه پیدا میکند؟ چرا یک پرامیسِ resolveشده همیشه قبل از setTimeout(fn, 0) اجرا میشود؟ و چرا پیام «در حال بارگذاری…» که درست قبل از یک محاسبهٔ سنگین تنظیم میکنید، گاهی هرگز روی صفحه دیده نمیشود؟
جواب هر سه سؤال یکی است: حلقهٔ رویداد (event loop). وقتی بتوانید آن را در ذهنتان اجرا کنید، یک دسته از باگهای زمانبندی، تستهای ناپایدار و رابطهای کاربریِ کُند دیگر رازآلود نیستند. این مقاله فرض میکند با پرامیسها و async/await راحت هستید؛ اگر نه، از درک async/await در جاوااسکریپت شروع کنید.
اجزای اصلی#
جاوااسکریپت در مرورگر روی یک رشتهٔ اصلی (main thread) اجرا میشود و این رشته فقط کد شما را اجرا نمیکند؛ ورودیهای کاربر را هم پردازش میکند و صفحه را هم رسم میکند. چهار چیز در این رشته شریکاند:
- پشتهٔ فراخوانی (call stack): توابعی که همین الان در حال اجرا هستند. حلقهٔ رویداد فقط وقتی وارد عمل میشود که پشته خالی باشد.
- صف تسکها (task queue): واحدهای کاملِ کار که منتظر نوبتاند؛ مثل callbackِ یک
setTimeout، یک هندلر کلیک یا یکpostMessageاز یک worker. (مستندات به اینها task میگویند؛ اسم macrotask را هم زیاد میبینید.) - صف میکروتسکها (microtask queue): کارهای کوچکِ پیگیری، که بیشترشان واکنشهای پرامیس هستند (callbackهای
.then()و کدِ بعد ازawait)، بهعلاوهٔqueueMicrotask()و callbackهایMutationObserver. - مراحل رندر: callbackهای
requestAnimationFrameو بعد محاسبهٔ استایل، چیدمان (layout) و رسم (paint).
یک دور از حلقه#
اگر جزئیات را کنار بگذاریم، مرورگر این کار را تا ابد تکرار میکند:
while (true) {
const task = taskQueue.takeOldest();
if (task) run(task); // 1. exactly ONE task
while (microtaskQueue.length > 0) { // 2. EVERY microtask,
run(microtaskQueue.shift()); // including ones added meanwhile
}
if (isTimeToRender()) { // 3. maybe a frame
runAnimationFrameCallbacks();
updateStyleLayoutAndPaint();
}
}کل ماجرا در یک عدمتقارن است: حلقه هر بار فقط یک تسک برمیدارد، اما صف میکروتسکها را کاملاً خالی میکند؛ حتی اگر میکروتسکها مدام میکروتسکهای تازه اضافه کنند. رندر فقط بین تسکها انجام میشود، آن هم فقط وقتی نمایشگر آمادهٔ فریم تازه است (در یک صفحهٔ ۶۰ هرتزی تقریباً هر ۱۶٫۷ میلیثانیه).
اسکریپت شما هم یک تسک است
اجرای اولیهٔ یک <script> خودش یک تسک است. به همین دلیل callbackهای پرامیسی که در حین آن ساخته میشوند، به محض تمام شدن اسکریپت و قبل از هر تایمری اجرا میشوند.
خروجی را پیشبینی کنید#
این یک معمای کلاسیک است. قبل از خواندن جواب، ترتیب را خودتان حدس بزنید:
console.log("script start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve()
.then(() => console.log("then 1"))
.then(() => console.log("then 2"));
queueMicrotask(() => console.log("microtask"));
(async () => {
console.log("async start");
await null;
console.log("async after await");
})();
console.log("script end");script start
async start
script end
then 1
microtask
async after await
then 2
timeoutقدمبهقدم:
- اول کد همگام اجرا میشود:
script start، بعدasync start(یک تابع async تا اولینawaitبهصورت همگام اجرا میشود) و بعدscript end. - در همین حین سه میکروتسک به ترتیب در صف قرار گرفتند:
then 1،microtaskو ادامهٔ تابع async. اما callbackِ تایمر به صف تسکها رفت. - تسکِ اسکریپت تمام میشود، پس صف میکروتسکها خالی میشود. اجرای
then 1پرامیس بعدیِ زنجیره را resolve میکند وthen 2را به انتهای صف اضافه میکند. به همین دلیلthen 2بعد ازasync after awaitمیآید. - فقط وقتی صف میکروتسکها خالی شد، حلقه سراغ تسک بعدی میرود: تایمر.
معنی setTimeout(fn, 0) این است: «fn را در یک تسکِ آینده اجرا کن»، نه «همین الان اجرایش کن». هر میکروتسکی که از قبل در صف است، و هر میکروتسکی که آنها به صف اضافه میکنند، زودتر اجرا میشود.
میکروتسکها میتوانند صفحه را گرسنه نگه دارند#
چون حلقه تا وقتی صف میکروتسکها خالی نشده از مرحلهٔ ۲ بیرون نمیآید، میکروتسکی که مدام میکروتسک دیگری زمانبندی کند، هرگز به صفحه اجازهٔ رندر یا پاسخگویی نمیدهد:
function spinForever() {
Promise.resolve().then(spinForever); // freezes the tab: rendering never gets a turn
}اگر پرامیس را با یک تایمر عوض کنید، صفحه پاسخگو میماند؛ چون هر callbackِ تایمر یک تسک جداست و حلقه میتواند بینِ آنها رندر کند:
function spinPolitely() {
setTimeout(spinPolitely, 0); // one task per call: the browser can paint in between
}setTimeout(fn, 0) واقعاً صفر نیست
وقتی تایمرها پنج لایه تودرتو شوند، مرورگرها تأخیر را دستکم ۴ میلیثانیه در نظر میگیرند، و در تبهای پسزمینه تایمرها را خیلی بیشتر کُند میکنند. هیچوقت برای زمانبندیِ دقیق به تایمرها تکیه نکنید.
چرا پیام «در حال پردازش» هرگز نمایش داده نمیشود#
این رایجترین باگ حلقهٔ رویداد در دنیای واقعی است:
button.addEventListener("click", () => {
status.textContent = "Crunching numbers…";
crunchNumbers(); // synchronous, takes ~2 seconds
status.textContent = "Done!";
});کاربر هرگز متن «Crunching numbers…» را نمیبیند. DOM بهروز شده است، اما رسم صفحه فقط بین تسکها انجام میشود و هندلر کلیک یک تسکِ طولانی است. وقتی نوبت رسم برسد، متن دیگر «Done!» شده است. در این دو ثانیه صفحه همهٔ کلیکها و کلیدها را هم نادیده میگیرد.
راهحل این است که کار طولانی را تکهتکه کنید و بین تکهها کنترل را به حلقهٔ رویداد واگذار کنید (yield). این واگذاری باید یک تسک تازه بسازد: await Promise.resolve() فقط یک میکروتسک در صف میگذارد، پس هرگز به مرورگر فرصت رسم نمیدهد.
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield(); // newer browsers
}
return new Promise((resolve) => setTimeout(resolve, 0)); // works everywhere
}
async function processAll(items) {
let lastYield = performance.now();
for (const item of items) {
processItem(item);
if (performance.now() - lastYield > 50) { // a "long task" is anything over 50 ms
await yieldToMain(); // let the browser paint and handle input
lastYield = performance.now();
}
}
}حالا پیام بارگذاری نمایش داده میشود، کلیکها پردازش میشوند و کار همچنان سریع تمام میشود، چون فقط هر ۵۰ میلیثانیه یک بار کنترل را واگذار میکند، نه بعد از هر آیتم. هرجا scheduler.yield() در دسترس باشد انتخاب بهتری است: مرورگر به ادامهٔ کار شما نسبت به بقیهٔ تسکهای منتظر اولویت میدهد، پس کدهای دیگر نمیتوانند مدام جلوی کار شما بپرند.
وقتی تکهتکه کردن کافی نیست
اگر خودِ کار چند ثانیه محاسبهٔ خالص است (مثل پردازش یک فایل خیلی بزرگ یا پردازش تصویر)، آن را به یک Web Worker منتقل کنید. هر worker رشته و حلقهٔ رویداد خودش را دارد، پس رشتهٔ اصلی آزاد میماند.
requestAnimationFrame: کار در لحظهٔ درست#
callbackهای requestAnimationFrame در مرحلهٔ ۳ و درست قبل از رسم صفحه اجرا میشوند. به همین دلیل جای درستِ بهروزرسانیهای بصری هستند: دقیقاً یک بار در هر فریم اجرا میشوید؛ نه دو بار بین دو رسم، و نه وسط یک رسم.
function animate(time) {
box.style.transform = `translateX(${(time / 5) % 600}px)`;
requestAnimationFrame(animate); // schedule the next frame
}
requestAnimationFrame(animate);اگر همین انیمیشن را با setTimeout(animate, 16) پیش ببرید، با نمایشگر هماهنگ نمیماند؛ بعضی فریمها دو بهروزرسانی میگیرند و بعضی هیچ، و این همان لرزشی است که کاربر حس میکند.
یک نکتهٔ ظریف: میکروتسکها بین شنوندههای رویداد#
میکروتسکها هر بار که پشتهٔ فراخوانی خالی میشود اجرا میشوند، نه فقط در پایان یک تسک. به دو شنونده روی یک دکمه نگاه کنید:
button.addEventListener("click", () => {
Promise.resolve().then(() => console.log("microtask 1"));
console.log("listener 1");
});
button.addEventListener("click", () => {
Promise.resolve().then(() => console.log("microtask 2"));
console.log("listener 2");
});| عامل اجرا | خروجی |
|---|---|
| کلیکِ واقعیِ کاربر | listener 1، microtask 1، listener 2، microtask 2 |
button.click() در کد |
listener 1، listener 2، microtask 1، microtask 2 |
وقتی کاربر کلیک میکند، مرورگر هر شنونده را با پشتهٔ خالی صدا میزند، پس میکروتسکها بین آنها اجرا میشوند. اما وقتی کد شما button.click() را صدا میزند، کد شما هنوز روی پشته است، پس همهٔ میکروتسکها منتظر میمانند تا هر دو شنونده تمام شوند. اگر تستی که کلیک را شبیهسازی میکند با صفحهٔ واقعی فرق داشت، این نکته را به یاد بیاورید.
Node.js دو صف دیگر هم دارد#
Node.js همین مدل را دارد (هر بار یک تسک، و خالی کردن میکروتسکها بعد از هر callback) اما دو ابزار زمانبندیِ مخصوص به خودش را هم اضافه میکند:
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));اگر این کد را بهصورت یک اسکریپت CommonJS اجرا کنید، اول nextTick، بعد promise و در آخر timeout و immediate چاپ میشوند؛ ترتیبِ این دو آخری ممکن است از اجرایی به اجرای دیگر عوض شود.
- callbackهای
process.nextTick()قبل از میکروتسکهای پرامیس اجرا میشوند. با احتیاط از آن استفاده کنید: فراخوانی بازگشتیِnextTickهم درست مثل میکروتسکهای بازگشتی، حلقه را گرسنه نگه میدارد. setImmediate()در یک مرحلهٔ مخصوص بعد از callbackهای I/O اجرا میشود. داخل یک callbackِ I/O همیشه قبل ازsetTimeout(fn, 0)اجرا میشود؛ اما در اسکریپت اصلی ترتیبشان تضمینی نیست.
ماژولهای ES
در یک ماژول ES (فایل .mjs) ممکن است promise قبل از nextTick چاپ شود، چون خودِ بدنهٔ ماژول از داخل صف میکروتسکها اجرا میشود. کدی ننویسید که به ترتیبِ دقیقِ nextTick و پرامیسها وابسته باشد.
جمعبندی#
- حلقهٔ رویداد یک تسک را اجرا میکند، بعد همهٔ میکروتسکها را، بعد اگر وقت فریم تازه رسیده باشد رندر میکند و دوباره تکرار میکند.
- callbackهای پرامیس، کدِ بعد از
awaitوqueueMicrotask()میکروتسک هستند: همیشه قبل از تایمر، رویداد یا رسمِ بعدی اجرا میشوند. - میکروتسکی که تا ابد میکروتسک زمانبندی کند صفحه را قفل میکند؛ تایمرها این کار را نمیکنند، چون هر کدام یک تسک جداست.
- کارهای همگامِ طولانی جلوی رسم صفحه و پردازش ورودی را میگیرند. آنها را تکهتکه کنید و با
setTimeoutیاscheduler.yield()کنترل را واگذار کنید.await Promise.resolve()کنترل را واگذار نمیکند. - برای بهروزرسانیهای بصری از
requestAnimationFrameو برای محاسبات سنگین از Web Worker استفاده کنید.
دفعهٔ بعد که چیزی «به ترتیب اشتباه» اجرا شد، از خودتان بپرسید: این یک تسک است یا میکروتسک، و چه چیزهای دیگری در همان صف هستند؟