前言
@tanstack/vue-query
以其强大的缓存和状态管理能力,极大地简化了数据获取逻辑。然而,当它与 Vue3 复杂的响应式系统结合时,如果不深入理解其工作原理,很容易陷入"重复请求"的陷阱。在最近一次开发中就遇到了这个问题,本文展现了从一个页面加载时触发4次重复API请求,到2次,并最终实现1次请求的解决过程,以免下次再掉进这个坑里。
问题的初现:一个"勤奋过头"的列表页
我们有一个需求:开发一个包含Tabs切换、独立筛选条件和分页功能的数据列表页面。技术栈是 Vue 3 (Composition API) 和 @tanstack/vue-query
,并使用了一个名为 <cec-page-wrapper>
的高度封装的表格组件。
页面完成后,一切似乎工作正常,但打开浏览器网络面板,我们惊恐地发现,每次加载页面,获取列表数据的API transactionList
竟然被调用了4次!
发生了什么???
第一阶段:从4次请求到2次 ------ 寻找"幽灵触发者"
我们首先审查了代码,试图找出所有可能调用 loadData
(即 useQuery
的 refetch
) 的地方。
当时的 script setup
核心逻辑:
javascript
// ...
const activeTab = ref('PROVIDER');
const tablePage = ref({ currentPage: 1, ... });
const filterParams = ref({ ... });
const { isFetching, data: tableData, refetch: loadData } = useQuery({
queryKey: ['transactionList', activeTab, tablePage, filterParams],
queryFn: async () => { /* ... */ },
});
watch(activeTab, () => {
tablePage.value.currentPage = 1;
loadData(); // Tab 切换时加载
});
onMounted(() => {
getBusinessNodesList(); // 获取下拉框选项
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
已经为我们处理了大部分情况,从而导致了多次重复的调用。
解决方案 (V1) : 我们决定信任 useQuery
,并精确控制请求时机。
- 禁用自动请求 : 给
useQuery
添加enabled: false
选项。 - 添加
isMounted
守卫 : 创建一个isMounted
标志位,阻止分页组件在onMounted
完成前的初始化调用。 - 统一入口 : 在
onMounted
的最后 ,手动调用refetch()
来发起唯一的第一次请求。
javascript
// ...
const { ..., refetch } = useQuery({ ..., enabled: false });
const isMounted = ref(false);
const handlePageChange = (pageInfo) => {
if (!isMounted.value) return; // 守卫
// ...
refetch();
};
onMounted(async () => {
await getBusinessNodesList();
searchParams.value = cloneDeep(activeFilters.value);
await refetch(); // 手动触发
isMounted.value = true;
});
结果: 这个修改非常有效!请求次数从4次锐减到了2次。但为什么还有2次?
第二阶段:从2次请求到1次 ------ 深入响应式依赖
我们再次审查代码,发现 onMounted
中手动调用的 refetch()
仍然是多余的。尽管我们禁用了它,但 watch
和其他地方的逻辑仍然可能在初始化阶段触发 refetch
。
更深层次的根源 : useQuery
的 queryKey
依赖于 searchParams
。而 searchParams
的初始值是通过 cloneDeep(activeFilters.value)
计算得来的。activeFilters
又依赖 filterParams
和 activeTab
。整个依赖链条是这样的:
activeTab
-> activeFilters
-> searchParams
-> queryKey
在组件挂载的微任务队列中,这些响应式数据的初始化和 watch
的触发顺序存在我们未预料到的交互,导致了 searchParams
在短时间内被更新了两次。
最终的解决方案 (V2) : 我们意识到,问题的关键不是去"堵"住所有的触发点,而是让触发机制变得单一和可预测。
- 回归
useQuery
的自动行为 : 我们移除enabled: false
,我们相信并利用它的自动加载能力。 - 确保初始
queryKey
的稳定性 : 最重要的修改------在定义searchParams
时,就立即 用稳定的初始值cloneDeep(activeFilters.value)
对其进行初始化。 - 移除所有手动的首次加载调用 : 删除
onMounted
中的refetch()
或search()
调用。
最终只有1次请求的代码:
javascript
// 1. 状态定义:searchParams 在定义时就拥有了正确的初始值
const activeTab = ref('PROVIDER');
const activeFilters = computed(() => filterParams[activeTab.value]);
const searchParams = ref(cloneDeep(activeFilters.value)); // 关键!
// 2. useQuery: 默认启用,它会在挂载时自动使用上面的 searchParams 发起请求
const { isFetching, data: tableData, refetch } = useQuery({
queryKey: computed(() => ['transactionList', activeTab.value, ..., searchParams.value]),
queryFn: async () => { /* ... */ },
});
// 3. 事件处理:只负责更新状态,不直接调用 refetch
const search = () => {
activeTableState.value.currentPage = 1;
// 只更新 searchParams,useQuery 会自动响应 queryKey 的变化
searchParams.value = cloneDeep(activeFilters.value);
};
// 4. 生命周期:只做与列表数据无关的初始化
onMounted(() => {
getBusinessNodesList();
// 不需要任何 loadData 或 refetch 调用!
});
为什么这次能成功?
- 单一入口 :
useQuery
的queryFn
现在是获取数据的唯一入口。 - 单一触发器 :
queryKey
的变化是触发queryFn
的唯一方式。 - 可预测的状态 : 在组件挂载时,
searchParams
的初始值是确定的、稳定的。useQuery
使用这个稳定的key
发起了唯一一次初始请求。 - 清晰的职责 :
- 用户的输入 只改变
activeFilters
(UI状态)。 - 用户的搜索动作 (
@search
,@change
,reset
) 才去调用search()
,将 UI 状态"提交"给searchParams
(查询状态)。 useQuery
忠实地响应searchParams
的变化。
- 用户的输入 只改变
结论
还是要深入了解 Vue 3 响应式系统和 vue-query
声明式数据获取的理念啊~~~不然有AI也要卡壳子,耽误牛马下班~
- 不要与框架对抗 : 相信
useQuery
的响应式能力,避免在onMounted
或watch
中进行手动的、命令式的refetch
调用。 - 分离状态 : 将用于UI双向绑定的状态(如
activeFilters
)和用于触发数据请求的状态(如searchParams
)分离开,可以有效切断意外的响应式连锁反应。 - 稳定
queryKey
: 确保useQuery
在首次自动执行时,其queryKey
所依赖的所有数据都已处于稳定和正确的初始状态。
通过遵循这些原则,我们可以构建出既简洁又健壮的数据获取逻辑,真正发挥出现代前端框架的威力。