Topik temu duga representatif

Temuduga Frontend: Bagaimanakah Gelung Peristiwa (Event Loop) JavaScript Berfungsi?

FrontendSukar
Pasukan Editorial Offer.ccDiterbitkan Dikemas kini

Soalan

Satu pengendali klik menjadualkan tindak balas Promise, queueMicrotask, setTimeout, dan rantaian mikrotugasan rekursif yang besar. Ramalkan urutan log, jelaskan mengapa pemasa, input, dan proses mengecat (painting) tertangguh, serta reka semula pengiraan tersebut untuk memastikan halaman kekal responsif.

Soalan dan bila patut menggunakannya

Anda sedang mengendalikan satu klik dalam papan pemuka data. Mulakan dengan meramalkan output kod ini:

javascript
console.log("A");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => {
  console.log("promise");
  queueMicrotask(() => console.log("nested"));
});

queueMicrotask(() => console.log("microtask"));

console.log("B");

Jelaskan setiap langkah, kemudian analisis masalah prestasi ini:

javascript
let remaining = 100_000;

function continueInMicrotask() {
  remaining -= 1;
  if (remaining > 0) {
    queueMicrotask(continueInMicrotask);
  }
}

queueMicrotask(continueInMicrotask);
setTimeout(() => console.log("timer can run"), 0);
requestAnimationFrame(() => console.log("frame can render"));

Jelaskan mengapa pemasa, klik terkemudian, dan proses mengecat (painting) tertangguh, kemudian reka semula pengiraan kelompok (batch computation) tersebut supaya halaman kekal responsif. Soalan ini melibatkan JavaScript pada thread utama pelayar. Seorang worker mempunyai gelung peristiwanya sendiri, dan Node.js mempunyai model fasa yang tidak sepatutnya disalin secara langsung ke dalam jawapan pelayar.

Ini berguna untuk temuduga frontend peringkat pertengahan dan kanan, prestasi web, serta full-stack. Jawapan yang kukuh menghubungkan model penjadualan dengan pengalaman pengguna yang boleh diperhatikan: penyelesaian akhirnya tidak bermakna halaman kekal interaktif semasa kerja sedang dijalankan.

Perkara yang dinilai oleh penemuduga

Isyarat pertama ialah model yang tepat. Pelaksanaan skrip awal, panggilan balik klik, dan panggilan balik pemasa yang telah tamat tempoh berjalan sebagai tugasan (tasks). Tindak balas Promise, panggilan balik queueMicrotask(), dan panggilan balik MutationObserver menggunakan mikrotugasan (microtasks). Selepas satu tugasan selesai, gelung peristiwa melaksanakan titik pemeriksaan mikrotugasan (microtask checkpoint) dan terus memproses mikrotugasan sehingga barisan gilir (queue) kosong.

Isyarat kedua ialah sama ada calon mengelak daripada menggambarkan pelayar sebagai hanya mempunyai satu “barisan gilir makrotugasan” (macrotask queue) yang kekal. Piawaian HTML membenarkan sumber tugasan yang berbeza dikaitkan dengan barisan gilir tugasan yang berbeza. Pelayar boleh membuat pilihan yang ditentukan oleh pelaksanaan antara barisan gilir yang boleh dijalankan sambil mengekalkan susunan dalam satu sumber tugasan. “Makrotugasan” boleh menjadi istilah ringkas perbualan, tetapi tugasan (task) ialah istilah piawaian yang lebih tepat.

Isyarat ketiga ialah sempadan rendering yang betul. Hanya selepas titik pemeriksaan mikrotugasan tamat, pelayar boleh beralih ke tugasan lain atau kemas kini rendering, dan rendering tidak dijamin selepas setiap tugasan. Jika kod terus menambah mikrotugasan lain sebelum barisan gilir kosong, peristiwa input, pemasa, dan peluang rendering semuanya boleh mengalami kelaparan (starved).

Isyarat keempat ialah memilih primitif penjadualan yang betul. await Promise.resolve() hanya menyambung semula fungsi dalam mikrotugasan, jadi ia tidak membenarkan tugasan kemudian atau cat dijalankan terlebih dahulu. Penyerahan semula (yield) thread utama yang sebenar menjadualkan kesinambungan dalam tugasan masa hadapan, seperti scheduler.yield() di mana disokong atau sandaran setTimeout(). Kerja CPU yang tidak boleh dipecahkan secara selamat sepatutnya diletakkan dalam worker.

