前言
@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所依赖的所有数据都已处于稳定和正确的初始状态。
通过遵循这些原则,我们可以构建出既简洁又健壮的数据获取逻辑,真正发挥出现代前端框架的威力。