Mengoptimalkan tugas yang berjalan lama

Anda telah diberi tahu "jangan blokir thread utama" dan “uraikan tugas yang lama Anda”, tapi apa artinya melakukan hal-hal itu?

Saran umum untuk menjaga agar aplikasi JavaScript tetap cepat cenderung mengarah pada saran berikut:

  • "Jangan blokir thread utama."
  • "Pisahkan tugas panjang Anda."

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, tapi 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 pekerjaan terpisah apa pun yang dilakukan browser. Pekerjaan tersebut termasuk 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.

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

Tugas yang terkait dengan JavaScript memengaruhi performa dalam 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 diantrekan saat JavaScript 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 thread utamanya?

Thread utama adalah tempat sebagian besar tugas dijalankan di browser, dan tempat hampir semua JavaScript yang Anda tulis dijalankan.

Thread utama hanya dapat memproses satu tugas pada satu waktu. Setiap tugas yang memerlukan waktu lebih dari 50 milidetik adalah tugas yang panjang. Untuk tugas yang melebihi 50 milidetik, total waktu tugas minus 50 milidetik disebut periode pemblokiran tugas.

Browser memblokir interaksi yang terjadi saat tugas dengan durasi berapa pun berjalan, tetapi hal ini tidak terlihat oleh pengguna selama tugas tidak berjalan terlalu lama. Namun, saat pengguna mencoba berinteraksi dengan halaman ketika ada banyak tugas yang berjalan lama, antarmuka pengguna akan terasa tidak responsif dan bahkan mungkin rusak jika thread utama diblokir dalam jangka waktu yang sangat lama.

Tugas yang berjalan lama di profiler performa DevTools Chrome. Bagian pemblokiran tugas (lebih dari 50 milidetik) digambarkan dengan pola garis diagonal merah.
Tugas panjang seperti yang digambarkan dalam profiler performa Chrome. Tugas panjang ditunjukkan dengan segitiga merah di sudut tugas, dengan bagian pemblokir 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 dipecah menjadi tugas yang lebih singkat. Tugas panjang adalah satu persegi panjang besar, sedangkan tugas yang dipotong adalah lima kotak lebih kecil yang secara kolektif memiliki lebar yang sama dengan tugas panjang.
Visualisasi satu tugas panjang versus tugas yang sama yang 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 memecah tugas dapat memfasilitasi interaksi pengguna. Di bagian atas, tugas yang panjang memblokir pengendali peristiwa agar tidak berjalan sampai tugas selesai. Di bagian bawah, tugas yang dipotong memungkinkan {i>event handler<i} untuk berjalan lebih cepat dari yang seharusnya.
Visualisasi tentang apa yang terjadi pada interaksi saat tugas terlalu panjang dan browser tidak dapat merespons interaksi dengan cukup cepat, dibandingkan saat tugas yang berdurasi lama dipecah menjadi tugas yang lebih kecil.

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

Sekarang setelah Anda tahu pentingnya memisahkan tugas, Anda dapat mempelajari caranya 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() didesain dengan arsitektur yang baik. Jika perlu men-debug salah satu fungsi ini, Anda dapat menelusuri hierarki project untuk mengetahui apa yang dilakukan setiap fungsi. Memecah pekerjaan seperti ini membuat proyek lebih mudah untuk dinavigasi dan dikelola.

Namun, potensi masalah di sini adalah JavaScript tidak menjalankan setiap fungsi tersebut sebagai tugas terpisah karena dieksekusi dalam fungsi saveSettings(). Ini berarti kelima fungsi akan berjalan sebagai satu tugas.

Fungsi saveSettings seperti yang ditampilkan dalam profiler performa Chrome. Sementara fungsi tingkat atas memanggil lima fungsi lainnya, semua pekerjaan terjadi dalam satu tugas panjang yang memblokir thread utama.
saveSettings() fungsi tunggal yang memanggil lima fungsi. Tugas 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 dengan resource yang terbatas.

Tunda 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 harus berjalan secara berurutan.

Namun, kode Anda mungkin tidak selalu diatur dengan cara ini. Misalnya, Anda mungkin memiliki data dalam jumlah besar yang perlu diproses dalam satu loop, dan tugas itu bisa memakan waktu yang sangat lama jika ada banyak iterasi.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Penggunaan setTimeout() di sini menimbulkan masalah 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() bukan alat yang tepat untuk tugas tersebut—setidaknya tidak saat digunakan dengan cara ini.

Gunakan async/await untuk membuat poin hasil

Untuk memastikan tugas penting yang dihadapi pengguna terjadi sebelum tugas berprioritas lebih rendah, Anda dapat menghasilkan thread utama dengan menginterupsi task queue sebentar untuk memberikan peluang browser untuk 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, tugas yang tadinya monolitik kini dipecah menjadi beberapa tugas terpisah.

