Vue 计算属性和侦听器详解

274 阅读7分钟

Vue 计算属性和侦听器详解

计算属性(computed)和侦听器(watch)是 Vue 响应式系统中两个核心概念,它们都以不同的方式响应数据变化。下面我将全面解析它们的原理、使用场景和最佳实践。

一、计算属性 (Computed)

1.1 基本概念

计算属性是基于它们的响应式依赖进行缓存的派生值,只有当依赖发生变化时才会重新计算

const count = ref(0)
const doubleCount = computed(() => count.value * 2)

1.2 特点

  • 缓存机制:依赖不变时直接返回缓存值
  • 惰性求值只有被访问时才会计算
  • 响应式:自动追踪依赖关系

1.3 完整语法

const userInfo = computed({
  get() {
    return `${firstName.value} ${lastName.value}`
  },
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})

1.4 计算属性缓存原理

Vue 内部实现简化的依赖追踪机制:

function computed(getter) {
  let value;
  let dirty = true; // 标记是否需要重新计算
  
  const runner = effect(getter, {
    lazy: true,
    scheduler() {
      dirty = true
      trigger(this, 'value') // 触发依赖更新
    }
  })
  
  return {
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
      }
      track(this, 'value') // 收集依赖
      return value
    }
  }
}

二、侦听器 (Watch)

2.1 基本概念

侦听器用于在数据变化时执行副作用操作,比计算属性更适合执行异步或开销较大的操作

watch(count, (newVal, oldVal) => {
  console.log(`count变化: ${oldVal} -> ${newVal}`)
})

2.1.1 高级配置选项

watch(source, callback, {
  immediate: true, // 立即执行
  deep: true,      // 深度监听
  flush: 'post',   // DOM更新后触发
  onTrack(e) {     // 调试依赖追踪
    debugger
  },
  onTrigger(e) {   // 调试触发更新
    debugger
  }
})

2.2 watch API 变体

2.2.1 侦听单个源
// ref
watch(count, callback)

// getter 函数
watch(() => state.count, callback)

// 响应式对象属性
watch(() => obj.prop, callback)
2.2.2 侦听多个源
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
  /* ... */
})
2.2.3 watchEffect

自动追踪依赖的即时回调:

watchEffect(() => {
  console.log('count:', count.value)
})

特点

  • 自动收集依赖
  • 立即执行
  • 无需指定侦听源

2.3 watch中的陷阱 (避坑指南)

2.3.1. Ref

当使用 ref 包裹一个对象时,Vue 的响应式系统仍然能够监听到对象内部的变化,但有一些特定的行为需要注意。

1. ref 包裹对象的工作原理
const objRef = ref({
  name: 'Alice',
  age: 25
})

实际上等价于:

const objRef = ref(reactive({
  name: 'Alice',
  age: 25
}))

Vue 会自动用 reactive() 包裹对象值,所以:

  • 通过 .value 访问的是 reactive 代理对象
  • 对象内部的修改会被追踪
2. 监听对象内部变化
watch(objRef, (newVal, oldVal) => {
  console.log('对象变化:', newVal)
}, { deep: true }) // Vue 3.4+ 可以省略 deep: true

可以监听到

  • 添加/删除属性
  • 修改嵌套属性
  • 数组变化
3.监听方式对比
  1. 监听整个 ref 对象
// 方式1:监听整个ref(需要.value访问)
watch(() => objRef.value, (newVal) => {
  console.log('变化:', newVal)
}, { deep: true })

// 方式2:直接解包(Vue 3.3+)
watch(objRef, (newVal) => {
  console.log('变化:', newVal)
}, { deep: true })
  1. 监听特定属性
// 监听特定属性(不需要deep)
watch(() => objRef.value.name, (newName) => {
  console.log('名字变化:', newName)
})
4.特殊情况处理
  1. 替换整个对象
// 替换整个对象(会触发响应)
objRef.value = { name: 'Bob', age: 30 }

// 监听会触发,且能获取正确的oldVal
  1. 解构问题
// 错误!失去响应性
const { name, age } = objRef.value;

// 正确保持响应性
const name = computed(() => objRef.value.name);
const { name } = toRefs(objRef.value);
2.3.2. Reactive

在 Vue 3 的响应式系统中,当你在 watch 中使用 reactive 对象时,会有一些特定的行为和注意事项。

// 推荐 - 只侦听需要的属性
watch(() => state.importantProp, callback)

// 推荐 - 侦听多个属性
watch([() => state.a, () => state.b], ([a, b]) => {})

// 必要时才侦听整个对象
watch(state, callback, { deep: true }) // Vue 3.4+ 可省略 deep
1. 直接侦听整个 reactive 对象
const state = reactive({ count: 0, user: { name: 'Alice' } })

// 侦听整个 reactive 对象
watch(state, (newVal, oldVal) => {
  console.log('state changed:', newVal)
})

行为特点

  • 任何嵌套属性的变化都会触发回调
  • newValoldVal 将是相同的引用(因为 reactive 对象是引用类型,所以 newVal === oldVal )
  • 需要 deep: true 才能正常工作(Vue 3.4+ 已默认启用)
2. 侦听 reactive 对象的特定属性
watch(() => state.count, (newVal, oldVal) => {
  console.log('count changed:', newVal, oldVal)
})

