🐥

手を動かして理解するVue3 Reactivityの仕組み 前編

2021/03/21に公開

はじめに

まずは以下のようなシンプルなVueのコードの断片をご覧ください。

<template>
  <p>{{ total }}</p>
  <button @click="quantity = 3">set quantity = 3</button>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      price: 10,
      quantity: 2,
    };
  },
  computed: {
    total() {
      return this.price * this.quantity;
    },
  },
};
</script>

ここでボタンを押すとcomputed properyによって、totalが更新され数字が20→30に変わります。

ではVueは内部的にどのようにしてtemplateのtotalに値を反映しているのでしょうか?

この記事ではVue3がどのようにこのReactivity(ある値の変更に応じて別の値を書き換える)を達成しているかについて実際に手を動かして実装しながら説明していきます。(※本記事で説明する仕組みはVue3のもので、Vue2におけるReactiveの仕組みとは異なるのでご注意ください。)

読者の方もぜひ自分で実装しながら読み進めてみてください。
空のjsのcodesandboxはこちら
前半終了時点での最終コードはこちら

また、この記事はこちらの動画を大いに参考にしてるので、英語の動画が問題ない方はぜひこちらをみてみることをお勧めします。

VueのReactivityの基本となる仕組みと用語

まず、以下のコードを見てください。
当たり前ですがquantityを変えてもtotalの値は変更されません。

let price = 200;
let quantity = 3;

let total = price * quantity;
console.log("変更前:", total); // => 600

// quantityを変更
quantity = 4;

// 当たり前だが変更されない
console.log("変更後:", total); // => 600

さてこのtotalをReactive、つまりquantity(またはprice)が変更されたらtotalの値が更新されるようにするにはどのようにすれば良いでしょうか?

実際に実装に入る前に基本となる仕組みと用語についてわかっておくと理解しやすくなるので、先にご説明します。
まず以下のようにquantityへの依存を保存できる領域を用意します。これをVue内ではdepと呼びます。

次に再計算させたいtotal=price*quantityをquantityのdepに登録します。このdepへの依存の追加をVue内ではtrackと呼び、コードブロック自体をeffectと呼びます。

quantityの値が変更されるとdep内にある全てのeffectを再度実行して値を更新します。この値の更新をVueではtriggerと呼びます。

いきなり用語がたくさん出てきたので改めて整理してみます。

用語 意味
dep 値が変更されたら、再計算させたいコードブロックの集合
effect 再計算させたいコードブロック
track depにeffectを登録すること
trigger dep内にある全てのeffectを再計算すること

これらの用語は実際のVueの実装に使われているものなので、覚えておいて損はないかと思います。

単純なReactiveを実装してみる

上記を踏まえて、単純なReactiveの仕組みを実装したのが以下です。
実行するとtotalの値が更新されているのが分かります。

// 変数定義
let price = 200;
let quantity = 3;
// totalの初期値は600 = 200 * 3
let total = price * quantity;

// totalを計算するeffect
const totalEffect = () => {
  total = price * quantity;
};

// dep
let quantityDep = new Set();

// track関数
const track = (effect) => {
  quantityDep.add(effect);
};
// trigger関数
const trigger = () => {
  quantityDep.forEach((effect) => effect());
};

// trackを実行してquantityDepにeffectを追加
track(totalEffect);

// 値段を変更
quantity = 4;

// triggerを実行してeffectを全て更新
trigger();

// totalが800 = 200 * 4に更新される
console.log(total);

詳しくみていきましょう。

必要な変数、関数の定義

まず、際実行させたいコードブロックであるtotalEffectを定義します。

const totalEffect = () => {
  total = price * quantity;
};

次にquantityのdepであるquantityDepを定義します。
これはquantityが変更されたら再実行されるコードブロックの集合であり、Setを用いて定義します。
Setを用いているのは同じ値を複数回登録できないようにするためです。

let quantityDep = new Set();

さらにquantityDepのtrack関数、trigger関数を定義します。

const track = (effect) => {
  quantityDep.add(effect);
};

const trigger = () => {
  quantityDep.forEach((effect) => effect());
};

実行

これで準備が完了したのであとは実行するだけです。
まず、track関数を使ってtotalEffectquantityDepに追加します。

track(totalEffect);

次にquantityの値を変更します。

quantity = 4;

値が変更されたのでtrigger関数を呼びます。

trigger();
console.log("after:", total); // 800に更新された🔥

見事totalの値が更新されました!

複数のプロパティに対応する

