使用 onCleanup处理异步副作用

Vue 3.4+ 的新特性

1. watch 中的 onCleanup

javascript

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

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

// 监听搜索词变化,自动清理前一个请求
watch(searchQuery, async (newValue, oldValue, onCleanup) => {
  if (!newValue.trim()) return
  
  let cancelled = false
  const controller = new AbortController()
  
  // 注册清理函数
  onCleanup(() => {
    cancelled = true
    controller.abort()
  })
  
  try {
    const response = await fetch(`/api/search?q=${newValue}`, {
      signal: controller.signal
    })
    const data = await response.json()
    
    // 检查是否已取消
    if (!cancelled) {
      searchResults.value = data
    }
  } catch (error) {
    if (error.name !== 'AbortError' && !cancelled) {
      console.error('搜索失败:', error)
    }
  }
})

2. watchEffect 中的 onCleanup

javascript

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

const userId = ref(1)
const userData = ref(null)

// watchEffect 自动追踪依赖,包含清理函数
watchEffect(async (onCleanup) => {
  const id = userId.value
  
  let cancelled = false
  const controller = new AbortController()
  
  onCleanup(() => {
    cancelled = true
    controller.abort()
  })
  
  try {
    const response = await fetch(`/api/users/${id}`, {
      signal: controller.signal
    })
    const data = await response.json()
    
    if (!cancelled) {
      userData.value = data
    }
  } catch (error) {
    if (error.name !== 'AbortError' && !cancelled) {
      console.error('获取用户失败:', error)
    }
  }
})

🏗️ 实际使用场景

场景1:搜索功能(推荐方案)

javascript

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

export function useSearch() {
  const searchQuery = ref('')
  const results = ref([])
  const isLoading = ref(false)
  
  // 防抖函数
  const debounce = (fn, delay) => {
    let timeoutId
    return (...args) => {
      clearTimeout(timeoutId)
      timeoutId = setTimeout(() => fn(...args), delay)
    }
  }
  
  // 监听搜索词变化
  const stopWatch = watch(searchQuery, async (newValue, oldValue, onCleanup) => {
    if (newValue.trim().length < 2) {
      results.value = []
      return
    }
    
    let cancelled = false
    const controller = new AbortController()
    
    // 注册清理函数
    onCleanup(() => {
      cancelled = true
      controller.abort()
      isLoading.value = false
    })
    
    // 添加延迟防止频繁请求
    await new Promise(resolve => setTimeout(resolve, 300))
    
    if (cancelled) return
    
    isLoading.value = true
    
    try {
      const response = await fetch(`/api/search?q=${encodeURIComponent(newValue)}`, {
        signal: controller.signal
      })
      
      if (cancelled) return
      
      const data = await response.json()
      
      if (!cancelled) {
        results.value = data
      }
    } catch (error) {
      if (error.name !== 'AbortError' && !cancelled) {
        console.error('搜索失败:', error)
        results.value = []
      }
    } finally {
      if (!cancelled) {
        isLoading.value = false
      }
    }
  })
  
  return {
    searchQuery,
    results,
    isLoading,
    stopWatch
  }
}

场景2:轮询数据

javascript

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

export function usePollingData() {
  const isPolling = ref(false)
  const data = ref(null)
  const error = ref(null)
  
  watch(isPolling, (shouldPoll, _, onCleanup) => {
    if (!shouldPoll) {
      data.value = null
      return
    }
    
    let cancelled = false
    let intervalId
    
    // 清理函数
    onCleanup(() => {
      cancelled = true
      if (intervalId) {
        clearInterval(intervalId)
      }
    })
    
    const fetchData = async () => {
      if (cancelled) return
      
      try {
        const response = await fetch('/api/data')
        const result = await response.json()
        
        if (!cancelled) {
          data.value = result
          error.value = null
        }
      } catch (err) {
        if (!cancelled) {
          error.value = err
        }
      }
    }
    
    // 立即获取一次
    fetchData()
    
    // 设置轮询
    intervalId = setInterval(fetchData, 5000)
  })
  
  return {
    isPolling,
    data,
    error,
    togglePolling: () => isPolling.value = !isPolling.value
  }
}

场景3:多数据源监听

javascript

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

export function useDashboardData() {
  const filters = ref({
    dateRange: 'today',
    category: 'all'
  })
  
  const metrics = ref(null)
  const chartData = ref(null)
  
  // 监听多个数据源
  watch([() => filters.value.dateRange, () => filters.value.category], 
    async ([dateRange, category], _, onCleanup) => {
    
    let cancelled = false
    const controller = new AbortController()
    
    onCleanup(() => {
      cancelled = true
      controller.abort()
    })
    
    // 并行请求多个数据
    try {
      const [metricsRes, chartRes] = await Promise.all([
        fetch(`/api/metrics?range=${dateRange}&category=${category}`, {
          signal: controller.signal
        }),
        fetch(`/api/chart-data?range=${dateRange}&category=${category}`, {
          signal: controller.signal
        })
      ])
      
      if (cancelled) return
      
      const [metricsData, chartDataResult] = await Promise.all([
        metricsRes.json(),
        chartRes.json()
      ])
      
      if (!cancelled) {
        metrics.value = metricsData
        chartData.value = chartDataResult
      }
    } catch (error) {
      if (error.name !== 'AbortError' && !cancelled) {
        console.error('获取数据失败:', error)
      }
    }
  }, { immediate: true })
  
  return { filters, metrics, chartData }
}

