Mengoptimalkan tugas yang berjalan lama

Anda diberi tahu "jangan blokir thread utama" dan "hentikan tugas yang lama", tetapi apa artinya melakukan hal-hal itu?

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 fungsinya? Pengiriman lebih sedikit JavaScript bagus, tetapi apakah hal ini akan menjadikan antarmuka pengguna lebih responsif secara otomatis? Mungkin ya, mungkin tidak.

Untuk memahami cara mengoptimalkan tugas di JavaScript, Anda harus terlebih dahulu mengetahui tugas apa saja, dan bagaimana browser menanganinya.

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 dapat Anda kontrol secara langsung. Dari semua ini, JavaScript yang Anda tulis mungkin merupakan sumber tugas terbesar.

Visualisasi tugas seperti yang digambarkan dalam profiler performa DevTools Chrome. Tugas berada di bagian atas stack, dengan pengendali peristiwa klik, panggilan fungsi, dan item lainnya di bawahnya. Tugas ini juga mencakup beberapa pekerjaan rendering di sisi kanan.
Tugas yang dimulai oleh pengendali peristiwa click di, yang ditampilkan di profiler performa Chrome DevTools.

Tugas yang terkait dengan JavaScript memengaruhi performa dengan beberapa cara:

  • Ketika browser mengunduh file JavaScript saat startup, browser akan mengantrekan tugas untuk mengurai dan mengompilasi JavaScript tersebut sehingga dapat dieksekusi nanti.
  • Pada waktu lain selama masa aktif halaman, tugas akan diantrekan saat JavaScript berfungsi, seperti mendorong 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 thread utamanya?

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 yang 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 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.

Tugas yang lama di profiler performa DevTools Chrome. Bagian pemblokiran tugas (lebih dari 50 milidetik) digambarkan dengan pola garis diagonal merah.
Tugas yang berjalan lama seperti yang digambarkan dalam profiler performa Chrome. Tugas yang berjalan lama ditunjukkan dengan segitiga merah di sudut tugas, dengan bagian pemblokiran tugas diisi dengan pola garis merah diagonal.

Untuk mencegah thread utama diblokir terlalu lama, Anda dapat membagi tugas yang panjang menjadi beberapa tugas yang lebih kecil.

Satu tugas panjang versus tugas yang sama yang dibagi menjadi tugas yang lebih singkat. Tugas panjang adalah satu persegi panjang besar, sedangkan tugas yang dikelompokkan adalah lima kotak kecil yang secara kolektif memiliki lebar yang sama dengan tugas panjang.
Visualisasi satu tugas yang panjang versus tugas yang sama tersebut dipecah menjadi lima tugas yang lebih singkat.

Hal ini penting karena ketika tugas dipisahkan, browser bisa merespons pekerjaan dengan prioritas lebih tinggi jauh lebih cepat—termasuk interaksi pengguna. Setelah itu, tugas yang tersisa kemudian dijalankan hingga selesai, memastikan pekerjaan yang awalnya Anda antrikan selesai.

Penggambaran tentang bagaimana membagi tugas dapat memfasilitasi interaksi pengguna. Di bagian atas, tugas yang lama akan memblokir pengendali peristiwa agar tidak berjalan hingga tugas selesai. Di bagian bawah, tugas yang dikelompokkan memungkinkan pengendali peristiwa berjalan lebih cepat dari biasanya.
Visualisasi tentang apa yang terjadi pada interaksi saat tugas terlalu lama dan browser tidak dapat merespons interaksi dengan cukup cepat, dibandingkan saat tugas yang lebih lama dibagi menjadi tugas yang lebih kecil.

Di bagian atas gambar sebelumnya, pengendali peristiwa yang diantrekan oleh interaksi pengguna harus menunggu satu tugas panjang sebelum dapat dimulai. Hal ini menunda interaksi yang terjadi. Dalam skenario ini, pengguna mungkin telah melihat jeda. Di bagian bawah, pengendali peristiwa dapat mulai berjalan lebih cepat, dan interaksinya mungkin terasa instan.

Setelah mengetahui pentingnya membagi tugas, Anda dapat mempelajari cara melakukannya di JavaScript.