前項の実装ではquantityプロパティにしか対応できていなかったので、複数プロパティを持ったオブジェクトにも対応できるようにします。
複数プロパティに対応するには、プロパティそれぞれにdepを作って、オブジェクトのプロパティが更新されたらそのプロパティに紐づくtriggerを実行して、depのeffectを再計算する必要があります。
そこでそれらを管理するためにdepsMapと呼ばれるオブジェクトの各プロパティのdepsを保持するMapを追加します。

図にすると以下のようなイメージです。

疑似コードにするとこのような形になります。

productDepsMap = {
  price: Set(priceのdeps) 
  quantity: Set(quantityのdeps)
}

depsMapを用いた実装が以下です

コードを展開
let product = { price: 100, quantity: 2 };
let total = product.price * product.quantity;

const totalEffect = () => {
  total = product.price * product.quantity;
};
// productオブジェクトのdepsMap
const depsMap = new Map();

function track(key, effect) {
  // depsMapからkeyでdepを取得
  let dep = depsMap.get(key);
  // depが存在しなければ作成する
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  // depにeffectを追加する
  dep.add(effect);
}

function trigger(key) {
  // depsMapからkeyでdepを取得
  let dep = depsMap.get(key);
  // depのeffectを再実行
  if (dep) {
    dep.forEach((effect) => {
      effect();
    });
  }
}

// 変更前
console.log(total);
// priceをtrack
track("price", totalEffect);
// priceを変更
product.price = 500;
// priceをtrigger
trigger("price");
// priceが変更される
console.log(total);

ポイントはdepsMapが追加されたところ

const depsMap = new Map();

track, trigger関数の引数にkeyが追加されてdepsMapから対応するプロパティのdepを引くようになったことです。

// depsMapからkeyでdepを取得
let dep = depsMap.get(key);

また、プロパティに対応するdepがなければ自動で作成されるようにもなっています。

  // depが存在しなければ作成する
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }

複数のオブジェクトに対応する

さて予想できてた方もいるかと思いますが、今度は複数オブジェクトにも対応できるようにしていきます。
Vue3ではtargetMapと呼ばれるdepsMapの親にあたるWeakMapを定義します。

WeakMapとはキーがオブジェクトでないといけないMapで、キーのオブジェクトに対して弱参照を持ちます。
明記はされていませんが、これら2つの特性がReactivityを実装する上で都合がいいので、MapではなくWeakMapを使っているのだと思います。

targetMapの疑似コードは以下のようになります。

targetMap = {
  // keyはproductオブジェクト
  product: {
  price: Set(priceのdeps) 
  quantity: Set(quantityのdeps)
  },
  // keyはuserオブジェクト
  user: {
  firstName: Set(firstNameのdeps) 
  lastName: Set(lastNameのdeps)
  }
}

これらを実装したのが以下です。

コードを展開
let product = { price: 200, quantity: 3 };
let total = 0;

const totalEffect = () => {
  total = product.price * product.quantity;
};

// targetMapを定義
const targetMap = new WeakMap();

function track(target, key, effect) {
  // targetMapからdepsMapを取得
  let depsMap = targetMap.get(target);
  // depsMapがなければ作成する
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  // depを取得
  let dep = depsMap.get(key);
  // depが存在しなければ作成する
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}
function trigger(target, key) {
  // targetMapからdepsMapを取得
  const depsMap = targetMap.get(target);
  // なければリターン
  if (!depsMap) {
    return;
  }
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach((effect) => effect());
  }
}

track(product, "quantity", totalEffect);
product.quantity = 4;
trigger(product, "quantity");
console.log(total);

ポイントはtargetMapが定義され、track、triggerともにtargetMapからtargetで指定したdepsMapを引くようになったところです。

// 
const targetMap = new WeakMap();
function track(target, key, effect) {
  // targetMapからdepsMapを取得
  let depsMap = targetMap.get(target);
  ...
}
function trigger(target, key) {
  // targetMapからdepsMapを取得
  const depsMap = targetMap.get(target);
  ...
}

本当のReactivityへ

お疲れ様でした、ここまでで前編は終了です。

これで複数オブジェクトにも対応できるようになったわけですが、
コードを書いていても
🤔なんかこれReactivityな感じしないなー🤔
と感じたのではないでしょうか?
それもそのはず現状のコードは手動でtracktriggerを呼び出しているからです。

track(product, "quantity", totalEffect); // ← 手動で呼び出し
product.quantity = 4;
trigger(product, "quantity"); // ← 手動で呼び出し

後編ではこれらを自動的に呼ばれるようにして、本当のReactiveな実装を作っていきます。
後編へ

Discussion