Vue2.xのリアクティブな動きを分かりやすく説明してみる
つい最近 Vue3 がリリースされましたが、
個人で開発している 時間管理アプリ やお仕事では、まだまだ現役で Vue2 を利用しています。
せっかくなので、裏側でどういう方法でリアクティブを実現しているのか調べてみました。
今回は、data
をメインに追っていきます。
下記のサンプルコードを使って見ていきます
今回は下記のコードを使って流れを見ていきましょう。
最初は画面上に EXAMPLE
の文字列が表示され、1秒後立つと CHANGED
の文字列に変わります。
<!DOCTYPE html>
<html>
<body>
<div id="target"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script>
new Vue({
el: '#target',
data() {
return {
message: 'EXAMPLE'
}
},
mounted() {
setTimeout(() => this.message = 'CHANGED', 1000)
},
render(h) {
return h('div', this.message)
},
})
</script>
</body>
</html>
Dep
と Watch
が裏で仕事をしている
setTimeout
により、1秒後に this.message
が変更されると、画面上の表示も自動的(リアクティブに)更新されます。内部ではどのように動作しているのか気になりますね。
実は、Vueの内部にある Dep
と Watch
というオブジェクトが仕事をしています。
Dep
は変更があったことを通知する側です。
今回だと data
の message
が該当します。
Watch
は変更の通知を受け取り、何らかの処理を実行する側です。
今回だとVueの render
が該当します。
この構成はよく Observerパターン と呼ばれています。
Dep
が変更を通知して、 Watch
が表示内容更新する、というイメージですね。
Dep
はどうやって通知先の Watcher
を知るのか?
おおまかな流れは以上ですが、Dep
は一体どうやって通知先の Watcher
を知るのでしょうか。
結論からいうと、JavaScriptの ゲッター という構文と、 Dep.target
という変数が活躍しています。
ゲッターって何?
オブジェクトのプロパティを取得しようとした際に、任意の関数を実行できる便利なものです。
下記はゲッターの簡単な例です。
obj.message
を呼び出したタイミングで、message()
の関数が処理されます。
const obj = {
get message() {
console.log("CALLED");
return 'EXAMPLE';
}
}
const text = obj.message; // => 'CALLED' とコンソールに表示される
console.log(text); // => 'EXAMPLE'
data
はゲッターになっている
最初に記載したコードをもう一度見てみましょう。
<!DOCTYPE html>
<html>
<body>
<div id="target"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script>
new Vue({
el: '#target',
data() {
return {
message: 'EXAMPLE'
}
},
mounted() {
setTimeout(() => this.message = 'CHANGED', 1000)
},
render(h) {
return h('div', this.message) // <--
},
})
</script>
</body>
</html>
render(h)
のメソッドの中で this.message
を呼び出しています。
戻り値はもちろん EXAMPLE
なのですが、
実はこの this.message
プロパティはゲッターになっています。
Vueのインスタンスを生成する過程で data
はゲッター化されます。
単純に EXAMPLE
を返している訳ではなく、何らかの処理が裏で走っているのです。
では、実際にゲッターの内部で何をしているか、Vueのコードを確認してみます。
下記はVueが data
をゲッター化している箇所です。
/**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
...
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
// 注1
if (Dep.target) {
dep.depend()
...
}
return value
},
...
})
}
注1で、Dep.target
が存在すれば dep.depend()
を実行しています。
dep.depend()
を見ると...
/**
* A dep is an observable that can have multiple
* directives subscribing to it.
*/
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
...
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
}
Dep.target
の addDep
を呼んでいます。
後ほど説明するのですが、 Dep.target
には呼び出し元の Watcher
が入っています。
Watcher
の addDep
を見ます。
/**
* Add a dependency to this directive.
*/
Watcher.prototype.addDep = function addDep (dep) {
var id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
dep.addSub(this); // <--
}
}
};
dep.addSub
を呼んでいます。
また Dep
に戻って、 addSub
を見ます。
/**
* A dep is an observable that can have multiple
* directives subscribing to it.
*/
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub) // <--
}
...
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
}
Dep
の subs
という配列に Watcher
を追加しているようです。
ここの subs
というのが 「変更があったときに通知する先の Watcher
」 の配列です。
ここまでを図でまとめるとこんな感じです。
ゲッターが呼ばれたタイミングで Dep.target
を見て、
Watcher
を通知先一覧 (subs
) に追加しているのですね。
Dep.target
は何処で設定しているの?
では、Dep.target
は誰が何処で設定しているのでしょうか。
結論からいうと、Wacher
自身で設定しています。
Watcher
の get
メソッドのコードを見ます。
get () {
pushTarget(this) // 注1
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm) // 注2
} catch (e) {
...
} finally {
...
popTarget() // 注3
this.cleanupDeps()
}
return value
}
この get
メソッドは Dep
から変更の通知がきた場合や、Wacher
のインスタンス生成時など、
更新処理が必要になったタイミングで呼ばれます。
注1、注2、注3に注目します。
pushTarget
が呼ばれた後に this.getter
が呼ばれて、
最後に popTarget
が呼ばれています。
この pushTarget
と popTarget
が Dep.target
を書き換えている箇所です。
// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null
const targetStack = []
export function pushTarget (_target: Watcher) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target // <--
}
export function popTarget () {
Dep.target = targetStack.pop() // <--
}
つまり、更新処理をする前に、「今から自分が呼び出します」というのを、
Dep.target
の変数を使って Dep
に宣言しているのですね。
また、this.getter
は実際に更新処理をするメソッドです。
今回の場合だと、巡り巡って最初に記載した render(h)
が呼ばれます。
render(h) {
return h('div', this.message)
},
そして、this.message
はゲッターになっており、
Dep
は Dep.target
を見て、 呼び出し元の Watcher
を通知先の一覧に追加する...という流れです。
以上の内容を整理すると下記のイメージです。
data
に変更があった時はどう Watcher
に通知されるの?
通知先を知る方法はわかりました。
では、data
が書き換えられたら、どのようにして Watcher
へ通知されるのでしょうか。
最初に貼った画像を再度記載します。 「2. 変更を通知する」 の箇所ですね。
通知の仕組みには、今度は セッター が使われています。
セッターって何?
オブジェクトのプロパティを設定しようとした際に、任意の関数を実行できる便利なものです。
下記はセッターの簡単な例です。
obj.message
に値を代入しようとしたタイミングで、message()
の関数が処理されます。
const obj = {
set message(newMessage) {
console.log(`CALLED:${newMessage}`);
}
}
obj.message = 'CHANGED' // => CALLED:CHANGED とコンソールに表示される
data
はセッターにもなっている
data
にはゲッターに加えてセッターも設定されています。
結論からいうと、 Dep
はセッターを使って Watcher
に値の変更があったことを伝えます。
改めて、最初のコードを記載します。
<!DOCTYPE html>
<html>
<body>
<div id="target"></div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script>
new Vue({
el: '#target',
data() {
return {
message: 'EXAMPLE'
}
},
mounted() {
setTimeout(() => this.message = 'CHANGED', 1000)
},
render(h) {
return h('div', this.message)
},
})
</script>
</body>
</html>
setTimeout
で1秒後に this.message = 'CHANGED'
が実行されますが、
このタイミングでセッターの処理が走ります。
セッターでは何をしているかというと...
/**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
...
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
...
set: function reactiveSetter (newVal) {
var 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 ("development" !== 'production' && customSetter) {
customSetter();
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify(); // <--
}
})
}
最終行で、dep.notify
を呼んでいます。
dep.notify
では、
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
...
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // <--
}
}
}
subs
の配列に入っている、全ての Watcher
の update
を呼んでいます。
update
というメソッドを呼び出して、Watcher
に変更があったことを伝えているわけですね。
では、通知を受け取った Watcher
は何をしているかというと、
export default class Watcher {
...
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this) // <--
}
}
Watcher
にある、 lazy
や sync
というフラグによって、
通知を受け取った後の挙動が変わります。
Watcher
は、今回の例のようにコンポーネントの更新のほか、
computed や props の実装にも使用されています。
そのため、通知を受け取ったらすぐに更新処理を実行するか、
もしくは、 dirty
というフラグだけを付与しておいて、必要になったタイミングで更新処理を実行するなど、フラグによって更新処理のタイミングを変えることができます。
今回のコンポーネントの更新の場合、 queueWatcher(this)
が実行されます。
これは Vueのドキュメント にもある通り、一旦キューに溜めておいて、
次のイベントループでコンポーネントの更新を行います。
いずれのタイミングでも、更新処理を実行する際は Watcher
の get
が呼ばれます。
少し前に見たコードですね。
get () {
pushTarget(this)
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm)
} catch (e) {
...
} finally {
...
popTarget() // 注3
this.cleanupDeps()
}
return value
}
pushTarget
で宣言して、 this.getter
でコンポーネントを更新して、という流れが始まります。
これ以降は、「Dep.target
は何処で設定しているの?」 のセクションでお伝えした流れと同じです。
おわりに
今回は data
と コンポーネントの更新の流れを追いました。
computed
や props
の場合も、細かい違いはあるものの、大体の流れは同じになります。
この記事が誰かの参考になれば幸いです!
Discussion