猿でも分かる!Vueのリアクティブシステム
Vueのリアクティブシステムを理解する
皆さん、Vue.jsを使っていますか?個人的には結構好きでよく使っているのですが、あのリアクティブ性はどうやって実現しているのか気になりませんか?本記事ではVue2系のリアクティブシステムの原理を簡単に説明していきます。
フロントは流行り廃りの激しい分野だからこそ、主要技術がどうやって実現しているのかを知ることが今後にも生きるはずです。
今回使用したコード(sandbox)を記事最下部に貼っておきます。
今回の設定
item
オブジェクトには価格price
と量quantity
プロパティがあります。
それらの総額に相当するtotal
プロパティをitemオブジェクトの変更を検知して自動的に自動的に再計算されて欲しい。
vue.js的に書くとこんな感じ。
<script>
export default {
data() {
item: {
price: 100,
quantity: 3,
}
},
computed: {
total() {
return this.item.price * this.item.quantity; // -> 300
}
}
}
</script>
(vueの原理を解説するので以降はバニラjsで説明します。やりたいことは変わりません。)
もしリアクティブ性がないと...
item.quantity
に5を代入しても、totalが再計算されないので300のままになってしまいます。
これが自動的に500になるようにしたい。
const item = {
price: 100,
quantity: 3
};
let total: number = 0;
const calcTotal = () => (total = item.price * item.quantity);
calcTotal();
console.log(total); // -> 300
item.quantity = 5;
console.log(total); // -> 300(これが500になって欲しい)
どんな機能が必要か
上の例から、リアクティブ性というのは、
プロパティが変更されたらそれを検知してそのプロパティが使用されている関数を自動的に再計算すること
と言い換えることができます。つまり、これを実現するには
- プロパティ毎にそのプロパティがどんな関数で使われているかを保存しておく
- そのプロパティの更新時に関連する関数を全て再計算する
機能があれば良いわけです。どうですか?なんだかできそうな気がしてきませんか?
quantity
に対してこれを実装してみる
const item = {
price: 100,
quantity: 3
};
let total: number = 0;
const storageForQuantity: Function[] = [];
const record = (func: Function) => {
storageForQuantity.push(func);
};
const reCalculate = () => {
storageForQuantity.forEach((func) => func());
};
const calcTotal = () => (total = item.price * item.quantity);
calcTotal(); // 初期totalの計算
record(calcTotal); // calcTotalはquantityに依存しているのでこの関数をstorageに保存
console.log(total); // -> 300
item.quantity = 5;
reCalculate(); // quantityを変更したのでstorage内部の関数を全て再実行
console.log(total); // -> 500
まだ手動で再計算をしていますが、なんとなく処理の流れは掴めたでしょうか。
-
item.quantity
に対して、依存関数を入れておく -
item.quantity
が変更されたら、依存関数を全て再計算する
では、上の機能をDep
というクラスにまとめてみましょう!(Dep: Dependancy)
Dep
クラスの導入
vueの内部で使われている命名と合わせるために、先程とは名前を変えていますが、機能は変わりません!
-
storageForQuantity
->subscribers
: 依存関数を保存する配列 -
record
->depend
: 依存関数を追加するメソッド -
reCalculate
->notify
: 依存関数を再計算するメソッド
また、target
という変数を導入しました。dependする時にtargetの中に入っている関数を依存関数として追加します。
(これによってdependが副作用のある関数になってしまうのですが、こうしないと後々困るので甘んじて受け入れてください...本家vueもこのやり方です...)
let target: Function = () => {}; // 依存関数を保存する際の対象関数を入れておく変数
interface Dep {
subscribers: Function[];
}
class Dep {
constructor() {
this.subscribers = [];
}
depend() {
if (target && !this.subscribers.includes(target)) {
this.subscribers.push(target);
}
}
notify() {
this.subscribers.forEach((func) => func());
}
}
const item = {
price: 100,
quantity: 3
};
let total: number = 0;
const calcTotal = () => (total = item.price * item.quantity);
target = calcTotal; // quantityの依存先をtargetに入れておくことで後でdependできる
calcTotal(); // 初期totalの計算
const dep = new Dep();
dep.depend();
console.log(total); // -> 300
item.quantity = 5;
dep.notify();
console.log(total); // -> 500
そろそろ手動で依存関数を追加したり再実行するのに飽きてきましたね...次のセクションではこの部分を自動化したいと思います。
Object.definePropertyの導入
Object.defineProperty
というメソッドを使います。
詳細はMDNドキュメントを見ていただきたいですが、このメソッドはオブジェクトのプロパティを宣言する際にgetter, setterを定義できるというものです。
詳しくはこちら
これを用いて、
get() {
depend(`プロパティを呼び出した関数`) // 依存関数を保存
return `プロパティの値`
}
set(value) {
`value`をセット
notify() // 依存関数を再計算
}
のように書けば、
- 値を参照した(
item.quantity
)時にgetter
が呼ばれて関数をsubscribes
に保存 - 値を変更した(
item.quantity = 5
)時にsetter
が呼ばれて依存関数を全て再計算
することができます。これを実装すればほぼ完成です!!
let target: Function = () => {}; // 依存関数を保存する際の対象関数を入れておく変数
interface Dep {
subscribers: Function[];
}
class Dep {
constructor() {
this.subscribers = [];
}
depend() {
if (target && !this.subscribers.includes(target)) {
this.subscribers.push(target);
}
}
notify() {
this.subscribers.forEach((func) => func());
}
}
const item = {
price: 100,
quantity: 3
};
let total: number = 0;
const calcTotal = () => (total = item.price * item.quantity);
// itemのそれぞれのプロパティに対してDepを作成し、getterではdepend, setterではnotifyをする
Object.keys(item).forEach(key => {
let _value = item[key];
const dep = new Dep();
Object.defineProperty(item, key, {
get() {
dep.depend();
return _value;
},
set(value) {
_value = value
dep.notify()
}
})
})
target = calcTotal; // quantityの依存先をtargetに入れておくことで後でdependできる
calcTotal(); // 初期totalの計算
console.log(total); // -> 300
item.quantity = 5;
console.log(total); // -> 500 (quantityを書き換えただけなのに!!!!)
最後を見てください!!!
item.quantity
を書き換えただけなのにも関わらず自動的にtotal
が更新されました🎉
これにてリアクティブ性の獲得を達成することができました。
最後はもう少し処理を抽象化します。
watcherの追加(おまけ)
前回まではtarget
に依存関数を入れてから関数を実行して...という処理を行なっていました。しかし、これは関数が複数になった場合に大変なことになってしまいます。
そこで、関数群をまとめて上記の処理を行なってくれるwatcher
関数を作成します。
今回はitem.taxRate
を追加し、税込みの総額を入れておくtotalWithTax
もリアクティブな変数にしてあります。
おまけ程度に、オブジェクトを渡すとリアクティブにしてくれるinitData
関数も作成しました。
let target: Function | null = null; // 依存関数を保存する際の対象関数を入れておく変数
interface Dep {
subscribers: Function[];
}
class Dep {
constructor() {
this.subscribers = [];
}
depend() {
if (target && !this.subscribers.includes(target)) {
this.subscribers.push(target);
}
}
notify() {
this.subscribers.forEach((func) => func());
}
}
const watcher = (funcObj: Record<string, Function>) => {
Object.values(funcObj).forEach((func) => {
target = func;
func();
target = null;
});
};
const initData = (dataObj: Object) => {
Object.keys(dataObj).forEach((key) => {
let _value = dataObj[key];
const dep = new Dep();
Object.defineProperty(dataObj, key, {
get() {
dep.depend();
return _value;
},
set(value) {
_value = value;
dep.notify();
}
});
});
}
const item = {
price: 100,
quantity: 3,
taxRate: 1.1,
};
let total = 0;
let totalWithTax = 0;
const calcTotal = () => (total = item.price * item.quantity);
const calcTotalWithTax = () => (totalWithTax = item.price * item.quantity * item.taxRate);
// itemをリアクティブに
initData(item)
// vueで言うところのcomputedのような関数をwatcherに渡す。
watcher({
calcTotal,
calcTotalWithTax,
})
console.log(`total: ${total}, totalWithTax:${totalWithTax}`); // -> 300
// -> total: 300, totalWithTax:330
item.quantity = 5;
console.log(`total: ${total}, totalWithTax:${totalWithTax}`); // -> 300
// -> total: 500, totalWithTax:550
おわりに
ここまで読んでくださりありがとうございます!
Vueのリアクティブシステムがどのように実現されているかを説明しました。実際のvueもこのような実装になっているので興味がある方は覗いてみると面白いと思います!(vueのレポジトリ内でdepend()
やnotify()
で検索すると出てくると思います)
Vue3系のリアクティブシステムはProxy
を使っているらしく、こちらも勉強したら記事を書こうと思います。
少しでも参考になったという方はいいね!お願いします!
Discussion