computed 的缓存哲学:如何避免不必要的重复计算?

0 阅读8分钟

前言

在 Vue 应用中,计算属性 computed 是最常用也是最重要的特性之一。它让我们能够声明式地创建基于其他响应式数据的衍生状态。但很多开发者对 computed 的理解停留在表面,不知道它背后的缓存机制,也不清楚何时该用 computed、何时该用 methods。更有甚者,在 computed 中做大量复杂计算,导致性能问题而不自知。

本文将深入探讨 computed 的缓存哲学,通过原理分析和实战案例,帮我们掌握计算属性的正确使用姿势,避免重复计算,提升应用性能。

computed 的工作原理

懒计算:只在访问时求值

computed 的第一个重要特性是懒计算(Lazy Evaluation)。这意味着计算属性不会在创建时立即执行,而是在第一次读取它的值时才会进行计算:

import { ref, computed } from 'vue'
const count = ref(1)
const double = computed(() => {
  console.log('double 被计算了')
  return count.value * 2
})

// 第一次访问 double,触发计算
console.log(double.value) // 输出: "double 被计算了", 2

// 再次访问,使用缓存,不重新计算
console.log(double.value) // 只输出 2,没有计算日志

缓存机制:依赖不变就不重新计算

computed 最核心的特性是缓存。它会记录上一次计算的结果,只有当依赖的响应式数据发生变化时,才会重新计算。如同上述例子一样,当 count 的值没有变化时,重复访问 double,读取的是缓存中的值,并不会重新走计算流程。

依赖追踪:自动收集响应式依赖

computed 本质上是一个特殊的 effect,能够精确知道自己的依赖项,在计算属性执行时,访问到的响应式数据会被自动记录为依赖:

const a = ref(1)
const b = ref(2)
const c = ref(3)
const condition = ref(true)

const result = computed(() => {
  console.log('result 重新计算')
  // 只有 condition 为 true 时才会访问 a
  // 为 false 时访问 b
  if (condition.value) {
    return a.value + c.value
  } else {
    return b.value + c.value
  }
})

console.log(result.value) // 计算一次,依赖: condition, a, c

// 修改 b - 不会触发重新计算,因为当前依赖中不包含 b
b.value = 10
console.log(result.value) // 使用缓存

// 修改 condition
condition.value = false
console.log(result.value) // 重新计算,现在依赖变为 condition, b, c

// 现在修改 b 会触发重新计算
b.value = 20
console.log(result.value) // 重新计算

computed vs methods:性能对比

多次渲染时的表现差异

在开发中,我们可以使用 computed, 也可以使用 methods 来获取衍生数据。它们在功能上没有太大的区别,但在表现上缺有着本质上的区别:

<template>
  <div>
    <!-- 三次使用 computed -->
    <p>Computed: {{ double }}</p>
    <p>Computed: {{ double }}</p>
    <p>Computed: {{ double }}</p>
    
    <!-- 三次调用 methods -->
    <p>Methods: {{ getDouble() }}</p>
    <p>Methods: {{ getDouble() }}</p>
    <p>Methods: {{ getDouble() }}</p>
    
    <button @click="count++">增加</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)

// computed:只会计算一次,缓存三次使用
const double = computed(() => {
  console.log('computed 计算')
  return count.value * 2
})

// methods:每次调用都执行
function getDouble() {
  console.log('methods 执行')
  return count.value * 2
}
</script>

性能对比实验

我们可以写一个简单的例子,对比两者的性能:

<template>
  <div>
    <p>渲染次数: {{ renderCount }}</p>
    <p>Computed 结果: {{ expensiveComputed }}</p>
    <p>Methods 结果: {{ expensiveMethod() }}</p>
    <button @click="count++">更新 count</button>
    <button @click="forceUpdate++">强制更新</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const count = ref(0)
const forceUpdate = ref(0)
const renderCount = ref(0)

// 模拟耗时计算
function expensiveOperation() {
  let result = 0
  for (let i = 0; i < 1000000; i++) {
    result += i
  }
  return result + count.value
}

// computed 版本
const expensiveComputed = computed(() => {
  console.log('耗时计算开始 (computed)')
  const start = performance.now()
  const result = expensiveOperation()
  const end = performance.now()
  console.log(`耗时计算结束,用时: ${(end - start).toFixed(2)}ms`)
  return result
})

// methods 版本
function expensiveMethod() {
  console.log('耗时计算开始 (methods)')
  const start = performance.now()
  const result = expensiveOperation()
  const end = performance.now()
  console.log(`耗时计算结束,用时: ${(end - start).toFixed(2)}ms`)
  return result
}

// 模拟重新渲染
watch(forceUpdate, () => {
  renderCount.value++
})
</script>

上述代码中:

  • 点击"更新 count"(依赖变化):
    • computed:重新计算一次
    • methods:重新计算一次
    • 此时两者的耗时基本一致,没有太大的差别
  • 点击"强制更新"(依赖未变化):
    • computed:使用缓存,不计算
    • methods:不管依赖变不变,每次渲染都重新计算!
    • 这时两者的差别就体现出来了,computed 缓存的性能更好

何时用 computed,何时用 methods

基于以上对比,我们可以得出清晰的选择原则:

  • 基于现有数据衍生出新值:用 computed
  • 事件处理、非响应式计算、需要传参等:用 methods

选择决策树

选择决策树

计算属性的性能陷阱

计算量过大:在 computed 中做复杂计算

computed 虽然会缓存结果,但如果计算本身非常耗时,第一次访问时还是会造成卡顿,因此我们并不推荐在 computed 中做大量复杂的计算:

// ❌ 不好的做法:在 computed 中做大数据处理
const processedData = computed(() => {
  // 假设 data 是一个包含 10 万条记录的数组
  return data.value
    .filter(item => item.active)
    .sort((a, b) => b.value - a.value)
    .map(item => ({
      id: item.id,
      displayName: `${item.name} - ${item.category}`,
      score: item.score * item.weight
    }))
    .reduce((acc, item) => {
      // 复杂的聚合计算
      if (!acc[item.category]) {
        acc[item.category] = []
      }
      acc[item.category].push(item)
      return acc
    }, {})
})

这样当 data 变化时,computed 会重新执行整个复杂计算,可能导致界面卡顿。这种情况,我们一般推荐用多个 computed 去处理,而不是写在一个 computed 中:

const activeItems = computed(() => 
  data.value.filter(item => item.active)
)

const sortedItems = computed(() => 
  [...activeItems.value].sort((a, b) => b.value - a.value)
)

const formattedItems = computed(() => 
  sortedItems.value.map(item => ({
    id: item.id,
    displayName: `${item.name} - ${item.category}`,
    score: item.score * item.weight
  }))
)

const groupedItems = computed(() => 
  formattedItems.value.reduce((acc, item) => {
    if (!acc[item.category]) {
      acc[item.category] = []
    }
    acc[item.category].push(item)
    return acc
  }, {})
)

依赖过多:依赖太细导致频繁重新计算

computed 依赖了太多响应式数据时,任何一个小变化都会导致重新计算:

// ❌ 不好的做法:依赖太多,频繁重新计算
const userProfile = computed(() => {
  return {
    fullName: `${user.value.firstName} ${user.value.lastName}`,
    age: user.value.age,
    email: user.value.email,
    phone: user.value.phone,
    address: `${user.value.city} ${user.value.street}`,
    permissions: user.value.roles.map(r => r.permissions).flat(),
    lastLogin: formatDate(user.value.lastLogin),
    // ... 更多依赖
  }
})

如此一来,computed 几乎每次都会重新计算,丢失了缓存优势。这种情况,也是推荐用多个 computed 去处理:

const basicInfo = computed(() => ({
  fullName: `${user.value.firstName} ${user.value.lastName}`,
  age: user.value.age,
  email: user.value.email
}))

const contactInfo = computed(() => ({
  phone: user.value.phone,
  address: `${user.value.city} ${user.value.street}`
}))

const permissionInfo = computed(() => ({
  roles: user.value.roles,
  permissions: user.value.roles.map(r => r.permissions).flat()
}))

const lastLoginInfo = computed(() => ({
  lastLogin: formatDate(user.value.lastLogin)
}))

副作用问题:computed 中修改数据

computed 中,通常是禁止修改数据的,但缺经常有人这么做,这其实是一个严重的反模式:


