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

9 min read読了の目安(約8500字

前編はこちら

前編で複数のオブジェクトをtargetMapで管理することができるところまで実装できました。
後編では以下のように手動で呼び出していたtracktrigger関数を自動で呼び出されるように変更していきます。

track(product, "quantity"); // ←自動で呼ばれて欲しい
product.quantity = 5;
trigger(product, "quantity"); // ←自動で呼ばれて欲しい

やりたいこととしては、

  • プロパティがgetされたタイミングでtrackが呼ばれる
  • プロパティがsetされたタイミングでtriggerが呼ばれる
    つまり、プロパティのget/setをインターセプトできれば良いわけです。

これを実現するためにVue3ではReflectとProxyというES6で導入された新しいAPIを用いている(Vue2ではObject.defineProperyt)ので、まずそれらについて説明します。

Reflect

Reflectとは

介入可能な JavaScript 操作に対するメソッドを提供する組込みオブジェクトです。
MDNより
で、targetで指定したオブジェクトの関数を呼び出したり、プロパティを取得したりできるやつです。

その中でも今回はReflect.getを用います。
Reflect.getを用いるとオブジェクトからプロパティを取得できます。
(receiverについては後述します。)

Reflect.get(product, 'quantity', receiver) // => 3

Proxy

Proxyは以下のようにオブジェクトをラップしてオブジェクトへのアクセスにProxy(代理人)を作ることができます。
例えば以下のようにProxyの第二引数にハンドラーを渡すことで、プロパティにアクセスした際にget関数をインターセプトできます。

let product = { price: 5, quantity: 2 };
let proxiedProduct = new Proxy(product, {
  get(target, key) {
    console.log("Get key", key);
    return target[key];
  }
});
// Get key quantity 
// 2
console.log(proxiedProduct.quantity);

さらにこれをReflect.getと組み合わせて使うことで以下のように書くことができます。

let product = { price: 5, quantity: 2 };
let proxiedProduct = new Proxy(product, {
  get(target, key, receiver) {
    console.log("Get key", key);
    return Reflect.get(target, key, receiver);
  }
});
console.log(proxiedProduct.quantity);

ここでreceiverを使うことで、targetのオブジェクトが別のオブジェクトから継承されたものであった場合にthisが継承されたオブジェクト自身であることを保証することができます。(詳細はこちらの記事が参考になります。)

get同様、setも以下のように定義できます。

let product = { price: 5, quantity: 2 };
let proxiedProduct = new Proxy(product, {
  get(target, key, receiver) {
    console.log("Get key", key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    console.log("Set called", key, value);
    return Reflect.set(target, key, value, receiver);
  }
});
proxiedProduct.quantity = 10;
console.log(proxiedProduct.quantity);

reactiveを定義する

ReflectとProxyについて把握できたところでいよいよ実装に入っていきます。
ここではreactiveと言う引数で渡したtargetをProxy化して返す関数を定義します。

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      console.log("Get key", key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      console.log("Set called", key, value);
      return Reflect.set(target, key, value, receiver);
    }
  };
  return new Proxy(target, handler);
}
let product = { price: 5, quantity: 2 };
let proxiedProduct = reactive(product);

これで冒頭に書いたget/setをインターセプトできるようになりました。

つまり、プロパティのget/setをインターセプトできれば良いわけです。

あとはtrack、triggerをそれぞれ実行するだけです。

プロパティがgetされたタイミングでtrackが呼ばれる
プロパティがsetされたタイミングでtriggerが呼ばれる

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      // getされたらtrackを呼び出す
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let result = Reflect.set(target, key, value, receiver);
      // 値が変更されたらtriggerを呼び出す
      if (result && oldValue !== value) {
        trigger(target, key);
      }
      return result;
    }
  };
  return new Proxy(target, handler);
}

これで、プロパティの変更に応じて、track,triggerを呼び出すことができるようになりました。
勘の言い方はお気づきかと思いますが、これはCompositionAPIにおけるreactiveに相当します。

Reactiveついに完成🎉

さて最後に定義したreactive関数が正しく動くことを確認しましょう。
前半も含めたコード全体は以下になります。

コードを展開
// 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());
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      track(target, key, totalEffect);
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = Reflect.get(target, key, receiver);
      let result = Reflect.set(target, key, value, receiver);
      if (result && oldValue !== value) {
        trigger(target, key);
      }
      return result;
    }
  };
  return new Proxy(target, handler);
}

