Vue 3/Composition APIの公式ドキュメントには、フレームワークのコア機能の一つであるリアクティビティについて、「リアクティビティーの探求」というページを作って公式の解説があります。フレームワークやライブラリが自身の構造を丁寧に説明してくれることって少ない感覚で、Vue歴が短い自分にとっては嬉しい & 興味深い解説ページでした。
いっぽうで、ページにあるコードは断片的で、そのままでは動かず再現が難しいです(実際難しかった)。
せっかくなら、と@vue/reactivityを見たり解説動画見たりしながら動くコードを作ってみました。この記事はその超初歩の部分を言語化したものです。
はじめに
リアクティビティの簡易な再現を試みることをゴールとします。そして、値の計算をリアクティブにできたとして、DOMレンダリングの問題が残りますがこれも扱いません。
なお、Vue Masteryから本格的な解説動画が出ているので、興味のある方は見てみると学びが深いです。実は、この記事は動画とVue 3のコードを読んで理解したことのサマリでもあります。
Reactivity in Vue 3 - How does it work?
コードの補足
記事等から再現を試みた際に、型というかオブジェクトの形がわからず苦労したので、できる限りTypeScriptで書きます。
記事に登場するコードは説明のために切り取ったり一部省略されているところがあります。前編はリポジトリに置かれているのでこちらもご参考ください。
記事に書ききれなかったコードの全容を見れます。
リアクティビティとはなんなのか
筆者は、はじめリアクティビティがいまいちわかったような気でわかっていませんでした。かんたんにどういうものかを整理します。
リアクティビティが実現するとは、以下のコードが成り立つ状態でもあります。
const price = 5
const quantity = 2
const total = price * quantity
console.log(total) // 10
const price = 8
console.log(total) // 16
通常、 total
が評価されたあとで構成要素に変化があろうが結果が更新されることはないわけです。total
がリアクティブになるなら、price
への代入を検知してtotal
が再計算されるようになります。
では、リアクティビティをどう達成するかなのですが、すごーくざっくりいうと
effectを構成するプロパティを変更追跡対象にして、変更を検知したらeffectを再評価する
といった感じになります。
もうちょっと実装の話をすると、再評価したいeffect
(total = price * quantity
のような式)を集めるSetを用意して、要素の変更を検知したらSetから取り出して評価を実行する流れとなります。扱いやすいように色々ゴニョゴニョするけど、コアのアイデアは本当にそんな感じです。
ちなみに、公式の解説にもコードにも effect
という命名が頻出します。日本語にするとニュアンスが抜け落ちてしまう感覚があるので、以後断りなく effect
と書きますが「複数の要素からなる式」、くらいの認識で問題ありません。
function effect () {
return price * quantity
}
見取り図はこのへんにして、具体的なコードで再現していきます。
effectを追跡する
Vue3のリアクティビティはMapやSetで変更対象の追跡登録・再評価を行うため、どこに何が入っているのか結構混乱します…
コードと図で1つずつ読み解きます。
まずは、先ほど上げたSetと各種関数を書きます。追跡には track
、再評価には trigger
を定義します。 dep
や track
、 trigger
の名前は本家コードにもそのまま登場するものです。
let price = 5
let quantity = 2
let total = 0
let dep = new Set()
let effect = () => { total = price * quantity }
const track = () => { dep.add(effect) }
const trigger = () => { dep.forEach(effect => effect()) }
track()
effect()
図で整理してみると…
オブジェクトの導入
単純な数値や文字列だけにとどまらず、オブジェクトとそのプロパティも変更対象として検知したいことがあります。そこでプロパティをキーに dep
を保持するMapを用意します。
const depsMap = new Map<any, Set>()
このマップはオブジェクトのプロパティをキーにdep
を対応させるので、track
とtrigger
でそれぞれ対応付け・キーによる検索を実現させたいです。
const track = (key: unknown) => {
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(effect)
}
const trigger = () => {
const dep = depsMap.get(key)
if (dep) {
dep.forEach(effect => effect())
}
}
追跡対象の登録、評価はそれぞれ呼び出すだけです。
track('quantity')
effect()
逆にわかりづらいかもですが…図にすると以下のような感じになります。
depsMap
はオブジェクトのプロパティと、プロパティに依存しているeffectの集合ということがわかりました。最後に、そもそも depsMap
のキーを持つオブジェクト自体を格納しておくなにかがあると便利です(ないと不便)。オブジェクトをキーに、depsMap
を持つ targetMap
を定義します。
const targetMap = new WeakMap<any, Map<any, Set>>()
WeakMap はオブジェクトをキーにできるよわいMapです。ここでは、普通のMapと同じように使うので特殊性などの説明は割愛します。
track
, triggerを
targetMap
から順番に dep
を引くように直します。なお、get
の過程でSetがなければnewして空箱を用意しておきます。
const track = (target: any, key: unknown) => {
let dep = depsMap.get(key)
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(effect)
}
const trigger = (target: object, key: unknown) => {
const dep = targetMap.get(target)?.get(key)
if (dep) {
dep.forEach(effect => effect())
}
}
track
とtrigger
を手動で呼び出して追跡と評価を実行してみます。
const price = 5
const quantity = 2
const product = { quantity * price }
track(product, 'quantity')
trigger()
なお、普段目にするVueの機能とは少し距離があるな、と感じると思います。実際のフレームワーク内部は、これに色々かぶせて ref
や computed
などを実現しています。
おわりに
ここで書いたようなアイデアがわかれば、Vueのコードが読みやすくなるしデバッグもいくばくか気楽になります。
記事で紹介した簡易再現コードのフルセットはリポジトリの core/packages/reactivity
に実態があります。この記事や動画、公式ドキュメントと合わせてぜひご参照ください。