🧮

Vue.js 3.4 で向上した Computed プロパティ

2024/01/04に公開

2023年末、Vue.js 3.4 がリリースされました。

このバージョンでは、Computed プロパティのパフォーマンスが向上しています。
この記事では Vue 3.4 においてどのような改善が行われ、それによって今後のベストプラクティスがどのように変化するかを考えてみたいと思います。

どんな改善があったか一目瞭然の ツイート Post

https://twitter.com/johnsoncodehk/status/1695383715906744449

ms の更新により secminhour の順に更新されていく Computed プロパティです。

動画を見ると分かるとおり Vue 3.4 の Computed プロパティ minsec の値が変更されたときのみ再計算されています(hour も同様)

この投稿をしたJohnson Chu氏によってアップデートされたのが Vue 3.4 の Computed プロパティです。

Vue 3.4 のコード(script部のみ)
let sec_counter = 0
let min_counter = 0
let hour_counter = 0

const ms = ref(0)
const sec = computed(() => { sec_counter++; return Math.floor(ms.value / 1000) })
const min = computed(() => { min_counter++; return Math.floor(sec.value / 60) })
const hour = computed(() => { hour_counter++; return Math.floor(min.value / 60) })

setInterval(() => {
  ms.value += 10
}, 10)

そもそも Computed プロパティとは

Vue.js ではリアクティブな値をもとに(副作用を伴うことなく、その値をもって)別のリアクティブな値を取得する場合に、Computed プロパティを利用します。

もっとも単純な例はつぎのようなものです。

const counter = ref(0)
const doubled = computed(() => counter.value * 2)

const inc = () => counter.value++

(ボタンのクリックなどで inc() が呼ばれ)counter の値が変更されると、doubled の値も自動的に更新されます。

doubled の値が(テンプレート内のHTMLや、その他の場所で)参照されるたびにcounter の値を2倍にする関数が実行されるのではなく counter の値が変更されたときのみ Computed プロパティに記述した関数が実行されます。
そのため、開発者はとくに意識することなく、パフォーマンスの良いコードを書くことができます。

もう少し凝った例

たとえばユーザー情報 User の配列があり、そのなかの特定のユーザーを選択するとします。

type User = {
  id: number
  name: string
  age: number
  companyId: string
  companyName: string
}

const props = defineProps<{
  userId: number
  users: User[]
}>()

const userId = computed(() => props.userId) // toRef でも可
const users = computed(() => props.users) // toRef でも可

const userCompanyId = computed<string | null>(() => {
  const user = users.value.find((user) => user.id === userId.value)
  return user?.companyId ?? null
})
const coworkers = computed<User[]>(() => {
  if (userCompanyId.value == null) return []
  return users.value.filter((user) => user.companyId === userCompanyId.value)
})

ここでは props.users はあまり変化しないことを想定したいと思います。
(一度 fetch したらその後は更新しないようなケースです)

Computed プロパティ userCompanyIduserId の値が変更されたときのみ算出されます。
また、同僚を意図する Computed プロパティ coworkersuserCompanyId の値が変更されたときに算出されますので、やはり userId の値の変更に反応します。

Vue 3.3 までは userId の値が変更されるたびに userCompanyIdcoworkers の双方の関数が実行されていました。
しかし userId の値が変更されたとしても userCompanyId の値が同じ値を返す場合、coworkers の関数は実行される必要がありません。
たとえば、同じ会社の別のユーザーが選択されたケースです。

Vue 3.4 での改善点

Vue 3.4 では、このようなケースにおいて userCompanyId の値が変更されなかった場合、coworkers の関数をトリガーしない最適化が加わりました。
coworkers を watchEffect している場合でも userCompanyId に変化がなかった場合は呼ばれなくなりました)

そのため、もし coworkers の算出に大きなコストがかかる場合、この最適化によってパフォーマンスが向上することが期待できます。
このパフォーマンス向上はアプリケーション側での変更なしに得られるものです。お得ですね。

