Vue3 + vue-query 的重复请求问题解决记录

237 阅读6分钟

前言

@tanstack/vue-query 以其强大的缓存和状态管理能力,极大地简化了数据获取逻辑。然而,当它与 Vue3 复杂的响应式系统结合时,如果不深入理解其工作原理,很容易陷入“重复请求”的陷阱。在最近一次开发中就遇到了这个问题,本文展现了从一个页面加载时触发4次重复API请求,到2次,并最终实现1次请求的解决过程,以免下次再掉进这个坑里。

问题的初现:一个“勤奋过头”的列表页

我们有一个需求:开发一个包含Tabs切换、独立筛选条件和分页功能的数据列表页面。技术栈是 Vue 3 (Composition API),@tanstack/vue-query,以及一个名为 <cec-page-wrapper> 的高度封装的表格组件。

页面完成后,一切似乎工作正常,但打开浏览器网络面板,我们惊恐地发现,每次加载页面,获取列表数据的API transactionList 竟然被调用了4次!

(假设这里有一个4次请求的网络截图)

这不是在挑战我么~~~

第一阶段:从4次请求到2次 —— 寻找“幽灵触发者”

我们首先审查了代码,试图找出所有可能调用 loadData (即 useQueryrefetch) 的地方。

当时的 script setup 核心逻辑:

// ...
const { isFetching, data: tableData, refetch: loadData } = useQuery({
  queryKey: ['transactionList', activeTab, tablePage, filterParams],
  queryFn: async () => { /* ... */ },
});

watch(activeTab, () => { loadData(); });
onMounted(() => { loadData(); });
// 模板中,分页组件 @change 事件也调用了 loadData

经过仔细的日志打印和分析,我们定位到了这4次请求的来源:

  1. 第一次 (useQuery 自动触发): useQuery 在组件挂载时,会使用初始的 queryKey 自动发起一次请求。
  2. 第二次 (onMounted 手动触发): onMounted 钩子中明确调用了 loadData()
  3. 第三次 (watch(activeTab)): activeTab 在初始化时,watch 监听器被触发,调用了 loadData()
  4. 第四次 (PageWrapper 初始化): 封装的 <cec-page-wrapper> 组件在内部的分页器初始化时,会 emit 一次 @load-data 事件,再次调用了 loadData()

根源: 我们犯了一个典型的错误——不信任框架,手动控制一切。我们试图在每个可能的地方都调用 loadData,却没有意识到 useQuery 的响应式 queryKey 已经为我们处理了大部分情况,从而导致了多次重复的调用。

第二阶段:从2次请求到1次 —— 精确控制请求时机

第一次修复后,我们清除了大部分手动的 loadData 调用,但页面加载时仍然会请求2次。经过进一步排查,我们发现这是 useQuery自动首次请求onMounted准备依赖数据的逻辑之间存在竞争关系。

最终的解决方案: 我们决定采用一种更健壮的模式,让我们对数据请求的流程拥有最终的控制权。

  1. 分离状态: 将用于UI双向绑定的筛选框状态 (activeFilters) 和用于触发API请求的查询快照状态 (searchParams) 分离开。
  2. 精确 queryKey: useQueryqueryKey 只依赖于 searchParams 和分页状态。
  3. 受控触发:
    • 用户的输入只改变 activeFilters触发请求。
    • 只有当用户点击“搜索”、切换Tab或重置时,才调用 search() 函数。
    • search() 函数的核心作用是:activeFilters 的当前状态深拷贝给 searchParams
  4. 信任 useQuery: searchParams 的变化会引起 queryKey 的变化,useQuery自动检测到这个变化并发起新的网络请求。

最终代码 (script setup 核心):

// 筛选框绑定的状态
const activeFilters = computed(() => filterParams[activeTab.value]);
// 用于触发查询的“快照”状态
const searchParams = ref(cloneDeep(activeFilters.value));

const { isFetching, data: tableData, refetch } = useQuery({
  queryKey: computed(() => ['transactionList', ..., searchParams.value]),
  queryFn: async () => { /* ... */ },
});

