Rantai Prototipe JavaScript, Rantai Lingkup, dan Kinerja: Yang Perlu Anda Ketahui

Diterbitkan: 2022-03-11

JavaScript: Lebih dari yang terlihat

JavaScript bisa tampak seperti bahasa yang sangat mudah dipelajari pada awalnya. Mungkin karena sintaksnya yang fleksibel. Atau mungkin karena kemiripannya dengan bahasa terkenal lainnya seperti Java. Atau mungkin karena memiliki tipe data yang sangat sedikit dibandingkan dengan bahasa seperti Java, Ruby, atau .NET.

Namun sebenarnya, JavaScript jauh lebih sederhana dan lebih bernuansa daripada yang awalnya disadari kebanyakan pengembang. Bahkan untuk pengembang dengan lebih banyak pengalaman, beberapa fitur JavaScript yang paling menonjol terus disalahpahami dan menyebabkan kebingungan. Salah satu fitur tersebut adalah cara pencarian data (properti dan variabel) dilakukan dan konsekuensi kinerja JavaScript yang harus diperhatikan.

Dalam JavaScript, pencarian data diatur oleh dua hal: pewarisan prototipe dan rantai lingkup . Sebagai pengembang, memahami dengan jelas kedua mekanisme ini sangat penting, karena hal itu dapat meningkatkan struktur, dan seringkali kinerja, kode Anda.

Pencarian properti melalui rantai prototipe

Saat mengakses properti dalam bahasa berbasis prototipe seperti JavaScript, pencarian dinamis mengambil tempat yang melibatkan lapisan berbeda di dalam pohon prototipe objek.

Dalam JavaScript, setiap fungsi adalah objek. Ketika suatu fungsi dipanggil dengan operator new , objek baru dibuat. Sebagai contoh:

 function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');

Dalam contoh di atas, p1 dan p2 adalah dua objek yang berbeda, masing-masing dibuat menggunakan fungsi Person sebagai konstruktor. Mereka adalah instance independen dari Person , seperti yang ditunjukkan oleh cuplikan kode ini:

 console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'

Karena fungsi JavaScript adalah objek, mereka dapat memiliki properti. Properti yang sangat penting yang dimiliki setiap fungsi disebut prototype .

prototype , yang merupakan objek, mewarisi dari prototipe induknya, yang mewarisi dari prototipe induknya, dan seterusnya. Ini sering disebut sebagai rantai prototipe . Object.prototype , yang selalu berada di akhir rantai prototipe (yaitu, di bagian atas pohon pewarisan prototipe), berisi metode seperti toString() , hasProperty() , isPrototypeOf() , dan seterusnya.

Hubungan antara prototipe JavaScript dan rantai lingkup itu penting

Prototipe setiap fungsi dapat diperluas untuk menentukan metode dan properti kustomnya sendiri.

Saat Anda membuat instance objek (dengan memanggil fungsi menggunakan operator new ), objek tersebut mewarisi semua properti dalam prototipe fungsi tersebut. Perlu diingat, bahwa instance tersebut tidak akan memiliki akses langsung ke objek prototype tetapi hanya ke propertinya. Sebagai contoh:

 // Extending the Person prototype from our earlier example to // also include a 'getFullName' method: Person.prototype.getFullName = function() { return this.firstName + ' ' + this.lastName; } // Referencing the p1 object from our earlier example console.log(p1.getFullName()); // prints 'John Doe' // but p1 can't directly access the 'prototype' object... console.log(p1.prototype); // prints 'undefined' console.log(p1.prototype.getFullName()); // generates an error

Ada poin penting dan agak halus di sini: Bahkan jika p1 dibuat sebelum metode getFullName didefinisikan, ia masih memiliki akses ke sana karena prototipenya adalah prototipe Person .

(Perlu dicatat bahwa browser juga menyimpan referensi ke prototipe objek apa pun di properti __proto__ , tetapi merupakan praktik yang sangat buruk untuk langsung mengakses prototipe melalui properti __proto__ , karena itu bukan bagian dari Spesifikasi Bahasa ECMAScript standar, jadi jangan jangan lakukan itu! )

Karena instance p1 dari objek Person tidak memiliki akses langsung ke objek prototype , jika kita ingin menimpa getFullName di p1 , kita akan melakukannya sebagai berikut:

 // We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }

Sekarang p1 memiliki properti getFullName sendiri. Tetapi instance p2 (dibuat dalam contoh kita sebelumnya) tidak memiliki properti seperti itu sendiri. Oleh karena itu, memanggil p1.getFullName() mengakses metode getFullName dari instance p1 itu sendiri, sementara memanggil p2.getFullName() menaikkan rantai prototipe ke objek prototipe Person untuk menyelesaikan getFullName :

 console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe' 

Lihat bagaimana P1 dan P2 berhubungan dengan prototipe Person dalam contoh prototipe JavaScript ini.

Hal penting lainnya yang harus diperhatikan adalah bahwa mungkin juga untuk mengubah prototipe objek secara dinamis . Sebagai contoh:

 function Parent() { this.someVar = 'someValue'; }; // extend Parent's prototype to define a 'sayHello' method Parent.prototype.sayHello = function(){ console.log('Hello'); }; function Child(){ // this makes sure that the parent's constructor is called and that // any state is initialized correctly. Parent.call(this); }; // extend Child's prototype to define an 'otherVar' property... Child.prototype.otherVar = 'otherValue'; // ... but then set the Child's prototype to the Parent prototype // (whose prototype doesn't have any 'otherVar' property defined, // so the Child prototype no longer has 'otherVar' defined!) Child.prototype = Object.create(Parent.prototype); var child = new Child(); child.sayHello(); // prints 'Hello' console.log(child.someVar); // prints 'someValue' console.log(child.otherVar); // prints 'undefined'

Saat menggunakan pewarisan prototipe, ingatlah untuk mendefinisikan properti dalam prototipe setelah mewarisi dari kelas induk atau menetapkan prototipe alternatif.

Diagram ini menunjukkan contoh hubungan antara prototipe JavaScript dalam rantai prototipe.

Untuk meringkas, pencarian properti melalui rantai prototipe JavaScript berfungsi sebagai berikut:

  • Jika objek memiliki properti dengan nama yang diberikan, nilai tersebut dikembalikan. (Metode hasOwnProperty dapat digunakan untuk memeriksa apakah suatu objek memiliki properti bernama tertentu.)
  • Jika objek tidak memiliki properti bernama, prototipe objek diperiksa
  • Karena prototipe juga merupakan objek, jika tidak mengandung properti juga, prototipe induknya diperiksa.
  • Proses ini berlanjut ke rantai prototipe sampai properti ditemukan.
  • Jika Object.prototype tercapai dan juga tidak memiliki properti, properti tersebut dianggap undefined .

Memahami cara kerja pewarisan prototipe dan pencarian properti secara umum penting bagi pengembang tetapi juga penting karena konsekuensi kinerja JavaScript (terkadang signifikan). Seperti disebutkan dalam dokumentasi untuk V8 (sumber terbuka Google, mesin JavaScript kinerja tinggi), sebagian besar mesin JavaScript menggunakan struktur data seperti kamus untuk menyimpan properti objek. Oleh karena itu, setiap akses properti memerlukan pencarian dinamis dalam struktur data tersebut untuk menyelesaikan properti. Pendekatan ini membuat mengakses properti dalam JavaScript biasanya jauh lebih lambat daripada mengakses variabel instan dalam bahasa pemrograman seperti Java dan Smalltalk.

Pencarian variabel melalui rantai lingkup

Mekanisme pencarian lain dalam JavaScript didasarkan pada ruang lingkup.

Untuk memahami cara kerjanya, perlu diperkenalkan konsep konteks eksekusi.

Dalam JavaScript, ada dua jenis konteks eksekusi:

  • Konteks global, dibuat saat proses JavaScript diluncurkan
  • Konteks lokal, dibuat ketika suatu fungsi dipanggil

Konteks eksekusi diatur ke dalam tumpukan. Di bagian bawah tumpukan, selalu ada konteks global, yang unik untuk setiap program JavaScript. Setiap kali fungsi ditemukan, konteks eksekusi baru dibuat dan didorong ke atas tumpukan. Setelah fungsi selesai dijalankan, konteksnya akan dikeluarkan dari tumpukan.

Perhatikan kode berikut:

 // global context var message = 'Hello World'; var sayHello = function(n){ // local context 1 created and pushed onto context stack var i = 0; var innerSayHello = function() { // local context 2 created and pushed onto context stack console.log((i + 1) + ': ' + message); // local context 2 popped off of context stack } for (i = 0; i < n; i++) { innerSayHello(); } // local context 1 popped off of context stack }; sayHello(3); // Prints: // 1: Hello World // 2: Hello World // 3: Hello World