この効果が期待できないケース

このような効果が望めるのは、依存する userCompanyId.value の型が string | null だからです。
内部的に Object.is による比較が行われているため、同じような結果が返されたとしても、別物と解釈されるケースがあることに注意が必要です。

たとえば、空オブジェクト {} も、空配列 [] も、Object.is による比較では異なるものと判定されます。
=== による判定と近いですが、同一ではありません。

const obj = { foo: 123 }
const arr = [1, 2, 3]

;[
  ['1', 1],
  [NaN, NaN],
  [undefined, undefined],
  [undefined, null],
  [null, null],
  [-0, 0],
  [true, true],
  [{}, {}],
  [[], []],
  [obj, obj],
  [arr, arr],
].map(([a, b]) => console.log(JSON.stringify(a), Object.is(a, b), a === b))

実行するとつぎのような結果が得られます。
(左右の値の同一性を判定。ひとつめの true/falseObject.is による比較結果、2つめが === による比較結果)

> '"1"' false false
> "null" true false  // NaN
> undefined true true
> undefined false false // undefined と null
> "null" true true  // null
> "0" false true
> "true" true true
> "{}" false false
> "[]" false false
> '{"foo":123}' true true
> "[1,2,3]" true true

そのため、つぎのようなケースにおいては、注意が必要です。

type User = {
  id: number
  name: string
  age: number
  companyId: string
  companyName: string
}
type Company = {
  id: string
  name: string
}

const props = defineProps<{
  userId: number
  users: User[]
}>()

const userId = computed(() => props.userId) // toRef でも可
const users = computed(() => props.users) // toRef でも可

const userCompany = computed<Company | null>(() => {
  const user = users.value.find((user) => user.id === userId.value)
  if (user === undefined) return null
  // オブジェクトリテラルを返すと同じ内容でも別の値として扱われる
  return {
    id: user.companyId,
    name: user.companyName,
  }
})
const coworkers = computed<User[]>(() => {
  if (userCompany.value == null) return []
  return users.value.filter((user) => user.companyId === userCompany.value.id)
})

さきの例との違いは Computed プロパティ userCompanyCompany | null 型を オブジェクトリテラルを使って 返す点です。
この関数で return されるオブジェクトは常に異なるものと判定されるため、userCompany の値が同じでも coworkers の関数は実行されてしまいます。

これを解決するために、Vue 3.4 で導入されたもうひとつの変更を知る必要があります。

Vue 3.4 では Computed プロパティは最後の結果を利用できるようになった

Vue 3.4 以降、Computed プロパティの getter 関数は、第1引数として最後の結果が渡されるようになりました。

これにより、つぎのように書くことができます。

type User = {
  id: number
  name: string
  age: number
  companyId: string
  companyName: string
}
type Company = {
  id: string
  name: string
}

const props = defineProps<{
  userId: number
  users: User[]
}>()

const userId = computed(() => props.userId) // toRef でも可
const users = computed(() => props.users) // toRef でも可

const userCompany = computed<Company | null>((lastResult) => {
  const user = users.value.find((user) => user.id === userId.value)
  if (user === undefined) return null
  if (lastResult != null && user.companyId === lastResult.id) {
    return lastResult // ← ここで最後の結果を返す
  }
  return {
    id: user.companyId,
    name: user.companyName,
  }
})
const coworkers = computed<User[]>(() => {
  if (userCompany.value == null) return []
  return users.value.filter((user) => user.companyId === userCompany.value.id)
})

userCompanylastResult を返した場合(同じオブジェクトのため) coworkers の関数は実行されません。

従来の記法で記述することができるケースもあります

なお、このケースでは従来通りに書くこともできます。
(今後はこのように多段に Computed プロパティを使用することはこれまで以上に望ましい手法となるはずです)

