Anda telah diberi tahu "jangan memblokir thread utama" dan "membagi tugas yang lama", tetapi apa artinya melakukan hal-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".
- "Bagi tugas panjang Anda menjadi beberapa bagian."
Ini adalah saran yang bagus, tetapi apa yang harus dilakukan? Mengirimkan lebih sedikit JavaScript itu bagus, tetapi apakah hal itu otomatis sama dengan antarmuka pengguna yang lebih responsif? Mungkin ya, mungkin tidak.
Untuk memahami cara mengoptimalkan tugas di JavaScript, Anda harus mengetahui tugas itu sendiri dan cara browser menanganinya terlebih dahulu.
Apa yang dimaksud dengan tugas?
Tugas adalah bagian pekerjaan terpisah yang dilakukan browser. Pekerjaan tersebut mencakup rendering, mengurai HTML dan CSS, menjalankan JavaScript, dan jenis pekerjaan lainnya yang mungkin tidak Anda kontrol secara langsung. Dari semua ini, JavaScript yang Anda tulis mungkin merupakan sumber tugas terbesar.
Tugas yang terkait dengan JavaScript memengaruhi performa dengan beberapa cara:
- Saat mendownload file JavaScript selama startup, browser akan mengantrekan tugas untuk mengurai dan mengompilasi JavaScript tersebut agar dapat dieksekusi nanti.
- Pada waktu lain selama siklus proses halaman, tugas akan diantrekan saat JavaScript berfungsi, seperti merespons interaksi melalui pengendali peristiwa, animasi yang didorong 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. Setiap tugas yang memerlukan waktu lebih dari 50 milidetik adalah tugas panjang. 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 berdurasi berapa pun sedang berjalan, tetapi hal ini tidak dapat dilihat oleh pengguna selama tugas tidak berjalan terlalu lama. Namun, saat pengguna mencoba berinteraksi dengan halaman saat ada banyak tugas yang lama, antarmuka pengguna akan terasa tidak responsif, dan mungkin bahkan rusak jika thread utama diblokir untuk jangka waktu yang sangat lama.
Untuk mencegah thread utama diblokir terlalu lama, Anda dapat membagi tugas yang panjang 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 berjalan hingga selesai, sehingga memastikan pekerjaan yang awalnya Anda antrekan akan selesai.
Di bagian atas gambar sebelumnya, pengendali peristiwa yang diantrekan oleh interaksi pengguna harus menunggu satu tugas panjang sebelum dapat dimulai. Hal ini menunda interaksi. Dalam skenario ini, pengguna mungkin telah melihat jeda. Di bagian bawah, pengendali peristiwa dapat mulai berjalan lebih cepat, dan interaksi mungkin terasa instan.
Setelah mengetahui pentingnya membagi tugas, Anda dapat mempelajari cara melakukannya di JavaScript.
Strategi pengelolaan tugas
Saran umum dalam arsitektur software adalah membagi pekerjaan Anda menjadi beberapa 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, memperbarui antarmuka pengguna, dan mengirim analisis.
Secara konseptual, saveSettings()
dirancang dengan baik. Jika perlu men-debug salah satu fungsi ini, Anda dapat menjelajahi hierarki project untuk mengetahui fungsi setiap fungsi. Dengan membagi pekerjaan seperti ini, project akan lebih mudah dikelola dan dipelihara.
Namun, potensi masalah di sini adalah JavaScript tidak menjalankan setiap fungsi ini sebagai tugas terpisah karena fungsi tersebut dieksekusi dalam fungsi saveSettings()
. Artinya, kelima fungsi tersebut akan berjalan sebagai satu tugas.
Dalam skenario kasus terbaik, bahkan hanya satu dari fungsi tersebut yang dapat berkontribusi 50 milidetik atau lebih pada total durasi tugas. Dalam kasus terburuk, lebih banyak tugas tersebut dapat berjalan lebih lama—terutama di perangkat yang memiliki keterbatasan resource.
Dalam hal ini, saveSettings()
dipicu oleh klik pengguna, dan karena browser tidak dapat menampilkan respons hingga seluruh fungsi selesai berjalan, hasil dari tugas yang lama 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 prioritas rendah, Anda dapat menghasilkan thread utama dengan mengganggu pekerjaan Anda sebentar untuk memberi browser kesempatan menjalankan tugas yang lebih penting.
Salah satu metode yang digunakan developer untuk membagi tugas menjadi tugas yang lebih kecil melibatkan setTimeout()
. Dengan teknik ini, Anda meneruskan fungsi ke setTimeout()
. Tindakan ini 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 menghasilkan, dan berfungsi paling baik untuk serangkaian fungsi yang perlu dijalankan secara berurutan.
Namun, kode Anda mungkin tidak selalu diatur dengan cara ini. Misalnya, Anda dapat 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 pengunduran: 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 yang menghasilkan khusus: scheduler.yield()
scheduler.yield()
adalah API yang dirancang khusus untuk menghasilkan thread utama di browser.
Ini bukan sintaks tingkat bahasa atau konstruksi khusus; scheduler.yield()
hanyalah fungsi yang menampilkan Promise
yang akan diselesaikan dalam tugas mendatang. Setiap kode yang dirantai untuk dijalankan setelah Promise
tersebut di-resolve (baik dalam rantai .then()
eksplisit atau setelah await
dalam fungsi asinkron) akan dijalankan dalam tugas mendatang tersebut.
Dalam praktiknya: masukkan await scheduler.yield()
dan fungsi akan menjeda eksekusi pada titik tersebut dan menghasilkan thread utama. Eksekusi fungsi lainnya—disebut lanjutan fungsi—akan dijadwalkan untuk berjalan dalam tugas loop peristiwa baru. Saat tugas tersebut dimulai, promise yang ditunggu akan diselesaikan, dan fungsi akan terus dieksekusi dari tempat terakhir dieksekusi.
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();
}
Namun, manfaat sebenarnya dari scheduler.yield()
dibandingkan pendekatan yang menghasilkan lainnya adalah kelanjutannya diprioritaskan, yang berarti jika Anda menghasilkan di tengah tugas, kelanjutan tugas saat ini akan berjalan sebelum tugas serupa lainnya dimulai.
Hal ini menghindari kode dari sumber tugas lain agar tidak mengganggu urutan eksekusi kode Anda, seperti tugas dari skrip pihak ketiga.
Dukungan lintas browser
scheduler.yield()
belum didukung di semua browser, sehingga diperlukan penggantian.
Salah satu solusinya adalah dengan menempatkan scheduler-polyfill
ke dalam build, lalu scheduler.yield()
dapat digunakan secara langsung; polyfill akan menangani kembali ke fungsi penjadwalan tugas lainnya sehingga berfungsi dengan cara yang sama 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 tetap akan menghasilkan agar tetap responsif.
Terakhir, mungkin ada kasus saat kode Anda tidak dapat memberikan thread utama jika kelanjutannya tidak diprioritaskan (misalnya, halaman yang diketahui sibuk yang berisiko tidak menyelesaikan tugas selama beberapa waktu). Dalam hal ini, scheduler.yield()
dapat diperlakukan sebagai jenis progressive enhancement: hasil di browser tempat scheduler.yield()
tersedia, jika tidak, lanjutkan.
Hal ini dapat dilakukan dengan mendeteksi fitur dan kembali menunggu satu tugas mikro 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
-nya dalam fungsi async
apa pun.
Misalnya, jika Anda memiliki array tugas yang akan dijalankan yang sering kali berakhir dengan tugas yang panjang, Anda dapat menyisipkan hasil untuk membagi tugas.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Lanjutan runJobs()
akan diprioritaskan, tetapi tetap memungkinkan pekerjaan dengan prioritas lebih tinggi seperti merespons input pengguna secara visual untuk dijalankan, tanpa harus menunggu daftar tugas yang berpotensi panjang selesai.
Namun, ini bukan penggunaan yang efisien dari penghasil. 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, hanya menghasilkan di antara tugas tersebut jika sudah cukup lama sejak hasil terakhir. Batas waktu umum adalah 50 milidetik untuk mencoba mencegah tugas menjadi tugas yang lama, tetapi 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 adalah tugas dipecah agar tidak pernah 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 berlanjut jika tidak ada input yang tertunda, bukan menghasilkan dan berakhir di bagian belakang antrean tugas. Hal ini dapat menghasilkan peningkatan performa yang mengesankan, seperti yang dijelaskan dalam Intent to Ship, untuk situs yang mungkin tidak kembali ke thread utama.
Namun, sejak peluncuran API tersebut, pemahaman kami tentang penghasil telah meningkat, terutama dengan diperkenalkannya INP. Kami tidak lagi merekomendasikan penggunaan API ini, dan sebagai gantinya merekomendasikan untuk menghasilkan terlepas dari apakah input tertunda atau tidak karena sejumlah alasan:
isInputPending()
mungkin salah menampilkanfalse
meskipun pengguna telah berinteraksi dalam beberapa situasi.- Input bukanlah satu-satunya kasus saat tugas harus menghasilkan. Animasi dan pembaruan antarmuka pengguna reguler lainnya juga sama pentingnya untuk menyediakan halaman web yang responsif.
- API yang lebih komprehensif telah diperkenalkan sejak saat itu yang mengatasi masalah yang menghasilkan, seperti
scheduler.postTask()
danscheduler.yield()
.
Kesimpulan
Mengelola tugas memang sulit, tetapi melakukannya akan memastikan halaman Anda merespons interaksi pengguna dengan lebih cepat. Tidak ada satu saran untuk mengelola dan memprioritaskan tugas, tetapi ada sejumlah teknik yang berbeda. Untuk mengulang, berikut adalah hal-hal utama yang perlu dipertimbangkan saat mengelola tugas:
- Menghasilkan thread utama untuk tugas penting yang ditampilkan kepada pengguna.
- Menggunakan
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 scheduler.yield()
, scheduler.postTask()
penjadwalan tugas relatif eksplisit, dan prioritas tugas lebih lanjut, lihat dokumen Prioritized Task Scheduling API.
Dengan satu atau beberapa alat ini, Anda akan dapat menyusun pekerjaan di aplikasi sehingga memprioritaskan kebutuhan pengguna, sekaligus memastikan bahwa pekerjaan yang kurang penting tetap dapat diselesaikan. Hal ini akan menciptakan pengalaman pengguna yang lebih baik, yang lebih responsif dan lebih menyenangkan untuk digunakan.
Terima kasih khusus kepada Philip Walton atas pemeriksaan teknisnya terhadap panduan ini.
Gambar thumbnail bersumber dari Unsplash, atas izin Amirali Mirhashemian.