本記事では、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