目的
Object.defineProperty
の getter/setter を使って簡易的な Vue のリアクティブシステムを実装していくことがこの記事の目的です。実装は、Vue Mastery の「Build a Reactivity System」を参考にしています。
公式ドキュメントによると...
Vue2 のリアクティブシステムは、Object.defineProperty
の getter/setter を使って実装されています。これについては Vue 公式ドキュメントの「リアクティブの探求」のセクションで触れられています。
プレーンな JavaScript オブジェクトを data オプションとして Vue インスタンスに渡すとき、Vue はその全てのプロパティを渡り歩いて、それらを Object.defineProperty を使用して getter/setter に変換します。
https://jp.vuejs.org/v2/guide/reactivity.html
Object.defineProperty
のド基礎
Object.defineProperty
の公式ドキュメントです。
ディスクリプターとは
Javascript のオブジェクトは、値(value)以外に詳細設定を持っています。詳細設定は、ディスクリプターと呼ばれており、例えば、書き換え可能なオブジェクトなのかどうか、などがあります。
// ふつうのオブジェクト。値以外に詳細設定を持つ。
const obj = { name: 0 };
ディスクリプターは、getOwnPropertyDescriptor
で確認することができ、ふつうにオブジェクトを定義した場合のデフォルト値はtrue
になります。例えば、書き換え可能なオブジェクトかどうかを表すディスクリプターはwritable
であり、以下の例は、true
なので書き換え可能である、ということになります。
const obj = { name: 0 };
const descriptor = Object.getOwnPropertyDescriptor(obj, 'name')
console.log(descriptor)
// {value: 0, writable: true, enumerable: true, configurable: true}
defineProperty
で定義した場合は、ふつうにオブジェクトを定義した場合の挙動と異なり、デフォルト値はfalse
になります。
const obj = {};
Object.defineProperty(obj, 'name', {
value: 43
})
console.log(obj)
Object.getOwnPropertyDescriptor(obj, 'name')
// {value: 43, writable: false, enumerable: false, configurable: false}
getter/setter もディスクリプター
getter/setter は、オプショナルなディスクリプターで、デフォルト値はundefined
なので、使いたい場合は追加で定義する必要があります。
const obj = { _name: 'qiita' };
Object.defineProperty(obj, 'name', {
get: function() {
return this._name;
},
set: function (val) {
this._name = val
}
})
obj.name
// "qiita"
obj.name = 'twitter'
// "twitter"
obj.name
// "twitter"
obj.name
では、getter
経由でqiita
という戻り値があり、obj.name = 'twitter'
では、setter
経由でtwitter
を書き換えています。
ここらへんから、けっこう Vue のリアクティブなコードの記法と一緒なので、これが Vue のリアクティブシステムのコアであると言われてるとわりとすんなり理解できる気がしています。
変更が追跡できないパターン
Object.defineProperty
の getter/setter によってリアクティブシステムは実装されているため、 getter/setter のリアクティブの限界が Vue のリアクティブの限界でもあります(一部は Vue が補っている部分もあるため、完全なイコールではないです)。
例えば、配列への要素の追加の変更は追跡できないため、リアクティブになりません。
var vm = new Vue({
data: {
items: ['a', 'b', 'c']
}
})
vm.items[1] = 'x' // is NOT reactive
vm.items.length = 2 // is NOT reactive
リアクティブシステムを Javascript で実装する
目的地
すごくシンプルなprice
とquantity
の乗算によって、totalPrice
を算出する Vue のコードをObject.defineProperty
の getter/setter を使って実装していきます。
以下は、すごくシンプルな Vue のコードです。これと同じことを Javasciript で実装します。
<div id="app">
<div>Price: ${{ price }}</div>
<div>Total: ${{ totalPrice }}</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
var vm = new Vue({
el: '#app',
data: {
price: 5.00,
quantity: 2
},
computed: {
totalPrice() {
return this.price * this.quantity
}
}
})
</script>
実装する ~その1~
まずは、オブジェクトの定義から書いていきます。
let data = { price: 5, quantity: 2 }
次は、price
あるいはquantity
の値が変更されたときの再計算の関数を定義します.
target
が呼ばれ、data.total
の再計算が行われます。
let target = null
target = function () {
data.total = data.price * data.quantity
}
次にdata
に対して、getter/setter を設定します。
let internalValue = data.price
Object.defineProperty(data, 'price', {
get() {
return internalValue
},
set(newVal) {
internalValue = newVal
target()
}
})
現時点の動作を確認すると、data.price = 10
の実行により、setter が呼ばれ、その中でtarget()
が実行されることで、data.total
が更新されています。
data.total
// undefined
data.price = 10
// 10
data.total
// 20
ここまで実装すれば、リアクティブの実装の仕組みは理解できたはずです。
実装する ~その2~
このコーディングには以下の2点の問題があります。
- quantity のリアクティブの実装はまだしていない
- コードに柔軟性が足りない
quantity
の getter/setter を定義したいですが、もう一つdefineProperty
を書くのはナンセンスです。また、今はprice
の値が変更されればtarget()
を実行することを決め打ちで書いているので、set()
に直接、target()
を書いていますが、他の関数にしたいなどの要望があると追加しづらいです。
そのため、次は、依存関係を管理するクラスを定義します。
ようは、data.price
の値が書き換えられたら、target()
を実行する必要がある、という依存関係を管理します。
Dep
クラスを定義し、値が変更されたときに実行する関数(依存関係)をdepend()
によってsubscribers
リストに格納しています。これはあくまでも依存関係を取得しているのみであり、実際に値が変更されたときは、notify()
によって、実行する関数を呼びます。
class Dep {
constructor () {
this.subscribers = []
}
depend() {
if (target && !this.subscribers.includes(target)) {
this.subscribers.push(target)
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
data
のキーが複数でも問題ないようにObject.keys
での実装に書き換えています。また、get()
でdep.depend()
を実行することで、依存関係を取得し、値が書き換えられるset()
でdep.notify()
を実行することで、実行する関数を読んでいます。
Object.keys(data).forEach(key => {
let internalValue = data[key]
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
dep.depend()
return internalValue
},
set(newVal) {
internalValue = newVal
dep.notify()
}
})
})
最後にwatcher()
を定義し、ここでやっとtarget()
は実行されます。watcher()
でラップしているのは、target()
以外の関数でもいいようにです。
function watcher(myFunc) {
target = myFunc
target()
target = null
}
watcher(target)
最終成果物
実行結果。
data.price = 50
// 50
data.total
// 100
data.quantity = 100
// 100
data.total
// 5000
コード。
let data = { price: 5, quantity: 2 }
let target = null
target = function () {
data.total = data.price * data.quantity
}
class Dep {
constructor () {
this.subscribers = []
}
depend() {
if (target && !this.subscribers.includes(target)) {
// Only if there is a target & it's not already subscribed
this.subscribers.push(target)
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
Object.keys(data).forEach(key => {
let internalValue = data[key]
const dep = new Dep()
Object.defineProperty(data, key, {
get() {
dep.depend()
// console.log('i was assessed', internalValue)
return internalValue
},
set(newVal) {
// console.log('i was changed', internalValue, newVal)
internalValue = newVal
dep.notify()
}
})
})
function watcher(myFunc) {
target = myFunc
target()
target = null
}
watcher(target)
まとめ
defineProperty
の基礎から、簡単な Vue のリアクティブの実装をしました。
かなりシンプルですが、Vue のリアクティブシステムの実装ができたかと思います。
Vue3 からは、リアクティブの実装はProxy
オブジェクトを使用されているため、少し挙動は変わるようなので、次はそっちも見ていきたいです。