Anda telah diberi tahu untuk "tidak memblokir thread utama" dan "memecah tugas berdurasi lama", tetapi apa artinya melakukan hal tersebut?
Dipublikasikan: 30 September 2022, Terakhir diperbarui: 19 Desember 2024
Saran umum untuk menjaga aplikasi JavaScript tetap cepat cenderung bermuara pada saran berikut:
- "Jangan memblokir thread utama".
- "Membagi tugas berdurasi lama."
Saran ini bagus, tetapi pekerjaan apa yang diperlukan? Mengirimkan lebih sedikit JavaScript memang bagus, tetapi apakah hal itu otomatis menghasilkan antarmuka pengguna yang lebih responsif? Mungkin, tetapi mungkin juga tidak.
Untuk memahami cara mengoptimalkan tugas di JavaScript, Anda harus mengetahui apa itu tugas dan cara browser menanganinya.
Apa yang dimaksud dengan tugas?
Tugas adalah setiap bagian pekerjaan terpisah yang dilakukan browser. Pekerjaan tersebut mencakup rendering, penguraian HTML dan CSS, menjalankan JavaScript, dan jenis pekerjaan lain yang mungkin tidak Anda kontrol secara langsung. Dari semua ini, JavaScript yang Anda tulis mungkin merupakan sumber tugas terbesar.
click, ditampilkan di profiler performa Chrome DevTools.
Tugas yang terkait dengan JavaScript memengaruhi performa dalam beberapa cara:
- Saat browser mendownload file JavaScript selama startup, browser akan mengantrekan tugas untuk mengurai dan mengompilasi JavaScript tersebut agar dapat dieksekusi nanti.
- Pada waktu lain selama masa aktif halaman, tugas akan diantrekan saat JavaScript melakukan pekerjaan seperti merespons interaksi melalui pengendali peristiwa, animasi berbasis JavaScript, dan aktivitas latar belakang seperti pengumpulan analisis.
Semua hal ini—kecuali web worker dan API serupa—terjadi di thread utama.
Apa yang dimaksud dengan thread utama?
Thread utama adalah tempat sebagian besar tugas berjalan di browser, dan tempat hampir semua JavaScript yang Anda tulis dieksekusi.
Thread utama hanya dapat memproses satu tugas dalam satu waktu. Tugas apa pun yang memerlukan waktu lebih dari 50 milidetik adalah tugas berdurasi lama. Untuk tugas yang melebihi 50 milidetik, total waktu tugas dikurangi 50 milidetik dikenal sebagai periode pemblokiran tugas.
Browser memblokir interaksi agar tidak terjadi saat tugas dengan durasi berapa pun sedang berjalan, tetapi hal ini tidak terlihat oleh pengguna selama tugas tidak berjalan terlalu lama. Namun, saat pengguna mencoba berinteraksi dengan halaman saat ada banyak tugas berdurasi lama, antarmuka pengguna akan terasa tidak responsif, dan bahkan mungkin rusak jika thread utama diblokir dalam jangka waktu yang sangat lama.
Untuk mencegah thread utama diblokir terlalu lama, Anda dapat memecah tugas berdurasi lama menjadi beberapa tugas yang lebih kecil.
Hal ini penting karena saat tugas dipecah, browser dapat merespons pekerjaan dengan prioritas lebih tinggi jauh lebih cepat—termasuk interaksi pengguna. Setelah itu, tugas yang tersisa akan dijalankan hingga selesai, sehingga memastikan pekerjaan yang awalnya Anda antrekan selesai.
Di bagian atas gambar sebelumnya, pengendali peristiwa yang diantrekan oleh interaksi pengguna harus menunggu satu tugas berdurasi lama sebelum dapat dimulai. Hal ini menunda terjadinya interaksi. Dalam skenario ini, pengguna mungkin akan melihat adanya jeda. Di bagian bawah, pengendali peristiwa dapat mulai berjalan lebih cepat, dan interaksi mungkin terasa instan.
Setelah mengetahui alasan pentingnya memecah tugas, Anda dapat mempelajari cara melakukannya di JavaScript.
Strategi pengelolaan tugas
Saran umum dalam arsitektur software adalah membagi pekerjaan Anda menjadi fungsi-fungsi yang lebih kecil:
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
Dalam contoh ini, ada fungsi bernama saveSettings() yang memanggil lima fungsi untuk memvalidasi formulir, menampilkan indikator lingkaran berputar, mengirim data ke backend aplikasi, mengupdate antarmuka pengguna, dan mengirim analisis.
Secara konseptual, saveSettings() dirancang dengan baik. Jika perlu men-debug salah satu fungsi ini, Anda dapat melintasi hierarki project untuk mengetahui fungsi masing-masing fungsi. Membagi pekerjaan seperti ini akan membuat project lebih mudah dipahami dan dikelola.
Namun, potensi masalah di sini adalah JavaScript tidak menjalankan setiap fungsi ini sebagai tugas terpisah karena dijalankan dalam fungsi saveSettings(). Artinya, kelima fungsi tersebut akan berjalan sebagai satu tugas.
saveSettings() yang memanggil lima fungsi. Pekerjaan dijalankan sebagai bagian dari satu tugas monolitik yang panjang, sehingga memblokir respons visual apa pun hingga kelima fungsi selesai.
Dalam skenario kasus terbaik, bahkan hanya satu dari fungsi tersebut dapat berkontribusi 50 milidetik atau lebih terhadap total durasi tugas. Dalam kasus terburuk, lebih banyak tugas tersebut dapat berjalan lebih lama—terutama pada perangkat dengan resource terbatas.
Dalam hal ini, saveSettings() dipicu oleh klik pengguna, dan karena browser tidak dapat menampilkan respons hingga seluruh fungsi selesai berjalan, hasil dari tugas yang panjang ini adalah UI yang lambat dan tidak responsif, dan akan diukur sebagai Interaction to Next Paint (INP) yang buruk.
Menunda eksekusi kode secara manual
Untuk memastikan tugas penting yang ditampilkan kepada pengguna dan respons UI terjadi sebelum tugas berprioritas lebih rendah, Anda dapat menyerahkan tugas ke thread utama dengan menghentikan pekerjaan Anda sebentar untuk memberi browser peluang menjalankan tugas yang lebih penting.
Salah satu metode yang digunakan developer untuk memecah tugas menjadi tugas yang lebih kecil melibatkan setTimeout(). Dengan teknik ini, Anda meneruskan fungsi ke setTimeout(). Tindakan ini akan menunda eksekusi callback ke tugas terpisah, meskipun Anda menentukan waktu tunggu 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);
}
Hal ini dikenal sebagai yielding, dan paling cocok untuk serangkaian fungsi yang perlu dijalankan secara berurutan.
Namun, kode Anda mungkin tidak selalu diatur dengan cara ini. Misalnya, Anda mungkin memiliki data dalam jumlah besar yang perlu diproses dalam loop, dan tugas tersebut dapat memerlukan waktu yang sangat lama jika ada banyak iterasi.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Penggunaan setTimeout() di sini bermasalah karena ergonomi developer, dan setelah lima putaran setTimeout() bertingkat, browser akan mulai menerapkan penundaan minimum 5 milidetik untuk setiap setTimeout() tambahan.
setTimeout juga memiliki kelemahan lain dalam hal penyerahan: saat Anda menyerahkan ke thread utama dengan menunda kode untuk dijalankan dalam tugas berikutnya menggunakan setTimeout, tugas tersebut akan ditambahkan ke akhir antrean. Jika ada tugas lain yang menunggu, tugas tersebut akan berjalan sebelum kode yang ditangguhkan.
API penyerahan khusus: scheduler.yield()
scheduler.yield() adalah API yang dirancang khusus untuk mengalihkan kontrol ke thread utama di browser.
Ini bukan sintaksis tingkat bahasa atau konstruksi khusus; scheduler.yield() hanyalah fungsi yang menampilkan Promise yang akan diselesaikan dalam tugas mendatang. Kode apa pun yang dirantai untuk dijalankan setelah Promise tersebut diselesaikan (baik dalam rantai .then() eksplisit atau setelah await dalam fungsi asinkron) kemudian akan dijalankan dalam tugas mendatang tersebut.
Dalam praktiknya: sisipkan await scheduler.yield() dan fungsi akan menjeda eksekusi pada titik tersebut dan beralih ke thread utama. Eksekusi bagian fungsi lainnya—yang disebut kelanjutan fungsi—akan dijadwalkan untuk dijalankan dalam tugas loop peristiwa baru. Saat tugas tersebut dimulai, promise yang ditunggu akan diselesaikan, dan fungsi akan melanjutkan eksekusi dari bagian terakhir yang ditinggalkan.
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() kini dibagi menjadi dua tugas. Hasilnya, tata letak dan paint dapat berjalan di antara tugas, sehingga memberikan respons visual yang lebih cepat kepada pengguna, seperti yang diukur oleh interaksi pointer yang kini jauh lebih singkat.
Namun, manfaat nyata scheduler.yield() dibandingkan pendekatan penyerahan lainnya adalah kelanjutannya diprioritaskan, yang berarti jika Anda menyerahkan di tengah tugas, kelanjutan tugas saat ini akan berjalan sebelum tugas serupa lainnya dimulai.
Hal ini mencegah kode dari sumber tugas lain mengganggu urutan eksekusi kode Anda, seperti tugas dari skrip pihak ketiga.
scheduler.yield(), kelanjutan akan melanjutkan dari bagian terakhir yang ditinggalkan sebelum beralih ke tugas lain.
Dukungan lintas browser
scheduler.yield() belum didukung di semua browser, sehingga diperlukan penggantian.
Salah satu solusinya adalah dengan memasukkan scheduler-polyfill ke dalam build Anda, lalu scheduler.yield() dapat digunakan secara langsung; polyfill akan menangani penggantian ke fungsi penjadwalan tugas lainnya sehingga berfungsi serupa di seluruh browser.
Atau, versi yang kurang canggih dapat ditulis dalam beberapa baris, hanya menggunakan setTimeout yang digabungkan dalam Promise sebagai penggantian jika scheduler.yield() tidak tersedia.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Meskipun browser tanpa dukungan scheduler.yield() tidak akan mendapatkan kelanjutan yang diprioritaskan, browser tersebut akan tetap menghasilkan agar browser tetap responsif.
Terakhir, mungkin ada kasus saat kode Anda tidak dapat menghasilkan thread utama jika kelanjutannya tidak diprioritaskan (misalnya, halaman yang diketahui sibuk, yang menghasilkan risiko tidak menyelesaikan pekerjaan selama beberapa waktu). Dalam hal ini, scheduler.yield() dapat diperlakukan sebagai semacam progressive enhancement: menghasilkan di browser tempat scheduler.yield() tersedia, jika tidak, lanjutkan.
Hal ini dapat dilakukan dengan mendeteksi fitur dan beralih ke menunggu satu microtask dalam satu baris yang praktis:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Membagi pekerjaan yang berjalan lama dengan scheduler.yield()
Manfaat menggunakan salah satu metode penggunaan scheduler.yield() ini adalah Anda dapat await dalam fungsi async apa pun.
Misalnya, jika Anda memiliki array tugas yang akan dijalankan dan sering kali menghasilkan tugas berdurasi lama, Anda dapat menyisipkan yield untuk memecah tugas tersebut.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Kelanjutan runJobs() akan diprioritaskan, tetapi tetap memungkinkan pekerjaan dengan prioritas lebih tinggi seperti merespons input pengguna secara visual untuk berjalan, tanpa harus menunggu daftar tugas yang berpotensi panjang selesai.
Namun, ini bukan penggunaan penyerahan yang efisien. scheduler.yield() cepat dan efisien, tetapi memiliki beberapa overhead. Jika beberapa tugas di jobQueue sangat singkat, overhead dapat dengan cepat bertambah menjadi lebih banyak waktu yang dihabiskan untuk menghasilkan dan melanjutkan daripada menjalankan tugas yang sebenarnya.
Salah satu pendekatannya adalah dengan mengelompokkan tugas, dan hanya menghasilkan jeda di antara tugas tersebut jika sudah cukup lama sejak jeda terakhir. Batas waktu yang umum adalah 50 milidetik untuk mencegah tugas menjadi tugas berdurasi lama, tetapi batas waktu ini dapat disesuaikan sebagai kompromi antara responsivitas dan waktu untuk menyelesaikan antrean tugas.
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();
}
}
}
Hasilnya, tugas dipecah agar tidak memerlukan waktu terlalu lama untuk dijalankan, tetapi runner hanya menghasilkan thread utama sekitar setiap 50 milidetik.
Jangan gunakan isInputPending()
isInputPending() API menyediakan cara untuk memeriksa apakah pengguna telah mencoba berinteraksi dengan halaman dan hanya menghasilkan jika input tertunda.
Hal ini memungkinkan JavaScript melanjutkan jika tidak ada input yang tertunda, alih-alih menghasilkan dan berakhir di bagian belakang task queue. Hal ini dapat menghasilkan peningkatan performa yang mengesankan, seperti yang dijelaskan dalam Intent to Ship, untuk situs yang mungkin tidak menghasilkan kembali ke thread utama.
Namun, sejak peluncuran API tersebut, pemahaman kami tentang penyerahan telah meningkat, terutama dengan diperkenalkannya INP. Kami tidak lagi merekomendasikan penggunaan API ini, dan sebagai gantinya merekomendasikan penyerahan terlepas dari apakah input tertunda atau tidak karena sejumlah alasan:
isInputPending()mungkin salah menampilkanfalsemeskipun pengguna telah berinteraksi dalam beberapa situasi.- Input bukanlah satu-satunya kasus saat tugas harus dihentikan. Animasi dan update antarmuka pengguna reguler lainnya juga dapat sama pentingnya untuk menyediakan halaman web yang responsif.
- API penyerahan yang lebih komprehensif telah diperkenalkan sejak saat itu, yang mengatasi masalah penyerahan, seperti
scheduler.postTask()danscheduler.yield().
Kesimpulan
Mengelola tugas memang sulit, tetapi hal ini memastikan halaman Anda merespons interaksi pengguna dengan lebih cepat. Tidak ada satu saran pun untuk mengelola dan memprioritaskan tugas, melainkan sejumlah teknik yang berbeda. Sekali lagi, berikut adalah hal-hal utama yang perlu Anda pertimbangkan saat mengelola tugas:
- Menyerahkan ke thread utama untuk tugas penting yang ditampilkan kepada pengguna.
- Gunakan
scheduler.yield()(dengan penggantian lintas browser) untuk menghasilkan dan mendapatkan kelanjutan yang diprioritaskan secara ergonomis - Terakhir, lakukan pekerjaan sesedikit mungkin dalam fungsi Anda.
Untuk mempelajari lebih lanjut scheduler.yield(), scheduler.postTask() relatif penjadwalan tugas eksplisitnya, dan prioritas tugas, lihat Dokumen Prioritized Task Scheduling API.
Dengan satu atau beberapa alat ini, Anda akan dapat menyusun pekerjaan dalam aplikasi sehingga memprioritaskan kebutuhan pengguna, sekaligus memastikan bahwa pekerjaan yang kurang penting tetap diselesaikan. Hal ini akan menciptakan pengalaman pengguna yang lebih baik, lebih responsif, dan lebih menyenangkan untuk digunakan.
Terima kasih khusus kepada Philip Walton atas pemeriksaan teknisnya terhadap panduan ini.