Menulis Tugas Asinkron Dalam JavaScript Modern

Diterbitkan: 2022-03-10
Ringkasan cepat Pada artikel ini, kita akan mengeksplorasi evolusi JavaScript seputar eksekusi asinkron di era lalu dan bagaimana hal itu mengubah cara kita menulis dan membaca kode. Kami akan mulai dengan awal pengembangan web, dan berlanjut ke contoh pola asinkron modern.

JavaScript memiliki dua karakteristik utama sebagai bahasa pemrograman, keduanya penting untuk memahami bagaimana kode kita akan bekerja. Pertama adalah sifat sinkronnya , yang berarti kode akan berjalan baris demi baris, hampir seperti yang Anda baca, dan kedua bahwa itu adalah single-threaded , hanya satu perintah yang dieksekusi setiap saat.

Saat bahasa berkembang, artefak baru muncul di tempat kejadian untuk memungkinkan eksekusi asinkron; pengembang mencoba pendekatan yang berbeda sambil memecahkan algoritme dan aliran data yang lebih rumit, yang menyebabkan munculnya antarmuka dan pola baru di sekitarnya.

Eksekusi Sinkron Dan Pola Pengamat

Seperti disebutkan dalam pendahuluan, JavaScript menjalankan kode yang Anda tulis baris demi baris, sebagian besar waktu. Bahkan di tahun-tahun pertamanya, bahasa memiliki pengecualian untuk aturan ini, meskipun ada beberapa dan Anda mungkin sudah mengetahuinya: Permintaan HTTP, peristiwa DOM, dan interval waktu.

 const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })

Jika kita menambahkan event listener, misalnya klik elemen dan pengguna memicu interaksi ini, mesin JavaScript akan mengantrekan tugas untuk callback event listener tetapi akan terus mengeksekusi apa yang ada di tumpukannya saat ini. Setelah selesai dengan panggilan yang ada di sana, sekarang akan menjalankan panggilan balik pendengar.

Perilaku ini mirip dengan apa yang terjadi dengan permintaan jaringan dan timer, yang merupakan artefak pertama yang mengakses eksekusi asinkron untuk pengembang web.

Meskipun ini adalah pengecualian dari eksekusi sinkron umum dalam JavaScript, penting untuk dipahami bahwa bahasa tersebut masih single-threaded dan meskipun dapat mengantri taks, menjalankannya secara asinkron dan kemudian kembali ke thread utama, itu hanya dapat mengeksekusi satu bagian kode pada suatu waktu.

Lebih banyak setelah melompat! Lanjutkan membaca di bawah ini

Misalnya, mari kita periksa permintaan jaringan.

 var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();

Saat server kembali, tugas untuk metode yang ditetapkan ke onreadystatechange diantrekan (eksekusi kode berlanjut di utas utama).

Catatan : Menjelaskan bagaimana mesin JavaScript mengantri tugas dan menangani utas eksekusi adalah topik yang kompleks untuk dibahas dan mungkin layak mendapatkan artikel tersendiri. Tetap saja, saya sarankan untuk menonton “What The Heck Is The Event Loop Anyway?” oleh Phillip Roberts untuk membantu Anda mendapatkan pemahaman yang lebih baik.

Dalam setiap kasus yang disebutkan, kami menanggapi peristiwa eksternal. Interval waktu tertentu tercapai, tindakan pengguna atau respons server. Kami tidak dapat membuat tugas asinkron, kami selalu mengamati kejadian yang terjadi di luar jangkauan kami.

Inilah sebabnya mengapa kode yang berbentuk seperti ini disebut Pola Pengamat , yang lebih baik diwakili oleh antarmuka addEventListener dalam kasus ini. Segera, perpustakaan atau kerangka kerja penghasil acara yang mengekspos pola ini berkembang pesat.

Node.js Dan Pemancar Acara

Contoh yang baik adalah Node.js yang halamannya menggambarkan dirinya sebagai “runtime JavaScript yang digerakkan oleh peristiwa asinkron”, sehingga penghasil peristiwa dan panggilan balik adalah warga kelas satu. Bahkan konstruktor EventEmitter sudah diimplementasikan.

 const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');

Ini bukan hanya pendekatan yang harus dilakukan untuk eksekusi asinkron tetapi juga pola inti dan konvensi ekosistemnya. Node.js membuka era baru penulisan JavaScript di lingkungan yang berbeda — bahkan di luar web. Akibatnya, situasi asinkron lainnya mungkin terjadi, seperti membuat direktori baru atau menulis file.

 const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })

Anda mungkin memperhatikan bahwa panggilan balik menerima error sebagai argumen pertama, jika data respons diharapkan, itu akan menjadi argumen kedua. Ini disebut Error-first Callback Pattern , yang menjadi konvensi yang diadopsi oleh penulis dan kontributor untuk paket dan pustaka mereka sendiri.

Janji Dan Rantai Panggilan Balik Tanpa Akhir

Karena pengembangan web menghadapi masalah yang lebih kompleks untuk dipecahkan, kebutuhan akan artefak asinkron yang lebih baik muncul. Jika kita melihat cuplikan kode terakhir, kita dapat melihat rantai panggilan balik berulang yang tidak dapat diskalakan dengan baik seiring bertambahnya jumlah tugas.

