🙊

猿でも分かる!Vueのリアクティブシステム

2021/05/08に公開

Vueのリアクティブシステムを理解する

皆さん、Vue.jsを使っていますか?個人的には結構好きでよく使っているのですが、あのリアクティブ性はどうやって実現しているのか気になりませんか?本記事ではVue2系のリアクティブシステムの原理を簡単に説明していきます。

フロントは流行り廃りの激しい分野だからこそ、主要技術がどうやって実現しているのかを知ることが今後にも生きるはずです。

今回使用したコード(sandbox)を記事最下部に貼っておきます。

今回の設定

itemオブジェクトには価格priceと量quantityプロパティがあります。
それらの総額に相当するtotalプロパティをitemオブジェクトの変更を検知して自動的に自動的に再計算されて欲しい。

vue.js的に書くとこんな感じ。

example.vue
<script>
export default {
  data() {
    item: {
      price: 100,
      quantity: 3,
    }
  },
  computed: {
    total() {
      return this.item.price * this.item.quantity; // -> 300
    }
  }
}
</script>

(vueの原理を解説するので以降はバニラjsで説明します。やりたいことは変わりません。)

もしリアクティブ性がないと...

item.quantityに5を代入しても、totalが再計算されないので300のままになってしまいます。
これが自動的に500になるようにしたい。

level1.ts
const item = {
  price: 100,
  quantity: 3
};
let total: number = 0;
const calcTotal = () => (total = item.price * item.quantity);

calcTotal();

console.log(total); // -> 300

item.quantity = 5;

console.log(total); // -> 300(これが500になって欲しい)

どんな機能が必要か

上の例から、リアクティブ性というのは、
プロパティが変更されたらそれを検知してそのプロパティが使用されている関数を自動的に再計算すること
と言い換えることができます。つまり、これを実現するには

  • プロパティ毎にそのプロパティがどんな関数で使われているかを保存しておく
  • そのプロパティの更新時に関連する関数を全て再計算する

機能があれば良いわけです。どうですか?なんだかできそうな気がしてきませんか?

quantityに対してこれを実装してみる

level2.ts
const item = {
  price: 100,
  quantity: 3
};
let total: number = 0;

const storageForQuantity: Function[] = [];
const record = (func: Function) => {
  storageForQuantity.push(func);
};
const reCalculate = () => {
  storageForQuantity.forEach((func) => func());
};

const calcTotal = () => (total = item.price * item.quantity);

calcTotal(); // 初期totalの計算

record(calcTotal); // calcTotalはquantityに依存しているのでこの関数をstorageに保存
console.log(total); // -> 300

item.quantity = 5;
reCalculate(); // quantityを変更したのでstorage内部の関数を全て再実行
console.log(total); // -> 500

まだ手動で再計算をしていますが、なんとなく処理の流れは掴めたでしょうか。

  1. item.quantityに対して、依存関数を入れておく
  2. item.quantityが変更されたら、依存関数を全て再計算する

では、上の機能をDepというクラスにまとめてみましょう!(Dep: Dependancy)

Depクラスの導入

vueの内部で使われている命名と合わせるために、先程とは名前を変えていますが、機能は変わりません!

  • storageForQuantity -> subscribers: 依存関数を保存する配列
  • record -> depend: 依存関数を追加するメソッド
  • reCalculate -> notify: 依存関数を再計算するメソッド

また、targetという変数を導入しました。dependする時にtargetの中に入っている関数を依存関数として追加します。
(これによってdependが副作用のある関数になってしまうのですが、こうしないと後々困るので甘んじて受け入れてください...本家vueもこのやり方です...)

level3.ts
let target: Function = () => {}; // 依存関数を保存する際の対象関数を入れておく変数

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

const item = {
  price: 100,
  quantity: 3
};
let total: number = 0;
const calcTotal = () => (total = item.price * item.quantity);

target = calcTotal;  // quantityの依存先をtargetに入れておくことで後でdependできる
calcTotal(); // 初期totalの計算

const dep = new Dep();
dep.depend();
console.log(total); // -> 300

item.quantity = 5;
dep.notify();
console.log(total); // -> 500

そろそろ手動で依存関数を追加したり再実行するのに飽きてきましたね...次のセクションではこの部分を自動化したいと思います。