Soalan untuk dijelaskan sebelum menjawab

  • Pelayar manakah yang mesti disokong? scheduler.yield() tidak tersedia dalam setiap pelayar yang digunakan secara meluas, jadi sokongan yang luas memerlukan pengesanan ciri (feature detection) dan sandaran (fallback).
  • Bolehkah kerja dibahagikan antara rekod, atau bolehkah satu panggilan menyekat untuk masa yang lama? Gelung yang boleh dibahagikan boleh menyerahkan kawalan (yield) antara kelompok. Jika satu rekod itu sendiri mahal, pengelompokan pada thread utama masih membenarkan sekatan yang panjang; gunakan worker atau tukar algoritma.
  • Adakah kemajuan mesti dicat selepas setiap kelompok, atau adakah hanya hasil akhir diperlukan? Kemajuan yang kelihatan memerlukan penyerahan thread utama selepas mengemas kini keadaan. Hasil akhir sahaja boleh mengelakkan kos DOM, susun atur, dan cat yang berulang.
  • Adakah hasil mesti dilakukan (commit) mengikut urutan yang ketat? Worker boleh mengira secara serentak, tetapi penyelesaian luar urutan memerlukan nombor jujukan, peraturan gabungan, atau penimbal komit yang tersusun.
  • Adakah pemprosesan mesti diteruskan dalam tab latar belakang? requestAnimationFrame() dijeda dalam kebanyakan tab latar belakang, jadi ia adalah penjadual umum yang lemah untuk kemajuan latar belakang yang wajib.
  • Bagaimanakah keterikatan responsif akan diterima? Bersetuju tentang kependaman interaksi, bajet setiap kelompok, daya pemprosesan keseluruhan, dan tingkah laku pembatalan supaya “tidak membeku” menjadi sesuatu yang boleh diuji.

Rangka kerja jawapan 30 saat

“Pelayar memilih satu tugasan yang boleh dijalankan, menjalankannya, dan kemudian melaksanakan titik pemeriksaan mikrotugasan yang mengosongkan barisan gilir mikrotugasan. Hanya selepas itu ia boleh beralih ke rendering atau tugasan lain. Keratan kod pertama mencatat A dan B secara segerak, kemudian promise dan microtask mengikut urutan dimasukkan ke dalam barisan gilir (enqueue). Panggilan balik promise menambah nested di belakang microtask yang sedia menunggu, dan timeout berjalan paling akhir. Mikrotugasan rekursif memastikan titik pemeriksaan kekal terbuka, menyebabkan pemasa, input, dan rendering kelaparan. await Promise.resolve() masih merupakan mikrotugasan, jadi ia bukan penyerahan thread utama yang sebenar. Saya akan membahagikan masa bagi kerja yang boleh dipecahkan dan memanggil scheduler.yield() yang dikesan cirinya antara kelompok, dengan setTimeout() sebagai sandaran. Jika satu unit masih berat CPU, saya akan memindahkannya ke worker, kemudian mengesahkan kependaman interaksi dengan rakaman prestasi dan input sebenar.”

Penyelesaian langkah demi langkah

Langkah 1: Terbitkan output daripada masa masuk ke barisan gilir (enqueue time)

Keseluruhan skrip sedang berjalan sebagai satu tugasan. Kenyataan segerak tidak menunggu gelung peristiwa, jadi output pertama ialah:

text
A
B

setTimeout(..., 0) bermaksud bahawa selepas syarat pemasaan dipenuhi, panggilan baliknya boleh menjadi tugasan masa hadapan. Sifar tidak mengganggu skrip semasa. Promise.resolve().then(...) memasukkan tindak balas Promise ke dalam barisan gilir sebagai mikrotugasan pertama, dan queueMicrotask(...) yang berikut memasukkan mikrotugasan kedua ke dalam barisan gilir.

Apabila tugasan skrip selesai, titik pemeriksaan mikrotugasan bermula. Mikrotugasan pertama mencatat promise dan menambah nested ke hujung barisan gilir mikrotugasan. Mikrotugasan eksplisit sudah pun menunggu, jadi ia mencatat microtask sebelum nested. Sebaik sahaja barisan gilir mikrotugasan kosong, tugasan pemasa mendapat peluang untuk dijalankan:

text
A
B
promise
microtask
nested
timeout

Bagi setiap baris, rekod bila panggilan balik dimasukkan ke dalam barisan gilir dan kategori penjadualan mana yang dimasukinya. “Mikrotugasan mempunyai keutamaan” tidak mencukupi jika salah satu daripada panggilan balik yang bersaing belum dimasukkan ke dalam barisan gilir lagi.

Langkah 2: Tetapkan sempadan tugasan, mikrotugasan, dan rendering

Urutan dipermudahkan yang boleh diguna semula ialah:

  1. Pelayar memilih satu tugasan daripada barisan gilir tugasan yang boleh dijalankan.
  2. Ia menjalankan tugasan itu sehingga tindanan panggilan (call stack) JavaScript kosong.
  3. Ia melaksanakan titik pemeriksaan mikrotugasan; mikrotugasan yang ditambah semasa titik pemeriksaan diproses dalam titik pemeriksaan yang sama.
  4. Berdasarkan peluang rendering, keterlihatan dokumen, dan dasar pelaksanaan, pelayar boleh mengemas kini rendering.
  5. Gelung diteruskan dan boleh memproses tugasan lain.

Ini menjelaskan dua pemerhatian praktikal. Pertama, jika pengendali klik menukar DOM dan segera memulakan pengiraan segerak yang besar, pengguna biasanya tidak melihat keadaan perantaraan kerana pelayar belum memperoleh semula peluang mengecat. Kedua, mikrotugasan sesuai untuk kerja ketekalan pendek yang mesti berlaku sebelum peristiwa dan pemasa lain, tetapi bukan untuk pengiraan rekursif tanpa had atau besar.

Langkah 3: Diagnosis kelaparan mikrotugasan (microtask starvation)

Setiap seruan mikrotugasan pertama dalam keratan kedua memasukkan mikrotugasan lain ke dalam barisan gilir. Titik pemeriksaan tidak boleh selesai sehingga barisan gilurnya kosong, jadi 100,000 panggilan balik selesai sebelum tugasan pemasa atau tugasan klik seterusnya boleh dijalankan.

Tanpa syarat penamatan, barisan gilir mikrotugasan tidak pernah kosong dalam model penjadualan. Pelayar akhirnya mungkin menunjukkan amaran halaman tidak responsif, menamatkan halaman, atau menggunakan perlindungan pelaksanaan, tetapi ketepatan aplikasi tidak boleh bergantung padanya. requestAnimationFrame() tidak mendahului (preempt) JavaScript yang sedang berjalan; ia meminta panggilan balik sebelum proses mengecat semula (repaint) masa hadapan. Jika thread utama tidak pernah mencapai langkah rendering yang berkaitan, panggilan balik akan terus menunggu.

Penyerahan ketara ini masih salah:

javascript
async function processAll(records) {
  for (const record of records) {
    normalize(record);
    await Promise.resolve();
  }
}

Setiap kesinambungan await bersambung semula melalui mikrotugasan Promise. Tindanan panggilan menjadi kosong buat seketika, tetapi titik pemeriksaan terus menggunakan mikrotugasan tersebut, jadi tugasan input dan pemasa kemudian masih tidak boleh masuk.

Langkah 4: Bahagikan kerja yang boleh dipecahkan merentasi tugasan

Beri setiap kelompok bajet masa yang boleh diukur, kemudian serahkan semula kawalan (yield) secara sebenar antara kelompok:

javascript
function yieldToMain() {
  return globalThis.scheduler?.yield
    ? globalThis.scheduler.yield()
    : new Promise((resolve) => setTimeout(resolve, 0));
}

async function processRecords(records, budgetMs = 5) {
  let index = 0;

  while (index < records.length) {
    const deadline = performance.now() + budgetMs;

    while (index < records.length && performance.now() < deadline) {
      normalize(records[index]);
      index += 1;
    }

    updateProgress(index / records.length);

    if (index < records.length) {
      await yieldToMain();
    }
  }
}