Misalnya, mari tambahkan hanya dua langkah lagi, pembacaan file dan prapemrosesan gaya.

 const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })

Kita dapat melihat bagaimana program yang kita tulis menjadi lebih kompleks, kode menjadi lebih sulit untuk diikuti oleh mata manusia karena beberapa rantai panggilan balik dan penanganan kesalahan berulang.

Janji, Pembungkus, dan Pola Rantai

Promises tidak mendapat banyak perhatian ketika pertama kali diumumkan sebagai tambahan baru untuk bahasa JavaScript, itu bukan konsep baru karena bahasa lain memiliki implementasi serupa beberapa dekade sebelumnya. Sebenarnya, mereka ternyata banyak mengubah semantik dan struktur sebagian besar proyek yang saya kerjakan sejak kemunculannya.

Promises tidak hanya memperkenalkan solusi bawaan bagi pengembang untuk menulis kode asinkron tetapi juga membuka tahap baru dalam pengembangan web yang berfungsi sebagai basis konstruksi fitur baru spesifikasi web seperti fetch .

Migrasi metode dari pendekatan callback ke pendekatan berbasis janji menjadi semakin biasa dalam proyek (seperti perpustakaan dan browser), dan bahkan Node.js mulai bermigrasi perlahan ke metode tersebut.

Mari, misalnya, bungkus metode readFile Node:

 const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }

Di sini kita mengaburkan callback dengan mengeksekusi di dalam konstruktor Promise, memanggil resolve ketika hasil metode berhasil, dan reject ketika objek kesalahan didefinisikan.

Ketika suatu metode mengembalikan objek Promise , kita dapat mengikuti resolusi yang berhasil dengan meneruskan fungsi ke then , argumennya adalah nilai yang telah diselesaikan oleh promise, dalam hal ini, data .

Jika kesalahan dilemparkan selama metode, fungsi catch akan dipanggil, jika ada.

Catatan : Jika Anda perlu memahami lebih mendalam bagaimana Promises bekerja, saya merekomendasikan artikel Jake Archibald “JavaScript Promises: An Introduction” yang dia tulis di blog pengembangan web Google.

Sekarang kita dapat menggunakan metode baru ini dan menghindari rantai panggilan balik.

 asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))

Memiliki cara asli untuk membuat tugas asinkron dan antarmuka yang jelas untuk menindaklanjuti kemungkinan hasil memungkinkan industri untuk keluar dari Pola Pengamat. Yang berbasis janji tampaknya memecahkan kode yang tidak dapat dibaca dan rawan kesalahan.

Karena penyorotan sintaks yang lebih baik atau pesan kesalahan yang lebih jelas membantu saat pengkodean, kode yang lebih mudah dinalar menjadi lebih dapat diprediksi oleh pengembang yang membacanya, dengan gambaran yang lebih baik tentang jalur eksekusi, semakin mudah untuk menangkap kemungkinan jebakan.

Promises Promise begitu global di komunitas sehingga Node.js dengan cepat merilis versi built-in dari metode I/O-nya untuk mengembalikan objek Promise seperti mengimpornya operasi file dari fs.promises .

Itu bahkan menyediakan util promisify untuk membungkus fungsi apa pun yang mengikuti Pola Panggilan Balik Pertama-Kesalahan dan mengubahnya menjadi yang berbasis Janji.

Tetapi apakah Janji membantu dalam semua kasus?

Mari kita bayangkan kembali tugas prapemrosesan gaya kita yang ditulis dengan Promises.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))

Ada pengurangan redundansi yang jelas dalam kode, terutama di sekitar penanganan kesalahan karena kita sekarang mengandalkan catch , tetapi Promises entah bagaimana gagal memberikan lekukan kode yang jelas yang secara langsung berhubungan dengan rangkaian tindakan.

Ini sebenarnya dicapai pada pernyataan pertama then setelah readFile dipanggil. Apa yang terjadi setelah baris-baris ini adalah kebutuhan untuk membuat ruang lingkup baru di mana kita dapat membuat direktori terlebih dahulu, untuk kemudian menulis hasilnya dalam sebuah file. Hal ini menyebabkan pecahnya ritme lekukan, tidak membuatnya mudah untuk menentukan urutan instruksi pada pandangan pertama.

Cara untuk mengatasi ini adalah dengan membuat metode kustom yang menangani ini dan memungkinkan penggabungan metode yang benar, tetapi kami akan memperkenalkan satu lagi kedalaman kompleksitas ke kode yang tampaknya sudah memiliki apa yang dibutuhkan untuk mencapai tugas kami ingin.

Catatan : Pertimbangkan ini adalah contoh program, dan kami mengendalikan beberapa metode dan semuanya mengikuti konvensi industri, tetapi tidak selalu demikian. Dengan penggabungan yang lebih kompleks atau pengenalan pustaka dengan bentuk yang berbeda, gaya kode kita dapat dengan mudah rusak.