Object.definePropertyの導入

Object.definePropertyというメソッドを使います。
詳細はMDNドキュメントを見ていただきたいですが、このメソッドはオブジェクトのプロパティを宣言する際にgetter, setterを定義できるというものです。
詳しくはこちら

これを用いて、

get() {
  depend(`プロパティを呼び出した関数`) // 依存関数を保存
  return `プロパティの値`
}
set(value) {
  `value`をセット
  notify() // 依存関数を再計算
}

のように書けば、

  • 値を参照した(item.quantity)時にgetterが呼ばれて関数をsubscribesに保存
  • 値を変更した(item.quantity = 5)時にsetterが呼ばれて依存関数を全て再計算

することができます。これを実装すればほぼ完成です!!

level4.ts
let target: Function = () => {}; // 依存関数を保存する際の対象関数を入れておく変数

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

const item = {
  price: 100,
  quantity: 3
};
let total: number = 0;
const calcTotal = () => (total = item.price * item.quantity);

// itemのそれぞれのプロパティに対してDepを作成し、getterではdepend, setterではnotifyをする
Object.keys(item).forEach(key => {
  let _value = item[key];
  const dep = new Dep();
  Object.defineProperty(item, key, {
    get() {
      dep.depend();
      return _value;
    },
    set(value) {
      _value = value
      dep.notify()
    }
  })
})

target = calcTotal; // quantityの依存先をtargetに入れておくことで後でdependできる
calcTotal(); // 初期totalの計算

console.log(total); // -> 300

item.quantity = 5;

console.log(total); // -> 500 (quantityを書き換えただけなのに!!!!)

最後を見てください!!!
item.quantityを書き換えただけなのにも関わらず自動的totalが更新されました🎉
これにてリアクティブ性の獲得を達成することができました。

最後はもう少し処理を抽象化します。

watcherの追加(おまけ)

前回まではtargetに依存関数を入れてから関数を実行して...という処理を行なっていました。しかし、これは関数が複数になった場合に大変なことになってしまいます。
そこで、関数群をまとめて上記の処理を行なってくれるwatcher関数を作成します。

今回はitem.taxRateを追加し、税込みの総額を入れておくtotalWithTaxもリアクティブな変数にしてあります。

おまけ程度に、オブジェクトを渡すとリアクティブにしてくれるinitData関数も作成しました。

level5.ts
let target: Function | null = null; // 依存関数を保存する際の対象関数を入れておく変数

interface Dep {
  subscribers: Function[];
}

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

const watcher = (funcObj: Record<string, Function>) => {
  Object.values(funcObj).forEach((func) => {
    target = func;
    func();
    target = null;
  });
};

const initData = (dataObj: Object) => {
  Object.keys(dataObj).forEach((key) => {
    let _value = dataObj[key];
    const dep = new Dep();
    Object.defineProperty(dataObj, key, {
      get() {
        dep.depend();
        return _value;
      },
      set(value) {
        _value = value;
        dep.notify();
      }
    });
  });
}

const item = {
  price: 100,
  quantity: 3,
  taxRate: 1.1,
};
let total = 0;
let totalWithTax = 0;
const calcTotal = () => (total = item.price * item.quantity);
const calcTotalWithTax = () => (totalWithTax = item.price * item.quantity * item.taxRate);

// itemをリアクティブに
initData(item)

// vueで言うところのcomputedのような関数をwatcherに渡す。
watcher({
  calcTotal,
  calcTotalWithTax,
})

console.log(`total: ${total}, totalWithTax:${totalWithTax}`); // -> 300
// -> total: 300, totalWithTax:330 

item.quantity = 5;

console.log(`total: ${total}, totalWithTax:${totalWithTax}`); // -> 300
// -> total: 500, totalWithTax:550 

おわりに

ここまで読んでくださりありがとうございます!

Vueのリアクティブシステムがどのように実現されているかを説明しました。実際のvueもこのような実装になっているので興味がある方は覗いてみると面白いと思います!(vueのレポジトリ内でdepend()notify()で検索すると出てくると思います)

Vue3系のリアクティブシステムはProxyを使っているらしく、こちらも勉強したら記事を書こうと思います。

少しでも参考になったという方はいいね!お願いします!

今回使ったコード

Discussion