Nilai milisaat 5 ialah andaian awal untuk latihan ini, bukan piawaian rentas peranti. Selaraskannya pada peranti sasaran terhadap kos setiap item, kependaman interaksi, dan daya pemprosesan. scheduler.yield() menjadualkan kesinambungan sebagai tugasan berkeutamaan kemudian, memberikan pelayar peluang untuk mengendalikan kerja yang perlu terlebih dahulu. Oleh kerana sokongan pelayarnya tidak lengkap, contoh ini melakukan pengesanan ciri padanya. Sandaran setTimeout() adalah lebih luas tetapi dipengaruhi oleh pengepitan pemasa (timer clamping), dasar latar belakang, dan persaingan daripada tugasan lain.

Terdapat sempadan lain: bajet hanya boleh disemak antara panggilan ke normalize(). Jika satu panggilan menyekat selama 80 milisaat, bajet lima milisaat tidak dapat membantu. Bahagikan normalize(), gantikan algoritma, atau pindahkan pengiraan ke worker.

Langkah 5: Padankan primitif dengan kerja

KeperluanPilihanKos utama atau sempadan
Jalankan pembersihan pendek atau pemberitahuan ketekalan selepas tugasan semasa tetapi sebelum peristiwa lainqueueMicrotask()Rekursi atau pengiraan berat menyebabkan kerja lain kelaparan
Bahagikan kerja thread utama yang panjang sambil mengekalkan kesinambungan berkeutamaanscheduler.yield()Memerlukan pengesanan ciri; sokongan tidak lengkap
Pindahkan kesinambungan ke tugasan masa hadapan dengan keserasian yang luassetTimeout()Kelewatan pemasa dan penjadualan bukan deterministik
Kemas kini keadaan animasi sebelum cat semula masa hadapanrequestAnimationFrame()Kerja panggilan balik yang berat masih menyekat cat tersebut; tab latar belakang biasanya menjedakannya
Jalankan kerja berat CPU yang tidak boleh dibahagikan secara selamatWeb WorkerKos protokol mesej, salinan, atau memori dikongsi

requestAnimationFrame() menyelaraskan kerja visual dengan proses mengecat; ia bukan barisan gilir kerja latar belakang umum. Gunakannya untuk menyerahkan perubahan visual yang ringan, bukan untuk menyembunyikan pengiraan yang besar sejurus sebelum mengecat. Worker mengalihkan pengiraan CPU daripada thread utama halaman, tetapi ia tidak menyelesaikan pembatalan, pelaporan kemajuan, susunan hasil, atau kos pemindahan secara automatik. Perkara tersebut memerlukan protokol yang eksplisit.

Langkah 6: Sahkan keterikatan responsif, bukan hanya penyiapan

Pengesahan harus merangkumi sekurang-kurangnya empat lapisan:

  1. Ujian susunan: Catat kod segerak, tindak balas Promise, queueMicrotask(), dan pemasa dalam halaman yang minimum dan sahkan susunan yang diperhatikan sepadan dengan penerbitan.
  2. Pemeriksaan garis masa: Rakam klik, kelompok, dan lukisan kemajuan dalam alat prestasi pelayar. Periksa tugasan yang panjang, mikrotugasan berterusan, jurang bingkai (frame gaps), dan masa panggilan balik input benar-benar berjalan.
  3. Tekanan dan pembatalan: Tingkatkan kiraan rekod, kurangkan kelajuan (throttle) CPU, klik dan tatal semasa pemprosesan, dan batalkan operasi. Sahkan bahawa barisan gilir tidak berkembang tanpa had.
  4. Persekitaran sempadan: Sahkan sandaran dalam pelayar tanpa scheduler.yield(). Alihkan halaman ke tab latar belakang dan sahkan aliran kerja perniagaan tidak bergantung secara salah pada panggilan balik requestAnimationFrame() yang berterusan.

Penerimaan memerlukan kedua-dua masa penyiapan dan kependaman interaksi. Pengelompokan menambah overhed penjadualan dan mungkin meningkatkan sedikit jumlah tempoh masa; tujuannya adalah untuk meninggalkan tetingkap pelaksanaan untuk input, proses mengecat, dan tugasan lain yang diperlukan. Jika kos daya pemprosesan itu tidak dapat diterima, optimumkan algoritma atau gunakan worker dan bukannya mengisi semula thread utama dengan mikrotugasan.

