Vueっぽいものを実装してComputedの仕組みを探る

作成日: 2022-10-28
VueのComputedのやや魔法っぽい、依存関係の自動導出についての仕組みを、簡単な実装で解説します。実装から効率良い更新方法が自動で求まる良い仕組みです。

フロントエンドライブラリのVue

JSのフロントエンドライブラリの一つです。Reactに押されてやや落ち目です。
JSが操作するModelを始めに定義し、ModelをTemplateが解釈して仮想DOMを構築するライブラリです。
ModelTemplate

VueのComputedとは

ModelComputedTemplate
ModelをTemplateをから扱いやすくするために、Computedは間に挟まる構造です。Computeの値はModelから自動的にComputedが計算されます。計算された値をTemplateに使用できます。
computed: {
  name() {
    return `${this.firtName} ${this.lastName}`;
  }
}
上の例では、Computedの name は、Modelの firstNamelastNameから自動で計算されます。

Computedの部分的な再計算

Modelが変更されると、Computedも変更されたModelの値を使って再計算されます。
Vueは再計算のとき、全てのComputedが再計算されるわけではありません。変更されたModelの値に影響のあるComputedだけが再計算されます。部分的な再計算によって仮想DOMも更新されるます。全体としての効率が良くなります。
ところで、この部分的な再計算はどのように実装されているのでしょう。Computedの実装では単にModelの値を直接使っているだけです。どのようにして、再計算が必要なComputedを見つけるのでしょうか。

Computedを実装してみる

Computedを実装したのがこちらの例です。ボタンを押すとテキストが変化します。
const data = {
  firstName: "Super",
  lastName: "Alice",
  age: 10,
}
const computed = {
  name(self) {
    // Modelを使ってComputedを定義
  	return `${self.data.firstName} ${self.data.lastName}`;
  },
  hello(self) {
    return `Hello, ${self.computed.name} (${self.data.age})`
  },
};
ボタンを押すと、メソッドが呼ばれて、dataが更新されます。名前を書き換えるLouderではlastName→name→helloと更新されます。歳を増やすGrowでは、age→helloと更新されます。無関係な Computedは再計算されていないことに注目しましょう。
const method = {
  louder(self) {
    // Modelを変更する
    self.data.lastName += "!";
  }, 
  grow(self) {
    self.data.age++;
  },
}
Computeクラスがdataとcomputedとmethodを処理することで、Computedを実現しています。
window.compute = new Compute(data, computed, method);
Computeクラスでは次の2つのトリックが使われています。
  • 依存グラフの自動生成
  • Proxy

依存グラフの自動生成

先程の例で、「lastName→name→hello」や、「age→hello」といった更新順を表すグラフがありました。値の依存関係によって更新順を決めたグラフです。この依存グラフを自動生成できれば、Computedは実現できそうです。
依存グラフの生成は、helloが呼ばれたときに行われています。値の中で他の値が使われるときに、使われる様子を追跡して、依存グラフを構築します
this.deps が依存を表します。 self.currentDeps が呼び出し元のリストです。
class DataValue {
  get(self) {
    if (self.currentDeps.length > 0) {
      // 最後の呼び出し元を依存として追加する。
    	this.deps.add(self.currentDeps.slice(-1)[0]);
    }
  	return this.value;
}
class ComputedValue {
  get(self) {
  	if (self.currentDeps.length > 0) {
    	this.deps.add(self.currentDeps.slice(-1)[0]);
    }
    self.currentDeps.push(this);
  	if (this.cache === undefined) {
    	this.cache = this.computed(self);
    }
    self.currentDeps.pop()
    return this.cache;
  }
}
値が更新されるたびに、作られた依存グラフに従ってComputedを再計算します。
class DataValue {
  set(self, value){
  	this.value = value;
    // モデルが更新されたときに、
    this.update(self);
  }
  update(self) {
    // 依存元を再計算する。
    this.deps.forEach(dep=>dep.update(self));
  }
}
class ComputedValue {
  update(self) {
  	this.cache = this.computed(self);
    this.deps.forEach(dep=>dep.update(self));
  }
}
では、単なるObjectの、どこに依存グラフを作るトリックが仕込まれているのでしょうか。

Objectのように振る舞うProxy

JavaScriptにはProxyという機能があります。Objectとして振る舞う値が、プロパティーの読み書きをフックして良からぬことをするためのAPIです。
Proxyによって、self.dataやself.computedはただのObjectではなくなっています。プロパティーが使われるたびに、プロパティーが誰によって使われたかを記録することで、依存グラフを構築します。のちに、this.dataのプロパティーが書き換えられると、ProxyがComputedを最計算します。
this.row_data = Object.entries(data).reduce(
	(o, [k, v]) => {return {[k]: new DataValue(v), ...o}}, {});
this.data = new Proxy(this.row_data, {
	get(t, prop) {
  	return t[prop].get(self);
  },
  set(t, prop, value) {
  	t[prop].set(self, value);
    return true;
  }
});
Proxyを使うことで、Modelを普通のObjectのように振る舞えるようになりました。素直にComputedを実装するだけで、依存グラフが得られる面白い仕掛けだと思います。