侦听器watch与watchEffect

在Vue 3中,侦听器是用于监听响应式数据变化并执行副作用的API。Vue 3提供了watchwatchEffect两个主要的侦听器API。本集我们将深入探讨这两个API的使用方法、实现原理和最佳实践。

一、侦听器的基本概念

1. 什么是侦听器?

侦听器是一种用于监听响应式数据变化并执行副作用的机制。当监听的响应式数据发生变化时,侦听器会自动执行指定的回调函数。

2. 侦听器的作用

  • 执行副作用:当数据变化时执行异步或开销较大的操作
  • 数据同步:将一个数据的变化同步到另一个数据
  • 状态管理:监听状态变化,执行相应的逻辑
  • API调用:当数据变化时调用API
  • DOM操作:当数据变化时操作DOM

3. watch与watchEffect的区别

特性 watch watchEffect
依赖追踪 手动指定依赖 自动追踪依赖
执行时机 依赖变化时执行 初始执行一次,依赖变化时执行
回调参数 可以访问新值和旧值 无法直接访问旧值
控制执行 可以更精细地控制何时执行 自动执行,控制较少
适用场景 需要访问旧值或精细控制 只需执行副作用,无需访问旧值

二、watch API的基本使用

1. 监听单个ref

import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`)
})

2. 监听reactive对象

import { reactive, watch } from 'vue'

const user = reactive({
  name: 'Alice',
  age: 30
})

watch(user, (newUser, oldUser) => {
  console.log('User changed:', newUser, oldUser)
}) // 注意:此处oldUser是新值的引用,不是真正的旧值

// 监听reactive对象的单个属性
watch(() => user.name, (newName, oldName) => {
  console.log(`Name changed from ${oldName} to ${newName}`)
})

3. 监听多个数据源

import { ref, watch } from 'vue'

const count = ref(0)
const message = ref('Hello')

watch([count, message], ([newCount, newMessage], [oldCount, oldMessage]) => {
  console.log(`Count: ${oldCount} -> ${newCount}`)
  console.log(`Message: ${oldMessage} -> ${newMessage}`)
})

4. 监听深度变化

import { reactive, watch } from 'vue'

const user = reactive({
  name: 'Alice',
  address: {
    city: 'Beijing',
    street: 'Main St'
  }
})

// 深度监听
watch(
  () => user.address,
  (newAddress, oldAddress) => {
    console.log('Address changed:', newAddress, oldAddress)
  },
  { deep: true } // 开启深度监听
)

5. 立即执行

import { ref, watch } from 'vue'

const count = ref(0)

watch(
  count,
  (newValue, oldValue) => {
    console.log(`Count: ${oldValue} -> ${newValue}`)
  },
  { immediate: true } // 立即执行一次
)

三、watchEffect API的基本使用

1. 基本用法

import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  console.log(`Count is: ${count.value}`)
}) // 初始执行一次,输出:Count is: 0

count.value++ // 依赖变化,执行副作用,输出:Count is: 1

2. 访问DOM

import { ref, watchEffect } from 'vue'

const count = ref(0)

watchEffect(() => {
  // 访问DOM元素
  document.title = `Count: ${count.value}`
})

3. 异步操作

import { ref, watchEffect } from 'vue'

const searchQuery = ref('')

watchEffect(async () => {
  console.log(`Searching for: ${searchQuery.value}`)
  const result = await fetch(`/api/search?q=${searchQuery.value}`)
  const data = await result.json()
  console.log('Search result:', data)
})

四、侦听器的实现原理

1. watch的实现原理

watch的实现基于Vue 3的响应式系统,主要包括以下步骤:

  1. 初始化:注册监听器,指定要监听的数据源和回调函数
  2. 依赖收集:当监听函数执行时,Vue会自动收集依赖的响应式数据
  3. 依赖变化:当依赖的响应式数据变化时,触发更新
  4. 执行回调:执行监听器的回调函数,传递新值和旧值

2. watchEffect的实现原理

watchEffect的实现也基于Vue 3的响应式系统,主要包括以下步骤:

  1. 初始执行:立即执行副作用函数
  2. 依赖收集:在执行副作用函数过程中,Vue会自动收集依赖的响应式数据
  3. 依赖变化:当依赖的响应式数据变化时,触发副作用函数重新执行
  4. 重新执行:重新执行副作用函数,完成副作用更新

3. 简化的实现原理

// watch的简化实现
function watch(source, callback, options = {}) {
  let getter
  
  // 处理不同类型的source
  if (typeof source === 'function') {
    getter = source
  } else if (isRef(source)) {
    getter = () => source.value
  } else if (isReactive(source)) {
    getter = () => source
  }
  
  let oldValue
  let newValue
  let cleanup
  
  // 副作用函数
  const effect = () => {
    newValue = getter()
    
    // 如果有清理函数,先执行
    if (cleanup) {
      cleanup()
    }
    
    // 执行回调,传递新值和旧值
    callback(newValue, oldValue, onCleanup => {
      cleanup = onCleanup
    })
    
    // 更新旧值
    oldValue = newValue
  }
  
  // 根据选项决定是否立即执行
  if (options.immediate) {
    effect()
  } else {
    oldValue = getter()
  }
  
  // 依赖收集,当依赖变化时执行effect
  trackEffect(effect)
}

// watchEffect的简化实现
function watchEffect(effect, options = {}) {
  let cleanup
  
  // 包装副作用函数
  const wrappedEffect = () => {
    // 如果有清理函数,先执行
    if (cleanup) {
      cleanup()
    }
    
    // 执行副作用函数,传递清理函数
    effect(onCleanup => {
      cleanup = onCleanup
    })
  }
  
  // 立即执行一次
  wrappedEffect()
  
  // 依赖收集,当依赖变化时执行wrappedEffect
  trackEffect(wrappedEffect)
}

五、清理副作用

1. watch的清理函数

import { ref, watch } from 'vue'

const searchQuery = ref('')

watch(searchQuery, async (newQuery, oldQuery, onCleanup) => {
  // 创建一个AbortController用于取消请求
  const controller = new AbortController()
  
  // 注册清理函数
  onCleanup(() => {
    controller.abort() // 取消之前的请求
    console.log('Cleaned up previous request')
  })
  
  try {
    const response = await fetch(`/api/search?q=${newQuery}`, {
      signal: controller.signal
    })
    const data = await response.json()
    console.log('Search result:', data)
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('Search error:', error)
    }
  }
})

2. watchEffect的清理函数

import { ref, watchEffect } from 'vue'

const searchQuery = ref('')

watchEffect(async (onCleanup) => {
  // 创建一个AbortController用于取消请求
  const controller = new AbortController()
  
  // 注册清理函数
  onCleanup(() => {
    controller.abort() // 取消之前的请求
    console.log('Cleaned up previous request')
  })
  
  try {
    const response = await fetch(`/api/search?q=${searchQuery.value}`, {
      signal: controller.signal
    })
    const data = await response.json()
    console.log('Search result:', data)
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('Search error:', error)
    }
  }
})

六、侦听器的高级用法

1. 停止侦听器

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

const count = ref(0)

// 停止watch
const stopWatch = watch(count, (newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`)
})