Contoh jawapan yang kukuh

“Saya akan menerbitkan urutan daripada masa masuk ke barisan gilir (enqueue time). Skrip semasa ialah satu tugasan, jadi A dan B adalah segerak. Pemasa hanya menjadualkan tugasan masa hadapan. Tindak balas Promise memasuki barisan gilir mikrotugasan sebelum panggilan balik queueMicrotask eksplisit, jadi titik pemeriksaan mencatat promise terlebih dahulu. Panggilan balik itu menambah nested di belakang mikrotugasan yang sedia menunggu. Urutan akhir ialah A, B, promise, microtask, nested, timeout.

Selepas satu tugasan, pelayar melaksanakan titik pemeriksaan mikrotugasan dan terus berjalan sehingga barisan gilir mikrotugasan kosong. Keratan kedua terus mengisi barisan gilir itu, jadi titik pemeriksaan kekal terbuka untuk masa yang lama. Pemasa dan klik ialah tugasan kemudian, dan proses mengecat memerlukan thread utama mencapai peluang rendering, jadi kesemuanya tertangguh. requestAnimationFrame tidak boleh mendahului JavaScript, dan await Promise.resolve hanya bersambung semula dalam mikrotugasan lain, jadi kedua-duanya tidak membetulkan kelaparan tersebut.

Jika setiap rekod adalah pantas, saya akan memproses mengikut bajet masa dan memanggil scheduler.yield antara kelompok. Memandangkan ia tidak tersedia dalam setiap pelayar, saya akan mengesan cirinya dan menggunakan setTimeout sebagai sandaran. Bermula dengan bajet lima milisaat hanyalah eksperimen; saya akan menyesuaikannya pada peranti sasaran terhadap kependaman input dan daya pemprosesan. Jika satu rekod itu sendiri mahal, saya akan membahagikan operasi itu atau memindahkannya ke worker.

Akhir sekali, saya akan merakam garis masa prestasi dan mengesahkan tiada lata (waterfall) mikrotugasan yang berterusan, kemajuan benar-benar dicat, klik dan penatalan berjalan semasa pemprosesan, serta pembatalan, tab latar belakang, dan sandaran keserasian berkelakuan dengan betul. Reka bentuk ini menerima sedikit overhed penjadualan sebagai pertukaran untuk keterikatan responsif yang boleh diukur.”

Kesilapan lazim

  • Menghafal “segerak, mikrotugasan, makrotugasan” → Ia tidak dapat menjelaskan mengapa mikrotugasan bersarang berjalan di belakang mikrotugasan yang telah sedia ada dalam barisan gilir dan mengabaikan pelbagai sumber tugasan → Anotasikan masa masuk ke barisan gilir, kategori, dan keadaan barisan gilir baris demi baris.
  • Memanggil setTimeout(..., 0) segera → Pemasa yang telah tamat tempoh hanya menjadikan tugasan masa hadapan layak dan tidak boleh mendahului tugasan atau titik pemeriksaan semasa → Nyatakan kelayakan terawal tanpa menjanjikan masa yang tepat.
  • Menganggap pengecatan berlaku selepas setiap mikrotugasan → Titik pemeriksaan mengosongkan mikrotugasan secara berterusan, dan peluang rendering kemudian mungkin masih dilangkau → Bincangkan proses mengecat selepas titik pemeriksaan selesai sepenuhnya.
  • Memecahkan kelompok dengan await Promise.resolve() Kesinambungannya masih merupakan mikrotugasan dan tidak membenarkan tugasan input atau pemasa kemudian masuk → Jadualkan kesinambungan dalam tugasan masa hadapan.
  • Menggunakan panggilan queueMicrotask() tanpa henti untuk “keutamaan” → Ia menyebabkan tugasan lain dan rendering kelaparan → Simpan mikrotugasan untuk kerja ketekalan yang pendek dan terhad.
  • Memindahkan kerja berat ke dalam requestAnimationFrame() Panggilan balik masih berjalan pada thread utama sebelum cat, jadi kerja berat melambatkan bingkai (frame) → Kekalkan kerja visual rAF ringan dan pecahkan atau pindahkan kerja CPU.
  • Memanggil scheduler.yield() tanpa syarat → Sesetengah pelayar yang digunakan secara meluas tidak menyokongnya → Kesan cirinya dan uji sandaran setTimeout().
  • Mengukur hanya jumlah tempoh masa → Daya pemprosesan biasa boleh menyembunyikan tempoh panjang input yang tidak responsif → Periksa kependaman interaksi, tugasan panjang, bingkai, dan pertumbuhan barisan gilir juga.
  • Menggunakan satu saiz kelompok tetap di semua tempat → Kos setiap item, kadar penyegaran (refresh rate), dan kelajuan peranti berbeza-beza → Mulakan dengan bajet masa dan selaraskannya dalam persekitaran sasaran.

