Vue.js响应式计算

Vue.js 提供了强大的响应式计算能力,主要通过计算属性(computed)侦听器(watch)来实现。这两种特性使得我们能够基于响应式数据执行复杂的计算和逻辑,同时保持代码的简洁和高效。

计算属性 (Computed Properties)

计算属性是基于它们的响应式依赖进行缓存的派生值。只有在相关响应式依赖发生改变时才会重新求值。这使得计算属性非常高效,特别适用于复杂计算。

核心优势

计算属性具有缓存机制:如果依赖的响应式数据没有变化,计算属性会直接返回缓存的结果,不会重新计算。

基本语法

Composition API计算属性的基本用法

// 导入 computed
import { ref, computed } from 'vue'

// 响应式数据
const firstName = ref('张')
const lastName = ref('三')

// 计算属性
const fullName = computed(() => {
  console.log('计算属性执行了')
  return firstName.value + lastName.value
})

// 使用计算属性
console.log(fullName.value) // 输出: 张三

// 修改依赖数据
firstName.value = '李'
console.log(fullName.value) // 输出: 李三

// 再次访问计算属性(依赖未改变,使用缓存)
console.log(fullName.value) // 不会输出"计算属性执行了"
                        
Options API计算属性的基本用法

export default {
  data() {
    return {
      firstName: '张',
      lastName: '三'
    }
  },
  computed: {
    // 计算属性定义
    fullName() {
      console.log('计算属性执行了')
      return this.firstName + this.lastName
    },

    // 计算属性也可以有 setter
    reversedFullName: {
      get() {
        return this.lastName + this.firstName
      },
      set(newValue) {
        const [last, first] = newValue.split('')
        this.lastName = last
        this.firstName = first
      }
    }
  },
  methods: {
    updateName() {
      this.firstName = '李'
      console.log(this.fullName) // 输出: 李三
    }
  }
}
                        

实际示例

示例购物车计算示例

import { ref, computed } from 'vue'

// 响应式数据
const cartItems = ref([
  { id: 1, name: '商品A', price: 100, quantity: 2 },
  { id: 2, name: '商品B', price: 200, quantity: 1 },
  { id: 3, name: '商品C', price: 50, quantity: 3 }
])

// 计算总数量
const totalQuantity = computed(() => {
  return cartItems.value.reduce((total, item) => total + item.quantity, 0)
})

// 计算总价
const totalPrice = computed(() => {
  return cartItems.value.reduce((total, item) => total + (item.price * item.quantity), 0)
})

// 计算折扣价(假设满500打9折)
const discountedPrice = computed(() => {
  const total = totalPrice.value
  return total >= 500 ? total * 0.9 : total
})

// 计算平均价格
const averagePrice = computed(() => {
  return totalQuantity.value > 0 ? totalPrice.value / totalQuantity.value : 0
})

// 使用计算属性
console.log(`总数量: ${totalQuantity.value}`) // 6
console.log(`总价: ${totalPrice.value}`) // 550
console.log(`折扣价: ${discountedPrice.value}`) // 495
console.log(`平均价格: ${averagePrice.value.toFixed(2)}`) // 91.67
                        

可写计算属性

计算属性默认是只读的,但你可以通过提供 getset 函数来创建一个可写的计算属性。

示例可写计算属性

import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')

// 可写计算属性
const fullName = computed({
  // getter
  get() {
    return firstName.value + ' ' + lastName.value
  },
  // setter
  set(newValue) {
    const names = newValue.split(' ')
    if (names.length >= 2) {
      firstName.value = names[0]
      lastName.value = names[1]
    }
  }
})

// 使用 getter
console.log(fullName.value) // 张 三

// 使用 setter
fullName.value = '李 四'
console.log(firstName.value) // 李
console.log(lastName.value) // 四
                        

侦听器 (Watchers)

侦听器用于观察和响应 Vue 实例上的数据变动。当需要在数据变化时执行异步或开销较大的操作时,侦听器是最有用的。

使用场景

侦听器适用于:执行异步操作、响应数据变化执行复杂逻辑、监听多个数据源、执行副作用操作。

基本语法

Composition API侦听器的基本用法

import { ref, watch } from 'vue'

// 响应式数据
const count = ref(0)
const username = ref('')

// 侦听单个数据源
watch(count, (newValue, oldValue) => {
  console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})

// 侦听多个数据源
watch([count, username], ([newCount, newName], [oldCount, oldName]) => {
  console.log(`count: ${oldCount} -> ${newCount}, name: ${oldName} -> ${newName}`)
})

// 立即执行侦听器
watch(count, (newValue, oldValue) => {
  console.log(`当前 count 值: ${newValue}`)
}, { immediate: true })

