前言
@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 (即 useQuery 的 refetch) 的地方。
当时的 script setup 核心逻辑:
// ...
const { isFetching, data: tableData, refetch: loadData } = useQuery({
queryKey: ['transactionList', activeTab, tablePage, filterParams],
queryFn: async () => { /* ... */ },
});
watch(activeTab, () => { loadData(); });
onMounted(() => { loadData(); });
// 模板中,分页组件 @change 事件也调用了 loadData
经过仔细的日志打印和分析,我们定位到了这4次请求的来源:
- 第一次 (useQuery 自动触发):
useQuery在组件挂载时,会使用初始的queryKey自动发起一次请求。 - 第二次 (
onMounted手动触发):onMounted钩子中明确调用了loadData()。 - 第三次 (
watch(activeTab)):activeTab在初始化时,watch监听器被触发,调用了loadData()。 - 第四次 (
PageWrapper初始化): 封装的<cec-page-wrapper>组件在内部的分页器初始化时,会emit一次@load-data事件,再次调用了loadData()。
根源: 我们犯了一个典型的错误——不信任框架,手动控制一切。我们试图在每个可能的地方都调用 loadData,却没有意识到 useQuery 的响应式 queryKey 已经为我们处理了大部分情况,从而导致了多次重复的调用。
第二阶段:从2次请求到1次 —— 精确控制请求时机
第一次修复后,我们清除了大部分手动的 loadData 调用,但页面加载时仍然会请求2次。经过进一步排查,我们发现这是 useQuery 的自动首次请求和 onMounted 中准备依赖数据的逻辑之间存在竞争关系。
最终的解决方案: 我们决定采用一种更健壮的模式,让我们对数据请求的流程拥有最终的控制权。
- 分离状态: 将用于UI双向绑定的筛选框状态 (
activeFilters) 和用于触发API请求的查询快照状态 (searchParams) 分离开。 - 精确
queryKey:useQuery的queryKey只依赖于searchParams和分页状态。 - 受控触发:
- 用户的输入只改变
activeFilters,不触发请求。 - 只有当用户点击“搜索”、切换Tab或重置时,才调用
search()函数。 search()函数的核心作用是:将activeFilters的当前状态深拷贝给searchParams。
- 用户的输入只改变
- 信任
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: 这是为了切断 searchParams 与 activeFilters 之间的响应式连接,从而精确控制 useQuery 的触发时机。
- 如果不拷贝:
searchParams和activeFilters会指向同一个内存地址。当用户在输入框输入时,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: 是的,绝对需要! 这是一个非常关键的概念。enabled 和 computed(queryKey) 控制着两件完全不同的事情。
enabled: false: 决定了查询是否会自动触发。它控制的是“何时 (When)”去获取数据。computed(queryKey): 决定了查询使用什么参数。它控制的是“获取什么 (What)”。
queryKey 是 vue-query 用来识别和缓存查询结果的唯一ID。即使在 enabled: false 的“手动模式”下,当你手动调用 refetch() 时,vue-query 依然会去读取 queryKey 的最新计算值,并用这个最新的 key 去执行 queryFn 和读写缓存。
- 如果
queryKey不是computed: 当你改变了分页或筛选条件后调用refetch(),useQuery看到的queryKey仍然是组件初始化时的旧值,导致它错误地用旧参数发起了请求,分页和筛选就会完全失效。 - 结论: 无论
enabled是true还是false,只要你的查询依赖于任何响应式数据(ref,reactive,props),将queryKey封装在computed中都是绝对必要且是最佳实践。