Strategi pengelolaan tugas

Saran umum dalam arsitektur perangkat lunak 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, 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.

Fungsi saveSettings seperti yang digambarkan dalam profiler performa Chrome. Sementara fungsi tingkat atas memanggil lima fungsi lainnya, semua pekerjaan terjadi dalam satu tugas panjang yang memblokir thread utama.
Satu fungsi saveSettings() yang memanggil lima fungsi. Pekerjaan dijalankan sebagai bagian dari satu tugas monolitik yang panjang.

Dalam skenario kasus terbaik, bahkan hanya satu dari fungsi tersebut yang dapat berkontribusi 50 milidetik atau lebih terhadap total panjang tugas. Dalam kasus terburuk, lebih banyak tugas tersebut dapat berjalan lebih lama—terutama di perangkat yang sumber dayanya terbatas.

Menunda eksekusi kode secara manual

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 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 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 seluruh array data dapat memerlukan waktu yang sangat lama untuk diproses, meskipun setiap iterasi berjalan dengan cepat. Semuanya akan bertambah, dan setTimeout() bukanlah alat yang tepat untuk tugas ini—setidaknya tidak jika digunakan dengan cara ini.

Gunakan async/await untuk membuat poin hasil

Untuk memastikan tugas penting yang ditampilkan kepada pengguna terjadi sebelum tugas berprioritas lebih rendah, Anda dapat menghasilkan thread utama dengan mengganggu task queue sebentar untuk memberi browser kesempatan menjalankan tugas yang lebih penting.

Seperti yang dijelaskan sebelumnya, setTimeout dapat digunakan untuk menghasilkan thread utama. Namun, agar memberikan kenyamanan dan keterbacaan yang lebih baik, Anda dapat memanggil setTimeout dalam Promise dan meneruskan metode resolve sebagai callback.

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Manfaat fungsi yieldToMain() adalah Anda dapat melakukan await dalam fungsi async apa pun. Berdasarkan contoh sebelumnya, Anda dapat membuat array fungsi untuk dijalankan, dan menghasilkan thread utama setelah masing-masing berjalan:

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 kini dipecah menjadi tugas terpisah.

Fungsi saveSettings yang sama yang digambarkan dalam profiler performa Chrome, hanya dengan menghasilkan. Hasilnya adalah tugas yang sebelumnya monolitik kini dipecah menjadi lima tugas terpisah—satu untuk setiap fungsi.
Fungsi saveSettings() kini mengeksekusi fungsi turunannya sebagai tugas terpisah.

API penjadwal khusus

setTimeout adalah cara yang efektif untuk membagi tugas, tetapi dapat memiliki kelemahan: saat Anda menyerahkan ke thread utama dengan menunda kode untuk dijalankan dalam tugas berikutnya, tugas tersebut akan ditambahkan ke akhir antrean.

Jika 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 dapat membaginya, atau secara eksplisit memberikan interaksi pengguna.

Dukungan Browser

  • Chrome: 94.
  • Edge: 94.
  • Firefox: di balik flag.
  • Safari: tidak didukung.

Sumber

API penjadwal menawarkan fungsi postTask() yang memungkinkan penjadwalan tugas yang lebih terperinci, dan merupakan salah satu cara untuk membantu browser memprioritaskan pekerjaan sehingga tugas dengan prioritas rendah akan menghasilkan thread utama. postTask() menggunakan promise, dan menerima salah satu dari tiga setelan priority:

  • 'background' untuk tugas dengan prioritas terendah.
  • 'user-visible' untuk tugas dengan prioritas sedang. Ini adalah default jika tidak ada priority 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 setinggi mungkin, dan dua tugas lainnya dengan 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 kebutuhan.

Fungsi saveSettings seperti yang digambarkan dalam profiler performa Chrome, namun penggunaan postTask. postTask membagi setiap fungsi yang ada dalam SaveSettings yang berjalan, dan memprioritaskannya sedemikian rupa sehingga interaksi pengguna dapat berjalan tanpa diblokir.
Saat saveSettings() dijalankan, fungsi akan menjadwalkan setiap fungsi menggunakan postTask(). Pekerjaan penting yang dihadapi pengguna dijadwalkan pada prioritas tinggi, sementara pekerjaan yang tidak diketahui pengguna dijadwalkan untuk berjalan di latar belakang. Hal ini memungkinkan interaksi pengguna dijalankan dengan lebih cepat, karena pekerjaan dibagi dan diprioritaskan dengan tepat.

