Vue.jsのドキュメントには以下のような記載があります。
プレーンな JavaScript オブジェクトを data オプションとして Vue インスタンスに渡すとき、Vue.js はその全てのプロパティを渡り歩いて、それらを Object.defineProperty を使用して、getter/setter に変換します。これは ES5 だけのシム (shim) ができない機能で、Vue.js が IE8 以下をサポートしない理由です。
Object.defineProperty メソッドを通してVue.jsのリアクティビティは実現されている、という内容です(ここでの「リアクティビティ」とは、モデルのデータ変更がリアルタイムでビューに伝播する仕組みのことをいいます)。
では、Vue.jsは Object.defineProperty
をどのように使ってリアクティビティを実現しているのでしょうか?
Vue.jsのソースコードを覗く
Vue.jsでは、以下のようにdataプロパティにオブジェクトを登録すると、そのオブジェクトのプロパティはリアクティブになります。
const vm = new Vue({
el: '#app',
data: {
message: 'Hello, World',
}
});
// ここではvm.messageはリアクティブ
vm.message = 'Hello, Vue.js!';
これを実現するのは、Vue.jsの内部APIにあるinitData
関数です。initData
関数はVueのコンストラクタ内で呼ばれます。
function initData (vm: Component) {
let data = vm.$options.data
// (中略)
// observe data
observe(data, true /* asRootData */)
}
ここでobserve
関数が使われています。この関数は、以下のようにObserver
オブジェクトを生成します。
export function observe (value: any, asRootData: ?boolean): Observer | void {
// valueの型に応じた処理等があるが、諸々省略すると以下のようになる
return new Observer(value)
}
さらにObserver
オブジェクトのコンストラクタをたどると、defineReactive
という関数を使っていることがわかります。核心に近づいてきました!
// リアクティビティに関係ある部分のみ抜粋
export class Observer {
constructor (value: any) {
this.walk(value)
}
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
ここでは、Observer.walk
メソッドの中で、渡されたオブジェクトのキーを走査している点に注目です。先ほどのサンプルコードでは { data: { message: 'Hello, World' } }
というdataプロパティを定義しましたが、この場合、defineReactive
関数は以下のように呼び出されます。
const data = { message: 'Hello, World' };
defineRaactive(data, 'message', 'Hello, World');
下記がdefineReactive
関数の実装です。複雑ですが、結局やっていることは、Object.defineProperty
メソッドを使って、第1引数のオブジェクトに、第2引数の名前のプロパティを設定しているだけです。
/**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
}
if (Array.isArray(value)) {
dependArray(value)
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
リアクティビティにおいて特に重要なのは、値がセットされた時の動作、つまりsetterです。ここから先は、さらに値をDOM(に限らず、値の変更を監視しているオブジェクト)に通知する仕組みがありますが、省略します。
まとめると、以下のように呼び出しが行われて、最終的にリアクティブプロパティが設定されます。
new Vue()
initData()
observe()
new Observe()
defineReactive()
自作してみる
概略はわかりましたが、具体的にどういう実装になっているのかが若干わかりづらいと思います。そこで、同等の仕組みを自作してみましょう。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>defineProperty</title>
</head>
<body>
<h1 id="message"></h1>
<input type="text" id="input">
<script>
// 下準備
let message = 'Hello, World!';
const data = {};
const h1 = document.getElementById('message');
const input = document.getElementById('input');
h1.textContent = input.value = message;
// リアクティブプロパティの定義
Object.defineProperty(data, 'message', {
get() {
return message;
},
set(newVal) {
message = newVal;
h1.textContent = message;
}
});
// 入力値をdataオブジェクトに伝播
input.addEventListener('input', (ev) => {
data.message = ev.target.value;
});
</script>
</body>
</html>
このソースコードを動かすと、以下のように、入力値が即座に反映されるようになります。Vue.jsのdata
とv-model
を使った時の動作と同じですね!
JSFiddle にもソースコードを置いてあるので、遊んでみてください。
ちなみに、setで message = newVal
ではなく date.message = newVal
にしたらどうなるか…。興味のある人は試してみてください(ブラウザがクラッシュする恐れがあるので注意!)。