// ❌ 绝对禁止:在 computed 中修改数据
const doubleCount = computed(() => {
  count.value++ // 副作用!修改其他响应式数据
  return count.value * 2
})

// ❌ 同样禁止:在 computed 中调用可能修改数据的函数
const userStatus = computed(() => {
  if (!user.value) {
    fetchUser() // 副作用!异步操作
    return 'loading'
  }
  return user.value.status
})

正确做法其实是使用 watch 处理副作用:

watch(user, (newUser) => {
  if (!newUser) {
    fetchUser()
  }
})

const userStatus = computed(() => {
  return user.value?.status || 'loading'
})

为什么不能在 computed 中修改数据呢?

  1. 违反单向数据流:计算属性应该是纯函数,不应该有副作用
  2. 可能导致死循环:修改依赖 -> 触发重新计算 -> 再次修改 -> 无限循环
  3. 不可预测的行为:computed 的求值时机不确定,副作用会导致难以调试的问题

优化策略

拆分计算:一个复杂的 computed 拆成多个小的

这是最常用也最有效的优化策略。通过拆分,我们可以:

  • 减少单个 computed 的计算量
  • 提高缓存命中率
  • 让代码更容易理解
  • 便于单元测试

缓存结果:对于极耗时的计算,使用 cache 模式

有些计算即使拆分后仍然很耗时,这时我们可以考虑手动缓存策略:

// 复杂的数据处理
import { shallowRef, computed } from 'vue'

// 方案1:使用 Map 缓存历史计算结果
const calculationCache = new Map()

const expensiveData = computed(() => {
  const key = JSON.stringify({
    data: rawData.value,
    config: config.value
  })
  
  if (calculationCache.has(key)) {
    console.log('使用缓存结果')
    return calculationCache.get(key)
  }
  
  console.log('执行复杂计算')
  const result = veryExpensiveCalculation(rawData.value, config.value)
  calculationCache.set(key, result)
  
  // 限制缓存大小
  if (calculationCache.size > 100) {
    const firstKey = calculationCache.keys().next().value
    calculationCache.delete(firstKey)
  }
  
  return result
})

// 方案2:使用 LRU 缓存库(如 lru-cache)
import LRU from 'lru-cache'

const cache = new LRU({
  max: 100, // 最多缓存100个结果
  maxAge: 1000 * 60 * 5 // 缓存5分钟
})

const cachedComputation = computed(() => {
  const key = generateKey(dep1.value, dep2.value)
  
  if (cache.has(key)) {
    return cache.get(key)
  }
  
  const result = expensiveComputation(dep1.value, dep2.value)
  cache.set(key, result)
  return result
})

使用 getter 和 setter:双向绑定时控制写操作

computed 默认只有 getter,但也可以提供 setter 来实现双向绑定:

const rawValue = ref(50)

const clampedValue = computed({
  get() {
    return rawValue.value
  },
  set(newValue) {
    // 确保数值在 0-100 之间
    rawValue.value = Math.max(0, Math.min(100, newValue))
  }
})

性能优化总结

  • 拆分大型 computed:将一个大计算拆分为多个小计算
  • 避免在 computed 中修改数据:保持纯函数
  • 减少依赖粒度:只依赖真正需要的数据
  • 使用缓存策略:对极耗时计算实现手动缓存
  • 考虑使用 watch:需要副作用时用 watch 替代

使用原则

应该使用 computed 的场景:

  • 从现有数据派生新数据
  • 需要在模板中多次使用同一个表达式
  • 计算逻辑较复杂,需要命名提高可读性
  • 希望利用缓存避免重复计算

不应该使用 computed 的场景:

  • 需要传参(用 methods)
  • 每次都需要新值(如随机数、时间戳)
  • 有副作用(修改其他数据)
  • 异步操作(用 watch 或 methods)

代码审查要点

  • computed 是否足够"纯"?(没有副作用)
  • 是否可以用 computed 替代 methods?(检查是否在模板中多次调用)
  • computed 的依赖是否都是响应式的?
  • 是否过度拆分?(拆分太多也会增加开销)
  • 计算逻辑是否复杂到需要拆分为多个 computed

结语

computed 的核心价值是缓存,而缓存的核心价值是避免不必要的重复计算。只有深刻理解这一点,才能真正用好 computed,写出高性能的 Vue 应用。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!