Membuat Sistem Reaktivitas Vue 3 Versi Sederhana
Vue 3 telah dirilis akhir tahun kemarin dengan membawa banyak perbaikan dan fitur yang keren. Pada artikel kali ini, kita akan mempelajari lebih dalam sistem reaktivitas yang digunakan di Vue 3 dan membuat versi sederhananya menggunakan teknologi yang sama.
Sebelum artikel kali ini, saya juga menulis tentang Membuat Sistem Reaktivitas Seperti Vue.js Versi Sederhana - Bagian 1 and Membuat Sistem Reaktivitas Seperti Vue.js Versi Sederhana - Bagian 2. Jadi, pastikan teman-teman telah membaca artikel tersebut juga untuk mendapatkan pemahaman dasar yang lebih baik.
Cuplikan kode pada artikel ini juga ditulis menggunakan bahasa pemrograman TypeScript yang juga valid JavaScript. Jadi jika teman-teman ingin meng-copy, past dan menjalankan kode tersebut di konsol peramban, harusnya kode tersebut dapat berjalan. Mengapa saya menulisnya pada bahasa pemrograman TypeScript? Alasannya adalah pada beberapa bagian cuplikan kode, kita dapat mengarahkan kursor di atas deklarasi tipe datanya juga.
Daftar Isi
Teknologi yang Digunakan
Untuk mengawali artikel ini, mari kita membicarakan teknologi yang digunakan. Sistem reaktivitas pada Vue 3 menggunakan beberapa API JavaScript modern, beberapa diantaranya adalah Proxy, Reflect, WeakMap, Map dan Set.
Proxy
Jika teman-teman berasal dari latar belakang IT, mungkin teman-teman sering mendengar istilah Proxy. Secara sederhana, proxy berperan sebagai penghubung 2 hal ketika mereka berkomunikasi. Proxy dapat mengubah atau hanya melewatkan sifat aslinya.
Katakanlah, ada 2 orang teman, rumah mereka berdekatan. Walaupun mereka dapat berkomunikasi secara lisan atau berteriak satu sama lain, hal tersebut sangat tidak nyaman bagi tetangga mereka. Oleh karena itu mereka memiliki sebuah walkie talkie untuk memfasilitasi komunikasinya.
Walkie talkie tersebut memiliki beberapa fitur selain fitur utamanya untuk berkomunikasi. Beberapa diantaranya adalah menaikkan dan menurunkan volume. Bahkan walkie talkie tersebut dapat melewatkan suara selayaknya kita berbicara secara langsung.
Dari kasus tersebut, kita dapat mengatakan bahwa walkie talkie adalah proxy. Walkie talkie dapat mengubah sifat asli suara dengan meningkatkan, menurunkan dan memperjernih suara aslinya.
Kita telah memahami konsep dari proxy secara sederhana. Sekarang mari kita membicarakan Proxy dalam konteks JavaScript. Mungkin akan lebih mudah jika kita mempelajarinya dari contoh, coba lihat contoh kode berikut:
const person = { name: 'jefrydco', age: 23}Kita memiliki sebuah objek bernama person yang memiliki 2 properti, name dan age yang memiliki nilainya masing-masing.
person.name// 'jefrydco'person.age// 23Kemudian kita mencetak setiap properti pada konsol. Keduanya akan mencetak nilai yang dimilikinya.
Handler Get Proxy
Bagaimana jika ketika kita mencetak properti name, kita juga mencetak teks lain, katakanlah "Hello <value>, nice to meet you!". Dan ketika kita mencetak properti age, kita mencetak tahun kapan orang tersebut lahir. Bagaimana kita dapat melakukannya? Gampang, kita dapat menggunakan Proxy! Mari kita menulis kode lain.
const proxiedPerson = new Proxy(person, { get(target, key) { const value = target[key] if (key === 'name') { console.log(`Hello ${value}, nice to meet you!`) } else if (key === 'age') { const year = new Date().getFullYear() - value console.log(`The person was born in ${year}`) } return value }})Kita telah mendeklarasikan object bernama proxiedPerson menggunakan konstruktor Proxy. Objek tersebut menerima 2 parameter:
target: objek asli yang ingin kita ubah sifatnyahandler: objek yang mendefinisikan bagaimana operasi perubahan sifatnya
Dari kode tersebut, kita hanya mendefinisikan handler get. Handler tersebut akan dipanggil kapanpun propertinya diakses. Handler get menerima 3 parameter:
target: objek asli yang ingin kita ubah sifatnyakey: nama properti yang sedang diaksesreceiver: objek yang diproxy (opsional)
Di dalam handler get, kita bisa mendapatkan nilai dari properti yang diakses menggunakan notasi array target[key]. Handler get akan dieksekusi untuk semua properti, sehingga untuk mengubah sifat spesifik properti, kita harus menambahkan kondisi.
Sekarang, kapanpun kita mengakses properti dari proxiedPerson, sifatnya akan diubah sesuai yang kita bahas sebelumnya:
proxiedPerson.name// 'Hello jefrydco, nice to meet you!'// 'jefrydco'proxiedPerson.age// 1998// 23Sifat tersebut hanya muncul ketika kita mengakses proxiedPerosn, bukan objek person-nya sendiri. Jadi objek aslinya masih sama.
Handler Set Proxy
Bagaimana jika kita ingin mengubah sifat jika properti diubah nilainya. Katakanlah kapanpun setiap properti diubah nilainya, ia akan mencetak teks "<property-name> has been modified". Mari kita lihat implementasi kodenya:
const proxiedPerson = new Proxy(person, { set(target, key, value, receiver) { console.log(`${key} has been modified`) target[key] = value return value }})Untuk mengubah sifat ketika kita mengubah nilai suatu properti, kita dapat menggunakan handler set. Handler tersebut menerima 4 parameter:
target: objek asli yang ingin kita ubah sifatnyakey: nama properti yang sedang diaksesvalue: nilai baru yang akan diubahreceiver: objek yang diproxy (opsional)
Teman-teman perhatikan bagian yang dicetak tebal. Untuk menggunakan handler set, jangan pernah lupa untuk mengubah properti ke nilai baru. Jika tidak, nilai sebelumnya tidak akan berubah.
Sekarang, kapanpun kita mengubah nilai setiap properti, kodenya akan mencetak teks juga:
proxiedPerson.name = 'jefry'// name has been modified// 'jefry'proxiedPerson.age = 22// age has been modified// 22Kita bisa melakukan banyak hal menggunakan handler set. Salah satunya adalah kita dapat menggunakannya untuk validasi tipe data. Katakanlah properti name hanya dapat diatur menggunakan tipe data string dan properti age menggunakan tipe data number.
const proxiedPerson = new Proxy(person, { set(target, key, value, receiver) { if (key === 'name' && typeof value !== 'string') { throw new Error('name must be a string') } else if (key === 'age' && typeof value !== 'number') { throw new Error('age must be a number') } target[key] = value return value }})Sekarang kapanpun kita mengatur properti tersebut ke nilai yang berbeda dengan yang validator atur, kode tersebut akan melemparkan galat.
proxiedPerson.name = 23// Uncaught Error: name must be a stringproxiedPerson.age = 'jefrydco'// Uncaught Error: age must be a numberAda masih banyak handler proxy, silahkan teman-teman baca lebih lanjut di Mozilla Developer Network: Proxy.
Reflect
Ketika saya mengetahui API JavaScript ini pertama kali, saya berpikir, "apa nih? Saya tidak pernah mendengarnya". Setelah menghabiskan beberapa waktu membaca dokumentasi Mozilla Developer Network about Reflect, Reflect berarti kemampuan untuk melihat dan mengubah sifat dari sebuah objek.
Dari definisi tersebut, kita dapat mengatakan bahwa Reflect kombinasi yang cocok dengan API Proxy. Kita membutuhkan API untuk mengubah sifat dan untuk membuatnya lebih mudah, kita dapat menggunakan API Reflect.
Reflect bukanlah konstruktor sehingga ia tidak dapat diinisiasi menggunakan kata kunci new. Ia hanya menyediakan beberapa fungsi statis untuk melakukan hal-hal 'reflecting'.
Mari kita mempelajari lebih dalam kemampuan Reflect dengan kembali ke contoh objek kita sebelumnya:
const person = { name: 'jefrydco', age: 23}Bagaimana kita mengakses nilai dari properti name dan age? Kita bisa menggunakan notasi titik dan notasi array seperti berikut:
person.name// 'jefrydco'person['name']// 'jefrydco'Reflect.get()
Keduanya bekerja dengan baik. Kita juga dapat melakukannya menggunakan Reflect.get():
Reflect.get(person, 'name')// 'jefrydco'Fungsi Reflect.get() menerima 3 parameter:
target: objek asli yang ingin kita ubah sifatnyakey: nama properti yang ingin kita ubahreceiver: objek yang diproxy (opsional)
Fungsi tersebut mengembalikan nilai yang diakses.
Reflect.set()
Selain itu, kita juga dapat mengubah nilai properti menggunakan Reflect.set():
Reflect.set(person, 'name', 'jefry')// trueFungsi Reflect.set() menerima 4 parameter:
target: objek asli yang ingin kita ubah sifatnyakey: nama properti yang ingin kita ubahvalue: nilai baru yang akan diubahreceiver: objek yang diproxy (opsional)
Fungsi tersebut mengembalikan true jika proses pengubahan nilai berhasil dan false jika gagal.
Jika kita melihat secara sekilas, parameter Reflect.get() dan Reflect.set() sama seperti fungsi get dan set pada properti handler Proxy. Karena memang demikian. Sebagian besar properti pada handler Proxy memiliki API/fungsi yang sama seperti pada di Reflect. Oleh karena itu kita dapat mengatakan Proxy dan Reflect adalah kombinasi yang tepat.
Ada masih banyak fungsi statis pada Reflect, silahkan teman-teman membaca lebih lanjut pada Mozilla Developer Network: Reflect - Static Methods.
Map
Tipe data Map menyimpan pasangan data berbentuk key-value. Dan jika teman-teman telah bekerja dengan JavaScript dalam beberapa waktu, mungkin teman-teman menyadari bahwa objek JavaScript biasa juga pasangan key-value. Lalu mengapa mengenalkan tipe data Map daripada menggunakan objek JavaScript biasa?
Tipe data Map memiliki beberapa keuntungan daripada objek JavaScript biasa. Mari kita lihat beberapa perbedaannya:
Properti yang Diwariskan
Objek JavaScript Biasa
Kapanpun kita membuat objek JavaScript biasa, objek tersebut juga mewariskan properti bawaan dari konstruktor Objek.
Reflect.get(person, 'toString')// ƒ toString() { [native code] }Kita dapat menggunakan fungsi Reflect.get() untuk mendapatkan nilainya. Properti-properti tersebut dapat membuat perilaku yang tidak diinginkan pada beberapa kasus. Properti bawaan tersebut dapat tertimpa secara tidak sengaja oleh kita. Fungsi toString merupakan salah satunya yang berfungsi untuk mengonversi objek menjadi tipe data string.
const person = { toString: '', age: 23}person.toString()// Uncaught TypeError: person.toString is not a functionJika secara tidak sengaja kita mendeklarasikan properti yang bernama sama seperti toString, kita akan menimpa properti bawaannya. Sehingga ketika kita memanggil fungsi tersebut, kode kita akan melemparkan galat.
Kita juga dapat menghapus properti bawaan menggunakan cara berikut:
const persons = Object.create(null)
Reflect.set(persons, 'name', 'jefrydco')Reflect.set(persons, 'age', 23)
Reflect.get(persons, 'toString')// undefinedDengan menggunakan fungsi Object.create() dan melewatkan null sebagai parameter, kita dapat menghilangkan properti bawaan tersebut. Ketika kita mengaksesnya, ia akan mengembalikan nilai undefined.
Map
Objek yang disimpan di dalam tipe data map hanya berisi apa yang secara jelas diletakkan di dalamnya. Tipe data map juga menyediakan fungsi yang mudah untuk mengakses dan menyimpan properti menggunakan fungsi get dan set.
const person = new Map()
person.set('name', 'jefrydco')person.set('age', 23)
person.get('name')// 'jefrydco'person.get('age')// 23Jenis-jenis Key
Objek JavaScript Biasa
Key untuk objek JavaScript biasa terbatas berupa tipe data string atau Symbol.
const symbolForAge = Symbol.for('age')const person = { name: 'jefrydco', // `string` key [symbolForAge]: 23 // `Symbol` key}Reflect.get(person, 'name')// 'jefrydco'Reflect.get(person, symbolForAge)// 23Tipe data Symbol merupakan tipe data primitif JavaScript yang baru. Secara singkat, tipe data ini pada umumnya digunakan untuk mencegah tabrakan antar key karena nilainya selalu unik.
Symbol() === Symbol()// falseSymbol.for('age') === Symbol.for('age')// falseMap
Kita dapat menggunakan tipe data apapun yang tersedia di JavaScript sebagai kunci. Seperti Function, Object, Array, tipe data Map yang lain, dan lain-lain. Atau kita juga dapat menggunakan tipe data primitif seperti string, number, float, dan lain-lain.
function main() {}const object = {}const array = []const map = new Map()
const person = new Map()
person.set(main, 'Entrypoint for all function invocation')person.set(object, 0)person.set(array, {})person.set(map, new Map())
person.get(main)// 'Entrypoint for all function invocation'person.get(object)// 0person.get(array)// {}person.get(map)// Map(0) {}Kita dapat mengisi nilainya dengan tipe data apun juga. Pada contoh tersebut, kita menggunakan Function, Object, Array dan Map sebagai key. Dan sebagai nilainya, kita menggunakan string, number, Object kosong dan Map.
Ukuran
Ukuran berarti banyaknya item yang disimpan di dalam objek atau map.
Objek JavaScript Biasa
Pada objek JavaScript biasa, kita harus menentukan secara manual berapa banyak data yang disimpan. Untungnya, JavaScript modern telah menyediakan fungsi yang cukup baik untuknya.
const person = { name: 'jefrydco', age: 23}
const keys = Object.keys(person)keys// ['name', 'age']keys.length// 2Kita dapat menggunakan fungsi Object.keys(), fungsi tersebut akan mengembalikan sebuah array yang berisi semua key yang dimiliki oleh objek kecuali yang diwariskan. Karena berupa array, kita dapat dengan mudah mengakses properti length untuk menentukan berapa banyak item yang dimiliki oleh objek tersebut.
Map
Tipe data Map menyediakan fungsionalitas bawaan untuk menentukan berapa banyak data yang ia miliki. Properti tersebut bernama size.
const person = new Map()
person.set('name', 'jefrydco')person.set('age', 23)
person.size// 2Masih banyak perbedaan lainnya selain perbedaan di atas, jika teman-teman ingin mempelajari lebih lanjut, silahkan membacanya di Mozilla Developer Network: Map - Objects vs. Maps.
WeakMap
Tipe data WeakMap mirip dengan tipe data Map. Fungsinya untuk menyimpan data berupa pasangan key-value. Tetapi ia memiliki beberapa perbedaan:
Key tidak Bisa Bertipe Primitif
Key tidak bisa berupa tipe data primitif (string, number, float, boolean, dan lain-lain), Key harus berupa tipe data yang kompleks (Function, Object, Array, WeakMap lainnya, dan lain-lain).
const object = {}
const person = new WeakMap()person.set(object, 'An empty object')
person.get(object)// 'An empty object'Jika kita mencoba menggunakan tipe data primitif sebagai key, kode kita akan melemparkan galat:
const person = new WeakMap()person.set('', 'An empty string key')// Uncaught TypeError: Invalid value used as weak map keyTeman-teman mungkin bertanya-tanya, mengapa key haruslah berupa tipe data yang kompleks? Tunggu sebentar, kita akan membahasnya sesaat lagi.
Tidak dapat Melakukan Perulangan pada Item
Kita tidak dapat melakukan perulangan pada item, Dapat diiterasi berarti mengiterasi nilai sebuah objek yang memang dapat diiterasi. Beberapa tipe data yang dapat diiterasi pada JavaScript diantaranya Map, Set, Array dan string.
const person = new WeakMap()person.set(object, 'An empty object')
for (let property of person) { console.log(property)}// Uncaught TypeError: person is not iterableKarena alasan ini, teman-teman mungkin bertanya-tanya juga, mengapa WeakMap tidak dapat diiterasi? Tunggu sebentar juga, kita akan membahasnya sesaat lagi.
Jadi, mengapa WeakMap key tidak bisa berupa tipe data primitif dan datanya tidak dapat diiterasi?
Mari kita lihat definisi WeakMap dari Mozilla Developer Network: WeakMap.
The
WeakMapobject is a collection of key/value pairs in which the keys are weakly referenced.
atau jika diterjemahkan dalam bahasa Indonesia kurang lebih:
Objek
WeakMapmerupakan koleksi data berupa pasangan key/nilai yang mana key-nya tereferensi secara lemah.
Jadi apa artinya key-nya tereferensi secara lemah? Untuk menjawab pertanyaan tersebut, mari kita lihat contoh berikut:
let object = {}const person = new WeakMap()person.set(object, 'An empty object')
person// WeakMap {{...} => 'An empty object'}
object = undefined// Need to trigger the garbage collection process,// take a look at the video below how to do that.person// WeakMap {}Kita memiliki sebuah objek sebagai kunci untuk sebuah string. Ketika kita mendapatkan string tersebut menggunakan fungsi person.get(), ia akan mengembalikan string yang dimaksud. Tetapi ketika kita mereferensikan objek menjadi undefined, sewaktu-waktu Garbage Collector akan menghapus objek tersebut.
I'm from the island of Java, Indonesia.
— Jesslyn 🇮🇩 (@jtannady) April 4, 2018
I am the Java Garbage Collector. pic.twitter.com/R5kfKYfP6c
Garbage Collector pada JavaScript memiliki tugas untuk menghapus objek yang tidak digunakan dimanapun untuk membebaskan memori. Proses tersebut dijalankan secara otomatis, biasanya ketika CPU diam.
Ketika kita mengatur nilai objek menjadi undefined, Garbage Collector JavaScript akan menghapus objek yang tidak tereferensi tersebut. Untungnya, Chrome memiliki fitur untuk menjalankan proses Garbage Collector. Kita harus membuka Devtools, kemudian buka tab Performance. Akan ada tombol dengan ikon sampah, jika kita mengarahkan kursor padanya, ia akan menampilkan label "Collect Garbage".
Setelah kita mengklik tombol tersebut, cetak objek person, hasilnya akan menampilkan WeakMap kosong. Oleh karena itu WeakMap disebut tereferensi secara lemah karena ketika objek dihapus, nilainya juga dihapus. Dampak dari tereferensi secara lemah tersebut adalah kita tidak dapat mengiterasi key maupun nilainya, karena kita benar-benar tidak mengetahui kapan objek tersebut dihapus dari memori.
Set
Tipe data Set mirip dengan tipe data Array, tetapi item yang tersimpan haruslah unik. Mari kita lihat bagaimana kita berinteraksi dengan Set:
const set = new Set()
set.add('First item')set.add(2)
set// Set(2) {"First item", 2}Kita dapat menambahkan apapun ke Set, tetapi kita harus memperhatikan jika kita berurusan dengan item berupa objek. Konsep Set adalah menyimpan item yang unik, dan terkadang 2 objek dengan properti yang sama dapat dimasukkan ke dalam set.
const set = new Set()
set.add({ name: 'jefrydco' })set.add({ name: 'jefrydco' })
set// Set(2) {{…}, {…}}Walaupun objek { name: 'jefrydco' } nampak serupa, keduanya mengarah ke alamat yang berbeda di memori. Itulah mengapa Set dapat memasukkan data tersebut. Jadi bagaimana untuk meyakinkan objek yang kita masukkan ke dalam set unik? Kita harus mereferensikannya pada variabel.
const set = new Set()const person = { name: 'jefrydco' }
set.add(person)set.add(person)
set// Set(1) {{…}}Kita memanggil fungsi add() dua kali dengan argumen yang sama dan kode tersebut hanya memasukkan objek sekali. Karena variabel person yang pertama dan kedua mereferensi pada objek yang sama.
Istilah-istilah
Keren!!! Sekarang kita telah mengenal beberapa API JavaScript yang mendukung sistem reaktivitas Vue 3. Sebelum membahas sistem reaktivitasnya, kita harus mengenal beberapa istilah yang pada umumnya kita gunakan untuk menjelaskan sistem reaktivitas. Mari kita mengenalnya.
State
State adalah objek umum yang merepresentasikan sesuatu. Mari kita kembali ke contoh sebelumnya:
const person = { name: 'jefrydco', age: 23}Kita dapat mengatakan bahwa objek person adalah state karena ia merepresentasikan manusia di kehidupan nyata. State dapat merepresentasikan apapun tidak hanya sesuatu di dunia nyata. Sebagai contoh, jika kita pernah bermain gim, pasti gim tersebut memiliki banyak state. Seberapa banyak proses, uang atau XP yang kita miliki atau di level mana sekarang kita berada. Kita dapat menyimpan hal tersebut di dalam state.
Reactive State
Reactive state adalah state lainnya yang melakukan sesuatu jika nilai propertinya berubah. Mari kita lihat contoh sebelumnya dan membuatnya menjadi reactive state menggunakan Proxy:
const reactivePerson = new Proxy(person, { set(target, key, value) { console.log(`Do something here when "${key}" property change`) target[key] = value return value }})reactivePerson.name = 'jefry'// 'Do something here when "name" property change'// 'jefry'Kita dapat mengatakan bahwa reactivePerson adalah sebuah reactive state karena ketika kita menguba, katakanlah properti name, ia akan mencetak sesuatu pada konsol. Kita dapat melakukan apapun sesuai keinginan kita, tidak harus mencetak sesuatu. Kita dapat memanggil fungsi lainnya, mengubah state lain, me-render sesuatu di layar, dan masih banyak lagi. Kemungkinannya tak terbatas.
Dependencies
Apa yang dapat kita lakukan di dalam fungsi handler set di atas, apapun bukan? Dependencies adalah sebuah fungsi yang harus dipanggil ketika perubahan nilai berubah. Mari kita lihat pada ocntoh sebelumnya:
function printInfoForName() { console.log(`Do something here when "name" property change`)}function printInfoForAge() { console.log(`Do awesome thing when "age" property change`)}
const reactivePerson = new Proxy(person, { set(target, key, value) { if (key === 'name') { printInfoForName() } else if (key === 'age') { printInfoForAge() } target[key] = value return value }})reactivePerson.name = 'jefry'// 'Do something here when "name" property change'// 'jefry'reactivePerson.age = 22// 'Do awesome thing when "age" property change'// 22Kita mendeklarasikan 2 fungsi, printInfoForName dan printInfoForAge. Kita dapat mengatakan printInfoForName adalah dependensi untuk properti name. Dan printInfoForAge adalah dependensi untuk age.
Tracker
Tracker adalah sebuah fungsi untuk menyimpan dependencies. Akan sangat melelahkan jika semua fungsi dependency ditulis manual. Biasanya, fungsi dependency ditulis sebagai fungsi anonymous. Fungsi anonymous adalah fungsi tanpa nama.
function namedFunction () { // Named Function Content}const anonymousFunction = () => { // Anonymous Function Content}Mengapa kita harus menggunakan sebuah tracker sebagai gantinya fungsi dependency secara langsung? Karena proses pemanggilannya dapat ditunda nantinya. Kita track dependency ketika properti diakses atau direferensi. Kemudian kita eksekusi semua fungsi dependency-nya ketika terjadi perubahan nilai.
Kita dapat menggunakan Object, Array, WeakMap, Map, Set untuk menyimpan semua fungsi dependency. Seharusnya ada satu solusi yang cocok dengan kebutuhan kita. Tunggu sebentar, kita akan membahasnya nanti.
Trigger
Trigger adalah fungsi yang bertugas untuk mengeksekusi semua dependencies yang tersimpan. Untuk mendapatkan pemahaman yang lebih baik bagaimana Tracker dan Trigger bekerja, mari kita lihat pada contoh kode berikut:
function tracker(target, key) { // Store all dependencies}
function trigger(target, key) { // Execute all dependencies}
const reactivePerson = new Proxy(person, { get(target, key) { tracker(target, key) return target[key] }, set(target, key, value) { trigger(target, key) target[key] = value return value }})tracker diletakkan di dalam handler get, seperti yang kita sebutkan pada penjelasan sebelumnya bahwa kita track dependency ketika properti diakses atau direferensikan. Dan handler get juga akan dieksekusi ketika properti diakses atau direferensikan.
trigger diletakkan di dalam handler set, jadi kapanpun nilai properti berubah, kita akan men-trigger atau mengekesekusi fungsi dependency.
Effect
Ada 2 hal yang harus kita pahami ketika kita membicarakan fungsi, fungsi pure dan fungsi impure.
Pure Function
Pure function adalah fungsi yang menerima masukkan dan mengembalikan keluaran tanpa memodifikasi data diluar ruang lingkupnya.
function sum(number1, number2) { return number1 + number2}
sum(4, 5)// 9Fungsi sum pada contoh di atas adalah pure function karena ia menerima 2 argumen dan mengembalikan sebuah nilai. Fungsi tersebut juga tidak mengakses maupun memodifikasi data diluar ruang lingkupnya. Sehingga kita dapat mengambil kesimpulan bahwa pure function memiliki 2 karakteristik:
- Masukkan yang sama selalu mengembalikan nilai keluaran yang sama
- Tidak memodifikasi data diluar ruang lingkupnya
Impure Function
Impure function adalah fungsi yang memodifikasi data diluar ruang lingkupnya.
const person = { name: 'jefrydco', age: 23}
function changeName(name) { person.name = name}
changeName('jefry')Fungsi changeName adalah contoh impure function karena ia mengubah properti name yang terletak di luar ruang lingkupnya.
Kita telah memahami mengenai pure dan impure function, lalu apa effect kalau begitu? Effect adalah fungsi yang melakukan side effect, side effect adalah memodifikasi data di luar ruang lingkupnya. Jadi secara teknis, effect adalah impure function.
Watch
Watch adalah fungsi yang "touch" properti dan mengeksekusi effect. Touch berarti secara disengaja mengakses properti tersebut untuk menyimpan dependencies.
function watch(target, key, effect) { const value = target[key] effect(value)}Pada contoh di atas, kita "touch" properti dengan cara mereferensikannya pada sebuah variabel bernama value.
Sistem Reaktivitas
Sekarang kita telah memiliki pemahaman yang cukup dari sisi teknologi dan istilah. Mari kita memahami lebih jauh pada sistem reaktivitasnya sendiri. Jadi apa itu sistem reaktivitas? Untuk menjawab hal tersebut, silahkan teman-teman melihat pada animasi di bawah ini:
Pertama-tama, kita memasukkan angka 1 dan 2, hasilnya terkalkulasi secara otomatis. Jika kita ubah ke angka 2 dan 2, hasilnya juga akan terkalkulasi secara otomatis. Mekanisme itulah yang disebut sistem reaktivitas. Kita dapat mengatakan bahwa sistem reaktivitas adalah sistem yang bereaksi terhadap perubahan secara otomatis.
Kita tidak akan membahas hal tersebut terlalu detail pada Vue. Tetapi jika teman-teman ingin mengetahui lebih lanjut, silahkan teman-teman membaca pada Membuat Sistem Reaktivitas Seperti Vue.js Versi Sederhana - Bagian 1 atau bahkan Vue.js 3: Reactivity in Depth. Jadi mari kita mulai menulis sistem reaktivitas sederhana kita sendiri.
Membuat Fungsi Reactive
Mari mulai dari dasar, coba lihat potongan kode berikut:
function reactive(target) { return new Proxy(target, { get(target, key, receiver) { track(target, key) return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { trigger(target, key, value) return Reflect.set(target, key, value, receiver) } })}Kita membuat sebuah fungsi bernama reactive. Fungsi tersebut mengembalikan instance Proxy yang memiliki handler get dan set. Di dalam getter, kita memanggil fungsi yang belum dideklarasikan bernama track dan di dalam settter, ktia juga memanggil fungsi yang belum dideklarasikan bernama trigger. Cukup mudah bukan? Kode tersebut sama seperti yang telah kita bahas pada bagian persyaratan.
Fungsi reactive kita di atas hanya bekerja untuk Object berstruktur linear. Ia tidak akan bekerja jika memiliki Object atau Array bersarang.
// Workingconst person = { name: 'jefrydco', age: 23}
// Not workingconst person = { name: { firstName: 'jefry', lastName: 'dewangga' }, skills: ['web', 'vue']}Saya harap teman-teman sabar menunggu, kita akan membuatnya bekerja untuk Object dan Array bersarang nanti.
Manajemen Dependencies
Kita membutuhkan struktur data untuk menyatukan semuanya. Beberapa bagian yang terhubung diantaranya:
- Target, adalah state yang akan kita ubah menjadi reactive state
- Key, properti dari state
- Dependencies, fungsi yang akan dijalankan jika nilai dari sebuah key berubah
Kita dapat menggunakan API JavaScript yang telah kita pelajari sebelumnya, Karena target merupakan Object, kita dapat menggunakan WeakMap. Dan sebagai nilainya adalah Map.
Key dari Map ini adalah properti target yang ingin kita track kemudian nilainya adalah sebuah Set yang berisi fungsi effect.
Silahkan teman-teman melihat contoh kode berikut untuk mendapatkan pemahaman yang lebih baik bagaimana setiap bagian berkorelasi.
const person = { name: 'jefrydco', age: 23}
const dep = new Set()dep.add((value) => { console.log(`Value change into ${value}`)})
const depsMap = new Map()depsMap.set('name', dep)
const targetMap = new WeakMap()targetMap.set(person, depsMap)Kita membuat sebuah variabel baru bernama dep menggunakan tipe data Set, kemudian kita menambahkan fungsi anonymous yang mencetak informasi mengenai value.
Setelah itu, kita membuat variabel baru bernama depsMap menggunakan tipe data Map, kemudian kita atur itemnya menggunakan salah satu properti yang terdapat pada objek person yakni name sebagai key. Nilainya adalah variabel dep yang sebelumnya telah kita deklarasi.
Bagian terakhir adalah kita membuat variabel baru bernama targetMap menggunakan tipe data WeakMap, kemudian kita atur itemnya menggunakan objek person sebagai key. Nilainya adalah variabel depsMap yang telah kita deklarasikan sebelumnya.
Membuat Fungsi Track
Jika teman-teman merasa kebingungan dengan diagram sebelumnya dan lebih suka belajar melalui kode. Mari kita menulis kodenya. Semua manajemen dependencies akan kita tulis di dalam fungsi track.
const targetMap = new WeakMap()let activeEffect = undefined
function track(target, key) { const dep = new Set() dep.add(activeEffect)
const depsMap = new Map() depsMap.set(key, dep)
targetMap.set(target, depsMap)}Pertama-tama, kita mendeklarasikan variabel bernama targetMap dan mereferensikan konstruktor WeakMap. Kita juga mendeklarasikan variabel lainnya bernama activeEffect, kita mereferensikannya ke undefined.
Variabel targetMap akan menjadi data struktur utama dari semua manajemen dependencies. Variabel activeEffect akan digunakan sebagai variabel sementara untuk menyimpan effect yang aktif sekarang.
Kode tersebut akan berjalan dengan baik jika target dan key bernilai baru. Variabel dep dan depsMap akan selalu mereferensikan pada objek baru. Kode tersebut tidak akan bekerja jika target dan key merupakan nilai yang telah kitagunakan sebelumnya. Kode tersebut akan selalu menimpa objek sebelumnya. Sehingga kita tidak akan bisa menyimpan sebanyak yang kita inginkan. Mari mengubahnya:
const targetMap = new WeakMap()let activeEffect = undefined
function track(target, key) { let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) }
// Next code}Untuk menyelesaikan permasalahan tersebut, kita harus menambahkan kondisi. Kita memeriksa di dalam targetmap apakah telah tersedia depsMap sebelumnya atau belum. Jika belum kemudian kita dapat menginisialisasinya menggunakan Map baru dan menambahkannya ke dalam targetMap.
const targetMap = new WeakMap()let activeEffect = undefined
function track(target, key) { // Previous code
let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) }
// Next Code}Kita melakukan hal yang sama pada depsMap, kita memeriksa di dalam depsMap apakah telah tersedia dep sebelumnya atau belum. Jika belum, maka kita menginisialisasinya menggunakan Set baru dan menambahkannya ke dalam depsMap.
const targetMap = new WeakMap()let activeEffect = undefined
function track(target, key) { // Previous code
if (!dep.has(activeEffect) && typeof activeEffect !== 'undefined') { dep.add(activeEffect) }
// Next Code}Setelah itu, kita harus memeriksa juga apakah dep memiliki activeEffect yang sama seperti pada activeEffect atau belum. Jika belum maka kita dapat menambahkan effect tersebut. Kita juga perlu memeriksa apakah activeEffect sekarang bernilai undefined atau tidak karena pada awalnya kita mereferensikan variabel tersebut ke undefined, sehingga ada kemungkinan nilainya masih undefined ketika kode dijalankan.
const targetMap = new WeakMap()let activeEffect = undefined
function track(target, key) { // Previous code
targetMap.set(target, depsMap)}Hal terakhir yang harus kita lakukan adalam memasukkan depsMap ke targetMap menggunakan target sebagai key. Fungsi track final akan seperti berikut:
const targetMap = new WeakMap()let activeEffect = undefined
function track(target, key) { let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) }
let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) }
if (!dep.has(activeEffect) && typeof activeEffect !== 'undefined') { dep.add(activeEffect) }
targetMap.set(target, depsMap)}Membuat Fungsi Watch
Fungsi watch akan "touch" properti dan mengeksekusi fungsi effect secara langsung. Jadi seharusnya cukup mudah bukan? Ya, tentu saja.
function watch(target, key, effect) { activeEffect = effect const value = target[key] effect(value) activeEffect = undefined}Fungsi watch memiliki 3 parameter, target, key dan effect. Argumen effect dalam bentuk fungsi callback yang akan dieksekusi ketika nilai key berubah.
Kita tidak dapat melewatkan target[key] secara langsung ke dalam fungsi effect karena ia harus "touch" terlebih dahulu sebelum kita mengeksekusi effect.
Kita juga perlu mengatur activeEffect sementara dan mereferensikan kembali ke undefined setelah proses "touch" dan pemanggilan effect selesai.
Reaktivitas Bersarang
Untuk membuat Object bersarang reaktiv, kita akan menggunakan metode rekursif. Secara umum, rekursif adalah fungsi yang memanggil dirinya sendiri teruse menerus hingga sampai titik pemberhentiannya. Titik pemberhentiannya adalah ketika fungsi tersebut berhenti memanggil dirinya sendiri. Mari kita lihat bentuk paling sederhana dari fungsi rekursif:
function printToZero(number) { if (number >= 0) { console.log(number) printToZero(number - 1) }}printToZero(3)// 3// 2// 1// 0Baris yang dicetak tebal pertama adalah titik pemberhentiannya dan baris yang dicetak tebal kedua adalah kita memanggil fungsinya menggunakan variabel yang sama dikurangi satu.
Jadi bagaimana kita menerapkan fungsi rekursif pada fungsi reactive kita? Yang perlu kita lakukan hanyalah melakukan pengecekan jika nilainya berupa obje, maka kita mengembalikan fungsi reactive nya sendiri.
function isObject(value) { return Object.prototype.toString.call(value) === '[object Object]'}
function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const value = target[key]
track(target, key)
if (isObject(value)) { return reactive(value) }
return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { trigger(target, key, value) return Reflect.set(target, key, value, receiver) } })}Membuat Fungsi Track Array
Mari kita membuat kode kita bekerja untuk Array. Melacak perubahan di Array cukup berbeda dari Object, sehingga akan lebih baik jiak membuat fungsi baru untuk menanganinya.
Namun sebelum itu, mari kita melihat bagaimana kita biasanya berurusan dengan pengubahan item di Array:
const person = []
person.push('jefry')person.push('dewangga')person// ['jefry', 'dewangga']
person.unshift('jefrydco')person// ['jefrydco', 'jefry', 'dewangga']
person.pop()// 'dewangga'person// ['jefrydco', 'jefry']
person.shift()// 'jefrydco'person// ['jefry']
person.push('jefrydco')person.push('dewangga')person// ['jefry', 'jefrydco', 'dewangga']person.splice(1, 1)// ['jefry', 'dewangga']push, memasukkan item di akhir arrayunshift, memasukkan item di awal arraypop, menghapus item dari akhir arrayshift, menghapus item dari awal arraysplice, menghapus n item dari indeks tertentu
Ada masih banyak fungsi Array, kita akan fokus pada 4 fungsi tersebut. Tetapi jika teman-teman ingin mengetahuinya, silahkan teman-teman membaca lebih lanjut pada Mozilla Developer Network: Array - Instance Method.
Idenya adalah ketika fungsi tersebut dieksekusi, kita akan memanggil fungsi trigger. Selain itu, kita juga harus memasikan fungsionalitas aslinya tetap sama. Jadi bagaimana kita akan melakukannya?
function trackArray(target, key) { const value = target[key]
return new Proxy(value, { get(arrayTarget, arrayKey) { const arrayMethod = arrayTarget[arrayKey]
// Do something with arrayMethod } })}Fungsi trackArray menerima 2 parameter, target dan key. Kita dapat mendapatkan nilai Array menggunakan notasi array. Setelah itu, kita dapat menggunakan nilai tersebut sebagai "target" untuk Proxy yang baru.
Jika Object memerlukan kita untuk memiliki handler get dan set, pada Array, kita hanya memerlukan handler get. Di dalam fungsi tersebut, kita dapat mendapatkan fungsi operasi Array yang saat ini dilakukan menggunakan notasi array.
function trackArray(target, key) { const value = target[key]
return new Proxy(value, { get(arrayTarget, arrayKey) { const arrayMethod = arrayTarget[arrayKey]
if (typeof arrayMethod === 'function') { if (['push', 'unshift', 'pop', 'shift', 'splice'].includes(arrayKey)) { // Do something if arrayMethod is one of item in the array } return arrayMethod.bind(arrayTarget) } return arrayMethod } })}Kita harus memastikan arrayMethod bertipe fungsi. Di dalam pemeriksaan tersebut, kita juga melakukan pemeriksaan lainnya. Pemeriksaan bersarang ini untuk fungsi operasi Array yang ingin kita ubah fungsionalitasnya. Dalam hal ini, kita hanya mengubah fungsionalitas untuk fungsi operasi array paling umum yakni push, unshift, pop, shift dan splice.
Kita juga perlu melakukan bind arrayMethod yang tidak termasuk pada fungsi operasi array tersebut pada konteks arrayTarget.
function trackArray(target, key) { const value = target[key]
return new Proxy(value, { get(arrayTarget, arrayKey) { const arrayMethod = arrayTarget[arrayKey]
if (typeof arrayMethod === 'function') { if (['push', 'unshift', 'pop', 'shift', 'splice'].includes(arrayKey)) { return function () { const result = Array.prototype[arrayKey].apply( arrayTarget, arguments ) } } return arrayMethod.bind(arrayTarget) } return arrayMethod } })}Jika kedua kondisi bernilai benar, kita mengembalikan named function. Di dalam named function tersebut, kita mengeksekusi fungsi operasi asli dari Array menggunakan konteks arrayTarget. Kita melakukannya dengan cara memanggil apply dari Array.prototype[arrayKey]. Setiap operasi fungsi array mengembalikan nilai yang berbeda, sehingga kita dapat mereferensikannya pada variabel bernama result.
Sebelum melanjutkan pembahasan, mari kita mempelajari secara singkat bagaimana Array.prototype[arrayKey] bekerja. Silahkan teman-teman melihat contoh kode berikut:
const array = []
array.push('jefrydco')array// ['jefrydco']
Array.prototype['push'].apply(array, ['jefry'])array// ['jefrydco', 'jefry']Keduanya dapat menghasilkan hasil yang sama, tetapi yang kedua biasanya digunakan ketika kita tidak memiliki akses terhadap parameter yang ingin dilewatkan.
const array = []
function push() { const result = Array.prototype['push'].apply(array, arguments) console.log(`Array index: ${result}`) return result}
push('jefrydco')// Array index: 1// 1Studi kasus lain ketika kita ingin menambah fungsionalitas dari fungsi asli, pada contoh di atas kita ingin mencetak indeks Array kapanpun kita memanggil fungsi push. Sehingga kita dapat menggunakan opsi kedua untuk memanggil fungsi operasi array aslinya, dan mereferensikan parameter fungsi bernama push melalui key JavaScript spesial bernama arguments.
function trackArray(target, key) { const value = target[key]
return new Proxy(value, { get(arrayTarget, arrayKey) { const arrayMethod = arrayTarget[arrayKey]
if (typeof arrayMethod === 'function') { if (['push', 'unshift', 'pop', 'shift', 'splice'].includes(arrayKey)) { return function () { const result = Array.prototype[arrayKey].apply( arrayTarget, arguments )
trigger(target, key, value)
return result } } return arrayMethod.bind(arrayTarget) } return arrayMethod } })}Mari kita kembali ke topik. Setelah kita mendapatkan hasil dari operasi fungsi array. Kita perlu memanggil fungsi trigger menunjukkan bahwa terdapat perubahan pada array. Setelah itu, kita mengembalikan variabel result.
function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const value = target[key]
track(target, key)
if (isObject(value)) { return reactive(value) }
if (Array.isArray(value)) { return trackArray(target, key) }
return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { trigger(target, key, value) return Reflect.set(target, key, value, receiver) } })}Kemudian hal terkahir yang perlu kita lakukan adalah memanggil fungsi di dalam fungsi reaktive kita. Tetapi kita juga perlu melakukan pengecekan apakah nilai dari target berupa Array atau bukan menggunakan fungsi Array.isArray().
Membuat Fungsi Trigger
Fungsi trigger akan dipanggil ketika nilai properti berubah, jadi kita perlu meletakkanya di dalam handler set. Kita juga perlu meletakkanya di dalam trackArray karena kita harus menambahkan fungsionalitas khusus untuk fungsi array. Mari kita lihat bagaimana fungsi trigger:
function trigger(target, key, value) { const effects = targetMap.get(target).get(key)
if (effects) { effects.forEach((effect) => { effect(value) }) }}
Teman-teman pasti ingat diagram tersebut bukan? Di dalam fungsi trigger, kita perlu mendapatkan effect yang tersimpan di dalam tipe data Set. Dan kita dapat melakukannya dengan cara memanggil fungsi get untuk setiap WeakMap dan Map.
Kita perlu memeriksa apakah nilainya ada atau tidak, jika ada, kita perlu mengiterasinya. Untungnya Set telah menyediakan fungsionalitas resmi untuk melakukan iterasi tersebut. Di dalam bagian iterasi tersebut, kita hanya perlu memanggil fungsi effect.
Kode Reaktivitas Final
Mari kita gabungkan semuanya menjadi satu, berikut merupakan kode final untuk impelementasi sederhana sistem reaktivitas Vue 3. Kita dapat menjalankan kode berikut melalui konsol peramban secara langsung. Kita juga dapat mencobanya di Simplified Vue 3 Reactivity System Demo.
const targetMap = new WeakMap()let activeEffect = undefined
function isObject(value) { return Object.prototype.toString.call(value) === '[object Object]'}
function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const value = target[key]
track(target, key)
if (isObject(value)) { return reactive(value) } if (Array.isArray(value)) { return trackArray(target, key) }
return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { trigger(target, key, value)
return Reflect.set(target, key, value, receiver) } })}
function track(target, key) { let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) }
let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) }
if (!dep.has(activeEffect) && typeof activeEffect !== 'undefined') { dep.add(activeEffect) }
targetMap.set(target, depsMap)}
function trackArray(target, key) { const value = target[key]
return new Proxy(value, { get(arrayTarget, arrayKey) { const arrayMethod = arrayTarget[arrayKey]
if (typeof arrayMethod === 'function') { if (['push', 'unshift', 'pop', 'shift', 'splice'].includes(arrayKey)) { return function () { const result = Array.prototype[arrayKey].apply( arrayTarget, arguments )
trigger(target, key, value)
return result } } return arrayMethod.bind(arrayTarget) } return arrayMethod } })}
function trigger(target, key, value) { const effects = targetMap.get(target).get(key) if(effects) { effects.forEach((effect) => { effect(value) }) }}
function watch(target, key, effect) { activeEffect = effect const value = target[key] effect(value) activeEffect = undefined}Penggunaan Sederhana
Kita telah menulis cukup banyak kode di atas, jadi bagaimana kita akan menggunakannya? Sederhana! Fungsi yang perlu kita perhatikan adalah reactive dan watch. Mari kita kembali ke contoh objek person.
Kita dapat menggunakan contoh yang sama seperti pada Proxy Get Handler, kita ingin mencetak pesan "Hello <value>, nice to meet you!" ketika kita mengubah nilai properti name.
const state = reactive(person)watch(state, 'name', (name) => { console.log(`Hello ${name}, nice to meet you!`)})// 'Hello jefrydco, nice to meet you!'
state.name = 'jefry'// 'Hello jefry, nice to meet you!'// 'jefry'Dan ketika kita mengubah nilai properti age, kita akan mencetak tahun dimana orang tersebut lahir.
const state = reactive(person)watch(state, 'age', (age) => { const year = new Date().getFullYear() - age console.log(`The person was born in ${year}`)})// 'The person was born in 1998'
state.name = 22// 'The person was born in 1999'// 22Penggunaan Kompleks
Dari kode pada Kode Reaktivitas Final, kita dapat membuat aplikasi yang lebih kompleks. Sebagai contohnya, kita akan membuat aplikasi "Hello World" yang umum untuk kerangka kerja JavaScript pada umumnya, yakni aplikasi Todo.
Jika demo di atas tidak bekerja, silahkan muat ulang halaman ini.
Jika masih tidak bisa, silahkan hubungi @jefrydco di Twitter.
Sunting demo di Github AppReactivityVue3ComplexDemo .
Kita juga dapat membuat aplikasi yang lebih kompleks lagi, silahkan teman-teman melihat aplikasi berikut, Anime Search One. Berkas reactivity.js, kode sumbernya berasal dari Kode Reaktivitas Final di atas dengan beberapa perbaikan.
Lebih Lanjut
Terakhir, jika teman-teman ingin memahami lebih dalam lagi mengenai implementasi sistem reaktivitas pada Vue 3. Kode sumbernya terletak di repositori Vue 3 pada lokasi direktori berikut /packages/reactivity/src.
Terima kasih, saya harap teman-teman menikmati artikel ini dan mempelajari sesuatu yang baru! Jika teman-teman memiliki pertanyaan, jangan sungkan-sungkan untuk mengirim pesan di @jefrydco.