Katashin .info

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 - 算出プロパティの実体 #

ComputedRefcomputed 関数が作成する特殊なリアクティブ値です。通常の ref と同様に .value プロパティでアクセスしますが、値は computed に渡した関数の計算結果から導出します。

ComputedRef は値を遅延評価し、キャッシュします。値が要求されるまで計算を実行せず、一度計算した結果は依存関係が変更されるまでキャッシュします。内部では _value(キャッシュ値)と _dirty(再計算フラグ)でこの挙動を制御します。

ReactiveEffect - 副作用の管理 #

ReactiveEffect はリアクティブな値の変更に応じて実行する副作用(関数)を管理するクラスです。computedwatchwatchEffect、コンポーネントの render 関数など、Vue のリアクティブ機能の多くが内部で ReactiveEffect を使用します。

ReactiveEffect は副作用関数のほかにスケジューラー関数を登録できます。通常、依存関係の変更時に副作用関数が直接実行されますが、スケジューラー関数を設定した場合はそれが実行されます。これにより副作用の実行タイミングを制御できます。たとえば watchflush オプションで実行タイミングが変わりますが、これはスケジューラー関数で制御しています

ComputedRef は内部で ReactiveEffect を持ち、計算関数が依存するリアクティブな値を追跡します。依存関係の変更時にスケジューラー関数が _dirty フラグをセットし、再計算を行わせます。

Dep - 依存関係の収集と通知 #

Dep(Dependency の略)はリアクティブな値とそれを監視する ReactiveEffect の関係を管理するデータ構造で、どの ReactiveEffect が値に依存するかを記録します。すべてのリアクティブな値は自身の依存を管理する Dep を所有します。

リアクティブな値(refcomputed)を読み取ると、現在アクティブな 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 で定義した countmultiplier はリアクティブな値として Dep インスタンスを持ち、依存する ReactiveEffect を記録します。

computed で定義した multiCount は ComputedRef インスタンスで、計算処理を ReactiveEffect で持ち、依存する値 (countmultiplier) の更新時に再計算されます。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 を実行すると以下の図の処理を行います。

Vue 3.3 以前の処理フロー図。A.value = 2 の変更により、B、C、watchEffect が順次通知され、値に変化がなくても再計算と実行が行われる様子を表している。

1. A の値変更と Dep への通知 #

2. B の ComputedRef への通知と dirty 化 #

3. C の ComputedRef への通知と dirty 化 #

4. watchEffect の実行とすべての computed の再計算 #

問題は、B の計算結果が0のまま、C の結果も 'even' のまま変わらないのに、すべての再計算と watchEffect が実行されることです。

Vue 3.4 の改善された処理フロー #

Vue 3.4 では PR #5912 の改善により処理フローが以下の図のように変わりました。

Vue 3.4 の改善された処理フロー図。A.value = 2 の変更後、B の再計算で値が変わらないことを検出し、C と watchEffect の実行をスキップする様子を表している。

ポイントは dirtyLevel の導入により、"not dirty"、"dirty" の二値に加え、未確定の "maybe dirty" も扱うようになったことです。

1. A の値変更と Dep への通知(3.3 と同じ) #

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 状態を確定させる #

もし上記の (*) の部分で 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 を使用できるようになりました。