84. 取消请求与防抖节流

概述

在现代Web应用中,高效的网络请求管理和事件处理是提升用户体验的重要环节。本集将深入探讨Vue 3项目中的三个关键优化技术:取消请求、防抖和节流。我们将学习如何使用Axios取消不必要的网络请求,以及如何通过防抖和节流技术优化用户交互,减少不必要的计算和网络请求,从而提升应用的性能和响应速度。

核心知识点

1. Axios取消请求

在某些场景下,我们需要取消正在进行的网络请求,例如:用户快速切换标签页、搜索框输入时的连续请求、组件卸载时的未完成请求等。Axios提供了多种取消请求的方式。

1.1 使用AbortController(推荐)

AbortController是现代浏览器提供的API,Axios从v0.22.0开始支持。

// src/utils/axios.ts
import axios from 'axios'

const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})

export default http

// 导出取消请求工具
export const createCancelToken = () => {
  const controller = new AbortController()
  return {
    signal: controller.signal,
    cancel: () => controller.abort()
  }
}

在组件中使用:

<!-- src/components/SearchComponent.vue -->
<template>
  <div class="search-container">
    <input 
      type="text" 
      v-model="keyword" 
      placeholder="搜索..." 
      @input="handleSearch"
    />
    <div v-if="loading" class="loading">搜索中...</div>
    <ul v-else class="search-results">
      <li v-for="result in results" :key="result.id">{{ result.name }}</li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import http, { createCancelToken } from '../utils/axios'

const keyword = ref('')
const results = ref([])
const loading = ref(false)
let cancelSearch: (() => void) | null = null

const handleSearch = async () => {
  if (!keyword.value.trim()) {
    results.value = []
    return
  }

  // 取消之前的搜索请求
  if (cancelSearch) {
    cancelSearch()
  }

  const { signal, cancel } = createCancelToken()
  cancelSearch = cancel

  try {
    loading.value = true
    const response = await http.get('/api/search', {
      params: { keyword: keyword.value },
      signal // 传递signal给请求配置
    })
    results.value = response.data
  } catch (error: any) {
    if (error.name === 'CanceledError') {
      console.log('搜索请求已取消')
    } else {
      console.error('搜索失败:', error)
    }
  } finally {
    loading.value = false
  }
}

// 组件卸载时取消未完成的请求
onUnmounted(() => {
  if (cancelSearch) {
    cancelSearch()
  }
})
</script>

1.2 使用CancelToken(旧版方式)

对于旧版Axios,可以使用CancelToken API,但该方式已被废弃,推荐使用AbortController。

// 旧版Axios取消请求方式(不推荐)
import axios, { CancelToken } from 'axios'

const source = CancelToken.source()

axios.get('/api/data', {
  cancelToken: source.token
})

// 取消请求
source.cancel('请求已被取消')

2. 防抖(Debounce)

防抖是指在事件被触发n秒后再执行回调,如果在这n秒内事件又被触发,则重新计时。常用于搜索框输入、窗口大小调整等场景。

2.1 实现自定义防抖函数

// src/utils/debounce.ts
/**
 * 防抖函数
 * @param fn 需要防抖的函数
 * @param delay 延迟时间(毫秒)
 * @returns 防抖后的函数
 */
export function debounce<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timer: ReturnType<typeof setTimeout> | null = null
  
  return function(...args: Parameters<T>) {
    if (timer) {
      clearTimeout(timer)
    }
    
    timer = setTimeout(() => {
      fn.apply(this, args)
      timer = null
    }, delay)
  }
}

在组件中使用:

<!-- src/components/SearchComponent.vue -->
<template>
  <input 
    type="text" 
    v-model="keyword" 
    placeholder="搜索..." 
    @input="debouncedSearch"
  />
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { debounce } from '../utils/debounce'
import http from '../utils/axios'

const keyword = ref('')
const results = ref([])
const loading = ref(false)

// 使用防抖函数包装搜索方法
const debouncedSearch = debounce(async (value: string) => {
  if (!value.trim()) {
    results.value = []
    return
  }
  
  try {
    loading.value = true
    const response = await http.get('/api/search', {
      params: { keyword: value }
    })
    results.value = response.data
  } catch (error) {
    console.error('搜索失败:', error)
  } finally {
    loading.value = false
  }
}, 300) // 300毫秒延迟

// 监听keyword变化,调用防抖函数
const handleInput = (e: Event) => {
  const target = e.target as HTMLInputElement
  debouncedSearch(target.value)
}
</script>

2.2 使用Lodash的防抖函数

如果项目中已经使用了Lodash,可以直接使用其提供的debounce函数:

<script setup lang="ts">
import { ref } from 'vue'
import { debounce } from 'lodash-es'
import http from '../utils/axios'

const keyword = ref('')
const results = ref([])
const loading = ref(false)