Fungsi saveSettings yang sama seperti yang digambarkan dalam profiler performa Chrome, hanya dengan opsi menghasilkan. Hasilnya adalah tugas yang tadinya monolitik, kini dipecah menjadi lima tugas terpisah untuk setiap fungsi.
Fungsi saveSettings() kini menjalankan fungsi turunannya sebagai tugas terpisah.

API penjadwal khusus

setTimeout adalah cara efektif untuk memecah tugas, tetapi dapat memiliki kelemahan: jika Anda menyerah pada thread utama dengan menunda kode untuk dijalankan pada 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 menyesuaikan interaksi dengan pengguna.

Dukungan Browser

  • 94
  • 94
  • x

Sumber

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

  • 'background' untuk tugas prioritas terendah.
  • 'user-visible' untuk tugas berprioritas sedang. Nilai ini merupakan default jika tidak ada priority yang ditetapkan.
  • 'user-blocking' untuk tugas penting yang perlu dijalankan dengan prioritas tinggi.

Ambil kode berikut sebagai contoh, saat postTask() API digunakan untuk menjalankan tiga tugas dengan prioritas tertinggi, 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, tetapi menggunakan postTask. postTask membagi setiap fungsi yang dijalankan simpanSettings, dan memprioritaskannya sedemikian rupa sehingga interaksi pengguna dapat berjalan tanpa diblokir.
Saat saveSettings() dijalankan, fungsi menjadwalkan fungsi individual 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 cara menggunakan postTask(). Anda dapat membuat instance objek TaskController berbeda yang dapat berbagi prioritas antar-tugas, termasuk kemampuan untuk mengubah prioritas untuk berbagai instance TaskController sesuai kebutuhan.

Hasil bawaan dengan kelanjutan menggunakan scheduler.yield() API mendatang

Salah satu usulan tambahan untuk API penjadwal adalah scheduler.yield(), sebuah 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 sudah sangat umum, tetapi alih-alih menggunakan yieldToMain(), kode ini menggunakan await scheduler.yield().

Tiga diagram yang menggambarkan tugas tanpa mengalah, menghasilkan, dan dengan hasil dan kelanjutan. Tanpa mengalah, tugas akan berlangsung lama. Dengan menghasilkan, ada lebih banyak tugas yang lebih pendek, tetapi mungkin terganggu oleh tugas lain yang tidak terkait. Dengan hasil dan kelanjutan, ada lebih banyak tugas yang lebih pendek, tetapi urutan eksekusinya dipertahankan.
Saat Anda menggunakan scheduler.yield(), eksekusi tugas akan dilanjutkan dari posisi terakhirnya bahkan setelah titik hasil.

Manfaat scheduler.yield() adalah kelanjutan, yang berarti bahwa jika Anda menyerah di tengah serangkaian tugas, tugas terjadwal lainnya akan berlanjut dalam urutan yang sama setelah titik hasil. Hal ini untuk menghindari kode dari skrip pihak ketiga mengganggu urutan eksekusi kode Anda.

Penggunaan scheduler.postTask() dengan priority: 'user-blocking' juga memiliki kemungkinan tinggi untuk berlanjut karena prioritas user-blocking yang tinggi, sehingga sementara itu, pendekatan ini dapat digunakan sebagai alternatif.

Penggunaan setTimeout() (atau scheduler.postTask() dengan priority: 'user-visibile' atau tanpa priority eksplisit) akan menjadwalkan tugas di bagian belakang antrean, sehingga memungkinkan tugas tertunda lainnya berjalan sebelum kelanjutan.

Jangan gunakan isInputPending()

Dukungan Browser

  • 87
  • 87
  • x
  • x

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 Niat Pengiriman, 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, Anda merekomendasikan terlepas dari apakah input tertunda atau tidak karena sejumlah alasan:

  • isInputPending() dapat salah menampilkan false meskipun pengguna telah berinteraksi dalam keadaan tertentu.
  • Input bukanlah satu-satunya kasus di mana tugas harus menghasilkan. Animasi dan pembaruan antarmuka pengguna rutin lainnya bisa sama pentingnya untuk menyediakan laman web yang responsif.
  • API hasil yang lebih komprehensif telah diperkenalkan untuk mengatasi masalah yang menghasilkan, seperti scheduler.postTask() dan scheduler.yield().

Kesimpulan

Mengelola tugas itu sulit, tetapi hal itu 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 mengulangi, ini adalah hal-hal utama yang perlu Anda pertimbangkan saat mengelola tugas:

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

Dengan satu atau beberapa alat ini, Anda seharusnya bisa menyusun pekerjaan dalam aplikasi agar bisa memprioritaskan kebutuhan pengguna, sekaligus memastikan bahwa pekerjaan yang tidak terlalu penting masih bisa diselesaikan. Hal ini akan menciptakan pengalaman pengguna yang lebih baik, lebih responsif dan lebih menyenangkan untuk digunakan.

Terima kasih banyak kepada Philip Walton atas pemeriksaan teknisnya terkait panduan ini.

Gambar thumbnail yang diambil dari Unsplash, atas izin Amirali Mirhashemian.