Vue 3 筛选表格一改条件就连发请求,问题不在防抖
Vue 3 筛选表格一改条件就连发请求,问题不在防抖
后台系统里最容易写出“看着没问题,跑起来很蠢”的代码,就是筛选表格。
尤其是这种页面:
- 左边一堆筛选项
- 顶部有搜索框
- URL 要能保留筛选条件
- 表格支持分页、排序、状态切换
需求一上来,很多人会自然写成“谁变了就 watch 谁”。写着写着,请求越来越多,最后一个筛选动作能打出去三四次请求。
我遇到过一次真实事故,页面不是挂了,而是后端接口 QPS 被一个管理后台翻了 5 倍。
事故长什么样
页面是一个订单管理表格,支持:
- 关键词搜索
- 状态筛选
- 时间范围
- 排序
- 分页
初版写法大概是这样:
const query = reactive({
keyword: '',
status: '',
page: 1,
pageSize: 20,
sortBy: 'createdAt',
sortOrder: 'desc',
})
watch(() => query.keyword, fetchList)
watch(() => query.status, fetchList)
watch(() => query.page, fetchList)
watch(() => query.pageSize, fetchList)
watch(() => route.query, syncFromRoute, { deep: true })
watch(query, syncToRoute, { deep: true })
表面上看每个职责都合理:
- 条件变了就请求
- 条件变了同步到 URL
- URL 变了再同步回页面
但一旦这些 watch 串起来,事情就变味了。
为什么会连发
原因 1:你以为是“一次修改”,实际上是“多次响应式变更”
比如用户切换状态筛选:
query.status = 'PAID'
query.page = 1
这不是一次变更,是两次。只要你分别 watch status 和 page,就会触发两次请求。
如果再加上 syncToRoute(),路由更新还会再触发一轮同步。
原因 2:URL 同步和数据请求耦合在一起了
很多团队喜欢让页面状态和 URL 保持严格同步,这是对的。但错误通常出在“同步 URL 的过程也被当成数据变化”。
常见链路是:
- 用户改筛选条件
watch(query)触发router.replaceroute.query变化watch(route.query)再次写回querywatch(query.xxx)又触发请求
最后你看接口日志,会发现同一个条件组合被打了两三遍。
原因 3:防抖只能压输入频率,解决不了状态模型混乱
很多人第一反应是给搜索框加个防抖:
const debouncedFetch = useDebounceFn(fetchList, 300)
watch(() => query.keyword, debouncedFetch)
这只能解决“输入太快”。如果你的问题本质是:
- 一个动作改了多个字段
- 路由同步造成二次触发
- 排序和分页互相影响
那防抖只能让问题看起来没那么明显,根因还在。
正确思路:把“输入状态”和“生效查询”拆开
后来我把模型拆成两层:
formState:用户正在编辑的条件requestState:真正用于请求的稳定快照
const formState = reactive({
keyword: '',
status: '',
timeRange: [] as string[],
})
const requestState = reactive({
keyword: '',
status: '',
startTime: '',
endTime: '',
page: 1,
pageSize: 20,
sortBy: 'createdAt',
sortOrder: 'desc',
})
这两个对象职责完全不同。
formState可以频繁变化requestState只能在明确的“提交动作”里变
这样一来,请求入口就只剩一个。
我最后用的实现方式
1. 用户改筛选条件,只改表单态
function handleStatusChange(status: string) {
formState.status = status
}
2. 点击查询时,一次性提交为请求态
function applyFilters() {
requestState.keyword = formState.keyword.trim()
requestState.status = formState.status
requestState.startTime = formState.timeRange[0] ?? ''
requestState.endTime = formState.timeRange[1] ?? ''
requestState.page = 1
syncRequestToRoute()
fetchList()
}
这里最关键的是:不要再 watch 每个字段自动请求,而是由明确动作触发。
3. 分页和排序也只改请求态
function handlePageChange(page: number) {
requestState.page = page
syncRequestToRoute()
fetchList()
}
function handleSortChange(sortBy: string, sortOrder: string) {
requestState.sortBy = sortBy
requestState.sortOrder = sortOrder
requestState.page = 1
syncRequestToRoute()
fetchList()
}
请求逻辑始终只有一条线,不会到处散着 watch。
路由同步怎么做才不打架
我最后保留了 URL 同步,但只允许“单向主导”。
页面初始化时:route -> requestState -> formState
function initFromRoute() {
requestState.keyword = String(route.query.keyword ?? '')
requestState.status = String(route.query.status ?? '')
requestState.page = Number(route.query.page ?? 1)
requestState.pageSize = Number(route.query.pageSize ?? 20)
requestState.sortBy = String(route.query.sortBy ?? 'createdAt')
requestState.sortOrder = String(route.query.sortOrder ?? 'desc')
formState.keyword = requestState.keyword
formState.status = requestState.status
}
后续交互中:只允许 requestState -> route
function syncRequestToRoute() {
router.replace({
query: {
keyword: requestState.keyword || undefined,
status: requestState.status || undefined,
page: String(requestState.page),
pageSize: String(requestState.pageSize),
sortBy: requestState.sortBy,
sortOrder: requestState.sortOrder,
},
})
}
这里有一个很重要的原则:
- 初始化时接受路由输入
- 运行时不要再让
watch(route.query)持续回写页面
否则你永远在和自己打架。
还要补一层:处理请求竞态
就算请求频率降下来了,列表页还有另一个老问题:后发的请求不一定后回来。
如果用户连续点两次筛选,旧请求比新请求晚回来,你的表格还是会被旧数据覆盖。
我一般用请求序号做保护:
let latestRequestId = 0
async function fetchList() {
const requestId = ++latestRequestId
loading.value = true
try {
const res = await api.fetchOrders({ ...requestState })
if (requestId !== latestRequestId) return
tableData.value = res.list
total.value = res.total
} finally {
if (requestId === latestRequestId) {
loading.value = false
}
}
}
如果你们项目里用的是 fetch 或 Axios,也可以配合 AbortController 或 cancel token,但即使做了取消,我还是建议保留请求序号校验,因为最稳。
为什么这套设计在工程里更耐用
它不是“写法更优雅”,而是更符合复杂页面的控制关系:
- 输入可以频繁变化,但请求不能频繁发
- 路由是状态快照,不是状态源头
- 请求结果必须遵守最后一次操作优先
这套结构一旦立起来,后面加筛选项、加默认条件、加导出、加列设置,都不会继续把请求逻辑搅乱。
这类页面我现在基本只保留三个入口
我后来在团队里统一成三个动作:
initFromRoute():页面初始化applyFilters():提交筛选fetchList():真实请求
其他所有 UI 行为最终都只能汇聚到这三步里。
如果你们的列表页现在靠十几个 watch 在维持,只要业务继续长,这个页面迟早会变成请求风暴制造机。问题不在“没防抖”,问题在于状态流向从一开始就没收住。