🔄 组合式函数封装

高级封装:useAsyncWatch

javascript

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

export function useAsyncWatch(source, asyncFn, options = {}) {
  const {
    immediate = false,
    debounce = 0,
    deep = false
  } = options
  
  const data = ref(null)
  const error = ref(null)
  const isLoading = ref(false)
  
  let cleanupFn = null
  
  // 停止监听函数
  const stop = watch(source, async (newValue, oldValue, onCleanup) => {
    let cancelled = false
    
    // 如果有防抖需求
    if (debounce > 0) {
      await new Promise(resolve => setTimeout(resolve, debounce))
      if (cancelled) return
    }
    
    isLoading.value = true
    error.value = null
    
    // 注册当前清理函数
    onCleanup(() => {
      cancelled = true
      isLoading.value = false
    })
    
    // 保存清理函数供外部调用
    cleanupFn = () => {
      cancelled = true
      isLoading.value = false
    }
    
    try {
      const result = await asyncFn(newValue, oldValue, () => cancelled)
      
      if (!cancelled) {
        data.value = result
      }
    } catch (err) {
      if (!cancelled) {
        error.value = err
      }
    } finally {
      if (!cancelled) {
        isLoading.value = false
      }
    }
  }, { immediate, deep })
  
  // 手动取消当前操作
  const cancel = () => {
    if (cleanupFn) {
      cleanupFn()
      cleanupFn = null
    }
  }
  
  // 重新触发
  const trigger = () => {
    const currentValue = typeof source === 'function' 
      ? source() 
      : source.value
    // 这里需要手动触发 watch 回调
    cancel()
    // 可以结合 options.immediate 或重新设置值
  }
  
  onUnmounted(() => {
    stop()
    cancel()
  })
  
  return {
    data,
    error,
    isLoading,
    cancel,
    trigger,
    stop
  }
}

// 使用示例
const searchQuery = ref('')
const { data: results, isLoading, cancel } = useAsyncWatch(
  searchQuery,
  async (query, oldValue, isCancelled) => {
    if (!query.trim() || isCancelled()) return null
    
    const controller = new AbortController()
    const timeoutId = setTimeout(() => controller.abort(), 10000)
    
    try {
      const response = await fetch(`/api/search?q=${query}`, {
        signal: controller.signal
      })
      
      if (isCancelled()) return null
      
      return await response.json()
    } finally {
      clearTimeout(timeoutId)
    }
  },
  { debounce: 300, immediate: false }
)

处理竞态的通用 Hook

javascript

ini 复制代码
export function useRaceConditionWatch(source, asyncFn, options = {}) {
  const {
    immediate = false,
    cancelPrevious = true
  } = options
  
  const data = ref(null)
  const error = ref(null)
  const isLoading = ref(false)
  
  let currentToken = null
  
  const stop = watch(source, async (newValue, oldValue, onCleanup) => {
    const token = Symbol('request')
    currentToken = token
    
    let cancelled = false
    let abortController = null
    
    onCleanup(() => {
      cancelled = true
      if (abortController) {
        abortController.abort()
      }
      if (currentToken === token) {
        isLoading.value = false
      }
    })
    
    if (cancelPrevious && currentToken !== token) {
      return // 已经有新的请求
    }
    
    isLoading.value = true
    error.value = null
    
    try {
      abortController = new AbortController()
      const result = await asyncFn(newValue, abortController.signal, () => cancelled)
      
      // 检查是否是当前最新请求
      if (!cancelled && currentToken === token) {
        data.value = result
      }
    } catch (err) {
      if (err.name !== 'AbortError' && !cancelled && currentToken === token) {
        error.value = err
      }
    } finally {
      if (!cancelled && currentToken === token) {
        isLoading.value = false
      }
    }
  }, { immediate })
  
  return { data, error, isLoading, stop }
}

🎯 实际案例:实时聊天

javascript

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