let product = reactive({ price: 200, quantity: 3 });
let total = 0;

const totalEffect = () => {
  total = product.price * product.quantity;
};
// 初期化
// effect内でpriceとquantityがgetされることで
// track(product, 'price')
// track(procut, 'quantity')
// がコールされる
totalEffect()

// quantityにsetされることで
// trigger(product, 'quantity')がコールされ
// effectが再計算されるがコールされ、totalが更新される。
product.quantity = 4;
console.log(total); // => 800

codesandbox

ポイントは
まず、初期化する際にtotalEffect()がコールされます。
ここで内部的にtrackが呼び出され、price, quantityの変更を追跡するようになります。

// 初期化
// effect内でpriceとquantityがgetされることで
// track(product, 'price')
// track(procut, 'quantity')
// がコールされる
totalEffect()

さらにquantityに値がセットされることでProxy内部でtriggerがコールされ、totalの値が更新されるようになります。

// quantityにsetされることで
// trigger(product, 'quantity')がコールされ
// effectが再計算されるがコールされ、totalが更新される。
product.quantity = 4;
console.log(total); // => 800

以上、見事にtrack, triggerが自動で呼び出され、値をセットするだけでtotalが更新されるようになりましたね🎉

いかがだったでしょうか?
フレームワークのインターフェースだけを利用するのではなく、今回のように内部的な仕組みを理解することでデバッグがしやすくなったり、設計の勉強になったり、何より深く理解することでよりプログラミングを楽しむことができるようになるのではないかなと思います。

[追記] Refについて

reactiveの話があって、refはと思われた方も多いではないのかと思うので、軽く触れておきます。
例えば以下のようにtotalがsalePriceという別のeffectの結果として代入される値に依存している場合を考えています。

let product = reactive({ price: 5, quantity: 2 })
let salePrice = 0
let total = 0

effect(() => {
  // totalはsalePriceに依存する
  total = salePrice * product.quantity
})
effect(() => {
  salePrice = product.price * 0.9
})

// salePriceには変更されるが、salePriceに依存するtotalの値は変更されない
product.price = 10
console.log(
  `total (should be 18) = ${total} salePrice (should be 9) = ${salePrice}`
  // total (should be 18) = 0 salePrice (should be 9) = 9
)

ここでproduct.priceに変更を加えることで、直接依存するsalePriceは更新されていますが、salePriceに依存しているtotalは更新されていないことがわかります。

// salePriceには変更されるが、salePriceに依存するtotalの値は変更されない
product.price = 10
console.log(
  `total (should be 18) = ${total} salePrice (should be 9) = ${salePrice}`
  // totalが0更新されず0のままになっている。
  // total (should be 18) = 0 salePrice (should be 9) = 9
)

そこで利用できるのがrefです。
refとは
valueとして1つだけpropertyを持つreactiveなオブジェクト
のことで、そのままですが単一の値をreactiveにしたいときに利用できます。

今回の例では以下のように書くことで反映されるはずです。

let product = reactive({ price: 5, quantity: 2 })
let salePrice = ref(0) // refの使用
let total = 0

// effectの中では.valueを使うようにする
effect(() => {
  total = salePrice.value * product.quantity
})
effect(() => {
  salePrice.value = product.price * 0.9
})

refの実装

refを実装する方法は2つあり、
1つ目はシンプルにreactiveをラップする方法です。

function ref(initialValue){
  return reactive({value: initialValue})
}

しかし、これは実際にVue3のrefの実装とは異なります。

ObjectAccessorsについて

その実装を見る前にまずはObjectAccessors実て理解しておく必要があります。

これは別名JavaScript computed properties (Vueのcomputed propertyとは別物)とよばれるもので、以下のようにget/setを定義して、computed propertyを定義できます。

let user = {
  firstName: 'Gregg',
  lastName: 'Pollack',

  get fullName() {
    return `${this.firstName} ${this.lastName}`
  },

  set fullName(value) {
    [this.firstName, this.lastName] = value.split(' ')
  },
}

console.log(`Name is ${user.fullName}`)
user.fullName = 'Adam Jahr'
console.log(`Name is ${user.fullName}`)

こちらを使って、refは以下のようにreactiveを使わないシンプルな形で定義することができます

function ref(raw) {
  const r = {
    get value() {
      track(r, 'value')
      return raw
    },
    set value(newVal) {
      if(raw !== newVal){
        raw = newVal
        trigger(r, 'value')
      }
    },
  }
  return r
}