// 深度侦听对象
const user = ref({ name: '张三', age: 25 })
watch(user, (newValue, oldValue) => {
  console.log('user 对象发生变化:', newValue)
}, { deep: true })

// 触发变化
count.value++ // 输出: count 从 0 变为 1
username.value = '李四' // 输出: count: 1 -> 1, name:  -> 李四
user.value.age = 26 // 输出: user 对象发生变化: { name: '张三', age: 26 }
                        
Options API侦听器的基本用法

export default {
  data() {
    return {
      count: 0,
      username: '',
      user: {
        name: '张三',
        age: 25
      }
    }
  },
  watch: {
    // 侦听简单属性
    count(newValue, oldValue) {
      console.log(`count 从 ${oldValue} 变为 ${newValue}`)
    },

    // 侦听对象属性
    'user.name'(newValue, oldValue) {
      console.log(`用户名从 ${oldValue} 变为 ${newValue}`)
    },

    // 深度侦听对象
    user: {
      handler(newValue, oldValue) {
        console.log('user 对象发生变化:', newValue)
      },
      deep: true,
      immediate: true // 立即执行一次
    }
  },
  methods: {
    updateData() {
      this.count++
      this.user.name = '李四'
    }
  }
}
                        

实际示例

示例搜索框防抖示例

import { ref, watch } from 'vue'

// 搜索关键词
const searchQuery = ref('')
// 搜索结果
const searchResults = ref([])
// 加载状态
const isLoading = ref(false)

// 防抖函数
function debounce(func, delay) {
  let timer
  return function(...args) {
    clearTimeout(timer)
    timer = setTimeout(() => {
      func.apply(this, args)
    }, delay)
  }
}

// 搜索函数
async function performSearch(query) {
  if (!query.trim()) {
    searchResults.value = []
    return
  }

  isLoading.value = true
  try {
    // 模拟API调用
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
    const data = await response.json()
    searchResults.value = data.results
  } catch (error) {
    console.error('搜索失败:', error)
    searchResults.value = []
  } finally {
    isLoading.value = false
  }
}

// 防抖后的搜索函数
const debouncedSearch = debounce(performSearch, 500)

// 侦听搜索关键词变化
watch(searchQuery, (newQuery) => {
  debouncedSearch(newQuery)
})

// 当用户输入时,会自动触发搜索
searchQuery.value = 'Vue.js'
                        

计算属性 vs 方法 vs 侦听器

特性 计算属性 (computed) 方法 (methods) 侦听器 (watch)
缓存 ✅ 有缓存,依赖不变不重新计算 ❌ 每次调用都重新计算 ❌ 不缓存计算结果
异步操作 ❌ 不支持异步操作 ✅ 支持异步操作 ✅ 支持异步操作
返回值 ✅ 必须返回一个值 ✅ 可以返回任何值 ❌ 没有返回值(执行副作用)
使用场景 派生数据、复杂计算 事件处理、通用函数 数据变化时的响应操作
语法 computed(() => {}) method() {} watch(source, callback)
模板使用 {{ computedValue }} {{ method() }}@click="method" 不在模板中直接使用
使用建议
  • 使用计算属性:当需要基于其他数据计算派生数据时
  • 使用方法:当需要执行不依赖响应式数据的通用逻辑时
  • 使用侦听器:当需要在数据变化时执行异步操作或副作用时

性能优化技巧

技巧避免不必要的重新计算

import { ref, computed, watch } from 'vue'

// 优化前:每次访问都会重新计算
const items = ref([/* 大量数据 */])
const expensiveCalculation = () => {
  console.log('执行昂贵计算')
  return items.value
    .filter(item => item.active)
    .map(item => ({ ...item, processed: true }))
    .sort((a, b) => b.priority - a.priority)
}

// 优化后:使用计算属性进行缓存
const optimizedCalculation = computed(() => {
  console.log('执行昂贵计算(有缓存)')
  return items.value
    .filter(item => item.active)
    .map(item => ({ ...item, processed: true }))
    .sort((a, b) => b.priority - a.priority)
})

// 优化侦听器:避免深度侦听大型对象
const largeObject = ref({ /* 大型对象 */ })
watch(
  () => largeObject.value.specificProperty, // 只侦听需要的属性
  (newValue) => {
    console.log('specificProperty 变化了:', newValue)
  }
)

// 使用 watchEffect 自动追踪依赖
import { watchEffect } from 'vue'
watchEffect(() => {
  // 自动追踪所有访问过的响应式属性
  console.log('items 长度:', items.value.length)
  console.log('第一个项目:', items.value[0]?.name)
})
                        

综合示例:待办事项应用

完整示例使用计算属性和侦听器的待办事项