// 停止watchEffect
const stopWatchEffect = watchEffect(() => {
  console.log(`Count is: ${count.value}`)
})

// 停止侦听器
setTimeout(() => {
  stopWatch() // 停止watch
  stopWatchEffect() // 停止watchEffect
  console.log('Listeners stopped')
}, 5000)

2. 防抖与节流

import { ref, watch } from 'vue'

const searchQuery = ref('')

// 防抖函数
function debounce(fn, delay) {
  let timeoutId
  return (...args) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => fn(...args), delay)
  }
}

// 带防抖的watch
watch(
  searchQuery,
  debounce(async (newQuery) => {
    console.log(`Searching for: ${newQuery}`)
    const result = await fetch(`/api/search?q=${newQuery}`)
    const data = await result.json()
    console.log('Search result:', data)
  }, 300)
)

3. 监听嵌套对象

import { reactive, watch } from 'vue'

const user = reactive({
  profile: {
    name: 'Alice',
    address: {
      city: 'Beijing',
      street: 'Main St'
    }
  }
})

// 监听嵌套对象的单个属性
watch(
  () => user.profile.address.city,
  (newCity, oldCity) => {
    console.log(`City changed from ${oldCity} to ${newCity}`)
  }
)

// 深度监听嵌套对象
watch(
  () => user.profile,
  (newProfile, oldProfile) => {
    console.log('Profile changed:', newProfile, oldProfile)
  },
  { deep: true }
)

4. 与computed结合使用

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

const radius = ref(5)

// 计算属性:计算圆的面积
const area = computed(() => {
  return Math.PI * radius.value * radius.value
})

// watch:监听面积变化
watch(area, (newArea, oldArea) => {
  console.log(`Area changed from ${oldArea} to ${newArea}`)
})

七、侦听器的最佳实践

1. 选择合适的侦听器API

  • 当需要访问旧值或精细控制监听行为时,使用watch
  • 当只需执行副作用,无需访问旧值时,使用watchEffect
  • 当需要初始执行一次副作用时,使用watchEffect或带有immediate: true选项的watch

2. 避免在侦听器中修改响应式数据

// 不推荐:在侦听器中修改响应式数据,可能导致无限循环
const count = ref(0)

watch(count, (newValue) => {
  if (newValue > 10) {
    count.value = 10 // 可能导致无限循环
  }
})

// 推荐:使用计算属性或其他方式处理
const count = ref(0)
const limitedCount = computed(() => Math.min(count.value, 10))

3. 清理副作用

  • 当执行异步操作时,一定要清理之前的异步操作
  • 使用onCleanup函数注册清理逻辑
  • 清理函数会在副作用重新执行前或组件卸载时执行

4. 避免过度使用侦听器

  • 只在必要时使用侦听器
  • 对于简单的数据转换,使用计算属性
  • 对于复杂的副作用,才使用侦听器

5. 注意性能

  • 避免深度监听大型对象,这会导致性能问题
  • 对于大型对象,只监听需要变化的特定属性
  • 对于频繁变化的数据,使用防抖或节流

八、侦听器的常见错误

1. 监听reactive对象时无法获取旧值

import { reactive, watch } from 'vue'

const user = reactive({
  name: 'Alice',
  age: 30
})

watch(user, (newUser, oldUser) => {
  console.log(newUser === oldUser) // 输出:true,因为oldUser是新值的引用
})

解决方案:监听返回对象的函数,而不是对象本身

watch(
  () => ({ ...user }), // 创建对象的副本
  (newUser, oldUser) => {
    console.log(newUser === oldUser) // 输出:false
  }
)

2. 忘记清理异步操作

// 不推荐:未清理异步操作,可能导致竞态条件
watch(searchQuery, async (newQuery) => {
  const result = await fetch(`/api/search?q=${newQuery}`)
  const data = await result.json()
  console.log('Search result:', data)
  // 问题:如果searchQuery快速变化,可能会导致多个请求同时进行,最终结果可能不是最新的
})

解决方案:使用onCleanup清理异步操作

watch(searchQuery, async (newQuery, oldQuery, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  
  try {
    const result = await fetch(`/api/search?q=${newQuery}`, {
      signal: controller.signal
    })
    const data = await result.json()
    console.log('Search result:', data)
  } catch (error) {
    if (error.name !== 'AbortError') {
      console.error('Search error:', error)
    }
  }
})

3. 无限循环

// 不推荐:在侦听器中修改被监听的数据,导致无限循环
const count = ref(0)

watch(count, (newValue) => {
  count.value++ // 修改被监听的数据,导致无限循环
})

解决方案:避免在侦听器中修改被监听的数据,或者使用条件判断

const count = ref(0)

watch(count, (newValue) => {
  if (newValue < 10) {
    // 只有在特定条件下才修改数据
    count.value++
  }
})

4. 监听对象属性时,依赖未正确跟踪

// 不推荐:直接监听对象的属性,可能导致依赖未正确跟踪
const user = reactive({
  name: 'Alice'
})

watch(user.name, (newName, oldName) => {
  console.log(`Name changed from ${oldName} to ${newName}`)
}) // 错误:user.name不是响应式引用

解决方案:使用函数返回需要监听的属性

watch(
  () => user.name, // 正确:使用函数返回需要监听的属性
  (newName, oldName) => {
    console.log(`Name changed from ${oldName} to ${newName}`)
  }
)

九、watch与watchEffect的性能比较

1. 性能开销

  • watch:性能开销较小,因为它只在指定的依赖变化时执行
  • watchEffect:性能开销可能较大,因为它会自动追踪所有依赖,并在任何依赖变化时执行

2. 初始执行

  • watch:默认不执行,只有依赖变化时执行
  • watchEffect:默认执行一次,依赖变化时再执行

3. 依赖追踪

  • watch:手动指定依赖,追踪更精确
  • watchEffect:自动追踪所有依赖,可能追踪到不必要的依赖

4. 适用场景的性能建议

  • 对于需要频繁执行的副作用,使用watch并手动指定依赖
  • 对于大型对象,避免使用deep: true选项,而是监听特定属性
  • 对于频繁变化的数据,使用防抖或节流

十、侦听器与相关API的比较

1. watch vs computed

  • computed:用于派生值,具有缓存机制
  • watch:用于执行副作用,无缓存机制
  • 选择建议:当需要派生值时使用computed,当需要执行副作用时使用watch

2. watch vs watchEffect

  • watch:需要手动指定依赖,可以访问旧值,控制更精细
  • watchEffect:自动追踪依赖,初始执行一次,无法直接访问旧值
  • 选择建议:当需要访问旧值或精细控制时使用watch,当只需执行副作用时使用watchEffect

3. watch vs methods

  • methods:需要手动调用,不会自动执行
  • watch:自动执行,当依赖变化时
  • 选择建议:当需要自动执行副作用时使用watch,当需要手动触发时使用methods

十一、侦听器的调试

1. 使用console.log调试

watch(count, (newValue, oldValue) => {
  console.log('Count changed:', oldValue, '->', newValue)
  // 可以在这里添加更多调试信息
})

2. 使用debugger调试

watch(count, (newValue, oldValue) => {
  debugger // 断点调试
  console.log('Count changed:', oldValue, '->', newValue)
})

3. 查看依赖

watchEffect((onCleanup) => {
  console.log('watchEffect executing...')
  console.log('Count:', count.value)
  console.log('Message:', message.value)
  // 可以通过输出查看当前副作用依赖了哪些数据
})

十二、总结

1. 核心概念

  • 侦听器用于监听响应式数据变化并执行副作用
  • Vue 3提供了watchwatchEffect两个主要的侦听器API
  • watch需要手动指定依赖,可以访问新值和旧值
  • watchEffect自动追踪依赖,初始执行一次,无法直接访问旧值
  • 侦听器可以清理副作用,避免竞态条件

2. 使用原则

  • 根据需要选择合适的侦听器API
  • 避免在侦听器中修改响应式数据,防止无限循环
  • 一定要清理异步副作用,避免竞态条件
  • 避免过度使用侦听器,简单的数据转换使用计算属性
  • 注意性能,避免深度监听大型对象

3. 最佳实践

  • 选择合适的侦听器API
  • 清理副作用
  • 避免过度使用侦听器
  • 注意性能
  • 使用防抖或节流处理频繁变化的数据

4. 常见错误

  • 监听reactive对象时无法获取旧值
  • 忘记清理异步操作
  • 无限循环
  • 监听对象属性时依赖未正确跟踪

十三、练习题

  1. 基础练习

    • 创建一个计数器,使用watch监听其变化并输出旧值和新值
    • 使用watchEffect监听计数器变化并更新文档标题
  2. 进阶练习

    • 实现一个搜索功能,使用watch监听搜索关键词变化并调用API
    • 添加防抖机制,优化搜索性能
    • 使用onCleanup清理之前的搜索请求
  3. 综合练习

    • 实现一个用户管理系统,使用侦听器监听用户数据变化
    • 当用户数据变化时,保存到本地存储
    • 当本地存储中的数据变化时,同步到应用状态
    • 使用watchEffect处理初始数据加载
  4. 性能优化练习

    • 实现一个大型对象的监听,比较直接监听、监听特定属性和深度监听的性能差异
    • 为频繁变化的数据添加防抖或节流
    • 比较watchwatchEffect的性能差异

十四、扩展阅读

在下一集中,我们将学习"自定义ref实现原理",深入探讨如何在Vue 3中创建自定义的响应式引用。

« 上一篇 计算属性computed深度解析 下一篇 » 自定义ref实现原理