Dalam setiap konteks eksekusi adalah objek khusus yang disebut rantai lingkup yang digunakan untuk menyelesaikan variabel. Rantai cakupan pada dasarnya adalah tumpukan cakupan yang saat ini dapat diakses, dari konteks paling langsung hingga konteks global. (Agar lebih tepat, objek di bagian atas tumpukan disebut Objek Aktivasi yang berisi referensi ke variabel lokal untuk fungsi yang dieksekusi, argumen fungsi bernama, dan dua objek "khusus": this dan arguments . ) Sebagai contoh:

Cara rantai ruang lingkup berhubungan dengan objek diuraikan dalam contoh JavaScript ini.

Perhatikan dalam diagram di atas bagaimana this menunjuk ke objek window secara default dan juga bagaimana konteks global berisi contoh objek lain seperti console dan location .

Saat mencoba menyelesaikan variabel melalui rantai lingkup, konteks langsung pertama kali diperiksa untuk variabel yang cocok. Jika tidak ada kecocokan yang ditemukan, objek konteks berikutnya dalam rantai lingkup diperiksa, dan seterusnya, hingga kecocokan ditemukan. Jika tidak ada kecocokan yang ditemukan, ReferenceError dilempar.

Penting juga untuk dicatat bahwa lingkup baru ditambahkan ke rantai lingkup ketika blok try-catch atau blok with ditemui. Dalam salah satu dari kasus ini, objek baru dibuat dan ditempatkan di atas rantai cakupan:

 function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; }; function persist(person) { with (person) { // The 'person' object was pushed onto the scope chain when we // entered this "with" block, so we can simply reference // 'firstName' and 'lastName', rather than person.firstName and // person.lastName if (!firstName) { throw new Error('FirstName is mandatory'); } if (!lastName) { throw new Error('LastName is mandatory'); } } try { person.save(); } catch(error) { // A new scope containing the 'error' object is accessible here console.log('Impossible to store ' + person + ', Reason: ' + error); } } var p1 = new Person('John', 'Doe'); persist(p1);

Untuk memahami sepenuhnya bagaimana pencarian variabel berbasis ruang lingkup terjadi, penting untuk diingat bahwa dalam JavaScript saat ini tidak ada cakupan tingkat blok. Sebagai contoh:

 for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'

Dalam kebanyakan bahasa lain, kode di atas akan menyebabkan kesalahan karena "kehidupan" (yaitu, ruang lingkup) dari variabel i akan dibatasi untuk blok for. Namun, dalam JavaScript, ini tidak terjadi. Sebaliknya, i ditambahkan ke objek aktivasi di bagian atas rantai cakupan dan akan tetap di sana sampai objek tersebut dihapus dari ruang lingkup, yang terjadi ketika konteks eksekusi yang sesuai dihapus dari tumpukan. Perilaku ini dikenal sebagai pengangkat variabel.

Namun, perlu dicatat bahwa dukungan untuk cakupan level blok masuk ke JavaScript melalui kata kunci let yang baru. Kata kunci let sudah tersedia di JavaScript 1.7 dan dijadwalkan menjadi kata kunci JavaScript yang didukung secara resmi pada ECMAScript 6.

Ramifikasi Kinerja JavaScript

Cara pencarian properti dan variabel, masing-masing menggunakan rantai prototipe dan rantai lingkup, bekerja di JavaScript adalah salah satu fitur utama bahasa, namun ini adalah salah satu yang paling sulit dan paling halus untuk dipahami.

Operasi pencarian yang telah kami jelaskan dalam contoh ini, baik berdasarkan rantai prototipe atau rantai lingkup, diulang setiap kali properti atau variabel diakses. Ketika pencarian ini terjadi dalam loop atau operasi intensif lainnya, itu dapat memiliki konsekuensi kinerja JavaScript yang signifikan, terutama mengingat sifat bahasa utas tunggal yang mencegah beberapa operasi terjadi secara bersamaan.

Perhatikan contoh berikut:

 var start = new Date().getTime(); function Parent() { this.delta = 10; }; function ChildA(){}; ChildA.prototype = new Parent(); function ChildB(){} ChildB.prototype = new ChildA(); function ChildC(){} ChildC.prototype = new ChildB(); function ChildD(){}; ChildD.prototype = new ChildC(); function ChildE(){}; ChildE.prototype = new ChildD(); function nestedFn() { var child = new ChildE(); var counter = 0; for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += child.delta; } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');

Dalam contoh ini, kami memiliki pohon warisan yang panjang dan tiga loop bersarang. Di dalam loop terdalam, variabel counter bertambah dengan nilai delta . Tapi delta terletak hampir di puncak pohon warisan! Ini berarti bahwa setiap kali child.delta diakses, pohon lengkap perlu dinavigasi dari bawah ke atas. Ini dapat memiliki dampak yang sangat negatif pada kinerja.

Memahami hal ini, kita dapat dengan mudah meningkatkan kinerja fungsi nestedFn di atas dengan menggunakan variabel delta lokal untuk men-cache nilai di child.delta (dan dengan demikian menghindari kebutuhan untuk traversal berulang dari seluruh pohon warisan) sebagai berikut:

 function nestedFn() { var child = new ChildE(); var counter = 0; var delta = child.delta; // cache child.delta value in current scope for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += delta; // no inheritance tree traversal needed! } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');

Tentu saja, teknik khusus ini hanya dapat dijalankan dalam skenario yang diketahui bahwa nilai dari child.delta tidak akan berubah selama loop for sedang dieksekusi; jika tidak, salinan lokal perlu diperbarui dengan nilai saat ini.

Oke, mari jalankan kedua versi metode nestedFn dan lihat apakah ada perbedaan kinerja yang cukup besar di antara keduanya.

Kita akan mulai dengan menjalankan contoh pertama di REPL node.js:

 diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds

Sehingga membutuhkan waktu sekitar 8 detik untuk berjalan. Itu waktu yang lama.

Sekarang mari kita lihat apa yang terjadi ketika kita menjalankan versi yang dioptimalkan:

 diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds

Kali ini hanya butuh satu detik. Lebih cepat!

Perhatikan bahwa penggunaan variabel lokal untuk menghindari pencarian yang mahal adalah teknik yang dapat diterapkan baik untuk pencarian properti (melalui rantai prototipe) dan untuk pencarian variabel (melalui rantai lingkup).

Selain itu, jenis "caching" nilai ini (yaitu, dalam variabel dalam lingkup lokal) juga dapat bermanfaat saat menggunakan beberapa pustaka JavaScript yang paling umum. Ambil jQuery, misalnya. jQuery mendukung gagasan "pemilih", yang pada dasarnya merupakan mekanisme untuk mengambil satu atau lebih elemen yang cocok di DOM. Kemudahan dalam menentukan pemilih di jQuery dapat menyebabkan seseorang lupa betapa mahalnya (dari sudut pandang kinerja) setiap pencarian pemilih. Dengan demikian, menyimpan hasil pencarian pemilih dalam variabel lokal bisa sangat bermanfaat bagi kinerja. Sebagai contoh:

 // this does the DOM search for $('.container') "n" times for (var i = 0; i < n; i++) { $('.container').append(“Line “+i+”<br />”); } // this accomplishes the same thing... // but only does the DOM search for $('.container') once, // although it does still modify the DOM "n" times var $container = $('.container'); for (var i = 0; i < n; i++) { $container.append("Line "+i+"<br />"); } // or even better yet... // this version only does the DOM search for $('.container') once // AND only modifies the DOM once var $html = ''; for (var i = 0; i < n; i++) { $html += 'Line ' + i + '<br />'; } $('.container').append($html);

Khususnya pada halaman web dengan sejumlah besar elemen, pendekatan kedua dalam contoh kode di atas berpotensi menghasilkan kinerja yang jauh lebih baik daripada yang pertama.

Bungkus

Pencarian data dalam JavaScript sangat berbeda dari kebanyakan bahasa lain, dan sangat bernuansa. Oleh karena itu, penting untuk memahami sepenuhnya dan benar konsep-konsep ini agar dapat benar-benar menguasai bahasa. Pencarian data dan kesalahan JavaScript umum lainnya harus dihindari bila memungkinkan. Pemahaman ini kemungkinan akan menghasilkan kode yang lebih bersih dan kuat yang mencapai peningkatan kinerja JavaScript.

Terkait: Sebagai Pengembang JS, Inilah Yang Membuat Saya Tetap Terjaga di Malam Hari / Memahami Kebingungan Kelas ES6