
Vue 3.4 で変わった computed の再計算アルゴリズム – 処理順序の逆転による最適化
Vue 3.4 ではリアクティビティシステムの根幹が改善されました。特に注目すべきはcomputed
の挙動変更で、不要な再計算や再レンダリングが大幅に削減され、パフォーマンスが向上しています。
本記事は以前の記事で解説した従来の computed の課題と computedEager の解説を踏まえ、Vue 3.4 での computed
の内部実装に焦点を当て、効率化の仕組みを詳しく解説します。
リアクティビティシステムの改善が行われた PR #5912 #
Vue 3.4 の computed
改善はPR #5912 で実装されました。この変更の核心は「If computed
new value does not change, computed
, effect
, watch
, watchEffect
, render
dependencies will not be triggered」という一文に集約されます。つまり、computed
の計算結果が変わらない場合、その値に依存する処理を実行しないのです。以前の記事で解説したように、従来は計算結果が変化しなくても依存する計算を実行していました。computedEager
はこの問題を解決するユーティリティでした。
以降、PR #5912 のコードの差分を参照しながらこの変更を解説します。なお、Vue 3.5 ではリアクティビティシステムの大規模なリファクタリングによりパフォーマンスとメモリ効率がさらに改善されました。ユーザーから見える挙動に大きな違いはありませんが、内部構造は本記事で紹介するものから一部変更されています。
リアクティビティシステムの基本構造 #
Vue 3.4 の computed
改善を理解するには、Vue のリアクティビティシステムの基本構造を把握する必要があります。このシステムは主に3つの概念で構成されます。
ComputedRef - 算出プロパティの実体 #
ComputedRef は computed
関数が作成する特殊なリアクティブ値です。通常の ref
と同様に .value
プロパティでアクセスしますが、値は computed
に渡した関数の計算結果から導出します。
ComputedRef は値を遅延評価し、キャッシュします。値が要求されるまで計算を実行せず、一度計算した結果は依存関係が変更されるまでキャッシュします。内部では _value
(キャッシュ値)と _dirty
(再計算フラグ)でこの挙動を制御します。
ReactiveEffect - 副作用の管理 #
ReactiveEffect はリアクティブな値の変更に応じて実行する副作用(関数)を管理するクラスです。computed
、watch
、watchEffect
、コンポーネントの render
関数など、Vue のリアクティブ機能の多くが内部で ReactiveEffect を使用します。
ReactiveEffect は副作用関数のほかにスケジューラー関数を登録できます。通常、依存関係の変更時に副作用関数が直接実行されますが、スケジューラー関数を設定した場合はそれが実行されます。これにより副作用の実行タイミングを制御できます。たとえば watch
は flush
オプションで実行タイミングが変わりますが、これはスケジューラー関数で制御しています。
ComputedRef は内部で ReactiveEffect を持ち、計算関数が依存するリアクティブな値を追跡します。依存関係の変更時にスケジューラー関数が _dirty
フラグをセットし、再計算を行わせます。
Dep - 依存関係の収集と通知 #
Dep(Dependency の略)はリアクティブな値とそれを監視する ReactiveEffect の関係を管理するデータ構造で、どの ReactiveEffect が値に依存するかを記録します。すべてのリアクティブな値は自身の依存を管理する Dep を所有します。
リアクティブな値(ref
や computed
)を読み取ると、現在アクティブな ReactiveEffect がその値の Dep に追加されます。値の変更時は Dep に登録されたすべての ReactiveEffect に通知します。
コードと図による例 #
具体的なコードで3つの概念の関係を見てみましょう。
import { ref, computed, watchEffect } from 'vue'
// 1. リアクティブな値(ref/reactive)
const count = ref(0) // 内部に Dep を持つ
const multiplier = ref(2) // 内部に Dep を持つ
// 2. ComputedRef
// computed は内部で ReactiveEffect を作成し、引数の getter 関数をラップする
// また、ComputedRef も内部に Dep を持つ
const multiCount = computed(() => {
// この関数の実行中、count と multiplier へのアクセスが
// 追跡され、それぞれの Dep に現在 (multiCount) の ReactiveEffect が登録される
return count.value * multiplier.value
})
// 3. ReactiveEffect
// watchEffect も内部で ReactiveEffect を作成
watchEffect(() => {
// この関数の実行中、multiCount へのアクセスが追跡され、
// multiCount の Dep に現在 (watchEffect) の ReactiveEffect が登録される
console.log(`multiCount is: ${multiCount.value}`)
})
上記のコードは以下の図に示す内部状態を生成します。
ref
で定義した count
と multiplier
はリアクティブな値として Dep インスタンスを持ち、依存する ReactiveEffect を記録します。
computed
で定義した multiCount
は ComputedRef インスタンスで、計算処理を ReactiveEffect で持ち、依存する値 (count
と multiplier
) の更新時に再計算されます。ComputedRef の内部状態はキャッシュ値 (_value
) と再計算フラグ (_dirty
) を持ちます。また、ComputedRef もリアクティブな値として Dep インスタンスを持ちます。
console.log
を実行する watchEffect
は ReactiveEffect を生成し、multiCount
の Dep に依存関係として登録されます。
これら3つの概念の協調により Vue のリアクティビティシステムが機能します。ComputedRef は ReactiveEffect で計算関数を管理し、Dep で自身に依存する他の ReactiveEffect を追跡します。同時に ComputedRef 自身も他のリアクティブな値の Dep に登録され、変更通知を受け取ります。
この基本構造を踏まえて、Vue 3.4 の computed
改善を見ていきましょう。
computed の再計算アルゴリズムの改善 #
Vue 3.3 以前と 3.4 での処理フローの違いを、具体的な例を通じて詳しく見ていきましょう。以下のような依存関係チェーンを考えます。
const A = ref(0)
const B = computed(() => A.value % 2)
const C = computed(() => (B.value === 0 ? 'even' : 'odd'))
watchEffect(() => {
console.log(C.value)
})
A.value = 2
この例では A → B → C という依存関係チェーンが形成され、watchEffect
が C の値を監視します。
Vue 3.3 以前の処理フロー #
Vue 3.3 以前では A.value = 2
を実行すると以下の図の処理を行います。
1. A の値変更と Dep への通知 #
ref
である A の値を0から2に変更します- A の Dep に登録されたすべての ReactiveEffect(この場合は B)に通知します
2. B の ComputedRef への通知と dirty 化 #
- B の ReactiveEffect のスケジューラー関数が B を "dirty" 状態にマークします
- 即座に B の Dep に登録されたすべての ReactiveEffect(この場合は C)に通知します
3. C の ComputedRef への通知と dirty 化 #
- 同様に C も "dirty" 状態になります
- C の Dep に登録された
watchEffect
の ReactiveEffect に通知します
4. watchEffect の実行とすべての computed の再計算 #
watchEffect
のコールバックを実行します- コールバック関数で
C.value
にアクセスし、以下の連鎖的な再計算を行います- C を再計算する
- C の計算に B の値が必要 → B を再計算する
- B の値は
2 % 2 = 0
で0のまま - C の値は
0 === 0 ? 'even' : 'odd'
→'even'
で変わらない
console.log('even')
を実行します
問題は、B の計算結果が0のまま、C の結果も 'even' のまま変わらないのに、すべての再計算と watchEffect
が実行されることです。
Vue 3.4 の改善された処理フロー #
Vue 3.4 では PR #5912 の改善により処理フローが以下の図のように変わりました。
ポイントは dirtyLevel の導入により、"not dirty"、"dirty" の二値に加え、未確定の "maybe dirty" も扱うようになったことです。
1. A の値変更と Dep への通知(3.3 と同じ) #
ref
である A の値を0から2に変更します- A の Dep に登録されたすべての ReactiveEffect(この場合は B)に通知します
2. B の ComputedRef への通知と dirty 化 #
A の Dep から B の ReactiveEffect に通知します。この段階で B の ReactiveEffect が "dirty" 状態になります。3.4 では ComputedRef ではなく ReactiveEffect が dirty 状態を持ちます。
以下は ref
の値更新時に呼び出す triggerEffects
の簡略化した実装です。triggerEffects
は引数の dirtyLevel
を ReactiveEffect の状態にセットし、依存関係のトリガー関数を呼び出します。トリガー関数は 3.4 で ReactiveEffect に追加された機能で、ComputedRef においては 3.3 のスケジューラー関数と同様に依存からの通知を受け取り、dirty 状態を更新します。
export function triggerEffects(
dep: Dep,
dirtyLevel: DirtyLevels,
debuggerEventExtraInfo?: DebuggerEventExtraInfo,
) {
pauseScheduling()
for (const effect of dep.keys()) {
if (effect._dirtyLevel < dirtyLevel) {
// 依存の dirty 状態を更新する
effect._dirtyLevel = dirtyLevel
// 依存の ReactiveEffect のトリガー関数を実行する
effect.trigger()
if (effect.scheduler) {
queueEffectSchedulers.push(effect.scheduler)
}
}
}
resetScheduling()
}
3. C の ComputedRef への通知と maybe dirty 化 #
B の ReactiveEffect のトリガー関数が依存する ReactiveEffect へ通知します(この場合は C)。変更前は単に通知するだけでしたが、変更後は triggerRefValue
の引数で "maybe dirty" 状態を付与しながら通知します。これにより C は "maybe dirty" 状態になります。
this.effect = new ReactiveEffect(getter, () => {
triggerRefValue(this, DirtyLevels.ComputedValueMaybeDirty)
})
4. watchEffect への通知と maybe dirty 化 #
3.4 では ReactiveEffect が dirty 状態を持つため、C から通知を受けた watchEffect
も "maybe dirty" になります。
5. watchEffect から依存を遡り、dirty 状態を確定させる #
watchEffect
のスケジュール関数が ReactiveEffect のdirty
を参照しますwatchEffect
は "maybe dirty" なので、dirty
の getter で再帰的に依存の再計算を試みますwatchEffect
の依存である C も "maybe dirty" なので、同様に再帰的に依存の再計算を試みます- C の依存である B は "dirty" なので値を再計算しますが、結果は
2 % 2 = 0
で前回と変わらないため if 文内の通知をスキップします (*)
- C の依存である B は "dirty" なので値を再計算しますが、結果は
- C の
dirty
getter に戻り、すべての依存(B のみ)に更新がないため "not dirty" 状態となりdirty = false
を返します
watchEffect
のdirty
getter に戻り、すべての依存(C のみ)に更新がないため "not dirty" 状態となりdirty = false
を返しますdirty = true
のときのみwatchEffect
のコールバックを呼ぶため、この場合は実行しません。
もし上記の (*) の部分で B の値が変わっていれば B から C に通知し、C を "maybe dirty" から "computed value dirty" に更新します。それに依存する ReactiveEffect はすべて dirty = true
となり watchEffect
が実行されます。
処理フローの改善前後の概念的な違い #
Vue 3.3 と 3.4 の処理フローの違いを概念的に整理します。
Vue 3.3 以前は watchEffect の実行時に watchEffect → C → B という順番で再計算します。最終的な値を得るために依存チェーンを遡りながら必要な値を再計算する方式です。
一方 Vue 3.4 は "maybe dirty" 状態の導入により再計算の順番を逆転させました。watchEffect の実行判定時に B → C → watchEffect という順番で dirty 状態の確定と再計算を行います。B を先に再計算し、値が変わらなければ C と watchEffect を "not dirty" と判定し、再計算をスキップします。
この改善により Vue 3.4 では値の変更がない限り後続処理を実行しない挙動が実現されました。依存チェーンの途中で値の変更がないと判明したらそれ以降の処理をすべてスキップできます。
オブジェクトを返す computed の考慮事項 #
プリミティブ値を返す computed
では Vue 3.4 の改善が自動的に適用されます。しかしオブジェクトを返す computed
の場合は注意が必要です。
const user = ref({ name: 'John', age: 30 })
// 毎回新しいオブジェクトを生成している
const userInfo = computed(() => ({
displayName: user.value.name,
isAdult: user.value.age >= 18,
}))
// この変更によって userInfo の値は変わらないが、
// 変更された扱いになってしまう
user.value.age = 31
このケースでは内容が同じでも新しいオブジェクトを生成するため、Vue 3.4 でも毎回変更として扱われます。
この問題を解決するため、Vue 3.4 では computed
getter の第一引数に前回の値(oldValue
)が渡されます。
const userInfo = computed((oldValue) => {
const newValue = {
displayName: user.value.name,
isAdult: user.value.age >= 18,
}
// 手動で比較して、同じなら前の値を返す
if (
oldValue &&
oldValue.displayName === newValue.displayName &&
oldValue.isAdult === newValue.isAdult
) {
return oldValue
}
return newValue
})
computed
の依存関係に重い処理があるなど、パフォーマンス上重要な場所では手動比較で前回の値を返すとよいでしょう。
結論 #
Vue 3.4 の computed
改善は計算結果が変わらない場合に依存する処理を実行しない原則を実現しました。これは "maybe dirty" 状態の導入と再計算順序の逆転により達成されています。
Vue 3.3 は watchEffect → C → B という順番で依存チェーンを遡りながら再計算しましたが、Vue 3.4 は B → C → watchEffect という順番で根元から値の変更を確認します。この順序の逆転により依存チェーンの途中で値の変更がないと判明した時点でそれ以降の処理をすべてスキップできます。
この最適化により以前必要だった computedEager
のような回避策が不要になり、パフォーマンスを維持したままより直感的に computed
を使用できるようになりました。