Vue.jsを使っている人ならReactivity(リアクティビィティ)という言葉を聞いたことがない人はいないと思います。しかしリアクティビィティという単語を知っていてもどのような仕組みで動作しているのかわからないといった人は多いかと思います。

本文書ではVue.js 2のReactivityの元となる仕組みについてシンプルなコードを利用しながら説明を行ってみたいと思います。

Reactivityとは

本文書でのReactivityはある変数を変更を行うとその変更の影響を受けて別の値が自動で変更されるというものです。

実現したいこと

dataオブジェクトを作成し、priceプロパティとquantityプロパティの2つのプロパティを追加します。


let data = {
  price: 100,
  quantity: 5,
};

2つのプロパティを利用して金額を計算します。金額の計算は価格と数量を掛け合わせるだけなので下記のように記述することができます。特にここまでで何か問題になることはないかと思います。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;

total = data.price * data.quantity

console.log(total);
//結果
500

次に価格のpriceプロパティを100から120に更新します。priceを更新する前と更新した後のtotalを表示させますが実行してもtotalの値は変化はありません。totalへはpriceの更新は反映されません。当たり前ですが。。。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;

total = data.price * data.quantity;

console.log(total);

data.price = 120;

console.log(total);

//結果
500
500

もう一度priceを更新した後にtotalの計算式を入れればtotalは更新されます。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;

total = data.price * data.quantity;

console.log(total);

data.price = 120;

total = data.price * data.quantity;

console.log(total);
//結果
500
600

実現したいことはVue.jsのReactivityを理解するために更新後にtotalの計算式を入れるのではなくpriceとquantityを更新するとその更新を検知して自動でtotalを計算してくれる仕組みです。最終的なコードは下記のような形になります。


let data = {
  price: 100,
  quantity: 5,
};

// 価格,数量の更新を検知し、totalを自動で計算できる仕組みを作るここに追加

data.price = 120;
data.quantity = 10;

//totalを計算する式をここに入れることなくtotalを更新

console.log(total);

600

関数の追加

totalを計算すために掛け算の式をいれていましたがこれを関数へ変更します。


let total = 0
let totalCal = () => (total = data.price * data.quantity);

先ほど作成したコードを下記のように変更します。totalCalを実行するとtotalが更新されます。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;

totalCal = () => (total = data.price * data.quantity);

totalCal();

console.log(total);
//結果
500

自動でtotalは更新するためには、dataオブジェクトのプロパティpriceもしくはqutantityの値が更新されたことをきっかけに処理を行う仕組みが必要になります。その仕組みを理解することがReactivityを理解する上での非常に重要なポイントです。

プロパティの更新をきっかけにtatalを自動で再計算するためにVue.jsのバージョン2ではObject.definepropertyを利用します。

Vue 2ではObject.defineproperty, Vue 3ではProxyを利用しています。

Object.definepropertyの利用

Object.definepropertyがどのようなものかわからない場合は以下の文書を参考にしてみてください。Object.definepropertyを利用することで本来のプロパティの振る舞いを変更することができます。

まずはObject.definepropertyを使ってpriceの価格を更新するとpriceの値自体が問題なく更新されるか確認を行います。

Object.definepropertyでは第一引数にオブジェクト、第二引数にオブジェクトの中のキーを指定し、第三引数にディスクリプターを入れてます。ディスクリプターには、getとsetの処理が含まれています。

dataオブジェクトのpriceに対してObject.definepropertyを設定した場合、data.priceにアクセスするとgetの関数が実行されます。data.priceに値を設定するとsetの関数が実行され設定した値はsetの引数として渡された値を更新することができます。


let data = {
  price: 100,
  quantity: 5,
};

let priceValue = data.price;

Object.defineProperty(data, "price", {
  get() {
    return priceValue;
  },
  set(value) {
    priceValue = value;
  },
});

data.price = 120;
console.log(data.price);
// 結果
// 120

ここまでの設定ではこれまでのdata.priceへの更新方法との違いがありませんがObject.definepropertyはget, set実行時に別の処理を実行させることができます。ここがポイントです。

get, setの中に追加の処理であるconsole.logを設定し、メッセージが表示されるかされないかを見てgetとsetが実行されているのかを確認してみましょう。

実行するとdata.priceを120に設定するとsetが実行され、’update new value 120’が表示されます。console.logの中でdata.priceから値を取得するためにdata.priceにアクセスが行われ、getが実行され”access this value”が表示されます。


let data = {
  price: 100,
  quantity: 5,
};

let priceValue = data.price;

Object.defineProperty(data, "price", {
  get() {
    console.log("access this value");
    return priceValue;
  },
  set(value) {
    console.log(`upadte new value ${value}`);
    priceValue = value;
  },
});

