【Vue.js】reactiveを実装して仕組みを理解する
どうもフロントエンドエンジニアのoreoです。
普段の業務でVue.jsのComposition APIを使用しており、リアクティブな変数を定義する際に、reactive
やref
などを多用しています。この記事では、reactive
の仕組みについて、実装しながら理解したいと思います。今回はreactive
のみを扱いますが、今後の記事ではref
やcomputed
の仕組みについて解説する予定です!
reactive
とは?
1 Composition APIの1-1そもそもリアクティブとは?
ある変数が変更された際に、依存関係にある他の変数も更新されることをリアクティブといいます。
reactive
について
1-2 Vue.jsのReactivity APIで提供されており、reactive
を使うことでオブジェクトをリアクティブ化することができます。下記のように定義されており、objet
を引数にとってリアクティブなProxyオブジェクトを返します。
function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
参考
reactive
を実装してみる
2 それではreactive
を実装したいと思います。まずは、👇のようなJavaScriptのコードを考えます。
let product = { price: 5, quantity: 2 };
let total = product.price * product.quantity;
console.log(total); //10が出力
product.price = 100;
console.log(total); //10が出力。100×2=200にならない!!
このままではprice
を更新しても、total
の値は更新されません。このコードを段階的に改良し、product
オブジェクトをリアクティブ化することで、price
やquantity
を変更するとtotal
も更新されるようにしたいと思います。
この記事で扱うのは、Proxyを用いたVue3での方法となっています。Vue2での実装方法と異なるのでご注意ください。
2-1 用語の解説
コード内で使用している変数名・関数名とそれらの役割を以下にまとめておきます。混乱しやすいと思いますが、必要に応じて、この表をご参照いただければと思います。なお、これらは実際にVue3で使われている用語になります。
用語 | 役割 |
---|---|
effect | 値の変更を検知して実行する処理を格納する変数 |
dep | effectを格納するSetオブジェクト |
depsMap | プロパティとdepを紐づけるMapオブジェクト |
targetMap | オブジェクトとdepsMap紐づけるWeakMapオブジェクト |
track | depにeffectを格納する関数 |
trigger | depに格納されているeffectを実行する関数 |
※👆はPart1~4までの役割になります。Part5では役割が少し変わるので後述します。
effect
を保存し呼び出せるようにする
2-2 Part1 Part1では、total
の算出処理(effect
)をdep
に保存して、任意のタイミングで呼び出せるようにします。こうすることでprice
やquantity
を変更した際に、trigger
を実行することで、total
に変更を反映させることができます。
let product = { price: 5, quantity: 2 };
let total = 0;
//合計金額を算出する処理(値の変更を検知して実行する処理)
let effect = () => {
total = product.price * product.quantity;
};
//effectを保存するsetオブジェクト
let dep = new Set();
//effectをdepに保存する関数
function track() {
dep.add(effect);
}
//depに格納されているeffectを実行する関数
function trigger() {
dep.forEach((effect) => effect());
}
//depにeffectを保存
track();
//depに格納されているeffectを実行(合計金額を算出)
trigger();
console.log(total); //10が出力
//priceを更新してみる
product.price = 8;
trigger();
console.log(total); //16が出力!!!!!!!
処理の流れのイメージは、👇のようになります。
ポイントは、effect
を、set
オブジェクトであるdep
に格納していることです。setオブジェクトにeffect
を格納することで、同じ処理が格納されることを防げます。
※setオブジェクトとは、値を重複して持つことができないコレクションです。
値を確認する時に、いちいちconsole.log(total)
を書くのが面倒な場合は、effect
にconsole.log(total)
を登録し、track()
でdep
に処理を保存すると、trigger()
を実行した際にconsoleへの出力も可能になります。
effect = () => {
console.log(total);
};
//depに新たなeffectを保存
track();
product.price = 100;
trigger(); //200が出力。いちいちconsole.logを書かなくてよくなる
この時の処理のイメージは👇になります。
実際にdep
を確認すると二つの処理が格納されてます。
dep
を定義する
2-3 Part2 プロパティ毎にPart1では、dep
に格納したeffect
を、必要な時にtrigger()
を呼び出すことで実行できるようになりました。しかし、リアクティブにしたいproduct
オブジェクトは、price
とquantity
の2つのプロパティを持つため、それぞれのプロパティ毎にdep
を定義できるようにします。
//depとプロパティを紐づけるMapオブジェクト
const depsMap = new Map();
//key(対象プロパティ)に対応するdepを作成し、effectを登録する関数
function track(key) {
//keyに対応するdepを取り出す
let dep = depsMap.get(key);
//depsMapにdepがない場合は、keyに対応するdepを登録
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
//depにeffectを登録する
dep.add(effect);
}
//key(対象プロパティ)に対応するdepが存在すれば、dep内の処理を実行する関数
function trigger(key) {
//keyに対応するdepを取り出す
let dep = depsMap.get(key);
//keyに対応するdepが存在すれば,dep内の処理を実行
if (dep) {
dep.forEach((effect) => {
effect();
});
}
}
let product = { price: 5, quantity: 2 };
let total = 0;
let effect = () => {
total = product.price * product.quantity;
};
effect(); //totalを10に更新しておく
//quantityプロパティのdepに、effectを登録
track("quantity");
//quantityを更新して、trigger関数でquantityに紐づくdepを実行
product.quantity = 3;
trigger("quantity");
console.log(total); //15が出力
ポイントは、プロパティとdep
を紐付けるために、Mapオブジェクト(※)であるdepsMap
を定義していることです。depsMap
を定義することで各プロパティとdep
を紐付けることができます。
※Mapオブジェクトとは、キーと値をもつデータのコレクションになります。
処理のイメージは👇になります。
quantity
とprice
のdep
に処理を追加してみます。
effect = () => {
console.log("quantityのdepを実行");
};
track("quantity"); //quantityのdepに処理を追加
effect = () => {
total = product.price * product.quantity;
console.log("priceのdepを実行");
};
track("price"); //priceのdepに処理を追加
イメージは👇。
この時のdepsMap
は、👇のようになりプロパティ毎にdep
を保持しています。
depsMap
を管理する
2-4 Part3 オブジェクト毎に続いて、このPartではオブジェクト毎にdepsMap
を管理できるようにしたいと思います。
//オブジェクトとdepsMapを紐づけるWeakMapオブジェクト
const targetMap = new WeakMap();
//target(対象オブジェクト)のkey(対象プロパティ)に、effectを登録する関数
function track(target, key) {
// targetに紐づくdepsMapを取得
let depsMap = targetMap.get(target);
// targetに紐づくdepsMapがなければ作成する。
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
//keyに紐づくdepを取得
let dep = depsMap.get(key);
// keyに紐づくdepがなければ作成する。
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
//depにeffectを登録する
dep.add(effect);
}
//target(対象オブジェクト)のkey(対象プロパティ)に対応するdepが存在すれば、dep内の処理を実行する関数
function trigger(target, key) {
// targetに紐づくdepsMapを取得
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
//keyに紐づくdepを取得して、存在すれば実行する
let dep = depsMap.get(key);
if (dep) {
dep.forEach((effect) => {
// run them all
effect();
});
}
}
let product = { price: 5, quantity: 2 };
let total = 0;
let effect = () => {
total = product.price * product.quantity;
};
effect(); //totalを10に更新しておく
console.log(total);
//productオブジェクトのquantityプロパティにeffectを登録
track(product, "quantity");
//quantityを更新して、trigger関数でquantityに紐づくdepを実行
product.quantity = 3;
trigger(product, "quantity");
console.log(total); //15が出力
ポイントは、各オブジェクトとdepsMap
を紐付けるために、WeakMapオブジェクト(※)を用いたtargetMap
を使います。
※WeakMapオブジェクトは、キーがオブジェクトでなければならいコレクションになります。
targetMap
のイメージは👇。
また、product
に紐づくdepsMap
のquantity
とprice
に処理を追加してみます。
effect = () => {
console.log("quantityのdepを実行");
};
track(product, "quantity");
effect = () => {
total = product.price * product.quantity;
console.log("priceのdepを実行");
};
track(product, "price");
console.log(targetMap);
イメージは👇になります。
この時のtargetMap
は、👇のようになります。
track
やtrigger
を自動で呼び出す
2-5 Part4 Part3までは、effect
の登録や実行を、いちいちtrack
やtrigger
を呼び出すことで実行していました。ここでは、ProxyとReflect(※)を用いて、自動でそれらを呼び出せるように変更したいと思います。
※ProxyとReflectに関しての詳細は👇をご覧ください
具体的には、リアクティブにしたいオブジェクトをproxyオブジェクトに変換し、ハンドラーのgetトラップにtrack
を、setトラップにtrigger
をそれぞれ差し込みます。
const targetMap = new WeakMap();
function track(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(effect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let dep = depsMap.get(key);
if (dep) {
dep.forEach((effect) => {
effect();
});
}
}
//リアクティブにしたいオブジェクトをproxyオブジェクトにして返す関数
function reactive(target) {
//new Proxyに渡すハンドラーの定義
const handlers = {
get(target, key, receiver) {
let result = Reflect.get(target, key, receiver);
//trackを実行
track(target, key);
return result;
},
set(target, key, value, receiver) {
let oldValue = target[key];
let result = Reflect.set(target, key, value, receiver);
//setが成功してかつ値が変更されていればtriggerを実行
if (result && oldValue != value) {
trigger(target, key);
}
return result;
},
};
return new Proxy(target, handlers);
}
let product = reactive({ price: 5, quantity: 2 });
let total = 0;
var effect = () => {
total = product.price * product.quantity;
};
effect(); //totalを10に更新しておく
product.quantity = 3;
console.log(total); //trackやtriggerを呼び出さなくても、15が出力!!!!!!!
Proxyオブジェクトを使うことで、track
やtrigger
をいちいち呼び出さなくても、effect
を実行することができるようになりました。
しかし、このコードにはバグがあります。例えば下記のように、salePrice
を定義し、salePrice
を再計算するeffectを定義したとします。
let product = reactive({ price: 5, quantity: 2 });
let total = 0;
let salePrice = 0;
var effect = () => {
total = product.price * product.quantity;
};
effect(); //totalを10に更新しておく
effect = () => {
salePrice = product.price * 0.9;
};
product.quantity = 3;
console.log(total); //15が出力
この時のtargetMap
を覗いて見ると、quantity
と依存関係がない() => { salePrice = product.price * 0.9; }
が、quantity
のdep
に登録されており、quantity
がsetトラップで更新された時に不要な処理まで実行してしまいます。
2-6 Part5 完成形
最後に、Part4でのバグを修正し、プロパティと依存関係にある処理が適切に紐づくようにしたいと思います。
具体的には、activeEffect
を定義し、effect
関数でactiveEffect
への処理の保存・実行・resetを行い、activeEffect
経由でtrack
することで、プロパティと依存関係にある処理が適切に紐づけることができます。
const targetMap = new WeakMap();
//依存関係にある処理を保持するactiveEffect
let activeEffect = null;
function track(target, key) {
//activeEffectが存在するときに登録するようにする。
if (activeEffect) {
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(activeEffect);
}
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let dep = depsMap.get(key);
if (dep) {
dep.forEach((effect) => {
effect();
});
}
}
function reactive(target) {
const handler = {
get(target, key, receiver) {
let result = Reflect.get(target, key, receiver);
track(target, key);
return result;
},
set(target, key, value, receiver) {
let oldValue = target[key];
let result = Reflect.set(target, key, value, receiver);
if (result && oldValue != value) {
trigger(target, key);
}
return result;
},
};
return new Proxy(target, handler);
}
//eff(コールバック関数)を受け取り、activeEffectとして実行して、最後にnullでリセットする
function effect(eff) {
activeEffect = eff;
activeEffect();
activeEffect = null;
}
let product = reactive({ price: 5, quantity: 2 });
let salePrice = 0;
let total = 0;
effect(() => {
total = product.price * product.quantity;
});
effect(() => {
salePrice = product.price * 0.9;
});
product.quantity = 3;
console.log(total); //15が出力
console.log(salePrice); //4.5が出力
product.price = 10;
console.log(total); //30が出力
console.log(salePrice); //9が出力
targetMap
を確認すると依存関係が適切に反映される様になりました!
これでreactive
を実装することができました!!イメージが湧きづらい部分もあるかと思うので、実際に手を動かして理解を深めていただければと思います!
また、Part5では、「2-1 用語の解説」で記載した用語とその役割が少し変わるので最終版を記載しておきます。必要に応じてご参照ください。
用語 | 役割 |
---|---|
activeEffect | 値の変更を検知して実行する処理を格納する変数 |
effect | 引数に渡されたコールバック関数をactiveEffectに代入し、実行とresetを行う関数 |
dep | activeEffectを格納するSetオブジェクト |
depsMap | プロパティとdepを紐づけるMapオブジェクト |
targetMap | オブジェクトとdepsMap紐づけるWeakMapオブジェクト |
track | depにactiveEffectを格納する関数 |
trigger | depに格納されているactiveEffectを実行する関数 |
3 最後に
実際のVue3のソースコードでは、この記事のように単純化されていませんが、基本的な考え方は学べたかと思います。これからも適当にフレームワークを使うのではなく、その内部処理がどうなっているのか積極的に調べたいと思います。次回の記事では、ref
を実装したいと思います(次次回はcomputed
です)。
なお、Vue3のソースコードで、今回実装したような処理が記載されているのは下記になりますので、興味のある方は覗いてみてください!
packages/reactivity/src/baseHandlers.ts
packages/reactivity/src/reactive.ts
packages/reactivity/src/effect.ts
4 参考
Discussion