Sebagai Pengembang JS, Inilah Yang Membuat Saya Tetap Terjaga di Malam Hari

Diterbitkan: 2022-03-11

JavaScript adalah bahasa yang aneh. Meskipun terinspirasi oleh Smalltalk, ia menggunakan sintaks seperti C. Ini menggabungkan aspek prosedural, fungsional, dan paradigma pemrograman berorientasi objek (OOP). Ini memiliki banyak, seringkali berlebihan, pendekatan untuk memecahkan hampir semua masalah pemrograman yang mungkin dan tidak memiliki pendapat yang kuat tentang mana yang lebih disukai. Ini diketik dengan lemah dan dinamis, dengan pendekatan seperti labirin untuk mengetik paksaan yang membuat pengembang berpengalaman sekalipun tersandung.

JavaScript juga memiliki kutil, jebakan, dan fitur yang dipertanyakan. Pemrogram baru berjuang dengan beberapa konsep yang lebih sulit—pikirkan asinkronisitas, penutupan, dan pengangkatan. Pemrogram dengan pengalaman dalam bahasa lain secara wajar menganggap hal-hal dengan nama dan penampilan yang mirip akan bekerja dengan cara yang sama di JavaScript dan seringkali salah. Array sebenarnya bukan array; apa masalahnya dengan this , apa itu prototipe, dan apa yang sebenarnya dilakukan new ?

Masalah dengan Kelas ES6

Pelanggar terburuk sejauh ini adalah yang baru untuk versi rilis terbaru JavaScript, ECMAScript 6 (ES6): class . Beberapa pembicaraan di sekitar kelas terus terang mengkhawatirkan dan mengungkapkan kesalahpahaman yang mengakar tentang bagaimana bahasa itu sebenarnya bekerja:

"JavaScript akhirnya menjadi bahasa berorientasi objek nyata sekarang karena memiliki kelas!"

Atau:

“Kelas membebaskan kita dari memikirkan model warisan rusak JavaScript.”

Atau bahkan:

“Kelas adalah pendekatan yang lebih aman dan lebih mudah untuk membuat tipe dalam JavaScript.”

Pernyataan-pernyataan ini tidak mengganggu saya karena menyiratkan ada yang salah dengan pewarisan prototipikal; mari kita kesampingkan argumen-argumen itu. Pernyataan-pernyataan ini mengganggu saya karena tidak ada satupun yang benar, dan mereka menunjukkan konsekuensi dari pendekatan JavaScript "segalanya untuk semua orang" terhadap desain bahasa: Ini lebih sering melumpuhkan pemahaman programmer tentang bahasa daripada yang dimungkinkannya. Sebelum saya melangkah lebih jauh, mari kita ilustrasikan.

Kuis Pop JavaScript #1: Apa Perbedaan Penting Antara Blok Kode Ini?

 function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
 class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())

Jawabannya di sini adalah tidak ada . Ini melakukan hal yang sama secara efektif, itu hanya pertanyaan apakah sintaks kelas ES6 digunakan.

Benar, contoh kedua lebih ekspresif. Untuk alasan itu saja, Anda mungkin berpendapat bahwa class adalah tambahan yang bagus untuk bahasa tersebut. Sayangnya, masalahnya sedikit lebih halus.

Kuis Pop JavaScript #2: Apa Fungsi Kode Berikut?

 function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())

Jawaban yang benar adalah ia mencetak ke konsol:

 > MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance

Jika Anda menjawab salah, Anda tidak mengerti apa sebenarnya class itu. Ini bukan salahmu. Sama seperti Array , class bukanlah fitur bahasa, ini adalah sintaksis obscurantism . Ini mencoba menyembunyikan model pewarisan prototipikal dan idiom canggung yang menyertainya, dan itu menyiratkan bahwa JavaScript melakukan sesuatu yang tidak.

Anda mungkin telah diberitahu bahwa class diperkenalkan ke JavaScript untuk membuat pengembang OOP klasik yang berasal dari bahasa seperti Java lebih nyaman dengan model pewarisan kelas ES6. Jika Anda salah satu dari pengembang itu, contoh itu mungkin membuat Anda ngeri. Itu harus. Ini menunjukkan bahwa kata kunci class JavaScript tidak datang dengan jaminan apa pun yang dimaksudkan untuk disediakan oleh kelas. Ini juga menunjukkan salah satu perbedaan utama dalam model pewarisan prototipe: Prototipe adalah instance objek , bukan tipe .

Prototipe vs. Kelas

Perbedaan paling penting antara pewarisan berbasis kelas dan prototipe adalah bahwa kelas mendefinisikan tipe yang dapat dipakai saat runtime, sedangkan prototipe itu sendiri adalah instance objek.

Anak dari kelas ES6 adalah definisi tipe lain yang memperluas induk dengan properti dan metode baru, yang pada gilirannya dapat dipakai saat runtime. Anak dari prototipe adalah contoh objek lain yang mendelegasikan kepada induknya setiap properti yang tidak diimplementasikan pada anak.

Catatan tambahan: Anda mungkin bertanya-tanya mengapa saya menyebutkan metode kelas, tetapi bukan metode prototipe. Itu karena JavaScript tidak memiliki konsep metode. Fungsi adalah kelas satu dalam JavaScript, dan mereka dapat memiliki properti atau menjadi properti dari objek lain.

Konstruktor kelas membuat turunan dari kelas. Konstruktor dalam JavaScript hanyalah fungsi lama biasa yang mengembalikan objek. Satu-satunya hal khusus tentang konstruktor JavaScript adalah, ketika dipanggil dengan kata kunci new , ia menetapkan prototipenya sebagai prototipe objek yang dikembalikan. Jika itu terdengar sedikit membingungkan bagi Anda, Anda tidak sendirian—ini adalah, dan itu adalah sebagian besar alasan mengapa prototipe kurang dipahami.

Untuk memberikan poin yang sangat bagus tentang itu, anak dari prototipe bukanlah salinan dari prototipenya, juga bukan objek dengan bentuk yang sama dengan prototipenya. Anak memiliki referensi hidup ke prototipe, dan properti prototipe apa pun yang tidak ada pada anak adalah referensi satu arah ke properti dengan nama yang sama pada prototipe.

Pertimbangkan hal berikut:

 let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
Catatan: Anda hampir tidak akan pernah menulis kode seperti ini di kehidupan nyata—ini praktik yang buruk—tetapi ini menunjukkan prinsipnya secara ringkas.

Pada contoh sebelumnya, ketika child.foo undefined , ia merujuk parent.foo . Segera setelah kami mendefinisikan foo pada child , child.foo memiliki nilai 'bar' , tetapi parent.foo mempertahankan nilai aslinya. Setelah kami delete child.foo , ia kembali mengacu pada parent.foo , yang berarti bahwa ketika kami mengubah nilai induk, child.foo mengacu pada nilai baru.

Mari kita lihat apa yang baru saja terjadi (untuk tujuan ilustrasi yang lebih jelas, kita akan berpura-pura ini adalah Strings dan bukan literal string, perbedaannya tidak masalah di sini):

Berjalan melalui rantai prototipe untuk menunjukkan bagaimana referensi yang hilang ditangani dalam JavaScript.

Cara ini bekerja di bawah tenda, dan terutama kekhasan new dan this , adalah topik untuk hari lain, tetapi Mozilla memiliki artikel menyeluruh tentang rantai pewarisan prototipe JavaScript jika Anda ingin membaca lebih lanjut.

Kuncinya adalah bahwa prototipe tidak mendefinisikan type ; mereka sendiri adalah instances dan mereka bisa berubah saat runtime, dengan semua yang menyiratkan dan memerlukan.

Masih bersamaku? Mari kembali ke membedah kelas JavaScript.

Kuis Pop JavaScript #3: Bagaimana Anda Menerapkan Privasi di Kelas?

Prototipe dan properti kelas kami di atas tidak begitu banyak "dienkapsulasi" seperti "menggantung di luar jendela." Kita harus memperbaikinya, tapi bagaimana?

Tidak ada contoh kode di sini. Jawabannya adalah Anda tidak bisa.

JavaScript tidak memiliki konsep privasi, tetapi memiliki penutupan:

 function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"

Apakah Anda mengerti apa yang baru saja terjadi? Jika tidak, Anda tidak mengerti penutupan. Tidak apa-apa, sungguh — mereka tidak mengintimidasi seperti yang dibuat, mereka sangat berguna, dan Anda harus meluangkan waktu untuk mempelajarinya.

Kuis Pop JavaScript #4: Apa yang Setara dengan di Atas Menggunakan Kata Kunci class ?

Maaf, ini pertanyaan jebakan lainnya. Anda pada dasarnya dapat melakukan hal yang sama, tetapi terlihat seperti ini:

 class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"

Beri tahu saya jika itu terlihat lebih mudah atau lebih jelas daripada di SecretiveProto . Dalam pandangan pribadi saya, ini agak lebih buruk—ini merusak penggunaan idiomatis dari deklarasi class dalam JavaScript dan tidak berfungsi seperti yang Anda harapkan dari, katakanlah, Java. Hal ini akan diperjelas dengan hal-hal berikut:

Kuis Pop JavaScript #5: Apa SecretiveClass::looseLips() Lakukan?

Mari kita cari tahu:

 try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }

Yah… itu canggung.

Kuis Pop JavaScript #6: Mana yang Lebih Disukai Pengembang JavaScript Berpengalaman—Prototipe atau Kelas?

Anda dapat menebaknya, itu pertanyaan jebakan lainnya—pengembang JavaScript yang berpengalaman cenderung menghindari keduanya jika mereka bisa. Inilah cara yang bagus untuk melakukan hal di atas dengan JavaScript idiomatik:

 function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()

Ini bukan hanya tentang menghindari keburukan yang melekat pada warisan, atau menegakkan enkapsulasi. Pikirkan tentang apa lagi yang mungkin Anda lakukan dengan secretFactory dan leaker yang tidak dapat Anda lakukan dengan mudah dengan prototipe atau kelas.

Untuk satu hal, Anda dapat merusaknya karena Anda tidak perlu khawatir tentang konteks this :

 const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)

Itu cukup bagus. Selain menghindari kebodohan new dan this , ini memungkinkan kita untuk menggunakan objek kita secara bergantian dengan modul CommonJS dan ES6. Itu juga membuat komposisi sedikit lebih mudah:

 function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)

Klien blackHat tidak perlu khawatir tentang dari mana exfiltrate berasal, dan spyFactory tidak perlu dipusingkan dengan Function::bind context juggling atau properti yang sangat bersarang. Ingat, kami tidak perlu terlalu khawatir tentang this dalam kode prosedural sinkron sederhana, tetapi ini menyebabkan semua jenis masalah dalam kode asinkron yang sebaiknya dihindari.

Dengan sedikit pemikiran, spyFactory dapat dikembangkan menjadi alat spionase yang sangat canggih yang dapat menangani semua jenis target penyusupan—atau dengan kata lain, fasad.

Tentu saja Anda bisa melakukannya dengan kelas juga, atau lebih tepatnya, bermacam-macam kelas, yang semuanya mewarisi dari abstract class atau interface abstrak ...kecuali bahwa JavaScript tidak memiliki konsep abstrak atau antarmuka.

Mari kembali ke contoh penyambut untuk melihat bagaimana kita mengimplementasikannya dengan sebuah pabrik:

 function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!

Anda mungkin telah memperhatikan pabrik-pabrik ini semakin singkat seiring berjalannya waktu, tetapi jangan khawatir—mereka melakukan hal yang sama. Roda pelatihan akan lepas, teman-teman!

Itu sudah lebih sedikit boilerplate daripada prototipe atau versi kelas dari kode yang sama. Kedua, ia mencapai enkapsulasi propertinya secara lebih efektif. Juga, ia memiliki memori yang lebih rendah dan jejak kinerja dalam beberapa kasus (mungkin tidak tampak seperti itu pada pandangan pertama, tetapi kompiler JIT diam-diam bekerja di belakang layar untuk mengurangi duplikasi dan menyimpulkan jenis).

Jadi lebih aman, seringkali lebih cepat, dan lebih mudah untuk menulis kode seperti ini. Mengapa kita perlu kelas lagi? Oh, tentu saja, dapat digunakan kembali. Apa jadinya jika kita menginginkan varian penyambut yang tidak bahagia dan antusias? Nah, jika kita menggunakan kelas ClassicalGreeting , kita mungkin langsung membayangkan hierarki kelas. Kami tahu kami perlu membuat parameter tanda baca, jadi kami akan melakukan sedikit pemfaktoran ulang dan menambahkan beberapa anak:

 // Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!

Ini adalah pendekatan yang bagus, sampai seseorang datang dan meminta fitur yang tidak sesuai dengan hierarki dan semuanya berhenti masuk akal. Masukkan pin ke dalam pemikiran itu saat kami mencoba menulis fungsionalitas yang sama dengan pabrik:

 const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!

Tidak jelas bahwa kode ini lebih baik, meskipun sedikit lebih pendek. Faktanya, Anda dapat berargumen bahwa membaca lebih sulit, dan mungkin ini adalah pendekatan yang tumpul. Tidak bisakah kita memiliki unhappyGreeterFactory yang tidak bahagia dan sebuahGreeterFactory yang enthusiasticGreeterFactory ?