data.price = 120;
console.log(data.price);
// 結果
// upadte new value 120
// access this value

ここまでの動作でObject.definePropertyではプロパィにアクセスすると別の処理を追加することができるということがわかりました。

Vue.js 2のReactivityのベース

Object.definePropertyの基本動作が理解できたので先ほど作成したtotalCal()と組み合わせましょう。totalCal()をsetの中に追加することで値の変更が行った時にtotalの計算が再実行されることになります。

作成したコード実行するとtotalの値が自動更新されていることが確認できます。


let data = {
  price: 100,
  quantity: 5,
};

let total;

let priceValue = data.price;

Object.defineProperty(data, "price", {
  get() {
    // console.log("access this value");
    return priceValue;
  },
  set(value) {
    // console.log(`upadte new value ${value}`);
    priceValue = value;
    totalCal(); //追加
  },
});

let totalCal = () => (total = data.price * data.quantity);

totalCal(); //totalの初期化

console.log(total);

data.price = 120;

console.log(total);
//結果
500
600

data.priceを設定するとtotalが再計算されるので下記のようにdata.priceを変更しただけでtotalが更新されることになります。


data.price = 120;

console.log(total);

data.price = 200;

console.log(total);

data.price = 300;

console.log(total);
//結果
600
1000
1500

Object.definePropertyを追加するだけでpriceを更新することで自動でtotalを更新する仕組みを作ることができました。この仕組みを元に下記ではより汎用的なコードへと更新していきます。

Quantityの値でもtotal更新

ここまでの説明でpriceを更新するとtotalが自動で再計算され更新されることがわかりました。しかし、quantityの値を更新してもtotalが更新されることはありません。

priceだけではなくdataオブジェクトに含まれるプロパティがいくつあっても対応できるようにコードを変更します。

一見複雑そうに見えるかもしれませんが、中身はObjcet.keys(data)でdataオブジェクトのキーを取り出し、forEachで一つずつ取り出して、Object.definePropertyで取り出したキーを設定しているだけです。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;

Object.keys(data).forEach((key) => {
  let internalValue = data[key];
  Object.defineProperty(data, key, {
    get() {
      return internalValue;
    },
    set(value) {
      internalValue = value;
      totalCal();
    },
  });
});

let totalCal = () => (total = data.price * data.quantity);

totalCal();

// data.price = 120;
data.quantity = 10;

console.log(total);
//結果
1000

キー毎にObject.definePropertyを設定することでquantityを更新してもtotalの再計算が自動で行えるようになります。

ここまでの設定でprice, quantityを更新するとtotalの再計算が自動で行われるということが確認できました。次にtotalCal以外の関数を追加した場合について考えていきましょう。

discountPriceの追加

priceだけに依存するdiscountPriceを追加します。


let discountPrice = () => (discount = data.price * 0.9);
依存という言葉が急に出てきましたが、discountPriceはpriceだけに影響をうけるという意味です。totalCalについてはpriceとquantityの値に影響をうけるのでpriceとquantityに依存していることになります。

totalCalと同じ方法でdiscountPriceもコードに追加します。totalCal、discountPriceの関数の中身を異なりますが設定方法は同じです。追加した結果priceを更新するとtotalとdiscountが自動で更新されていることがわかります。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;
let discount = 0;

Object.keys(data).forEach((key) => {
  let internalValue = data[key];
  Object.defineProperty(data, key, {
    get() {
      return internalValue;
    },
    set(value) {
      internalValue = value;
      totalCal();
      discountPrice();
    },
  });
});

let totalCal = () => (total = data.price * data.quantity);

totalCal();

let discountPrice = () => (discount = data.price * 0.9);

discountPrice();

console.log(discount);

data.price = 120;

console.log(discount);
console.log(total);
//結果
90
108
600

上記のコードではsetの中にdiscountPriceが入っているので、discountPriceと依存関係のないquantityの値を更新してもsetの中でdiscountPriceが実行されることになります。

Depクラスの追加

各プロパティに依存関係のあるものだけを実行させるために新たにDepクラスの追加を行います。DepはDependencyの略で各プロパティの依存関係のある関数を入れるいれものです。各プロパティに個別のDepを持つことになります。


class Dep {
    constructor() {
        this.subscribers = []
    }
    depend() {
        if (target && !this.subscribers.includes(target)) {
            this.subscribers.push(target);
        }
    }
    notify() {
        this.subscribers.forEach(sub => sub());
    }
}

