Anda diberi tahu "jangan memblokir thread utama" dan "putuskan tugas yang panjang", tapi apa artinya melakukan hal-hal tersebut?
Jika Anda membaca banyak hal tentang performa web, saran untuk menjaga agar aplikasi JavaScript Anda tetap cepat cenderung melibatkan beberapa informasi berikut:
- "Jangan memblokir thread utama."
- "Pisahkan tugas yang berjalan lama."
Apa artinya? Mengirim JavaScript lebih sedikit memang bagus, tetapi apakah hal itu secara otomatis setara dengan antarmuka pengguna yang lebih cepat di seluruh siklus proses halaman? Mungkin ya, tapi mungkin tidak.
Untuk memahami pentingnya mengoptimalkan tugas di JavaScript, Anda perlu memahami peran tugas dan bagaimana browser menanganinya—dan itu dimulai dengan memahami apa itu tugas.
Apa itu tugas?
Tugas adalah setiap pekerjaan terpisah yang dilakukan browser. Tugas melibatkan pekerjaan seperti rendering, penguraian HTML dan CSS, menjalankan kode JavaScript yang Anda tulis, dan hal-hal lain yang mungkin tidak dapat Anda kontrol secara langsung. Dari semua ini, JavaScript yang Anda tulis dan deploy ke web adalah sumber utama tugas.
Tugas memengaruhi performa dalam beberapa cara. Misalnya, ketika browser mendownload file JavaScript saat startup, browser tersebut akan mengantrekan tugas untuk mengurai dan mengompilasi JavaScript tersebut agar dapat dieksekusi. Kemudian di dalam siklus proses halaman, tugas akan dimulai saat JavaScript Anda berfungsi seperti mendorong interaksi melalui pengendali peristiwa, animasi berbasis JavaScript, dan aktivitas latar belakang seperti pengumpulan analisis. Semua hal ini—kecuali pekerja web dan API serupa—terjadi di thread utama.
Apa yang dimaksud dengan thread utama?
Thread utama adalah tempat sebagian besar tugas dijalankan di browser. Fungsi ini disebut thread utama karena suatu alasan: ini adalah satu thread tempat hampir semua JavaScript yang Anda tulis melakukan tugasnya.
Thread utama hanya dapat memproses satu tugas dalam satu waktu. Jika tugas melampaui titik tertentu—tepat 50 milidetik—tugas tersebut akan diklasifikasikan sebagai tugas panjang. Jika pengguna mencoba berinteraksi dengan halaman saat tugas yang panjang berjalan—atau jika update rendering yang penting perlu dilakukan—browser akan tertunda dalam menangani pekerjaan tersebut. Hal ini menyebabkan latensi interaksi atau rendering.
Anda harus memecah tugas. Ini berarti mengambil satu tugas yang panjang dan membaginya menjadi tugas-tugas yang lebih kecil yang membutuhkan waktu lebih sedikit untuk dijalankan satu per satu.
Hal ini penting karena ketika tugas terbagi, browser memiliki lebih banyak peluang untuk merespons pekerjaan dengan prioritas lebih tinggi—termasuk interaksi pengguna.
Di bagian atas gambar sebelumnya, pengendali peristiwa yang diantrekan oleh interaksi pengguna harus menunggu satu tugas panjang sebelum dapat berjalan. Hal ini menunda berlangsungnya interaksi. Di bagian bawah, pengendali peristiwa memiliki kesempatan untuk berjalan lebih cepat. Karena memiliki peluang untuk berjalan di antara tugas-tugas yang lebih kecil, pengendali peristiwa akan berjalan lebih cepat daripada jika harus menunggu tugas yang panjang selesai. Dalam contoh atas, pengguna mungkin melihat adanya jeda; di bagian bawah, interaksi mungkin terasa langsung.
Namun, masalahnya adalah saran "bagi tugas yang panjang" dan "jangan blokir thread utama" tidak cukup spesifik kecuali jika Anda sudah mengetahui cara melakukan hal tersebut. Itulah yang akan dijelaskan dalam panduan ini.
Strategi manajemen tugas
Saran umum dalam arsitektur perangkat lunak adalah memecah pekerjaan menjadi beberapa fungsi yang lebih kecil. Ini memberi Anda manfaat berupa keterbacaan kode yang lebih baik, dan kemudahan pemeliharaan proyek. Hal ini juga membuat pengujian lebih mudah ditulis.
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
Dalam contoh ini, ada fungsi bernama saveSettings()
yang memanggil lima fungsi di dalamnya untuk melakukan tugas, seperti memvalidasi formulir, menampilkan indikator lingkaran berputar, mengirim data, dan sebagainya. Secara konseptual, hal ini dirancang dengan baik. Jika perlu men-debug salah satu fungsi ini, Anda dapat melintasi pohon proyek untuk mencari tahu apa yang dilakukan setiap fungsi.
Namun, masalahnya adalah JavaScript tidak menjalankan setiap fungsi ini sebagai tugas terpisah karena dijalankan dalam fungsi saveSettings()
. Ini berarti kelima fungsi tersebut berjalan sebagai satu tugas.
Dalam skenario kasus terbaik, bahkan hanya satu dari fungsi tersebut dapat berkontribusi 50 milidetik atau lebih untuk total durasi tugas. Dalam kasus terburuk, lebih banyak tugas tersebut dapat berjalan lebih lama—terutama di perangkat dengan resource yang terbatas. Berikut ini adalah seperangkat strategi yang dapat Anda gunakan untuk memecah dan memprioritaskan tugas.
Menunda eksekusi kode secara manual
Salah satu metode yang digunakan developer untuk memecah tugas menjadi lebih kecil adalah setTimeout()
. Dengan teknik ini, Anda akan 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);
}
Ini akan berfungsi dengan baik jika Anda memiliki serangkaian fungsi yang perlu dijalankan secara berurutan, tetapi kode Anda mungkin tidak selalu diatur dengan cara ini. Misalnya, Anda bisa memiliki data dalam jumlah besar yang perlu diproses dalam satu loop, dan tugas tersebut dapat memakan waktu yang sangat lama jika Anda memiliki jutaan item.
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
Menggunakan setTimeout()
di sini bermasalah, karena ergonomi membuatnya sulit untuk diterapkan, dan seluruh array data dapat memerlukan waktu yang sangat lama untuk diproses, meskipun setiap item dapat diproses dengan sangat cepat. Semuanya akan bertambah, dan setTimeout()
bukanlah alat yang tepat untuk tugas tersebut—setidaknya tidak saat digunakan dengan cara ini.
Selain setTimeout()
, ada beberapa API lain yang memungkinkan Anda menunda eksekusi kode ke tugas berikutnya. Salah satunya melibatkan penggunaan postMessage()
untuk waktu tunggu yang lebih cepat. Anda juga dapat membagi tugas menggunakan requestIdleCallback()
—tetapi hati-hati!—requestIdleCallback()
menjadwalkan tugas pada prioritas serendah mungkin, dan hanya selama waktu tidak ada aktivitas browser. Jika thread utama padat, tugas yang dijadwalkan dengan requestIdleCallback()
mungkin tidak dapat dijalankan.
Gunakan async
/await
untuk membuat poin hasil
Frasa yang akan Anda lihat di sepanjang panduan ini adalah "hasil untuk thread utama"—tapi apa artinya? Mengapa Anda harus melakukannya? Kapan Anda harus melakukannya?
Ketika tugas dipecah, tugas lain dapat diprioritaskan dengan lebih baik oleh skema prioritas internal browser. Salah satu cara untuk menghasilkan thread utama melibatkan penggunaan kombinasi Promise
yang di-resolve dengan panggilan ke setTimeout()
:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Dalam fungsi saveSettings()
, Anda dapat menghasilkan thread utama setelah setiap bit pekerjaan jika Anda melakukan await
pada fungsi yieldToMain()
setelah setiap panggilan fungsi:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread:
await yieldToMain();
}
}
Hasilnya adalah tugas yang sebelumnya monolitik sekarang dipecah menjadi beberapa tugas terpisah.
Manfaat menggunakan pendekatan berbasis promise untuk menghasilkan daripada penggunaan setTimeout()
manual adalah ergonomi yang lebih baik. Titik hasil menjadi deklaratif, sehingga lebih mudah ditulis, dibaca, dan dipahami.
Menghasilkan hanya jika diperlukan
Bagaimana jika Anda memiliki banyak tugas, tetapi Anda hanya ingin menghasilkan jika pengguna mencoba berinteraksi dengan halaman? Itulah tujuan pembuatan isInputPending()
.
isInputPending()
adalah fungsi yang dapat Anda jalankan kapan saja untuk menentukan apakah pengguna mencoba berinteraksi dengan elemen halaman: panggilan ke isInputPending()
akan menampilkan true
. Metode ini akan menampilkan false
jika tidak cocok.
Misalnya Anda memiliki antrean tugas yang harus dijalankan, tetapi Anda tidak ingin menghalangi input apa pun. Kode ini—yang menggunakan isInputPending()
dan fungsi yieldToMain()
kustom—memastikan bahwa input tidak akan tertunda saat pengguna mencoba berinteraksi dengan halaman:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
while (tasks.length > 0) {
// Yield to a pending user input:
if (navigator.scheduling.isInputPending()) {
// There's a pending user input. Yield here:
await yieldToMain();
} else {
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
}
Saat berjalan, saveSettings()
akan melakukan loop atas tugas dalam antrean. Jika isInputPending()
menampilkan true
selama loop, saveSettings()
akan memanggil yieldToMain()
sehingga input pengguna dapat ditangani. Jika tidak, ini akan menggeser tugas berikutnya dari depan antrean dan menjalankannya secara terus-menerus. Ini akan dilakukan sampai tidak ada lagi tugas yang tersisa.
Menggunakan isInputPending()
yang dikombinasikan dengan mekanisme hasil merupakan cara yang bagus untuk membuat browser menghentikan tugas apa pun yang sedang diproses sehingga dapat merespons interaksi penting yang ditampilkan kepada pengguna. Hal tersebut dapat membantu meningkatkan kemampuan halaman Anda untuk merespons pengguna dalam berbagai situasi saat banyak tugas yang sedang berlangsung.
Cara lain untuk menggunakan isInputPending()
—terutama jika Anda khawatir menyediakan penggantian untuk browser yang tidak mendukungnya—adalah dengan menggunakan pendekatan berbasis waktu bersama dengan operator perantaian opsional:
async function saveSettings () {
// A task queue of functions
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
];
let deadline = performance.now() + 50;
while (tasks.length > 0) {
// Optional chaining operator used here helps to avoid
// errors in browsers that don't support `isInputPending`:
if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
// There's a pending user input, or the
// deadline has been reached. Yield here:
await yieldToMain();
// Extend the deadline:
deadline = performance.now() + 50;
// Stop the execution of the current loop and
// move onto the next iteration:
continue;
}
// Shift the task out of the queue:
const task = tasks.shift();
// Run the task:
task();
}
}
Dengan pendekatan ini, Anda akan mendapatkan penggantian untuk browser yang tidak mendukung isInputPending()
dengan menggunakan pendekatan berbasis waktu yang menggunakan (dan menyesuaikan) batas waktu sehingga pekerjaan akan dipecah jika diperlukan, baik dengan memberikan input pengguna, atau pada titik waktu tertentu.
Kesenjangan dalam API saat ini
API yang telah disebutkan sejauh ini dapat membantu Anda memecah tugas, tetapi API tersebut memiliki sisi negatif yang signifikan: saat Anda menyerahkan thread utama dengan menunda kode untuk dijalankan di tugas berikutnya, kode tersebut akan ditambahkan ke akhir task queue.
Jika Anda mengontrol semua kode di halaman, Anda dapat membuat penjadwal sendiri dengan kemampuan untuk memprioritaskan tugas, tetapi skrip pihak ketiga tidak akan menggunakan penjadwal Anda. Akibatnya, Anda tidak dapat memprioritaskan pekerjaan di lingkungan tersebut. Anda hanya bisa memotongnya, atau secara eksplisit menghasilkan interaksi pengguna.
Untungnya, ada API penjadwal khusus yang saat ini sedang dalam pengembangan untuk mengatasi masalah ini.
API penjadwal khusus
API penjadwal saat ini menawarkan fungsi postTask()
yang, pada saat penulisan, tersedia di browser Chromium, dan di Firefox di belakang tanda. postTask()
memungkinkan penjadwalan tugas yang lebih mendetail, dan merupakan salah satu cara untuk membantu browser memprioritaskan pekerjaan sehingga tugas berprioritas rendah menghasilkan thread utama. postTask()
menggunakan promise, dan menerima setelan priority
.
postTask()
API memiliki tiga prioritas yang dapat Anda gunakan:
'background'
untuk tugas prioritas terendah.'user-visible'
untuk tugas prioritas sedang. Ini adalah default jika tidak adapriority
yang ditetapkan.'user-blocking'
untuk tugas penting yang perlu dijalankan dengan prioritas tinggi.
Ambil kode berikut sebagai contoh, dengan postTask()
API digunakan untuk menjalankan tiga tugas dengan prioritas tertinggi, dan dua tugas lainnya pada prioritas serendah mungkin.
function saveSettings () {
// Validate the form at high priority
scheduler.postTask(validateForm, {priority: 'user-blocking'});
// Show the spinner at high priority:
scheduler.postTask(showSpinner, {priority: 'user-blocking'});
// Update the database in the background:
scheduler.postTask(saveToDatabase, {priority: 'background'});
// Update the user interface at high priority:
scheduler.postTask(updateUI, {priority: 'user-blocking'});
// Send analytics data in the background:
scheduler.postTask(sendAnalytics, {priority: 'background'});
};
Di sini, prioritas tugas dijadwalkan sedemikian rupa sehingga tugas yang diprioritaskan browser—seperti interaksi pengguna—dapat berjalan sesuai keinginan.
Ini adalah contoh sederhana tentang cara penggunaan postTask()
. Anda dapat membuat instance objek TaskController
berbeda yang dapat berbagi prioritas antar-tugas, termasuk kemampuan mengubah prioritas untuk berbagai instance TaskController
sesuai kebutuhan.
Hasil bawaan dengan kelanjutan melalui scheduler.yield
Salah satu bagian yang diusulkan dari API penjadwal adalah scheduler.yield
, sebuah API yang dirancang khusus untuk menghasilkan thread utama di browser yang saat ini tersedia untuk dicoba sebagai uji coba origin. Penggunaannya menyerupai fungsi yieldToMain()
yang ditunjukkan sebelumnya dalam artikel ini:
async function saveSettings () {
// Create an array of functions to run:
const tasks = [
validateForm,
showSpinner,
saveToDatabase,
updateUI,
sendAnalytics
]
// Loop over the tasks:
while (tasks.length > 0) {
// Shift the first task off the tasks array:
const task = tasks.shift();
// Run the task:
task();
// Yield to the main thread with the scheduler
// API's own yielding mechanism:
await scheduler.yield();
}
}
Anda akan melihat bahwa kode di atas sebagian besar sudah dikenal, tetapi alih-alih menggunakan yieldToMain()
, Anda memanggil dan await scheduler.yield()
.
Manfaat scheduler.yield()
adalah kelanjutan, yang berarti jika Anda menyerahkan tugas di tengah serangkaian tugas, tugas terjadwal lainnya akan dilanjutkan dalam urutan yang sama setelah titik hasil. Tindakan ini akan menghindari kode dari skrip pihak ketiga menggunakan urutan eksekusi kode Anda.
Kesimpulan
Mengelola tugas memang sulit, tetapi dengan melakukannya membantu halaman Anda merespons interaksi pengguna lebih cepat. Tidak ada satu pun saran untuk mengelola dan memprioritaskan tugas. Sebaliknya, ini adalah sejumlah teknik yang berbeda. Untuk menegaskan kembali, berikut ini adalah hal-hal utama yang perlu Anda pertimbangkan saat mengelola tugas:
- Menghasilkan thread utama untuk tugas penting yang dihadapi pengguna.
- Gunakan
isInputPending()
untuk membuka thread utama saat pengguna mencoba berinteraksi dengan halaman. - Prioritaskan tugas dengan
postTask()
. - Terakhir, lakukan sesedikit mungkin pekerjaan di fungsi Anda.
Dengan satu atau beberapa alat ini, Anda seharusnya dapat membuat struktur pekerjaan di aplikasi sehingga aplikasi Anda dapat memprioritaskan kebutuhan pengguna, sekaligus memastikan bahwa pekerjaan yang kurang penting tetap dilakukan. Hal ini akan menciptakan pengalaman pengguna yang lebih baik yang lebih responsif dan lebih menyenangkan untuk digunakan.
Terima kasih banyak kepada Philip Walton atas pemeriksaan teknisnya atas artikel ini.
Gambar utama berasal dari Unsplash, atas izin Amirali Mirhashemian.