Ini adalah contoh sederhana tentang cara penggunaan postTask(). Anda dapat membuat instance objek TaskController yang berbeda yang dapat berbagi prioritas antartugas, termasuk kemampuan untuk mengubah prioritas untuk instance TaskController yang berbeda sesuai kebutuhan.

Hasil bawaan dengan kelanjutan menggunakan scheduler.yield() API

Dukungan Browser

  • Chrome: 129.
  • Edge: 129.
  • Firefox: tidak didukung.
  • Safari: tidak didukung.

Sumber

scheduler.yield() adalah API yang dirancang khusus untuk menghasilkan thread utama di browser. Penggunaannya menyerupai fungsi yieldToMain() yang ditunjukkan sebelumnya dalam panduan 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();
  }
}

Kode ini sebagian besar sudah dikenal, tetapi kode ini menggunakan await scheduler.yield(), bukan yieldToMain().

Tiga diagram yang menggambarkan tugas tanpa mengalah, menghasilkan, dan dengan hasil dan kelanjutan. Tanpa menghasilkan, ada tugas yang panjang. Dengan penghentian, ada lebih banyak tugas yang lebih singkat, tetapi dapat terganggu oleh tugas lain yang tidak terkait. Dengan penghentian dan kelanjutan, ada lebih banyak tugas yang lebih singkat, tetapi urutan eksekusinya dipertahankan.
Saat Anda menggunakan scheduler.yield(), eksekusi tugas akan dilanjutkan dari titik terakhir meskipun setelah titik hasil.

Manfaat scheduler.yield() adalah kelanjutan, yang berarti jika Anda menghasilkan di tengah kumpulan tugas, tugas terjadwal lainnya akan dilanjutkan dalam urutan yang sama setelah titik hasil. Tindakan ini akan mencegah kode dari skrip pihak ketiga mengganggu urutan eksekusi kode Anda.

Jangan gunakan isInputPending()

Dukungan Browser

  • Chrome: 87.
  • Edge: 87.
  • Firefox: tidak didukung.
  • Safari: tidak didukung.

Sumber

isInputPending() API memberikan cara untuk memeriksa apakah pengguna telah mencoba berinteraksi dengan halaman dan hanya memberikan hasil jika input tertunda.

Ini memungkinkan JavaScript melanjutkan jika tidak ada input yang tertunda, bukan 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 kembali ke thread utama.

Namun, sejak peluncuran API tersebut, pemahaman kami tentang hasil 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() dapat salah menampilkan false meskipun pengguna telah berinteraksi dalam situasi tertentu.
  • Input bukanlah satu-satunya kasus di mana tugas harus menghasilkan. Animasi dan pembaruan antarmuka pengguna reguler lainnya juga sama pentingnya untuk menyediakan halaman web yang responsif.
  • API hasil yang lebih komprehensif telah diperkenalkan untuk mengatasi masalah yang menghasilkan, seperti scheduler.postTask() dan scheduler.yield().

Kesimpulan

Mengelola tugas memang sulit, tetapi melakukannya akan memastikan halaman Anda merespons interaksi pengguna dengan lebih cepat. Tidak ada satu saran pun untuk mengelola dan memprioritaskan tugas, melainkan sejumlah teknik yang berbeda. Untuk mengulang, berikut adalah hal-hal utama yang perlu dipertimbangkan saat mengelola tugas:

  • Menuju thread utama untuk tugas penting yang dihadapi pengguna.
  • Prioritaskan tugas dengan postTask().
  • Pertimbangkan untuk bereksperimen dengan scheduler.yield().
  • Terakhir, lakukan pekerjaan sesedikit mungkin dalam fungsi Anda.

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 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 yang diambil dari Unsplash, atas izin Amirali Mirhashemian.