// 搜索函数:将UI状态“提交”给查询状态
const search = () => {
  activeTableState.value.currentPage = 1;
  searchParams.value = cloneDeep(activeFilters.value);
};

onMounted(() => {
  getBusinessNodesList();
  // 不再需要任何手动的数据加载调用!
});

这个方案彻底解决了重复请求的问题,并且代码结构清晰,职责分明。

实战问答 (Q&A)

在这次重构过程中,我们团队内部也产生了一些很有价值的讨论,这里整理出来分享给大家。

Q1: tableState (分页状态) 和 filterParams (筛选状态) 是否可以合并成一个大的 state 对象?

A: 技术上可以,但我们强烈不推荐。将它们分开是更优的设计。

  • 职责混淆: 合并后,一个对象同时管理两种不同的交互状态,代码意图会变得模糊。
  • queryKey 过于敏感: 如果 queryKey 依赖于这个合并后的大 state 对象,那么用户在输入框里每输入一个字符,都会改变 state,从而触发一次API请求,这会造成性能灾难。
  • 结论: 保持状态分离,可以让我们构建更精确、更可控的 queryKey,实现“只在用户完成操作后才请求数据”的交互。

Q2: 为什么 searchParams 需要使用 cloneDeep 进行深拷贝?

A: 这是为了切断 searchParamsactiveFilters 之间的响应式连接,从而精确控制 useQuery 的触发时机。

  • 如果不拷贝: searchParamsactiveFilters 会指向同一个内存地址。当用户在输入框输入时,activeFilters 变化,searchParams 也会立即跟着变,queryKey 随之改变,useQuery 就会立刻发起请求。
  • 使用 cloneDeep: searchParams 变成了一个在用户点击“搜索”那一刻的数据快照。用户的实时输入只会改变 activeFilters,而不会影响到 searchParams。只有当用户点击搜索,调用 search() 函数时,才会用 activeFilters 的最新快照去替换 searchParams 的旧快照。useQuery 只关心“快照”有没有换一张新的,从而实现了受控的请求。

Q3: 既然 useQuery 会自动请求,那为什么有时候我们需要用 enabled: false 来禁用它?

A: enabled: false 是一个强大的流程控制工具,适用于“查询依赖于尚未准备好的条件”的场景。

  • 级联查询: 比如,获取订单列表的查询,必须等待获取用户信息的查询返回 userId 后才能开始。这时就可以让订单查询 enabled: computed(() => !!userId.value)
  • 用户手动触发: 比如一个复杂的报表生成功能,只有当用户点击“生成报表”按钮后才应该执行查询。
  • 解决初始化竞争: 正如我们排查过程的V1版本,当 onMounted 中有多个异步任务,且它们都会影响 queryKey 时,使用 enabled: false 并在所有任务完成后手动 refetch(),是一种兜底的、确保请求只发一次的健壮方法。

Q4: 如果我将 enabled 设置为 false,是否还需要将 queryKey 封装在 computed 中?

A: 是的,绝对需要! 这是一个非常关键的概念。enabledcomputed(queryKey) 控制着两件完全不同的事情。

  • enabled: false: 决定了查询是否会自动触发。它控制的是“何时 (When)”去获取数据。
  • computed(queryKey): 决定了查询使用什么参数。它控制的是“获取什么 (What)”。

queryKeyvue-query 用来识别和缓存查询结果的唯一ID。即使在 enabled: false 的“手动模式”下,当你手动调用 refetch() 时,vue-query 依然会去读取 queryKey 的最新计算值,并用这个最新的 key 去执行 queryFn 和读写缓存。

  • 如果 queryKey 不是 computed: 当你改变了分页或筛选条件后调用 refetch()useQuery 看到的 queryKey 仍然是组件初始化时的旧值,导致它错误地用旧参数发起了请求,分页和筛选就会完全失效。
  • 结论: 无论 enabledtrue 还是 false,只要你的查询依赖于任何响应式数据(ref, reactive, props),将 queryKey 封装在 computed 中都是绝对必要且是最佳实践。