جاوااسکریپتپیشرفته

حلقهٔ رویداد در جاوااسکریپت: تسک‌ها، میکروتسک‌ها و رندر

چرا یک پرامیسِ 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).

یک دور از حلقه#

اگر جزئیات را کنار بگذاریم، مرورگر این کار را تا ابد تکرار می‌کند:

event-loop.js (pseudo-code)JavaScript
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();
  }
}
123 ⁧اجرای یک تسک⁩⁧همهٔ میکروتسک‌ها⁩⁧رندر یک فریم⁩ ⁧قدیمی‌ترین تسکِ صف⁩⁧تا وقتی صف خالی شود⁩⁧فقط وقتی نوبت فریم تازه است⁩ setTimeoutpostMessage.then() / awaitqueueMicrotask()MutationObserverrequestAnimationFrame ⁧رویداد کلیک⁩⁧استایل و چیدمان⁩⁧رسم صفحه⁩ ⁧و دوباره از اول، تا ابد⁩
هر دور از حلقهٔ رویداد یک تسک را اجرا می‌کند، بعد صف میکروتسک‌ها را خالی می‌کند و اگر وقتش رسیده باشد یک فریم رندر می‌کند

کل ماجرا در یک عدم‌تقارن است: حلقه هر بار فقط یک تسک برمی‌دارد، اما صف میکروتسک‌ها را کاملاً خالی می‌کند؛ حتی اگر میکروتسک‌ها مدام میکروتسک‌های تازه اضافه کنند. رندر فقط بین تسک‌ها انجام می‌شود، آن هم فقط وقتی نمایشگر آمادهٔ فریم تازه است (در یک صفحهٔ ۶۰ هرتزی تقریباً هر ۱۶٫۷ میلی‌ثانیه).

اسکریپت شما هم یک تسک است

اجرای اولیهٔ یک <script> خودش یک تسک است. به همین دلیل callbackهای پرامیسی که در حین آن ساخته می‌شوند، به محض تمام شدن اسکریپت و قبل از هر تایمری اجرا می‌شوند.

خروجی را پیش‌بینی کنید#

این یک معمای کلاسیک است. قبل از خواندن جواب، ترتیب را خودتان حدس بزنید:

order.jsJavaScript
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");
OutputText
script start
async start
script end
then 1
microtask
async after await
then 2
timeout

قدم‌به‌قدم:

  1. اول کد همگام اجرا می‌شود: script start، بعد async start (یک تابع async تا اولین await به‌صورت همگام اجرا می‌شود) و بعد script end.
  2. در همین حین سه میکروتسک به ترتیب در صف قرار گرفتند: then 1، microtask و ادامهٔ تابع async. اما callbackِ تایمر به صف تسک‌ها رفت.
  3. تسکِ اسکریپت تمام می‌شود، پس صف میکروتسک‌ها خالی می‌شود. اجرای then 1 پرامیس بعدیِ زنجیره را resolve می‌کند و then 2 را به انتهای صف اضافه می‌کند. به همین دلیل then 2 بعد از async after await می‌آید.
  4. فقط وقتی صف میکروتسک‌ها خالی شد، حلقه سراغ تسک بعدی می‌رود: تایمر.

معنی setTimeout(fn, 0) این است: «fn را در یک تسکِ آینده اجرا کن»، نه «همین الان اجرایش کن». هر میکروتسکی که از قبل در صف است، و هر میکروتسکی که آن‌ها به صف اضافه می‌کنند، زودتر اجرا می‌شود.

میکروتسک‌ها می‌توانند صفحه را گرسنه نگه دارند#

چون حلقه تا وقتی صف میکروتسک‌ها خالی نشده از مرحلهٔ ۲ بیرون نمی‌آید، میکروتسکی که مدام میکروتسک دیگری زمان‌بندی کند، هرگز به صفحه اجازهٔ رندر یا پاسخ‌گویی نمی‌دهد:

JavaScript
function spinForever() {
  Promise.resolve().then(spinForever);   // freezes the tab: rendering never gets a turn
}

اگر پرامیس را با یک تایمر عوض کنید، صفحه پاسخ‌گو می‌ماند؛ چون هر callbackِ تایمر یک تسک جداست و حلقه می‌تواند بینِ آن‌ها رندر کند:

JavaScript
function spinPolitely() {
  setTimeout(spinPolitely, 0);           // one task per call: the browser can paint in between
}

setTimeout(fn, 0) واقعاً صفر نیست

وقتی تایمرها پنج لایه تودرتو شوند، مرورگرها تأخیر را دست‌کم ۴ میلی‌ثانیه در نظر می‌گیرند، و در تب‌های پس‌زمینه تایمرها را خیلی بیشتر کُند می‌کنند. هیچ‌وقت برای زمان‌بندیِ دقیق به تایمرها تکیه نکنید.

چرا پیام «در حال پردازش» هرگز نمایش داده نمی‌شود#

این رایج‌ترین باگ حلقهٔ رویداد در دنیای واقعی است:

JavaScript
button.addEventListener("click", () => {
  status.textContent = "Crunching numbers…";
  crunchNumbers();                 // synchronous, takes ~2 seconds
  status.textContent = "Done!";
});

کاربر هرگز متن «Crunching numbers…» را نمی‌بیند. DOM به‌روز شده است، اما رسم صفحه فقط بین تسک‌ها انجام می‌شود و هندلر کلیک یک تسکِ طولانی است. وقتی نوبت رسم برسد، متن دیگر «Done!» شده است. در این دو ثانیه صفحه همهٔ کلیک‌ها و کلیدها را هم نادیده می‌گیرد.

راه‌حل این است که کار طولانی را تکه‌تکه کنید و بین تکه‌ها کنترل را به حلقهٔ رویداد واگذار کنید (yield). این واگذاری باید یک تسک تازه بسازد: await Promise.resolve() فقط یک میکروتسک در صف می‌گذارد، پس هرگز به مرورگر فرصت رسم نمی‌دهد.

yield.jsJavaScript
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 در مرحلهٔ ۳ و درست قبل از رسم صفحه اجرا می‌شوند. به همین دلیل جای درستِ به‌روزرسانی‌های بصری هستند: دقیقاً یک بار در هر فریم اجرا می‌شوید؛ نه دو بار بین دو رسم، و نه وسط یک رسم.

JavaScript
function animate(time) {
  box.style.transform = `translateX(${(time / 5) % 600}px)`;
  requestAnimationFrame(animate);       // schedule the next frame
}

requestAnimationFrame(animate);

اگر همین انیمیشن را با setTimeout(animate, 16) پیش ببرید، با نمایشگر هماهنگ نمی‌ماند؛ بعضی فریم‌ها دو به‌روزرسانی می‌گیرند و بعضی هیچ، و این همان لرزشی است که کاربر حس می‌کند.

یک نکتهٔ ظریف: میکروتسک‌ها بین شنونده‌های رویداد#

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

JavaScript
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) اما دو ابزار زمان‌بندیِ مخصوص به خودش را هم اضافه می‌کند:

order.cjsJavaScript
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 استفاده کنید.

دفعهٔ بعد که چیزی «به ترتیب اشتباه» اجرا شد، از خودتان بپرسید: این یک تسک است یا میکروتسک، و چه چیزهای دیگری در همان صف هستند؟