<template>
  <div class="todo-app">
    <h2>待办事项</h2>

    <div class="mb-3">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入新待办"
        class="form-control"
      >
      <button @click="addTodo" class="btn btn-primary mt-2">添加</button>
    </div>

    <div class="filters mb-3">
      <button
        @click="filter = 'all'"
        :class="{ active: filter === 'all' }"
        class="btn btn-sm me-2"
      >
        全部 ({{ totalCount }})
      </button>
      <button
        @click="filter = 'active'"
        :class="{ active: filter === 'active' }"
        class="btn btn-sm me-2"
      >
        待办 ({{ activeCount }})
      </button>
      <button
        @click="filter = 'completed'"
        :class="{ active: filter === 'completed' }"
        class="btn btn-sm"
      >
        已完成 ({{ completedCount }})
      </button>
    </div>

    <ul class="list-group">
      <li
        v-for="todo in filteredTodos"
        :key="todo.id"
        class="list-group-item d-flex justify-content-between align-items-center"
      >
        <div>
          <input
            type="checkbox"
            v-model="todo.completed"
            class="me-2"
          >
          <span :class="{ 'text-decoration-line-through': todo.completed }">
            {{ todo.text }}
          </span>
        </div>
        <button @click="removeTodo(todo.id)" class="btn btn-sm btn-danger">
          ×
        </button>
      </li>
    </ul>

    <div class="stats mt-3">
      <p>统计信息: {{ statsMessage }}</p>
    </div>
  </div>
</template>

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

// 响应式数据
const todos = ref([
  { id: 1, text: '学习 Vue.js', completed: true },
  { id: 2, text: '编写示例代码', completed: false },
  { id: 3, text: '阅读文档', completed: false }
])
const newTodo = ref('')
const filter = ref('all')

// 计算属性
const totalCount = computed(() => todos.value.length)
const activeCount = computed(() => todos.value.filter(t => !t.completed).length)
const completedCount = computed(() => todos.value.filter(t => t.completed).length)

const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter(t => !t.completed)
    case 'completed':
      return todos.value.filter(t => t.completed)
    default:
      return todos.value
  }
})

const statsMessage = computed(() => {
  const active = activeCount.value
  const total = totalCount.value
  const percentage = total > 0 ? Math.round((active / total) * 100) : 0
  return `还有 ${active} 项待办 (${percentage}% 未完成)`
})

// 方法
function addTodo() {
  if (newTodo.value.trim()) {
    todos.value.push({
      id: Date.now(),
      text: newTodo.value.trim(),
      completed: false
    })
    newTodo.value = ''
  }
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}

// 侦听器:保存数据到本地存储
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

// 侦听器:变化提示
watch(activeCount, (newCount, oldCount) => {
  if (newCount < oldCount) {
    console.log('恭喜!完成了一项任务!')
  }
})

// 初始化:从本地存储加载数据
const savedTodos = localStorage.getItem('todos')
if (savedTodos) {
  todos.value = JSON.parse(savedTodos)
}
</script>

<style scoped>
.todo-app {
  max-width: 500px;
  margin: 0 auto;
}
.active {
  background-color: #007bff;
  color: white;
}
.list-group-item {
  transition: background-color 0.3s;
}
.list-group-item:hover {
  background-color: #f8f9fa;
}
</style>
                        

最佳实践

计算属性最佳实践
  • 使用计算属性处理模板中需要的派生数据
  • 保持计算属性的纯净,避免副作用
  • 对于复杂计算,考虑使用计算属性进行缓存优化
  • 当需要异步操作时,不要使用计算属性
侦听器最佳实践
  • 使用侦听器处理数据变化时的副作用
  • 避免在侦听器中修改正在侦听的数据(可能导致无限循环)
  • 对于大型对象,使用函数返回要侦听的特定属性
  • 考虑使用 watchEffect 自动追踪依赖

总结

Vue.js 的响应式计算系统提供了强大的工具来处理派生数据和响应数据变化。计算属性适合处理需要缓存的派生数据,而侦听器适合处理数据变化时的副作用和异步操作。

理解计算属性和侦听器的区别及适用场景,能够帮助你编写更高效、更易维护的 Vue 应用。记住:

  • 计算属性用于计算派生数据,具有缓存机制
  • 侦听器用于响应数据变化,执行副作用操作
  • 方法用于处理事件和通用逻辑,每次调用都会执行

在实际开发中,合理使用这些特性可以显著提升应用性能。

测试你的理解

思考题
  1. 什么时候应该使用计算属性而不是方法?
  2. 计算属性和侦听器的主要区别是什么?
  3. 如何优化一个频繁重新计算的计算属性?
  4. 什么时候应该使用深度侦听(deep: true)?
@