Soalan susulan dan respons

Susulan 1: Yang manakah berjalan dahulu, dua pemasa sifar-kelewatan atau satu klik?

Nama API sahaja tidak menetapkan urutan sejagat merentasi sumber tugasan. Pelayar boleh mengekalkan pelbagai barisan gilir tugasan dan memilih antara barisan gilir yang boleh dijalankan sambil mengekalkan susunan dalam satu sumber tugasan. Jelaskan bila pemasa menjadi layak, bila klik berlaku, dan apa lagi yang menduduki halaman, kemudian perhatikan pelaksanaan sasaran. Keratan temuduga deterministik biasanya mengawal keadaan ini.

Susulan 2: Jika scheduler.yield() mengembalikan Promise, mengapakah ia merupakan penyerahan sebenar?

Ciri yang penting ialah cara API menjadualkan penyelesaian, bukan jenis pulangan. Kesinambungan selepas Promise yang telah diselesaikan menyertai titik pemeriksaan mikrotugasan semasa. scheduler.yield() menjadualkan tugasan berkeutamaan kemudian yang menyelesaikan Promisenya, membenarkan kerja yang belum selesai seperti input berjalan sebelum kesinambungan. Ia masih memerlukan pengesanan ciri, dan operasi kecil tidak sepatutnya menjadi tugasan berasingan setiap satu.

Susulan 3: Bolehkah pemprosesan 100,000 rekod dalam worker masih membekukan halaman?

Ya. Thread utama masih boleh terlebih beban akibat mesej yang berlebihan, salinan data yang besar, perubahan DOM yang kerap, atau penggabungan hasil yang mahal. Kelompokkan mesej kemajuan, hadkan kekerapannya, nilaikan objek boleh pindah (transferable objects) atau protokol memori dikongsi yang wajar, dan komit hanya perubahan visual yang perlu pada thread utama.

Susulan 4: Bilakah queueMicrotask() patut digunakan?

Gunakan ia untuk kerja pendek dan terhad yang mesti dijalankan selepas logik segerak semasa tetapi sebelum peristiwa lain, seperti memberikan cache hit segerak dan miss berasaskan Promise urutan panggilan balik yang sama, atau mengelompokkan satu pemberitahuan pustaka dalaman. Ia bukan penjadual kerja panjang. Nyatakan kedua-dua peraturan penamatan dan had kerja.

Susulan 5: Bagaimanakah anda akan membatalkan operasi yang dipecahkan itu?

Semak AbortSignal pada permulaan setiap kelompok dan selepas setiap penyerahan (yield), berhenti mengambil rekod, dan elakkan operasi yang lebih lama daripada menulis ganti kemajuan atau hasil permintaan yang lebih baharu. Reka bentuk worker memerlukan mesej pembatalan dan peraturan untuk membuang hasil yang lewat. Semakan yang terlalu jarang bertindak balas secara perlahan; semakan di sekeliling setiap operasi kecil menambah overhed, jadi ukur pada sempadan kelompok.

Susulan 6: Adakah requestAnimationFrame() menjamin proses mengecat segera?

Tidak. Ia meminta panggilan balik sebelum cat semula masa hadapan, manakala pelayar boleh menjadualkan atau melangkau rendering berdasarkan keterlihatan dan dasar rendering. Kebanyakan pelayar menjeda panggilan balik ini dalam tab latar belakang. Walaupun panggilan balik berjalan, kerja yang panjang di dalamnya terus melambatkan proses mengecat yang sebenar.

Sumber awam

Soalan berkaitan