Kemudian klien Anda datang dan berkata, "Saya membutuhkan penyambut baru yang tidak senang dan ingin seluruh ruangan mengetahuinya!"

 console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(

Jika kita perlu menggunakan penyambut yang tidak bahagia dengan antusias ini lebih dari sekali, kita dapat membuatnya lebih mudah untuk diri kita sendiri:

 const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())

Ada pendekatan untuk gaya komposisi ini yang bekerja dengan prototipe atau kelas. Misalnya, Anda dapat memikirkan kembali UnhappyGreeting dan EnthusiasticGreeting sebagai dekorator. Itu masih akan membutuhkan lebih banyak boilerplate daripada pendekatan gaya fungsional yang digunakan di atas, tetapi itulah harga yang Anda bayar untuk keamanan dan enkapsulasi kelas nyata .

Masalahnya, dalam JavaScript, Anda tidak mendapatkan keamanan otomatis itu. Kerangka kerja JavaScript yang menekankan penggunaan class melakukan banyak "keajaiban" untuk mengatasi masalah semacam ini dan memaksa kelas untuk berperilaku sendiri. Lihatlah kode sumber ElementMixin Polymer beberapa waktu, saya menantang Anda. Ini adalah level arch-wizard dari arcana JavaScript, dan maksud saya tanpa ironi atau sarkasme.

Tentu saja, kita dapat memperbaiki beberapa masalah yang dibahas di atas dengan Object.freeze atau Object.defineProperties ke efek yang lebih besar atau lebih kecil. Tetapi mengapa meniru formulir tanpa fungsi, sementara mengabaikan alat-alat yang diberikan JavaScript secara asli kepada kita yang mungkin tidak kita temukan dalam bahasa seperti Java? Apakah Anda akan menggunakan palu berlabel "obeng" untuk menggerakkan sekrup, ketika kotak peralatan Anda memiliki obeng yang sebenarnya duduk di sebelahnya?

Menemukan Bagian yang Baik

Pengembang JavaScript sering menekankan bagian bahasa yang baik, baik bahasa sehari-hari maupun referensi ke buku dengan nama yang sama. Kami mencoba menghindari jebakan yang dibuat oleh pilihan desain bahasa yang lebih dipertanyakan dan tetap berpegang pada bagian yang memungkinkan kami menulis kode yang bersih, mudah dibaca, meminimalkan kesalahan, dan dapat digunakan kembali.

Ada argumen yang masuk akal tentang bagian mana dari JavaScript yang memenuhi syarat, tetapi saya harap saya meyakinkan Anda bahwa class bukan salah satunya. Jika gagal, semoga Anda mengerti bahwa pewarisan dalam JavaScript bisa menjadi kekacauan yang membingungkan dan class itu tidak memperbaikinya atau membuat Anda harus memahami prototipe. Penghargaan ekstra jika Anda memahami petunjuk bahwa pola desain berorientasi objek berfungsi dengan baik tanpa kelas atau pewarisan ES6.

Saya tidak menyuruh Anda untuk menghindari class sepenuhnya. Terkadang Anda membutuhkan pewarisan, dan class menyediakan sintaks yang lebih bersih untuk melakukan itu. Secara khusus, class X extends Y jauh lebih bagus daripada pendekatan prototipe lama. Selain itu, banyak kerangka kerja front-end yang populer mendorong penggunaannya dan Anda mungkin harus menghindari penulisan kode non-standar yang aneh pada prinsipnya saja. Saya hanya tidak suka ke mana arahnya.

Dalam mimpi buruk saya, seluruh generasi perpustakaan JavaScript ditulis menggunakan class , dengan harapan akan berperilaku serupa dengan bahasa populer lainnya. Seluruh kelas bug baru (pun intended) ditemukan. Yang lama dibangkitkan yang bisa dengan mudah ditinggalkan di Graveyard of Malformed JavaScript jika kita tidak sembarangan jatuh ke dalam jebakan class . Pengembang JavaScript berpengalaman diganggu oleh monster-monster ini, karena yang populer tidak selalu yang bagus.

Akhirnya kita semua menyerah dalam frustrasi dan mulai menemukan kembali roda di Rust, Go, Haskell, atau siapa yang tahu apa lagi, dan kemudian kompilasi ke Wasm untuk web, dan kerangka kerja web dan perpustakaan baru berkembang biak menjadi tak terbatas multibahasa.

Itu benar-benar membuatku terjaga di malam hari.