在真实业务中,数据请求并不是“发了就收”那么简单。用户快速点击按钮、输入框实时搜索、多个请求同时发送却可能以错误的顺序返回——这些场景如果处理不当,轻则浪费服务器资源,重则导致界面展示的数据与用户操作不一致。本节聚焦三个最核心的请求控制问题:防抖、节流和竞态。
防抖(Debounce):等用户“停下来”再发请求
场景:搜索框输入联想、表单字段的实时校验、窗口 resize 触发重新计算。每次用户敲一个字符就立即发请求,会导致大量无效请求,后端压力大,前端也因频繁更新而卡顿。
核心思路:当事件持续触发时,不立即执行请求,而是等待一段时间。如果在这段时间内事件再次触发,则重新计时。只有在用户“停下来”的指定时间后,才真正执行一次请求。
实现方式:可以使用 lodash.debounce 或者手写一个防抖函数。在组合式 API 中,更推荐封装为自定义 Hook。
// hooks/useDebounce.js
import { ref, watch } from 'vue'
export function useDebounce(value, delay = 300) {
const debouncedValue = ref(value)
let timer = null
watch(value, (newVal) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
debouncedValue.value = newVal
}, delay)
})
return debouncedValue
}
在组件中使用:
<script setup>
import { ref, computed } from 'vue'
import { useDebounce } from './hooks/useDebounce'
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 500)
// 监听防抖后的值发请求
watch(debouncedKeyword, (val) => {
if (val) fetchSearchResults(val)
})
</script>
<template>
<input v-model="keyword" placeholder="输入关键词搜索" />
</template>
实用原则:延时通常设为 300-500 毫秒,根据用户感知和服务器承受能力调整。对于快速输入,用户几乎察觉不到延迟,但请求量减少了 80% 以上。
节流(Throttle):限制“发请求的最小时间间隔”
场景:滚动加载更多、鼠标拖拽实时同步坐标、上传进度更新。这些事件触发频率极高(每秒几十次),如果每次都发请求,浏览器会卡死。节流确保请求在一段时间内最多执行一次,而不是每次触发都执行。
核心思路:无论事件触发多频繁,都会被限制为每隔一段时间才真正执行一次请求。可以用时间戳或定时器实现。
与防抖的区别:防抖是“等操作停止再执行”,节流是“固定节奏执行”。搜索输入用防抖,滚动加载用节流。
实现示例(手写节流 Hook):
// hooks/useThrottle.js
import { ref } from 'vue'
export function useThrottle(fn, delay = 200) {
const lastTime = ref(0)
return function (...args) {
const now = Date.now()
if (now - lastTime.value >= delay) {
lastTime.value = now
fn.apply(this, args)
}
}
}
在组件中使用:
<script setup>
import { ref } from 'vue'
import { useThrottle } from './hooks/useThrottle'
import axios from 'axios'
const page = ref(1)
const list = ref([])
const loadMore = useThrottle(async () => {
const res = await axios.get('/api/list', { params: { page: page.value } })
list.value.push(...res.data)
page.value++
}, 500)
// 滚动事件绑定加载更多
</script>
适用场景:适合即使操作中间过程也要给予反馈的情况,比如地图拖拽时持续刷新周边 POI,滚动到底部自动加载下一页。
竞态问题(Race Condition):保证请求结果与操作意图一致
定义:当用户快速切换多个请求目标时,后发的请求可能先返回,先发的请求后返回,导致最终界面展示的是“旧数据”,与用户当前的操作意图不符。
典型案例:
- 在分页列表中,用户快速点击“上一页”“下一页”,结果返回顺序错乱,页面显示的是第3页,但数据是第2页的。
- 在搜索联想中,用户先输入“Apple”,后修改为“Amazon”,但“Apple”的结果后返回,覆盖了“Amazon”的结果。
根本原因:前一个请求的响应时机不可控,它的监听回调仍然会污染当前状态。
解决方案:
- 标记法(请求计数)
为每次请求分配一个递增的序列号,响应到达时检查该序列号是否为最新,如果不是则丢弃。
let requestId = 0
async function search(keyword) {
const currentId = ++requestId // 新的请求ID
const data = await fetch(`/api/search?q=${keyword}`)
if (currentId !== requestId) return // 非最新请求,丢弃
// 更新状态
results.value = data
}
在接口封装时,可以将该逻辑集成到拦截器中,为所有请求添加竞态控制标签。例如 Axios 封装:
// request.js
let pendingRequests = new Set()
axios.interceptors.request.use(config => {
// 可以基于请求url和method等作为key,终止上一个同类请求
const key = `${config.method}-${config.url}`
// 利用 CancelToken 取消前一次未完成的请求(可选用)
// ...
return config
})
- 请求取消法(AbortController)
在新请求发出时,主动取消上一次仍在 pending 的同类请求。这既避免了无效请求占用带宽,又保证了状态安全。
let controller = null
async function search(keyword) {
// 取消上次未完成的请求
if (controller) controller.abort()
// 创建新的 AbortController
controller = new AbortController()
try {
const response = await fetch(`/api/search?q=${keyword}`, {
signal: controller.signal
})
const data = await response.json()
results.value = data
} catch (err) {
if (err.name !== 'AbortError') {
// 处理真正的错误
}
// 被取消的请求忽略
}
}
在 Vue 组合式函数中,可以进一步包装:
import { ref, onUnmounted } from 'vue'
export function useAbortableRequest() {
const controllers = []
function request(url, options) {
const controller = new AbortController()
controllers.push(controller)
return fetch(url, { ...options, signal: controller.signal }).then(res => {
// 请求成功后从列表中移除 controller
const idx = controllers.indexOf(controller)
if (idx > -1) controllers.splice(idx, 1)
return res
})
}
// 组件卸载时取消所有进行中的请求
onUnmounted(() => {
controllers.forEach(c => c.abort())
})
return { request }
}
实践建议:
- 列表加载、搜索联想必须处理竞态。简单场景用标记法,复杂场景结合请求取消。
- 保存、删除等写操作,建议在前端做防抖或 loading 锁定,避免用户手快导致重复提交。
- 统一封装请求库时,将竞态控制作为
request的可选参数cancelPrevious: true,使项目内不同开发者无需重复造轮子。
这三板斧——防抖、节流、竞态处理——是保证数据请求“准确且优雅”不可缺少的工程手段,也是中大型项目接口封装层的标配能力。