export function useChatRoom(roomId) {
  const messages = ref([])
  const isConnected = ref(false)
  
  let socket = null
  let reconnectTimer = null
  
  // 监听 roomId 变化
  watch(() => roomId.value, (newRoomId, oldRoomId, onCleanup) => {
    if (!newRoomId) {
      messages.value = []
      isConnected.value = false
      return
    }
    
    let cancelled = false
    
    onCleanup(() => {
      cancelled = true
      
      // 清理 WebSocket 连接
      if (socket) {
        socket.close()
        socket = null
      }
      
      // 清理重连定时器
      if (reconnectTimer) {
        clearTimeout(reconnectTimer)
        reconnectTimer = null
      }
    })
    
    const connectWebSocket = () => {
      if (cancelled) return
      
      socket = new WebSocket(`wss://api.example.com/chat/${newRoomId}`)
      
      socket.onopen = () => {
        if (!cancelled) {
          isConnected.value = true
        }
      }
      
      socket.onmessage = (event) => {
        if (!cancelled) {
          const message = JSON.parse(event.data)
          messages.value.push(message)
        }
      }
      
      socket.onclose = () => {
        if (!cancelled) {
          isConnected.value = false
          
          // 尝试重连
          if (!cancelled) {
            reconnectTimer = setTimeout(connectWebSocket, 3000)
          }
        }
      }
      
      socket.onerror = (error) => {
        if (!cancelled) {
          console.error('WebSocket 错误:', error)
        }
      }
    }
    
    connectWebSocket()
  }, { immediate: true })
  
  const sendMessage = (content) => {
    if (socket && socket.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify({ content }))
    }
  }
  
  onUnmounted(() => {
    if (socket) {
      socket.close()
    }
  })
  
  return { messages, isConnected, sendMessage }
}

📝 最佳实践

1. 正确的清理顺序

javascript

javascript 复制代码
watch(source, async (value, oldValue, onCleanup) => {
  let cancelled = false
  
  // 先设置取消标志
  onCleanup(() => {
    cancelled = true
  })
  
  // 然后执行异步操作
  const data = await fetchData(value)
  
  // 操作完成后检查是否被取消
  if (!cancelled) {
    // 更新状态
  }
})

2. 组合使用多种清理

javascript

scss 复制代码
watch(source, async (value, oldValue, onCleanup) => {
  let cancelled = false
  const controller = new AbortController()
  const timeoutId = setTimeout(() => {
    controller.abort()
  }, 10000)
  
  // 注册多个清理操作
  onCleanup(() => {
    cancelled = true
    controller.abort()
    clearTimeout(timeoutId)
  })
  
  // 异步操作...
})

3. 处理竞态条件的模式

javascript

javascript 复制代码
const useLatestRequest = (asyncFn) => {
  let currentRequest = null
  
  return async (...args) => {
    // 取消前一个请求
    if (currentRequest?.cancel) {
      currentRequest.cancel()
    }
    
    const controller = new AbortController()
    const request = {
      promise: asyncFn(...args, controller.signal),
      cancel: () => controller.abort()
    }
    
    currentRequest = request
    
    try {
      const result = await request.promise
      // 检查是否仍然是当前请求
      if (currentRequest === request) {
        return result
      }
      return null
    } catch (error) {
      if (error.name !== 'AbortError') {
        throw error
      }
      return null
    }
  }
}

4. 避免的内存泄漏

javascript

scss 复制代码
// 错误示例:忘记清理
watch(source, async () => {
  const timer = setInterval(() => {
    // 做一些事情
  }, 1000)
  // 忘记清理定时器!
})

// 正确示例:使用 onCleanup
watch(source, async (value, oldValue, onCleanup) => {
  const timer = setInterval(() => {
    // 做一些事情
  }, 1000)
  
  onCleanup(() => {
    clearInterval(timer)
  })
})

🚀 总结

onCleanup 的核心优势:

  1. 自动清理:watch 回调执行前自动调用清理函数
  2. 竞态安全:确保只有最后一次请求的结果被处理
  3. 内存安全:防止内存泄漏
  4. 简化代码:无需手动管理清理逻辑

使用建议:

  • 所有涉及异步操作的 watch 都应该使用 onCleanup
  • 对于定时器、WebSocket、订阅等资源,必须使用清理函数
  • 结合 AbortController 取消网络请求
  • 在组合式函数中始终返回清理函数
相关推荐
qq_229058011 天前
lable_studio前端页面逻辑
前端
harrain1 天前
前端svg精微操作局部动态改变呈现工程网架状态程度可视播放效果
前端·svg·工程网架图
独自破碎E1 天前
Spring Boot支持哪些嵌入Web容器?
前端·spring boot·后端
大猫会长1 天前
tailwindcss中,自定义多个背景渐变色
前端·html
xj7573065331 天前
《python web开发 测试驱动方法》
开发语言·前端·python
IT=>小脑虎1 天前
2026年 Vue3 零基础小白入门知识点【基础完整版 · 通俗易懂 条理清晰】
前端·vue.js·状态模式
IT_陈寒1 天前
Python 3.12性能优化实战:5个让你的代码提速30%的新特性
前端·人工智能·后端
赛博切图仔1 天前
「从零到一」我用 Node BFF 手撸一个 Vue3 SSR 项目(附源码)
前端·javascript·vue.js
爱写程序的小高1 天前
npm ERR! code ERESOLVE npm ERR! ERESOLVE unable to resolve dependency tree
前端·npm·node.js