🏃

【Vue.js】reactiveを実装して仕組みを理解する

2022/06/20に公開

どうもフロントエンドエンジニアのoreoです。

普段の業務でVue.jsのComposition APIを使用しており、リアクティブな変数を定義する際に、reactiverefなどを多用しています。この記事では、reactiveの仕組みについて、実装しながら理解したいと思います。今回はreactiveのみを扱いますが、今後の記事ではrefcomputedの仕組みについて解説する予定です!

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>

参考
https://vuejs.org/api/reactivity-core.html

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オブジェクトをリアクティブ化することで、pricequantityを変更すると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に保存して、任意のタイミングで呼び出せるようにします。こうすることでpricequantityを変更した際に、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オブジェクトとは、値を重複して持つことができないコレクションです。
https://zenn.dev/oreo2990/articles/5ccc8323874560

値を確認する時に、いちいちconsole.log(total)を書くのが面倒な場合は、effectconsole.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オブジェクトは、pricequantityの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オブジェクトとは、キーと値をもつデータのコレクションになります。
https://zenn.dev/oreo2990/articles/74ad47c58d299f

処理のイメージは👇になります。

quantitypricedepに処理を追加してみます。

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オブジェクトは、キーがオブジェクトでなければならいコレクションになります。
https://zenn.dev/oreo2990/articles/d290ea0f08a3e0

targetMapのイメージは👇。

また、productに紐づくdepsMapquantitypriceに処理を追加してみます。

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 tracktriggerを自動で呼び出す

Part3までは、effectの登録や実行を、いちいちtracktriggerを呼び出すことで実行していました。ここでは、ProxyとReflect(※)を用いて、自動でそれらを呼び出せるように変更したいと思います。

※ProxyとReflectに関しての詳細は👇をご覧ください
https://zenn.dev/oreo2990/articles/6e4dc6c1eb48c3
https://zenn.dev/oreo2990/articles/a28abc47bc22b5

具体的には、リアクティブにしたいオブジェクトを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オブジェクトを使うことで、tracktriggerをいちいち呼び出さなくても、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; }が、quantitydepに登録されており、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

https://github.com/vuejs/core

4 参考

https://www.vuemastery.com/

https://zenn.dev/kazuwombat/articles/d4d6b73bc9534a

https://zenn.dev/kazuwombat/articles/3cc9b504dd5999

Discussion