【Vue.js】reactiveを実装して仕組みを理解する
どうもフロントエンドエンジニアのoreoです。
普段の業務でVue.jsのComposition APIを使用しており、リアクティブな変数を定義する際に、reactiveやrefなどを多用しています。この記事では、reactiveの仕組みについて、実装しながら理解したいと思います。今回はreactiveのみを扱いますが、今後の記事ではrefやcomputedの仕組みについて解説する予定です!
1 Composition APIのreactiveとは?
1-1そもそもリアクティブとは?
ある変数が変更された際に、依存関係にある他の変数も更新されることをリアクティブといいます。
1-2 reactiveについて
Vue.jsのReactivity APIで提供されており、reactiveを使うことでオブジェクトをリアクティブ化することができます。下記のように定義されており、objetを引数にとってリアクティブなProxyオブジェクトを返します。
function reactive<T extends object>(target: T): UnwrapNestedRefs<T>
参考
2 reactiveを実装してみる
それでは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では役割が少し変わるので後述します。
2-2 Part1 effectを保存し呼び出せるようにする
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を確認すると二つの処理が格納されてます。

2-3 Part2 プロパティ毎にdepを定義する
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を保持しています。

2-4 Part3 オブジェクト毎にdepsMapを管理する
続いて、この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は、👇のようになります。

2-5 Part4 trackやtriggerを自動で呼び出す
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.tspackages/reactivity/src/reactive.tspackages/reactivity/src/effect.ts
4 参考
Discussion