本記事では、Vue.js v3 のコア部分であるリアクティブシステムを実装し、その仕組みを学びます。
今回実装するリアクティブシステムを用いれば、以下のような簡単なアプリケーションが作れます。
最終的なコードはGitHubにあります。
https://github.com/hareku/simple-vue-reactivity
リアクティブとは
**「リアクティブである」とは、「その値が監視され、変更が検知される状態のこと」**を指します。
Vue.jsやReactでは、コンポーネントが保持している状態を変更すれば、その変更が検知されてHTMLに反映されます。それらを実現するためには、値の変更を検知可能な状態にするリアクティブシステムが必要です。
Vue.js v3 におけるリアクティブシステム
Vue.js のリアクティブシステムは、主に以下の3つで構成されます。
- ref
- effect
- computed
まずはref と effect を使ったサンプルコードを見てみましょう。
const counter = ref(0) // 0 は object になり、valueプロパティでアクセスできる
// counter.value が変更されたら、effectの引数に渡した関数が自動で再実行される
let double = 0
effect(() => {
  double = counter.value * 2
})
console.log(double) // 0
counter.value = 5
console.log(double) // 10 <- Changed!
このように ref でリアクティブにした値を effect に渡した関数内で参照すれば、変更を検知して自動で関数を再実行してくれます。
これは一見すると魔法のようですが、単純な仕組みがあります。
それでは、これらのリアクティブシステムを実装していきましょう。
リアクティブシステムの実装
※Vue.js はオブジェクトもリアクティブにできるのですが、今回は簡略化のためオブジェクトには対応しません。数値や文字列などのオブジェクトではない値のみ、リアクティブにします。
Ref の実装
ref 関数は、引数で渡した値をリアクティブにします。
ref に渡した値が value をキーとしたオブジェクトに変換される理由は、getter/setter によるフック処理を行うためです。
export function ref(value: any) {
  return {
    get value() {
      // TODO: track
      return value
    },
    set value(newVal) {
      value = newVal
      // TODO: trigger
    }
  }
}
JavaScript では、object のプロパティ名に get/set を付けることで getter/setter を実装できます。
TODO の通り、getter に「値が使われていることを知らせる処理 (track)」と、setter に「値が変更されたことを知らせる処理 (trigger)」が必要です。これらの処理は、次に解説する effect 関数に依存しているため、そちらで実装します。
Effect の実装
それでは effect 関数を実装します。これはとてもシンプルです。
let activeEffect: Function | null = null
export function effect(fn: Function) {
  activeEffect = fn
  fn()
}
effect 内では、引数に渡された関数を activeEffect という変数に格納しています。この変数は次のトラッキング処理で用います。
トラッキング処理(track)
次は ref の getter で必要なトラッキング処理である track を実装します。
const ref = {
  get value() {
    track(ref) // これを実装する
    return value
  },
  set value(newVal) {
    value = newVal
    // TODO: trigger
  }
}
track は、どの effect がどういった ref に依存しているかという依存関係を管理します。
一度 track のコードを見てみましょう。
// Set はユニークな値のみを格納できる配列
// https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Set
type Deps = Set<Function>
// WeakMap は Object をキーにできるオブジェクト
// targetMap では ref をキーとして、参照している effect のリストを格納する
const targetMap = new WeakMap<object, Deps>()
export function track(target: object) {
  // target (ref) に依存している関数リスト (deps) を取得する
  let deps = targetMap.get(target)
  // deps がなければ初期化する
  if (deps === undefined) {
    deps = new Set()
    targetMap.set(target, deps)
  }
  // 現在実行中の effect 関数を依存関係 (deps) に追加する
  if (activeEffect && !deps.has(activeEffect)) {
    deps.add(activeEffect)
  }
}
複雑に見えますが、やっていることは WeakMap で ref に依存している effect リストを管理しているだけです。
これらの依存関係をもとに、次のトリガー処理で、特定の ref に依存している effect をすべて再実行できます。
トリガー処理(trigger)
ref の setter 時の処理であるトリガーを実装します。
const ref = {
  get value() {
    track(ref)
    return value
  },
  set value(newVal) {
    value = newVal
    trigger(ref) // これを実装する
  }
}
export function trigger(target: object) {
  // target に依存している effect リストを取得する
  const deps = targetMap.get(target)
  if (deps === undefined) {
    return
  }
  // target に依存している effect を全て再度実行する
  deps.forEach(effect => {
    effect()
  })
}
これにより、 ref の value が更新されるたびに、依存関係にある effect を全て再実行することができます。これが Vue.js におけるリアクティブシステムの仕組みです。
computed
先ほど実装した ref と effect を組み合わせれば、Vue.js の computed を実装できます。
まずは computed の仕様の確認のため、jest でテストを書いてみます。
describe('reactivity/computed', () => {
  it('should observe ref', () => {
    const counter = ref(0)
    const double = computed(() => counter.value * 2)
    expect(double.value).toBe(0)
    counter.value = 5
    expect(double.value).toBe(10)
  })
})
computed を実装すれば、わざわざ effect の外に定義していた let double のような変数を省略することができます。computedはこのように実装します。
export function computed<T = any>(getter: () => T): { value: T } {
  let value: T
  effect(() => {
    value = getter()
  })
  return {
    get value() {
      return value
    }
  }
}
アプリケーションの実装
それでは実装したリアクティブシステムを用いて、冒頭のサンプルアプリケーションを実装してみましょう。
HTML
JavaScript 側で操作するために、各タグに適当な id を付与しておきます。
<div>
  <span id="count"></span> * 2 = <span id="multiplied"></span>