Dengan senang hati, komunitas JavaScript belajar lagi dari sintaks bahasa lain dan menambahkan notasi yang banyak membantu dalam kasus-kasus ini di mana penggabungan tugas asinkron tidak menyenangkan atau langsung dibaca seperti kode sinkron.

Async Dan Menunggu

Promise didefinisikan sebagai nilai yang belum terselesaikan pada waktu eksekusi, dan membuat instance Promise adalah panggilan eksplisit dari artefak ini.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))

Di dalam metode async, kita dapat menggunakan kata await yang dicadangkan untuk menentukan resolusi Promise sebelum melanjutkan eksekusinya.

Mari kita tinjau kembali atau cuplikan kode menggunakan sintaks ini.

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()

Catatan : Perhatikan bahwa kami perlu memindahkan semua kode kami ke suatu metode karena kami tidak dapat menggunakan await di luar cakupan fungsi async hari ini.

Setiap kali metode async menemukan pernyataan await , itu akan berhenti mengeksekusi sampai nilai atau janji yang diproses diselesaikan.

Ada konsekuensi yang jelas dari penggunaan notasi async/await, terlepas dari eksekusi asinkronnya, kode tersebut terlihat seperti synchronous , yang merupakan sesuatu yang lebih sering dilihat dan dipikirkan oleh pengembang kami.

Bagaimana dengan penanganan kesalahan? Untuk itu, kami menggunakan pernyataan yang sudah lama hadir dalam bahasa tersebut, try and catch .

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()

Kami yakin setiap kesalahan yang terjadi dalam proses akan ditangani oleh kode di dalam pernyataan catch . Kami memiliki tempat terpusat yang menangani penanganan kesalahan, tetapi sekarang kami memiliki kode yang lebih mudah dibaca dan diikuti.

Memiliki tindakan konsekuen yang mengembalikan nilai tidak perlu disimpan dalam variabel seperti mkdir yang tidak merusak ritme kode; juga tidak perlu membuat cakupan baru untuk mengakses nilai result di langkah selanjutnya.

Aman untuk mengatakan bahwa Janji adalah artefak mendasar yang diperkenalkan dalam bahasa tersebut, yang diperlukan untuk mengaktifkan notasi async/menunggu di JavaScript, yang dapat Anda gunakan di browser modern dan versi terbaru Node.js.

Catatan : Baru-baru ini di JSConf, Ryan Dahl, pencipta dan kontributor pertama Node, menyesal tidak berpegang pada Janji pada pengembangan awal terutama karena tujuan Node adalah untuk membuat server berbasis peristiwa dan manajemen file yang lebih baik untuk pola Pengamat.

Kesimpulan

Pengenalan Promises ke dalam dunia pengembangan web datang untuk mengubah cara kita mengantri tindakan dalam kode kita dan mengubah cara kita bernalar tentang eksekusi kode kita dan cara kita membuat pustaka dan paket.

Tetapi menjauh dari rantai panggilan balik lebih sulit untuk dipecahkan, saya pikir harus melewati metode untuk then tidak membantu kami menjauh dari jalur pemikiran setelah bertahun-tahun terbiasa dengan Pola Pengamat dan pendekatan yang diadopsi oleh vendor besar di komunitas seperti Node.js.

Seperti yang dikatakan Nolan Lawson dalam artikelnya yang luar biasa tentang penggunaan yang salah dalam rangkaian Promise, kebiasaan panggilan balik lama sulit dilakukan! Dia kemudian menjelaskan bagaimana untuk melarikan diri dari beberapa perangkap ini.

Saya percaya Janji diperlukan sebagai langkah tengah untuk memungkinkan cara alami untuk menghasilkan tugas asinkron tetapi tidak banyak membantu kami untuk bergerak maju pada pola kode yang lebih baik, terkadang Anda benar-benar membutuhkan sintaks bahasa yang lebih mudah beradaptasi dan ditingkatkan.

Saat kami mencoba memecahkan teka-teki yang lebih kompleks menggunakan JavaScript, kami melihat perlunya bahasa yang lebih matang dan kami bereksperimen dengan arsitektur dan pola yang tidak biasa kami lihat di web sebelumnya.

Kami masih tidak tahu bagaimana spesifikasi ECMAScript akan terlihat dalam beberapa tahun karena kami selalu memperluas tata kelola JavaScript di luar web dan mencoba memecahkan teka-teki yang lebih rumit.

Sulit untuk mengatakan sekarang apa sebenarnya yang kita perlukan dari bahasa untuk beberapa teka-teki ini untuk berubah menjadi program yang lebih sederhana, tetapi saya senang dengan bagaimana web dan JavaScript itu sendiri menggerakkan berbagai hal, mencoba beradaptasi dengan tantangan dan lingkungan baru. Saya merasa saat ini JavaScript adalah tempat yang lebih ramah asinkron daripada ketika saya mulai menulis kode di browser lebih dari satu dekade yang lalu.

Bacaan lebih lanjut

  • “JavaScript Menjanjikan: Sebuah Pengantar,” Jake Archibald
  • "Janji Anti-Pola", dokumentasi perpustakaan Bluebird
  • “Kami Memiliki Masalah Dengan Janji,” Nolan Lawson