行为特点

  • 只有特定属性变化才会触发
  • 可以正确获取 oldVal
  • 不需要 deep 选项
3.新旧值相同的问题

当侦听整个 reactive 对象时,newValoldVal 会是相同的:

watch(state, (newVal, oldVal) => {
  console.log(newVal === oldVal) // true
})

原因:reactive 对象是引用类型,Vue 不会对其进行深拷贝

解决方案

  1. 侦听特定属性

  2. 手动深拷贝旧值:

    watch(() => ({ ...state }), (newVal, oldVal) => {
      console.log(newVal === oldVal) // false
    }, { deep: true })
    

三、计算属性 vs 侦听器

特性计算属性侦听器
目的派生新数据执行副作用
缓存有缓存无缓存
返回值必须返回不需要返回
异步不支持支持
初始化惰性求值可配置 immediate
依赖追踪自动显式指定
适用场景模板渲染、数据转换API调用、DOM操作

四、最佳实践

4.1 计算属性最佳实践

  1. 纯函数:避免副作用
  2. 简单计算:复杂逻辑考虑拆分
  3. 命名语义化:如 fullNameisValid
  4. 避免修改依赖:保持单向数据流
// 好的实践
const discountedPrice = computed(() => {
  return basePrice.value * (1 - discount.value)
})

// 不好的实践 - 有副作用
const badComputed = computed(() => {
  fetchData() // 副作用操作
  return ...
})

4.2 侦听器最佳实践

  1. 明确依赖:避免过度使用 deep
  2. 防抖节流:高频操作优化
  3. 清理副作用:返回清理函数
  4. 避免无限循环:注意修改侦听的数据
// 带防抖的搜索
watch(searchQuery, debounce((query) => {
  fetchResults(query)
}, 500))

// 清理副作用示例
watch(data, (newVal) => {
  const timer = setInterval(() => {
    syncToServer(newVal)
  }, 1000)
  return () => clearInterval(timer)
})

五、性能优化

5.1 计算属性优化

  1. 减少依赖:只依赖必要的数据
  2. 避免复杂计算:大数组操作考虑预处理
  3. 使用 v-memo:配合计算属性优化渲染
const bigList = computed(() => {
  // 使用 Map 优化查找性能
  const map = new Map()
  rawList.value.forEach(item => map.set(item.id, item))
  return map
})

5.2 侦听器优化

  1. 避免深度监听:明确指定嵌套路径
  2. 惰性监听:使用 { lazy: true } 选项
  3. 分离监听器:不同逻辑分开监听
// 优化前 - 深度监听整个对象
watch(obj, callback, { deep: true })

// 优化后 - 只监听需要的属性
watch(() => obj.importantProp, callback)

六、实际应用场景

6.1 计算属性典型场景

  1. 数据格式化
const formattedDate = computed(() => {
  return new Date(date.value).toLocaleString()
})
  1. 过滤/排序列表
const filteredUsers = computed(() => {
  return users.value.filter(u => u.active)
})
  1. 条件显示
const showButton = computed(() => {
  return user.value.role === 'admin' && items.value.length > 0
})

6.2 侦听器典型场景

  1. API调用
watch(route.params.id, (newId) => {
  fetchUser(newId)
})
  1. 表单验证
watch(() => form.username, (newVal) => {
  validateUsername(newVal)
})
  1. 路由参数监听
watch(() => route.params.id, (newId) => {
  fetchUserDetails(newId)
}, { immediate: true })
  1. 本地存储同步
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

七、原理深入

7.1 计算属性实现机制

Vue 计算属性基于响应式系统和调度器实现:

  1. 首次访问:执行计算函数并缓存结果
  2. 依赖变化:标记缓存失效 (dirty = true)
  3. 再次访问:重新计算并更新缓存
  4. 无变化:直接返回缓存值

7.2 侦听器实现机制

Vue 侦听器基于 effect 和调度器:

  1. 初始化:创建 effect 并执行一次 (除非 lazy)
  2. 依赖变化:触发调度器
  3. 调度执行:根据 flush 时机执行回调
  4. 清理:组件卸载时自动清理

八、常见问题

8.1 计算属性 vs 方法

  • 计算属性:基于依赖缓存,适合派生数据
  • 方法:每次重新执行,适合事件处理
// 计算属性 - 高效
const fullName = computed(() => `${firstName} ${lastName}`)

// 方法 - 每次重新计算
function getFullName() {
  return `${firstName} ${lastName}`
}

8.2 watch vs watchEffect

  • watch:需要显式指定源,更精确控制
  • watchEffect:自动收集依赖,更简洁
// watch - 明确指定依赖
watch(() => state.count, (count) => {
  console.log(count)
})

// watchEffect - 自动追踪
watchEffect(() => {
  console.log(state.count)
})

8.3 为什么计算属性要有缓存?

  1. 性能优化:避免重复计算
  2. 一致性:保证多次访问同一值
  3. 避免副作用:防止意外多次执行

总结

计算属性和侦听器是 Vue 响应式系统的两大支柱:

  • 优先使用计算属性:用于派生数据和模板渲染
  • 合理使用侦听器:用于副作用操作和异步任务
  • 注意性能影响:避免不必要的重新计算和深度监听
  • 理解原理:有助于编写更高效的代码

掌握它们的区别和使用场景,可以显著提升 Vue 应用的性能和可维护性。