Depクラスの中身はシンプルで、先ほど記述した通り入れ物なのでsubscribersという名前の配列に依存関係のある関数をdependメソッドで追加し、notifyメソッドでsubscribersの配列に含まれている関数をすべて実行するだけのものです。つまりpriceプロパティと依存関係のあるtotalCalとdiscountPriceがsubscribersに登録されれば、notifyメソッドを実行すると必ず登録した関数が実行されることになります。

dependメソッドでは追加したい関数がsubscribers配列にあるかincludesメソッドでチェックを行いなければ配列に追加します。

作成したDepクラスをこれまでに作成したコードに追加します。これまでtotalCal()とdiscountPrice()としていた関数の名前をどちらもtargetに変更します。targetを実行した時にその実行したtarget関数をDepクラスに登録するためです。名前を個別につけると登録することができません。

1つ目のtarget(元はtotalCal)を実行した際にdata.priceとdata.quantityにアクセスされるのでgetが実行され、各プロパティのdepのsubscribers配列にtargetが保存されます。2つ目のtarget(元はdiscountPrice)はdata.priceのみにアクセスされるのでpriceプロパティのdepのsubscribers配列のみにこのtargetが保存されます。

Depクラスを追加しても処理の結果は変わりません。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;
let discount = 0;

class Dep {
  constructor() {
    this.subscribers = [];
  }
  depend() {
    if (target && !this.subscribers.includes(target)) {
      this.subscribers.push(target);
    }
  }
  notify() {
    this.subscribers.forEach((sub) => sub());
  }
}

Object.keys(data).forEach((key) => {
  const dep = new Dep();
  let internalValue = data[key];
  Object.defineProperty(data, key, {
    get() {
      dep.depend(target);
      return internalValue;
    },
    set(value) {
      internalValue = value;
      dep.notify();
    },
  });
});

let target = () => (total = data.price * data.quantity);

target();

target = () => (discount = data.price * 0.9);

target();

console.log(discount);

data.price = 120;

console.log(discount);
console.log(total);
//結果
90
108
600

しかし、これまではquantityを更新してもdiscountPriceが実行されていましたが上記のコードでは依存関係のないdiscountPriceは実行されません。

確認を行うためにtarget関数にconsole.logを追加します。これで各関数が実行されるかどうかを確認しましょう。


let target = () => {
  console.log("access totalCal");
  return (total = data.price * data.quantity);
};

target();

target = () => {
  console.log("access discountPrice");
  return (discount = data.price * 0.9);
};

target();

priceを更新すると”access totalCal”と”access discountPrice”が表示されます。priceと依存関係にある関数が実行されていることがわかります。


data.price = 120;
// 結果
access totalCal
access discountPrice

quantityを更新すると”access totalCal”のみ表示されます。quantityと依存関係のないdiscountPriceは実行されないことがわかります。


data.quantity = 10;
// 結果
access totalCal

Depクラスを追加することで依存関係のある関数のみ実行できるようになったことが確認できました。

watcher関数の追加

targetを利用して関数の追加を行っていましたが、watcher関数を追加しその引数に関数を入れることで関数の実行を行います。引数に関数をとり、内部で実行し、実行後にnullを入れています。wacherにより、登録させる関数が増えても効率的に行うことができます。


let watcher = (func) => {
  target = func;
  target();
  target = null;
};

先ほどtargetと名前をつけていたtotalCalとdiscontPriceを元に戻します。この2つの関数をwatcherの引数に入れます。他の関数を追加したい場合も同様に関数を作成して、watcherの引数に入れることになります。


let totalCal = () => (total = data.price * data.quantity);

let discountPrice = () => (discount = data.price * 0.9);

最終的なコードは以下のように記述することができます。


let data = {
  price: 100,
  quantity: 5,
};

let total = 0;
let discount = 0;

let totalCal = () => (total = data.price * data.quantity);

let discountPrice = () => (discount = data.price * 0.9);

class Dep {
  constructor() {
    this.subscribers = [];
  }
  depend() {
    if (target && !this.subscribers.includes(target)) {
      this.subscribers.push(target);
    }
  }
  notify() {
    this.subscribers.forEach((sub) => sub());
  }
}

Object.keys(data).forEach((key) => {
  const dep = new Dep();
  let internalValue = data[key];
  Object.defineProperty(data, key, {
    get() {
      dep.depend(target);
      return internalValue;
    },
    set(value) {
      internalValue = value;
      dep.notify();
    },
  });
});

let watcher = (func) => {
  target = func;
  target();
  target = null;
};

watcher(totalCal);
watcher(discountPrice);

console.log(total);
console.log(discount);

data.price = 120;
data.quantity = 10;

console.log(total);
console.log(discount);

// 結果
500
90
1200
108

priceの値を変更すると自動でtotalとdiscountの値も更新されます。

これでVue.js 2のリアクティビティのベースを理解することができました。