type User = {
  id: number
  name: string
  age: number
  companyId: string
  companyName: string
}
type Company = {
  id: string
  name: string
}

const props = defineProps<{
  userId: number
  users: User[]
}>()

const userId = computed(() => props.userId) // toRef でも可
const users = computed(() => props.users) // toRef でも可

const selectedUser = computed<User | undefined>(() => users.value.find((user) => user.id === userId.value))
const userCompany = computed<Company | null>(() => {
  if (selectedUser.value === undefined) return null
  return {
    id: selectedUser.value.companyId,
    name: selectedUser.value.companyName,
  }
})
const userCompanyId = computed<string | null>(() => userCompany.value?.id ?? null)
const coworkers = computed<User[]>(() => {
  if (userCompanyId.value == null) return []
  return users.value.filter((user) => user.companyId === userCompanyId.value)
})

userCompanyId は毎回呼ばれますが、userCompanyuserCompanyId の値が変更されたときのみ呼ばれます。
(このケースは coworkers の計算量が多いときを想定)

最大の恩恵を受けるために気をつけたいこと

いずれにせよ Computed プロパティが(オブジェクト/配列の参照ではなく)オブジェクト/配列リテラルを使って値を返す場合は気をつけましょう。

また coworkers が返す値は常に新しい配列です。
Array.prototype.filterArray.prototype.map 等を使用する場合は気をつけないといけません)
そのため、これを使ってまた別の Computed プロパティを定義する場合は、coworkerslastResult を返すような処理を含めておくとよいでしょう。

const coworkers = computed<User[]>((lastResult) => {
  if (lastResult !== undefined) {
    if (lastResult.length === 0 && userCompany.value == null) {
      return lastResult
    } else if (lastResult[0]?.companyId === userCompany.value.id) {
      return lastResult
    }
  }
  if (userCompany.value == null) return []
  return users.value.filter((user) => user.companyId === userCompany.value.id)
})
JavaScript に慣れてない方へ

つぎのように理解しておくとよいでしょう。

  • lastResult が不要なものの例
    • プリミティブな値(stringnumberbooleanundefinednullSymbol
      • NaN も含みます
    • オブジェクト・配列そのもの(MapSetWeakMapWeakSet も同様)
    • オブジェクト内のプロパティの値・配列内の要素(下記の例で objFoo など)
      • { foo: objFoo, bar: objBar, baz: objBaz } のそれぞれのプロパティ
      • [objFoo, objBar, objBaz] のそれぞれの要素
    • MapSetWeakMapWeakSet のそれぞれのキー・値
  • lastResult が必要になるものの例
    • オブジェクト・配列リテラル({}[])で新たに作成された値
    • 新しいオブジェクトや配列を返す関数の戻り値
      • Object.assignArray.prototype.mapArray.prototype.filterArray.prototype.slice

さいごに

Computed プロパティは、とても Vue らしい機能だと思っています。
Vue 3 の Composition API によって、より柔軟にアプリケーション内のステートを管理することができるようになりました。

Web アプリケーションの開発において、パフォーマンス等を過度に気にすることなく、アプリケーション本来のロジックに集中できることはとても有意義なことです。

一方で、リアクティブな値がどのように扱われるかを理解することは、Vue.js を使用した開発において大変重要です。
今回のようなアップデートがあった際に、公式ドキュメントやソースコードで確認する機会とするのは良い習慣だと思います。

また@ubugeeei氏によるThe chibivue Bookで学習するのもおすすめです。chibivue は、ステップバイステップで手を動かしながら Vue.js のなかを学ぶことができるチュートリアルです。
Vue.js をより深く理解したい方はぜひチャレンジしてみてください。

間違いや不充分な説明を見つけたら

間違いや不充分な説明を見つけたらぜひコメント欄等でお知らせください。
正確な情報を伝えることを大切にしています。些細なことで構いませんので、ぜひご協力お願いします。

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion