watch 与 watchEffect:精准监听,避免副作用滥用

前言

在 Vue 应用中,除了计算属性这种衍生状态,我们还需要处理各种副作用:网络请求、DOM 操作、本地存储、定时器等。Vue3 提供了两个强大的 API:watchwatchEffect 来响应式地执行副作用。然而,很多开发者对它们的使用场景和区别认识不清,要么过度使用导致性能问题,要么使用不当导致内存泄漏。

本文将深入剖析 watchwatchEffect 的工作原理、使用场景和优化策略,帮助我们精准监听、高效管理副作用。

watch vs watchEffect:核心区别

watch

watch 的基本概念

watch 的设计理念是精准控制:我们需要明确告诉它需要监听什么,以及当监听的数据发生变化时又需要做什么:

javascript 复制代码
import { ref, watch } from 'vue'

const count = ref(0)
const name = ref('张三')

// 基本用法:监听单个源
watch(count, (newValue, oldValue) => {
  console.log(`count 从 ${oldValue} 变为 ${newValue}`)
})

// 监听响应式对象
watch(name, (newValue, oldValue) => {
  console.log(`name 从 ${oldValue} 变为 ${newValue}`)
})

watch 的核心特点

  • 懒执行:只有在监听源发生变化时才执行,不会立即执行
  • 需要指定源:必须明确告诉它要监听什么
  • 可以访问新旧值:在回调中可以获得数据变化前后的值
  • 可以监听多个源:可以使用数组的形式监听多个源

watchEffect

watchEffect 的基本概念

watchEffect 的设计理念是自动追踪:它会立即执行一次,并且在执行过程中自动收集 所有 响应式依赖,当这些依赖发生变化时重新执行:

javascript 复制代码
import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('张三')

watchEffect(() => {
  // 自动追踪 count 和 name
  console.log(`count: ${count.value}, name: ${name.value}`)
})
// 立即输出: count: 0, name: 张三

// 修改 count,自动重新执行
count.value++ // 输出: count: 1, name: 张三

watchEffect 的核心特点

  • 立即执行:创建时会立即执行一次
  • 自动收集依赖:不需要指定监听源,依赖是自动收集的
  • 无法获取旧值:回调中只有当前值,没有变化前的值
  • 语法更简洁:适合不需要旧值的场景

选择决策树

watch 的进阶用法

深度监听:deep

当我们需要监听一个对象时,默认情况下只有对象的引用变化才会触发,对象中的属性变化并不会触发监听:

javascript 复制代码
const user = ref({
  name: '张三',
  address: {
    city: '北京'
  }
})

// ❌ 属性变化不会触发
watch(user, () => {
  console.log('user 变化')
})

user.value.name = '李四' // 不会触发

当我们使用 deep配置时,就可以触发深度监听,即:对象中的属性发生改变时也会触发监听:

javascript 复制代码
// ✅ 使用 deep: true 监听所有嵌套属性变化
watch(user, () => {
  console.log('user 变化')
}, { deep: true })

user.value.name = '李四' // 触发
user.value.address.city = '上海' // 触发

deep 的性能分析

  • 深度监听 deep: true:需要递归遍历所有嵌套属性,对大型对象开销较大
  • 监听具体属性:只监听需要的属性,性能更好
  • 使用 computed:可以组合多个属性,但只在这些属性变化时触发

立即执行:immediate

默认情况下,watch 都是懒执行的,但有些场景我们需要在初始化时就执行一次监听,此时就需要用到 immediate 配置:

javascript 复制代码
const userId = ref(1)
const userData = ref(null)

// 会立即执行一次
watch(userId, async (newId) => {
  userData.value = await fetchUser(newId)
}, { immediate: true })

监听多个源:使用数组

当需要监听多个数据源,并且希望在任何一个数据源变化时,都执行同一个回调:

javascript 复制代码
const categoryId = ref('all')
const sortBy = ref('relevance')

// 监听多个源
watch([categoryId, sortBy], () => {
  console.log('筛选条件变化')
})

flush 时机:pre | post | sync 的区别

flush 选项可以控制回调的执行时机,这对 DOM 操作特别重要:

  • pre:默认值,在组件更新前执行,此时无法操作 DOM
  • post:在组件更新后执行,可以访问更新后的 DOM
  • sync:在响应式依赖变化时立即执行(谨慎使用)
javascript 复制代码
import { ref, watch } from 'vue'

const count = ref(0)

// 默认 pre:在组件更新前执行
watch(count, () => {
  console.log('pre: DOM 还未更新')
}, { flush: 'pre' })

// post:在组件更新后执行,可以访问更新后的 DOM
watch(count, () => {
  console.log('post: DOM 已更新')
  // 可以安全地操作更新后的 DOM
}, { flush: 'post' })

// sync:在响应式依赖变化时立即执行(谨慎使用)
watch(count, () => {
  console.log('sync: 立即执行')
}, { flush: 'sync' })

副作用清理:避免内存泄漏

场景:监听路由变化,取消之前的请求

在处理异步操作时,最常见的场景就是竞态条件:当请求发起后,但还没返回结果时,参数又变化了。这时需要取消之前的请求:

javascript 复制代码
import { watch, ref } from 'vue'
import { searchAPI } from './api'

const searchQuery = ref('')
const searchResults = ref([])
const loading = ref(false)

// ❌ 错误:没有处理竞态条件
watch(searchQuery, async (newQuery) => {
  loading.value = true
  const results = await searchAPI(newQuery) // 慢请求
  // 如果此时 query 已经变化,这个结果可能是过时的
  searchResults.value = results
  loading.value = false
})

// ✅ 正确:使用 onCleanup 取消之前的请求
watch(searchQuery, async (newQuery, oldQuery, onCleanup) => {
  const controller = new AbortController()
  
  // 注册清理函数
  onCleanup(() => {
    controller.abort()
    console.log('取消请求:', newQuery)
  })
  
  loading.value = true
  try {
    const results = await searchAPI(newQuery, { 
      signal: controller.signal 
    })
    // 只有请求没有被取消时才更新结果
    searchResults.value = results
  } catch (error) {
    if (error.name === 'AbortError') {
      // 请求被取消,忽略
      console.log('请求已取消')
    } else {
      // 其他错误
      console.error('搜索失败:', error)
    }
  } finally {
    loading.value = false
  }
})

onCleanup 的实现原理

onCleanupwatch 回调的第三个参数,它是一个函数,用来注册清理回调:

javascript 复制代码
// 模拟 onCleanup 的工作原理
function createWatcher(source, callback) {
  let cleanup = null
  
  const registerCleanup = (fn) => {
    cleanup = fn
  }
  
  const runCallback = () => {
    // 执行之前的清理函数
    if (cleanup) {
      cleanup()
    }
    
    // 执行新的回调
    callback(source.value, null, registerCleanup)
  }
  
  // 监听变化
  onSourceChange(runCallback)
}

更多清理场景

清理定时器

javascript 复制代码
const delay = ref(1000)

watch(delay, (newDelay, oldDelay, onCleanup) => {
  const timer = setInterval(() => {
    console.log('定时器执行')
  }, newDelay)
  
  onCleanup(() => {
    clearInterval(timer)
    console.log('定时器已清理')
  })
}, { immediate: true })

取消 WebSocket 连接

javascript 复制代码
const roomId = ref('general')

watch(roomId, (newRoom, oldRoom, onCleanup) => {
  const socket = new WebSocket(`ws://server/${newRoom}`)
  
  socket.onmessage = (event) => {
    // 处理消息
  }
  
  onCleanup(() => {
    socket.close()
    console.log(`离开房间: ${oldRoom}`)
  })
}, { immediate: true })

移除事件监听

javascript 复制代码
const element = ref(null)
const eventType = ref('click')

watch([element, eventType], ([el, type], [oldEl, oldType], onCleanup) => {
  if (!el) return
  
  const handler = (e) => {
    console.log(`事件触发: ${type}`, e)
  }
  
  el.addEventListener(type, handler)
  
  onCleanup(() => {
    el.removeEventListener(type, handler)
    console.log(`移除事件监听: ${type}`)
  })
}, { immediate: true })

性能陷阱与优化

过度监听:监听整个对象 vs 监听具体属性

javascript 复制代码
const filters = ref({
  category: 'all',
  priceRange: [0, 1000],
  inStock: true,
  rating: 0,
  sortBy: 'price',
  keywords: ''
})

watch(filters, () => {
  // 任何 filter 属性变化都会触发 API 调用
  fetchProducts(filters.value)
}, { deep: true })
// 修改一个属性就调用一次 API,可能过于频繁

优化方案:监听特定属性

javascript 复制代码
const fetchTrigger = computed(() => ({
  category: filters.value.category,
  priceRange: filters.value.priceRange,
  inStock: filters.value.inStock
}))

watch(fetchTrigger, () => {
  // 只有这三个相关属性变化才触发
  fetchProducts(filters.value)
})

使用 debounce 进一步优化

javascript 复制代码
import { debounce } from 'lodash-es'

const debouncedFetch = debounce((filters) => {
  fetchProducts(filters)
}, 300)

watch(filters, () => {
  debouncedFetch(filters.value)
}, { deep: true })

频繁触发:使用 throttle 和 debounce

场景1:搜索输入 - 使用 debounce

javascript 复制代码
const debouncedSearch = debounce((query) => {
  console.log('执行搜索:', query)
}, 300)

watch(searchInput, (newValue) => {
  debouncedSearch(newValue)
})

场景2:滚动位置 - 使用 throttle

javascript 复制代码
const scrollPosition = ref(0)

const throttledSave = throttle((position) => {
  localStorage.setItem('scrollPosition', position)
}, 1000)

watch(scrollPosition, (newPos) => {
  throttledSave(newPos)
})

实战:实现一个可取消的异步请求监听器

完整实现

javascript 复制代码
// composables/useCancellableWatch.js
import { watch } from 'vue'

export function useCancellableWatch(source, asyncFn, options = {}) {
  const { immediate = false, debounce: debounceMs = 0, onError } = options
  
  let controller = new AbortController()
  let timeoutId = null
  
  const wrappedAsyncFn = (value) => {
    // 取消之前的请求
    controller.abort()
    controller = new AbortController()
    
    // 执行新的异步函数
    asyncFn(value, controller.signal).catch(error => {
      if (error.name !== 'AbortError' && onError) {
        onError(error)
      }
    })
  }
  
  const handler = (value) => {
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
    
    if (debounceMs > 0) {
      timeoutId = setTimeout(() => wrappedAsyncFn(value), debounceMs)
    } else {
      wrappedAsyncFn(value)
    }
  }
  
  // 创建监听
  const stop = watch(source, handler, { immediate })
  
  // 返回停止函数
  return () => {
    stop()
    controller.abort()
    if (timeoutId) {
      clearTimeout(timeoutId)
    }
  }
}

在组件中使用

html 复制代码
<template>
  <div class="search-container">
    <input 
      v-model="query" 
      placeholder="搜索..."
      @input="handleInput"
    />
    <span class="loading" v-if="loading">搜索中...</span>
    
    <div class="results">
      <div v-for="item in results" :key="item.id">
        {{ item.title }}
      </div>
    </div>
    
    <div v-if="error" class="error">
      出错了: {{ error.message }}
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useCancellableWatch } from './composables/useCancellableWatch'

const query = ref('')
const results = ref([])
const loading = ref(false)
const error = ref(null)

// 模拟搜索 API
async function searchAPI(query, signal) {
  loading.value = true
  error.value = null
  
  try {
    // 模拟网络请求
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    // 检查是否被取消
    if (signal.aborted) {
      throw new DOMException('Aborted', 'AbortError')
    }
    
    // 模拟返回结果
    const mockResults = [
      { id: 1, title: `${query} 结果1` },
      { id: 2, title: `${query} 结果2` },
      { id: 3, title: `${query} 结果3` }
    ]
    
    results.value = mockResults
  } finally {
    loading.value = false
  }
}

// 使用我们的自定义监听器
const stopWatch = useCancellableWatch(
  query,
  async (value, signal) => {
    if (value.length < 2) {
      results.value = []
      return
    }
    await searchAPI(value, signal)
  },
  {
    immediate: false,
    debounce: 300,
    onError: (err) => {
      if (err.name !== 'AbortError') {
        error.value = err
      }
    }
  }
)

// 组件卸载时自动清理
onUnmounted(() => {
  stopWatch()
})
</script>

决策指南

需求 推荐方案 原因
需要访问新旧值 watch watch 提供新旧值参数
需要立即执行一次 watch + immediate: truewatchEffect 两者皆可,看是否需要旧值
只需要知道变化了 watchEffect 语法更简洁
监听多个相关源 watch 数组形式 可以一起处理,也可以分别处理
需要操作更新后的 DOM watch + flush: post 确保 DOM 已更新
需要取消异步操作 watch + onCleanup 提供专门的清理机制
监听对象内部属性变化 watch + 函数返回具体属性 避免 deep: true 的性能开销

结语

watch 用于精确控制,watchEffect 用于自动追踪。开发中需要选择哪个,取决于我们的具体需求:需要细粒度控制就用 watch,想要简洁的自动追踪就用 watchEffect。理解它们的本质区别,就能在合适的场景做出正确的选择。

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

相关推荐
wuhen_n5 小时前
computed 的缓存哲学:如何避免不必要的重复计算?
前端·javascript·vue.js
闲云一鹤5 小时前
本地部署 B 站 IndexTTS2 模型 - AI 文本生语音神器
前端·人工智能
晓得迷路了5 小时前
栗子前端技术周刊第 119 期 - ViteLand 月度更新汇总、Angular 21.2、Bun v1.3.10...
前端·javascript·vite
鹏多多6 小时前
Flutter使用screenshot进行截屏和截长图以及分享保存的全流程指南
android·前端·flutter
拉不动的猪6 小时前
重温Vue异步更新队列
前端·javascript·面试
Mike_jia6 小时前
OpenClaw:开源个人AI助手的“执行革命“
前端
摸鱼的春哥6 小时前
吃龙虾🦞咯!万字拆解OpenClaw的架构与设计
前端·javascript·后端
恋猫de小郭6 小时前
什么 AI 写 Android 最好用?官方做了一个基准测试排名
android·前端·flutter
anOnion15 小时前
构建无障碍组件之Switch Pattern
前端·html·交互设计