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// 23
Kemudian 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// 23
Sifat 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// 22
Kita 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 number
Ada 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')// true
Fungsi 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 function
Jika 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')// undefined
Dengan 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')// 23
Jenis-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)// 23
Tipe 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')// false
Map
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// 2
Kita 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// 2
Masih 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 key
Teman-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 iterable
Karena 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
WeakMap
object is a collection of key/value pairs in which the keys are weakly referenced.
atau jika diterjemahkan dalam bahasa Indonesia kurang lebih:
Objek
WeakMap
merupakan 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'// 22
Kita 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)// 9
Fungsi 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// 0
Baris 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// 1
Studi 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'// 22
Penggunaan 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.
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.