</div>
<div>
  <button id="increment">Increment ++</button>
  <button id="decrement">Decrement --</button>
</div>
JavaScript
まずは、 count とそれを2倍した値である multiplied をリアクティブな値として作ってみます。
const count = ref(0)
const multiplied = computed(() => count.value * 2)
そして、effect 関数内で HTML の操作を行います。
const countElement = document.getElementById('count')
const multipliedElement = document.getElementById('multiplied')
effect(() => {
  countElement.innerText = String(count.value)
  multipliedElement.innerText = String(multiplied.value)
})
次に値を増減させるため、ボタンにクリックイベントを登録します。
const incrementButton = document.getElementById('increment')
const decrementButton = document.getElementById('decrement')
incrementButton.addEventListener('click', () => {
  count.value++
})
decrementButton.addEventListener('click', () => {
  count.value--
})
addEventListener に渡した関数内で count.value を変更しているので、変更する度に effect 内の HTML 操作が実行され、Viewが更新されます。
これでアプリケーションの完成です。
最終的なアプリケーションコード
import { ref } from '../ref'
import { effect } from '../effect'
import { computed } from '../computed'
const countElement = document.getElementById('count')
const multipliedElement = document.getElementById('multiplied')
const incrementButton = document.getElementById('increment')
const decrementButton = document.getElementById('decrement')
if(countElement && multipliedElement && incrementButton && decrementButton) {
  const count = ref(0)
  const multiplied = computed(() => count.value * 2)
  incrementButton.addEventListener('click', () => {
    count.value++
  })
  decrementButton.addEventListener('click', () => {
    count.value--
  })
  effect(() => {
    countElement.innerText = String(count.value)
    multipliedElement.innerText = String(multiplied.value)
  })
}
次なるステップ
本記事の解説は以上になります。
さらに理解を深めるためのステップとしては、オブジェクトのリアクティブ化があります。対応方法として、依存関係を管理していた WeakMap をさらに深くし、オブジェクトの各キーごとの effect を管理する手法があります。
また他にできることとして、プロパティの追加や削除への対応や、監視を浅く (shallowに) してパフォーマンスを改善することなどがあります。
実装したい方は Vue.js v3 のソースコード (@vue/reactivity) が参考になります。
https://github.com/vuejs/vue-next/tree/master/packages/reactivity
