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 statuspage,就会触发两次请求。

如果再加上 syncToRoute(),路由更新还会再触发一轮同步。

原因 2:URL 同步和数据请求耦合在一起了

很多团队喜欢让页面状态和 URL 保持严格同步,这是对的。但错误通常出在“同步 URL 的过程也被当成数据变化”。

常见链路是:

  1. 用户改筛选条件
  2. watch(query) 触发 router.replace
  3. route.query 变化
  4. watch(route.query) 再次写回 query
  5. watch(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,但即使做了取消,我还是建议保留请求序号校验,因为最稳。

为什么这套设计在工程里更耐用

它不是“写法更优雅”,而是更符合复杂页面的控制关系:

  • 输入可以频繁变化,但请求不能频繁发
  • 路由是状态快照,不是状态源头
  • 请求结果必须遵守最后一次操作优先

这套结构一旦立起来,后面加筛选项、加默认条件、加导出、加列设置,都不会继续把请求逻辑搅乱。

这类页面我现在基本只保留三个入口

我后来在团队里统一成三个动作:

  1. initFromRoute():页面初始化
  2. applyFilters():提交筛选
  3. fetchList():真实请求

其他所有 UI 行为最终都只能汇聚到这三步里。

如果你们的列表页现在靠十几个 watch 在维持,只要业务继续长,这个页面迟早会变成请求风暴制造机。问题不在“没防抖”,问题在于状态流向从一开始就没收住。

最后更新 4/20/2026, 6:02:32 AM