const debouncedSearch = debounce(async () => {
  if (!keyword.value.trim()) {
    results.value = []
    return
  }
  
  try {
    loading.value = true
    const response = await http.get('/api/search', {
      params: { keyword: keyword.value }
    })
    results.value = response.data
  } catch (error) {
    console.error('搜索失败:', error)
  } finally {
    loading.value = false
  }
}, 300)
</script>

3. 节流(Throttle)

节流是指在一定时间内只执行一次函数,常用于滚动事件、窗口 resize 事件、鼠标移动事件等场景。

3.1 实现自定义节流函数

// src/utils/throttle.ts
/**
 * 节流函数
 * @param fn 需要节流的函数
 * @param delay 延迟时间(毫秒)
 * @returns 节流后的函数
 */
export function throttle<T extends (...args: any[]) => any>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let lastCall = 0
  let timer: ReturnType<typeof setTimeout> | null = null
  
  return function(...args: Parameters<T>) {
    const now = Date.now()
    const timeSinceLastCall = now - lastCall
    
    if (timeSinceLastCall >= delay) {
      // 如果距离上次调用已经超过delay,直接执行
      lastCall = now
      fn.apply(this, args)
    } else {
      // 否则,设置定时器在剩余时间后执行
      if (timer) {
        clearTimeout(timer)
      }
      
      timer = setTimeout(() => {
        lastCall = Date.now()
        fn.apply(this, args)
        timer = null
      }, delay - timeSinceLastCall)
    }
  }
}

在组件中使用:

<!-- src/components/ScrollComponent.vue -->
<template>
  <div class="scroll-container" @scroll="throttledHandleScroll">
    <div class="content">
      <!-- 长内容 -->
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { throttle } from '../utils/throttle'

const scrollPosition = ref(0)

// 使用节流函数包装滚动处理方法
const throttledHandleScroll = throttle((e: Event) => {
  const target = e.target as HTMLElement
  scrollPosition.value = target.scrollTop
  console.log('滚动位置:', scrollPosition.value)
  
  // 可以在这里实现无限滚动加载等功能
  if (target.scrollTop + target.clientHeight >= target.scrollHeight - 100) {
    console.log('触底,加载更多数据')
    // loadMoreData()
  }
}, 200) // 200毫秒执行一次
</script>

<style scoped>
.scroll-container {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #ccc;
}

.content {
  height: 2000px;
  background: linear-gradient(to bottom, #f0f0f0, #e0e0e0);
}
</style>

3.2 使用Lodash的节流函数

<script setup lang="ts">
import { ref } from 'vue'
import { throttle } from 'lodash-es'

const scrollPosition = ref(0)

const throttledHandleScroll = throttle((e: Event) => {
  const target = e.target as HTMLElement
  scrollPosition.value = target.scrollTop
  console.log('滚动位置:', scrollPosition.value)
}, 200)
</script>

4. 结合取消请求与防抖节流

在实际开发中,我们经常需要将取消请求与防抖节流结合使用,以优化用户体验。

<!-- src/components/AdvancedSearch.vue -->
<template>
  <div class="advanced-search">
    <input 
      type="text" 
      v-model="keyword" 
      placeholder="搜索..." 
      @input="handleSearch"
    />
    <div v-if="loading" class="loading">搜索中...</div>
    <ul v-else class="results">
      <li v-for="result in results" :key="result.id">{{ result.name }}</li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'
import http, { createCancelToken } from '../utils/axios'

const keyword = ref('')
const results = ref([])
const loading = ref(false)
let cancelSearch: (() => void) | null = null

// 结合防抖和取消请求
const debouncedSearch = debounce(async (searchKeyword: string) => {
  if (!searchKeyword.trim()) {
    results.value = []
    return
  }

  // 取消之前的请求
  if (cancelSearch) {
    cancelSearch()
  }

  const { signal, cancel } = createCancelToken()
  cancelSearch = cancel

  try {
    loading.value = true
    const response = await http.get('/api/search', {
      params: { keyword: searchKeyword },
      signal
    })
    results.value = response.data
  } catch (error: any) {
    if (error.name !== 'CanceledError') {
      console.error('搜索失败:', error)
    }
  } finally {
    loading.value = false
  }
}, 300)

const handleSearch = () => {
  debouncedSearch(keyword.value)
}

onUnmounted(() => {
  if (cancelSearch) {
    cancelSearch()
  }
})
</script>

5. 在Vue组合式API中使用

我们可以创建自定义组合式函数,将取消请求、防抖和节流逻辑封装起来,以便在多个组件中复用。

// src/composables/useDebouncedSearch.ts
import { ref, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'
import http, { createCancelToken } from '../utils/axios'

export function useDebouncedSearch<T>(url: string, delay = 300) {
  const results = ref<T[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)
  let cancelRequest: (() => void) | null = null

  const search = debounce(async (keyword: string) => {
    if (!keyword.trim()) {
      results.value = []
      return
    }

    // 取消之前的请求
    if (cancelRequest) {
      cancelRequest()
    }

    const { signal, cancel } = createCancelToken()
    cancelRequest = cancel

    try {
      loading.value = true
      error.value = null
      const response = await http.get<T[]>(url, {
        params: { keyword },
        signal
      })
      results.value = response.data
    } catch (err: any) {
      if (err.name !== 'CanceledError') {
        error.value = '搜索失败,请稍后重试'
        console.error('搜索错误:', err)
      }
    } finally {
      loading.value = false
    }
  }, delay)

  onUnmounted(() => {
    if (cancelRequest) {
      cancelRequest()
    }
  })

  return {
    results,
    loading,
    error,
    search
  }
}

在组件中使用:

<!-- src/components/ReusableSearch.vue -->
<template>
  <div class="reusable-search">
    <input 
      type="text" 
      v-model="keyword" 
      placeholder="搜索..." 
      @input="handleSearch"
    />
    <div v-if="loading" class="loading">搜索中...</div>
    <div v-else-if="error" class="error">{{ error }}</div>
    <ul v-else class="results">
      <li v-for="result in results" :key="result.id">{{ result.name }}</li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useDebouncedSearch } from '../composables/useDebouncedSearch'

interface SearchResult {
  id: number
  name: string
}

const keyword = ref('')
const { results, loading, error, search } = useDebouncedSearch<SearchResult>('/api/search', 300)

const handleSearch = () => {
  search(keyword.value)
}
</script>

最佳实践

1. 合理选择取消请求的时机

  • 组件卸载时取消未完成的请求
  • 用户快速操作时取消之前的请求
  • 搜索框输入时取消之前的搜索请求
  • 路由切换时取消当前页面的未完成请求

2. 防抖与节流的使用场景

技术 适用场景 延迟建议
防抖 搜索框输入、表单验证、窗口大小调整 300-500ms
节流 滚动事件、鼠标移动、按钮点击频率限制 100-200ms

3. 结合使用的注意事项

  • 防抖和节流可以结合使用,例如:先防抖再节流
  • 取消请求应该在防抖/节流函数内部实现
  • 注意清理定时器和取消函数,避免内存泄漏
  • 在组件卸载时确保清理所有未完成的请求和定时器

4. 使用第三方库的建议

  • 如果项目中已经使用了Lodash,建议直接使用其提供的防抖和节流函数
  • 否则,可以使用自定义实现,减少依赖
  • 对于取消请求,推荐使用AbortController API

常见问题与解决方案

1. 问题:防抖函数没有生效

解决方案

  • 确保防抖函数是在组件初始化时创建的,而不是在事件处理函数中创建
  • 检查防抖函数的延迟时间是否设置合理
  • 确保正确传递了参数

2. 问题:取消请求后仍然执行了回调

解决方案

  • 确保在catch块中检查错误类型,区分取消请求和其他错误
  • 检查取消函数是否正确调用
  • 确保使用了正确的取消请求API

3. 问题:防抖/节流函数导致this指向错误

解决方案

  • 使用箭头函数或bind方法确保this指向正确
  • 在自定义实现中使用apply方法传递this上下文

4. 问题:组件卸载后仍然执行了异步操作

解决方案

  • 在组件卸载时取消所有未完成的请求
  • 清除所有定时器
  • 使用Vue的onUnmounted钩子进行清理

进一步学习资源

  1. Axios取消请求文档
  2. MDN AbortController文档
  3. Lodash debounce文档
  4. Lodash throttle文档
  5. 防抖与节流的深入理解
  6. Vue 3组合式API实战

课后练习

  1. 基础练习

    • 实现自定义的防抖和节流函数
    • 在Vue组件中使用Axios取消请求
    • 为搜索框添加防抖功能
  2. 进阶练习

    • 创建一个组合式函数,封装防抖搜索功能
    • 实现无限滚动加载,使用节流优化
    • 结合取消请求和防抖,优化复杂表单的实时验证
  3. 挑战练习

    • 实现一个可配置的防抖/节流组件,支持多种配置选项
    • 开发一个请求管理工具,支持批量取消请求
    • 设计一个性能监控组件,统计防抖和节流减少的请求数量

通过本集的学习,你应该能够掌握取消请求、防抖和节流的核心概念和实现方式,并能够在Vue 3项目中灵活应用这些技术来优化网络请求和用户交互。这些优化技术虽然简单,但在实际开发中能够显著提升应用的性能和用户体验,是每个前端开发者都应该掌握的重要技能。

« 上一篇 统一错误处理机制 - Vue 3全栈应用稳定性保障 下一篇 » RESTful API最佳实践 - Vue 3前后端通信架构设计