Vue 生态(番外):番外篇:现代前端数据层基石 · TanStack Query v5 完全指南


第一章: 思想革命 · 从“命令式”到“声明式”

摘要: 在本章中,我们将开启一场关于前端数据管理的思想革命。我们将首先回顾在使用 Pinia 手动管理异步数据时所面临的“命令式”困境——即繁琐的 isLoading, error 状态管理。接着,我们将建立“客户端状态”与“服务端状态”的核心分野,为引入新工具奠定理论基础。最后,我们将从零开始搭建一个完整的开发环境,集成 TanStack Query v5 及其强大的开发者工具,并通过 useQueryuseMutation 完成您的第一次“声明式”数据交互。


在本章中,我们将循序渐进,完成一次从旧模式到新范式的思维升级:

  1. 首先,我们将直面并剖析 现有数据管理方式的痛点,理解变革的必要性。
  2. 接着,我们将建立一个清晰的理论模型,区分两种核心的前端状态,理解不同工具的适用边界。
  3. 然后,我们将亲手 搭建一个现代化的、包含模拟后端的完整开发环境
  4. 最后,我们将学习 TanStack Query 最核心的 useQuery (读) 和 useMutation (写) API,完成一次真正意义上的“声明式”数据交互。

1.1. 序章:为何我们需要超越 fetchisLoading

本小节核心知识点:

  • 命令式 (Imperative) 数据获取:一种需要开发者手动、一步步地描述“如何做”的数据处理方式(例如,手动设置 `loading` 为 `true`,执行 `try/catch`,最后设置 `loading` 为 `false`)。
  • 模板代码 (Boilerplate):在多个不同功能模块中,反复出现的、几乎完全相同的代码结构。
  • 功能缺失: 手动模式天然缺失了缓存、请求去重、后台自动刷新等高级功能,自行实现成本极高。

痛点回顾:Pinia 中的“四件套”困境

在之前的学习中,我们已经熟练掌握了使用 Pinia 来管理全局状态。当需要从后端获取数据时,我们形成了一套标准流程,即在 Store 中定义“数据状态四件套”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// store/userStore.ts
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import type { User } from "@/types/user";
import { getUserById } from "@/api/services/userService";

export const useUserStore = defineStore("user", () => {
// 1. 数据本身
const user = ref<User | null>(null);
// 2. 加载状态
const isLoading = ref(false);
// 3. 错误状态
const error = ref<Error | null>(null);
// 4. 衍生状态
const isLoggedIn = computed(() => !!user.value);

// 命令式的 Action
async function fetchUser(userId: number) {
isLoading.value = true;
error.value = null;
try {
const response = await getUserById(userId);
user.value = response.data;
} catch (err) {
error.value = err as Error;
} finally {
isLoading.value = false;
}
}

return { user, isLoading, error, isLoggedIn, fetchUser };
});

这套模式虽然能工作,但“架构师学徒”和“开拓者”们很快就会发现它的弊端:

  1. 高度重复: 每当需要获取一种新的数据(如文章列表、商品详情),我们就必须重复这套“四件套”和 try/catch/finally 逻辑。
  2. 职责不清: Pinia 的核心职责是管理 客户端状态。现在,我们却让它强行管理着本应属于服务端的 数据缓存,这在架构上是不清晰的。
  3. 能力有限: 面对更复杂的真实场景,例如“当用户重新聚焦浏览器时自动刷新数据”或“多个组件同时请求同一份数据时只发送一次网络请求”,这套手动模式会变得异常复杂,难以维护。

这种手动控制每一步流程的编码方式,就是典型的 命令式编程。我们正在寻求一种更高级的 声明式 (Declarative) 范式——我们只关心“想要什么数据”,而把“如何获取、何时更新、如何缓存”这些复杂的细节交给专业工具处理。


1.2. 核心分野:客户端状态 (Pinia) vs. 服务端状态 (TanStack Query)

本小节核心知识点:

  • 服务端状态 (Server State):本质上是存储在远程服务器的数据,前端只拥有它的“快照”。它具有异步、可能被他人修改、可能过期的特性。
  • 客户端状态 (Client State):完全由前端应用自身控制的状态,与后端无直接关联。
  • 职责分离: 将两种状态的管理职责交给不同的专业工具,是构建清晰、可扩展前端架构的关键一步。

在引入解决方案之前,我们必须建立一个清晰的心智模型,正确区分应用中的两种核心状态。

定义: 应用自身拥有的、控制 UI 交互和行为的状态。

  • 特点: 同步、确定性、由用户或应用自身行为触发改变。
  • 管理者: Pinia
  • 生命周期: 与应用会话绑定。
  • 示例:
    • 网站的暗黑/明亮模式主题
    • 侧边栏菜单是否折叠
    • 多步骤表单当前所在的步骤
    • 用户登录后持久化的认证信息

定义: 我们并不“拥有”的状态,它远程存在于服务器。我们只是借用(缓存)它的一份“快照”。

  • 特点: 异步、不确定性、可能随时在后端被修改。
  • 管理者: TanStack Query
  • 生命周期: 复杂,涉及缓存、过期、后台同步。
  • 示例:
    • 用户个人资料
    • 文章列表
    • 任何需要通过 API 从后端获取的数据

核心结论: TanStack QueryPinia黄金搭档,而非竞争对手。Pinia 继续管理它最擅长的客户端状态,而我们将把所有与服务端数据交互的复杂工作,都交给 TanStack Query 这个专家。


1.3. 环境就绪:QueryClient 与 v5 开发者工具

本小节核心知识点:

  • 项目初始化: 使用 pnpm create vite 创建一个现代化的 Vue + TypeScript 项目。
  • 模拟后端: 使用 json-server 在本地快速启动一个功能完备的 RESTful API 服务。
  • 核心集成: TanStack Query 通过 QueryClientProviderQueryClient 实例,在应用根部注入其能力。
  • 调试神器: @tanstack/vue-query-devtools 是一个必须安装的开发者工具,它能让你在浏览器中直观地看到所有查询的状态、缓存和数据。

在开始编码前,我们先搭建一个完整的、可运行的开发环境。

1. 项目初始化与依赖安装

首先,使用 pnpm 创建一个新的 Vite + Vue 项目。

1
2
pnpm create vite tanstack-query-in-action --template vue-ts
cd tanstack-query-in-action

接着,安装我们项目所需的全部依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 核心库
pnpm add @tanstack/vue-query

# 开发者工具 (强烈建议安装)
pnpm add @tanstack/vue-query-devtools

# 安装 json-server 作为开发依赖,很遗憾作者已经停更了,新版本远不如稳定版来的划算,所以我们限制版本在 0.17.4
pnpm add -D json-server@0.17.4

# SCSS 支持
pnpm add -D sass

# (我们暂时保留 pinia 用于对比)
pnpm add pinia

2. 搭建模拟 API 服务 (json-server)

在项目根目录下创建一个 db.json 文件,用于存放我们的模拟数据。

文件路径: db.json

1
2
3
4
5
6
7
8
9
10
{
"users": [
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz"
}
]
}

然后,在 package.jsonscripts 中添加一个启动 json-server 的命令。

文件路径: package.json

1
2
3
4
5
6
7
8
{
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"mock": "json-server --watch db.json --port 3000"
}
}

现在,你可以打开一个终端运行 pnpm run mock,另一个终端运行 pnpm dev。你就拥有了一个运行在 http://localhost:3000 的后端服务和一个运行在 http://localhost:5173 的前端服务。

3. 在 Vue 应用中集成 TanStack Query

修改 main.ts 文件,初始化 QueryClient 并通过 QueryClientProvider 将其提供给整个应用。

文件路径: src/main.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'

import App from './App.vue'
import './assets/main.scss' // 引入 SCSS 入口文件

const app = createApp(App)
const pinia = createPinia()
const queryClient = new QueryClient() // 创建 QueryClient 实例

app.use(pinia)
app.use(VueQueryPlugin, { queryClient }) // 使用 VueQueryPlugin 注入

app.mount('#app')

4. 集成开发者工具

最后,在 App.vue 中引入并使用开发者工具组件。

文件路径: src/App.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script setup lang="ts">
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
</script>

<template>
<header>
<h1>TanStack Query in Action</h1>
</header>

<main>
</main>

<VueQueryDevtools />
</template>

<style lang="scss">
/* 你可以在这里写 SCSS 样式 */
body {
font-family: sans-serif;
background-color: #f0f2f5;
}
main {
padding: 2rem;
}
</style>

环境搭建完成!现在你的应用已经具备了使用 TanStack Query 的全部能力,并且拥有了强大的可视化调试工具。

image-20250907155046558


1.4. useQuery:声明你的第一个数据状态

朋友,我们已经成功搭建好了环境,现在是时候迎接第一个“啊哈!”时刻了。我们将要学习的 useQuery 不仅仅是 fetch 的替代品,它是一种全新的、关于如何“看待”和“描述”异步数据的思维方式。

为何一个 isLoading 远远不够?

在我们过去使用 Pinia 的岁月里,一个简单的 isLoading 标志位似乎已经能解决所有问题。但让我们深入思考一下现代 Web 应用的真实场景,你会发现它的局限性:

  1. 首次加载 vs. 后台刷新:当用户第一次打开一个页面时,数据是完全没有的。此时,我们可能需要展示一个大面积的“骨架屏”(Skeleton)来优化体验,防止页面空白和抖动。但是,当用户已经看到了数据,我们只是在后台静默地刷新它(比如用户重新聚焦了浏览器窗口),你还想用那个粗暴的骨架屏去打扰用户吗?当然不。这时,一个微小、不引人注目的加载指示器(比如一个小 spinner)才是更优雅的选择。

  2. 职责混淆:用一个 isLoading 来同时表示“页面正在初始化”和“数据正在后台更新”,这在逻辑上是含糊不清的。作为追求卓越架构的“开拓者”,我们需要更精确的工具来描述这两种截然不同的 UI 状态。

Pinia 的手动模式让我们陷入了两难。我们当然可以手动创建 isInitialLoadingisRefetching 两个状态,但这会让我们的 Store 变得更加臃肿,模板代码也越来越多。我们需要的,是一种原生就理解并区分这些状态的机制。

深入理解 statusfetchStatus 的双核系统

TanStack Query 的优雅之处,在于它为我们提供了一套精确而强大的双状态系统来描述查询的生命周期。请记住这两个核心概念,它们是你理解后面一切高级功能的基石:

状态类型核心职责可能的值描述
status描述 “数据” 的状态pending无数据 并且正在进行首次请求。这是真正的“从零到一”阶段。
success请求成功,我们手中 有可用的数据 可以渲染。
error请求失败,我们手中没有数据,但 有错误信息
fetchStatus描述 “网络请求” 的状态fetchingqueryFn 正在执行中。无论是首次请求还是后台刷新,只要在请求,它就是 fetching
paused请求因网络断开等原因被暂停,它会在网络恢复后自动重试。
idle当前没有任何网络请求在进行。

这看起来可能有点复杂,但别担心。TanStack Query 已经为我们封装好了更易于使用的衍生布尔值。你日常打交道最多的将是它们:

衍生状态核心含义最佳 UI 场景
isLoading首次加载中(因为还没有数据)。骨架屏、整页加载动画
isFetching任何 网络请求正在进行中。细微的后台加载指示器、刷新按钮的 loading 状态
isError请求失败。错误提示信息。
isSuccess请求成功且有数据。渲染主要内容。
isRefetching后台刷新中(因为已有旧数据)。等同于 isFetching 的场景。

看到了吗?isLoading 只是 isFetching 在一种特殊情况(statuspending)下的表现。通过同时使用 isLoadingisFetching,我们就能完美地解决之前提出的 UI 状态区分难题。

打造差异化的加载体验

理论已经足够,让我们用代码来感受它的威力。我们将创建一个 API 服务层来封装网络请求,这是一种良好的架构习惯。

文件路径: src/api/userService.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export interface User {
id: number;
name: string;
username: string;
email: string;
}

export const fetchUserById = async (userId: number): Promise<User> => {
// 模拟一个稍慢的网络请求
await new Promise(resolve => setTimeout(resolve, 1000));

const response = await fetch(`http://localhost:3000/users/${userId}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};

现在,我们来构建 UserProfile.vue 组件,并利用这套精细的状态模型实现差异化的加载体验。

文件路径: src/components/UserProfile.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { fetchUserById } from '@/api/userService'

const userId = 1

const {
isLoading,
isFetching,
isError,
data: user,
error
} = useQuery({
// 1. queryKey: 查询的唯一标识符。
// 数组结构让它可以包含动态参数,如 userId。
queryKey: ['user', userId],

// 2. queryFn: 一个返回 Promise 的异步函数,负责真正的获取逻辑。
queryFn: () => fetchUserById(userId),
})
</script>

<template>
<div class="user-profile">
<div class="header">
<h2>User Profile</h2>
<!-- 3. isFetching: 任何时候有网络请求,都显示这个微小指示器 -->
<span v-if="isFetching" class="fetching-indicator">Updating...</span>
</div>

<!-- 4. isLoading: 只有在首次加载时显示骨架屏 -->
<div v-if="isLoading" class="loading-skeleton">
<p>Loading user data...</p>
</div>

<!-- 5. isError: 处理错误状态 -->
<div v-else-if="isError" class="error">
An error has occurred: {{ error?.message }}
</div>

<!-- 6. 成功状态: 确保有 user 数据再渲染 -->
<div v-else-if="user" class="user-data">
<p><strong>ID:</strong> {{ user.id }}</p>
<p><strong>Name:</strong> {{ user.name }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
</div>
</div>
</template>

<style lang="scss" scoped>
.user-profile {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
background-color: white;
min-height: 150px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.fetching-indicator {
font-size: 0.8rem;
color: #3498db;
animation: pulse 1.5s infinite;
}
.loading-skeleton, .error {
color: #888;
padding-top: 1rem;
}
.error {
color: #e74c3c;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
</style>

将这个组件添加到 App.vue 中。首次加载时,你会看到 “Loading user data…”。加载完成后,打开开发者工具,找到 VueQueryDevtools,点击对应查询的 “Refetch” 按钮。你会发现,页面内容没有消失,只有右上角的 “Updating…” 指示器在闪烁。

我们做到了!我们没有增加任何一个自定义的 ref,仅仅通过 声明式 地使用 TanStack Query 提供的状态,就构建出了专业、精细的用户体验。这就是从“命令式”到“声明式”的第一次胜利。


1.5. useMutation:掌握“写”操作与数据同步

我们已经掌握了如何优雅地“读”数据。但一个完整的应用,必然涉及对数据的“写”操作,比如创建、更新、删除。这正是 useMutation 的舞台。在本节中,我们将学习如何将“读”与“写”无缝连接,形成一个自动化的数据同步闭环。

1.5.1 核心 API:mutateAsync 与链式操作

在一个典型的表单提交场景中,我们常常需要在请求成功后执行一系列后续操作:清空输入框、显示成功提示、在几秒后隐藏提示等。如果触发请求的函数只是“发完就走”,我们就很难用优雅的 async/await 语法来组织这些链式逻辑,容易再次陷入回调的困境。

为解决此问题,useMutation 提供了两种调用方式,其中一种正是为此而生:

调用方式返回类型核心用途
mutatevoid消防式:适用于简单的、不需要等待结果的变更。
mutateAsyncPromise<TData>链式操作(推荐) 返回一个 Promise,允许我们 await 其结果,然后像写同步代码一样执行后续逻辑。

通过 mutateAsync,我们可以将复杂的异步流程写得如同步代码般清晰。

1.5.2 数据同步:invalidateQueries 缓存失效

当“写”操作成功后,我们面临着另一个核心问题:如何让界面上所有依赖该数据的地方自动更新?手动重新调用 fetch 函数是一种可行但笨拙的方式。

TanStack Query 为此提供了它的“心脏”机制:queryClient.invalidateQueries

它的作用只有一个:告诉 TanStack Query,某个 queryKey 对应的数据已经“过时了”

一旦一个查询被标记为过时,如果它当前正被页面上的 useQuery 订阅,TanStack Query 就会 自动地、在后台重新执行它的 queryFn 来获取最新数据。这个机制是连接“写”操作 (useMutation) 和“读”操作 (useQuery) 的魔法桥梁。

让我们通过一个完整的更新流程来实践这一点。

首先,确保 API 服务层有更新函数:

文件路径: src/api/userService.ts

1
2
3
4
5
6
7
8
9
10
export const updateUser = async (user: Partial<User> & { id: number }): Promise<User> => {
await new Promise(resolve => setTimeout(resolve, 800));
const response = await fetch(`http://localhost:3000/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!response.ok) throw new Error('Failed to update user');
return response.json();
};

接着,在组件中集成 useMutation

文件路径: src/components/UserProfile.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<script lang="ts" setup>
import { ref } from "vue";
import { useQuery, useMutation, useQueryClient } from "@tanstack/vue-query";
import { fetchUserById, updateUser } from "@/api/userService";
import type { User } from "@/api/userService";

const userId = 1;
const newName = ref("");
const showSuccessMessage = ref(false);

// 1. 获取 QueryClient 实例,它是我们指挥缓存系统的“遥控器”
const queryClient = useQueryClient();

// `useQuery` 部分保持不变,它静静地等待着被通知更新
const {
isLoading,
isError,
data: user,
error,
isFetching,
} = useQuery<User, Error>({
queryKey: ["user", userId],
queryFn: () => fetchUserById(userId),
});
// 2. 创建一个 Mutation 实例并解构返回值
const {
mutate,
mutateAsync,
isPending,
isError: isMutationError,
error: mutationError,
isSuccess,
reset,
} = useMutation({
mutationFn: updateUser,
onSuccess: (data) => {
// 3. 核心!当变更成功后,让相关的查询缓存失效
// 这会触发 useQuery 自动重新获取数据
queryClient.invalidateQueries({ queryKey: ["user", userId] });
// 你也可以在这里做一些乐观更新,我们后续章节会讲到
},
onError: (error) => {
console.error("一个错误发生了", error);
},
});

// 4. 将整个操作封装在 async 函数中,以使用 await
const handleUpdateName = async () => {
if (!newName.value || !user.value) return;
// 5. 使用解构后的 mutateAsync 等待 Promise 完成
await mutateAsync({
id: user.value.id,
name: newName.value,
});
// 6. 在 await 之后,我们可以安全地执行任何后续的异步/同步操作
newName.value = ""; // 清空输入框
// 显示成功提示
showSuccessMessage.value = true;
setTimeout(() => {
showSuccessMessage.value = false;
}, 2500);
};
</script>

<template>
<div class="user-profile">
<div class="header">
<h2>User Profile</h2>
<!-- isFetching: 任何时候有网络请求,都显示这个微小指示器 -->
<span v-if="isFetching" class="fetching-indicator">Updating...</span>
</div>

<!-- isLoading: 只有在首次加载时显示骨架屏 -->
<div v-if="isLoading" class="loading-skeleton">
<p>Loading user data...</p>
</div>

<!-- isError: 处理错误状态 -->
<div v-else-if="isError" class="error">
An error has occurred: {{ error?.message }}
</div>

<!-- 成功状态: 确保有 user 数据再渲染 -->
<div v-else-if="user" class="user-data">
<p><strong>ID:</strong> {{ user.id }}</p>
<p><strong>Name:</strong> {{ user.name }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
</div>

<div class="user-actions">
<input v-model="newName" placeholder="请输入您的新名字" />
<button @click="handleUpdateName" :disabled="isPending">更新名字</button>

<!-- 显示成功消息 -->
<p v-if="showSuccessMessage" class="success-message">名字更新成功!</p>

<!-- 显示 mutation 错误 -->
<p v-if="isMutationError" class="error-message">
更新失败: {{ mutationError?.message }}
</p>
</div>
</div>
</template>

运行应用,当更新成功后,你会发现页面上的用户名自动刷新了,这正是 invalidateQueries 在背后发挥作用。

1.5.3 架构模式:管理多个写操作

现在,我们的组件需要增加“删除用户”的功能。此时,一个关键的架构问题出现了:我们应该如何在一个组件中管理多个不同的“写”操作?

一个常见的误区是尝试让一个 useMutation 实例处理所有逻辑。这将导致状态混淆(isPending 无法区分是哪个操作)、逻辑耦合和类型安全问题。

正确的架构模式,也是 TanStack Query 的最佳实践是:

黄金法则:一个动作,一个 useMutation 实例。

为每一个独立的“写”操作(Create, Update, Delete 等)创建专用的 useMutation 实例。这能确保状态隔离、逻辑清晰和类型安全。

让我们通过最终的示例来展示这个模式。

首先,在 API 层添加删除函数:
文件路径: src/api/userService.ts

1
2
3
4
5
6
export const deleteUserById = async (userId: number): Promise<{ success: boolean }> => {
await new Promise(resolve => setTimeout(resolve, 1000));
const response = await fetch(`http://localhost:3000/users/${userId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete user');
return { success: true };
};

然后,在组件中为“删除”操作创建第二个 useMutation 实例:
文件路径: src/components/UserProfile.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
<script lang="ts" setup>
import { ref } from "vue";
import { useQuery, useMutation, useQueryClient } from "@tanstack/vue-query";
import { fetchUserById, updateUser, deleteUserById } from "@/api/userService";

const userId = 1;
const queryClient = useQueryClient();

const {
isLoading,
isFetching,
isError,
data: user,
error
} = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUserById(userId),
});

// ==========================================================
// Mutation for UPDATING User
// ==========================================================
const {
mutateAsync: updateUserAsync,
isPending: isUpdating,
isError: isUpdateError,
error: updateError
} = useMutation({
mutationFn: updateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", userId] });
}
});

const handleUpdateName = async () => {
if (!user.value) return;
const newName = window.prompt("Enter new name:", user.value.name);
if (newName && newName !== user.value.name) {
try {
await updateUserAsync({ ...user.value, name: newName });
} catch (e) {
// 可选:错误处理
alert("Failed to update user name.");
}
}
};

// ==========================================================
// Mutation for DELETING User
// ==========================================================
const {
mutate: deleteUser,
isPending: isDeleting,
isError: isDeleteError,
error: deleteError
} = useMutation({
mutationFn: deleteUserById,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", userId] });
// 如果有用户列表,列表缓存也必须失效!
// queryClient.invalidateQueries({ queryKey: ["users"] });
alert("User deleted successfully!");
}
});

const handleDeleteUser = () => {
if (user.value && window.confirm(`Are you sure you want to delete ${user.value.name}?`)) {
deleteUser(user.value.id);
}
};
</script>

<template>
<div class="user-profile">
<div class="header">
<h2>User Profile</h2>
<span v-if="isFetching" class="fetching-indicator">Updating...</span>
</div>

<div v-if="isLoading" class="loading-skeleton">
<p>Loading user data...</p>
</div>

<div v-else-if="isError" class="error">
An error has occurred: {{ error?.message }}
</div>

<div v-else-if="user" class="user-data">
<p><strong>ID:</strong> {{ user.id }}</p>
<p><strong>Name:</strong> {{ user.name }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>

<div class="user-actions" style="margin-top: 1rem;">
<!-- Update Action Group -->
<div class="action-group" style="display: inline-block; margin-right: 1rem;">
<button @click="handleUpdateName" :disabled="isUpdating">
{{ isUpdating ? 'Updating...' : 'Update Name' }}
</button>
<span v-if="isUpdateError" class="error" style="margin-left: 0.5rem;">
{{ updateError?.message || 'Update failed' }}
</span>
</div>

<!-- Delete Action Group -->
<div class="action-group" style="display: inline-block;">
<button @click="handleDeleteUser" :disabled="isDeleting" class="delete-button">
{{ isDeleting ? 'Deleting...' : 'Delete User' }}
</button>
<span v-if="isDeleteError" class="error" style="margin-left: 0.5rem;">
{{ deleteError?.message || 'Delete failed' }}
</span>
</div>
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
.user-profile {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
background-color: white;
min-height: 150px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.fetching-indicator {
font-size: 0.8rem;
color: #3498db;
animation: pulse 1.5s infinite;
}
.loading-skeleton, .error {
color: #888;
padding-top: 1rem;
}
.error {
color: #e74c3c;
}
.delete-button {
background: #e74c3c;
color: #fff;
border: none;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
</style>

通过为每个动作创建独立的 useMutation 实例,并利用解构重命名(例如 isPending: isUpdating),我们的组件状态变得极其清晰。更新按钮的加载状态由 isUpdating 控制,删除按钮由 isDeleting 控制,它们互不影响,各自的错误处理和成功回调也完全解耦。这,就是专业、可扩展的前端数据层架构。


第二章: 架构演进 · 封装、缓存与同步

摘要: 在本章中,我们将直面第一章留下的架构问题——组件逻辑的日益臃肿。我们将通过引入 自定义 Hooks (Composables) 的设计模式,对现有的数据获取和变更逻辑进行彻底的 封装和重构。在此过程中,我们将深入 TanStack Query 的核心机制:探索作为缓存基石的 Query Keys 的设计哲学,精辨决定数据新鲜度的 staleTime 与决定内存回收的 gcTime,并掌握实现数据自动同步的利器—— Query Invalidation 的高级策略。


2.1. 架构的十字路口:为何要超越组件

在第一章的结尾,我们的 UserProfile.vue 组件虽然功能完备,但已经埋下了“技术债”的种子。所有的 useQueryuseMutation 调用、成功与失败的回调、缓存失效的逻辑都直接写在了组件内部。

这是一种必要的“入门”方式,但它很快就会遇到瓶颈:

  1. 违反单一职责原则 (SRP): 组件的核心职责是渲染 UI 和响应用户交互。现在,它却承担了过多的数据层逻辑定义,变得臃肿且难以理解。
  2. 缺乏可复用性: 如果应用的另一个页面(比如一个导航栏下拉菜单)也需要显示当前用户的名字,我们是否要在那再写一遍 useQuery({ queryKey: ['user', 1], ... })?这显然是不可接受的重复。
  3. 可测试性差: 如果想单独测试获取用户的逻辑,我们不得不渲染整个组件,而不是对一个纯粹的逻辑单元进行测试。

解决方案,正是 Vue 生态中最优雅的逻辑复用模式:自定义 Hooks (Composables)。我们将把所有与 TanStack Query 相关的逻辑,从组件中抽离出来,封装成独立的、可复用的 Composable 函数。

我们的目标是:让组件回归纯粹,只负责“使用”数据,而不是“定义如何获取数据”。


2.2. 封装“读”操作:构建 useUserQuery

现在,我们将动手创建第一个自定义 Query Hook,它将专门负责获取用户数据。

首先,在 src 目录下创建一个 composables 文件夹,并新建 useUserQuery.ts 文件。

文件路径: src/composables/useUserQuery.ts

1
2
3
4
5
6
7
8
9
10
import { useQuery } from '@tanstack/vue-query';
import { fetchUserById } from '@/api/userService';

export const useUserQuery = (userId: number) => {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUserById(userId),
// 在这里,我们可以为这类查询设置统一的配置
});
};

这个简单的封装已经解决了复用性问题。但在深入配置之前,我们必须先掌握两个 TanStack Query 中最核心的概念。

2.2.1 Query Keys:查询的 DNA

Query Keys 远不止是一个普通的数组,它是整个缓存系统的“数据库主键”。TanStack Query 根据 Query Key 的内容来缓存、检索、更新和失效数据。一个设计良好的 Query Key 体系是构建可维护应用的基础。

最佳实践:Query Key Factories (查询键工厂)

为了避免在应用各处手写 ['user', id] 这样的“魔法字符串”,导致拼写错误或结构不一致,我们采用 Query Key Factories 模式。

src 目录下创建一个 queryKeys.ts 文件:

文件路径: src/queryKeys.ts

1
2
3
4
5
6
7
export const userKeys = {
all: ['users'] as const, // 代表所有用户相关的查询
lists: () => [...userKeys.all, 'list'] as const, // 用户列表
list: (filters: string) => [...userKeys.lists(), { filters }] as const, // 带过滤器的用户列表
details: () => [...userKeys.all, 'detail'] as const, // 用户详情
detail: (id: number) => [...userKeys.details(), id] as const, // 特定用户的详情
};

这个工厂对象为我们提供了:

  • 单一数据源: 所有用户相关的 Query Key 都在这里定义,易于管理。
  • 类型安全与自动补全: 避免手写错误。
  • 强大的缓存失效能力: 我们可以轻易地让某一类查询全部失效,例如 queryClient.invalidateQueries({ queryKey: userKeys.lists() }) 可以让所有用户列表缓存都失效。

2.2.2 缓存的艺术:staleTime vs. gcTime

TanStack Query 的缓存行为由两个核心时间参数控制,理解它们的区别至关重要。

概念核心职责默认值对用户体验的影响
staleTime数据新鲜度:决定何时需要重新获取数据。0 ms(高) 设置一个大于 0 的值(如 5 分钟),可以显著减少不必要的网络请求,让页面切换和重访感觉“瞬时”。
gcTime内存管理:决定不再使用的数据何时被清除。5 分钟(低) 主要影响开发者体验和内存占用。用户通常无感知,除非在 gcTime 内快速返回一个页面。

最终的 useUserQuery.ts

现在,我们将这些知识应用到我们的自定义 Hook 中:

文件路径: src/composables/useUserQuery.ts (最终版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useQuery } from '@tanstack/vue-query';
import { fetchUserById } from '@/api/userService';
import { userKeys } from '@/queryKeys'; // 引入 Query Key Factory

export const useUserQuery = (userId: number) => {
return useQuery({
// 使用工厂函数生成 Query Key
queryKey: userKeys.detail(userId),
queryFn: () => fetchUserById(userId),

// 为用户数据设置 5 分钟的保鲜期
// 在这 5 分钟内,应用不会因为组件挂载等原因重新请求该用户数据
staleTime: 1000 * 60 * 5, // 5 minutes

// gcTime 保持默认的 5 分钟即可
});
};

2.3 封装“写”操作:构建健壮的 Mutation Hooks

现在,我们遵循同样的封装思想,为“更新”和“删除”操作创建各自的自定义 Hook。这一步的 核心与难点,在于 onSuccess 回调中缓存失效逻辑的实现。一个微小的疏忽都可能导致 UI 无法自动更新。

因此,我们将从一开始就遵循两条黄金法则,来构建无懈可击的 Mutation Hooks。

2.3.1 失效策略的核心:精确构建 Query Key

queryClient.invalidateQueries 的工作原理非常简单:它会寻找缓存中与你提供的 queryKey 完全匹配 的查询,并将其标记为过时。这里的“完全匹配”意味着 结构、值、以及值的类型 都必须一模一样。

当我们想要在 onSuccess 回调中失效 ['users', 'detail', 1] 这个查询时,我们必须在回调函数内部 重新、精确地 构建出一个完全相同的数组。这就引出了两个至关重要的问题:

  1. 我们从哪里获取 ID?
  2. 我们如何保证 ID 的类型正确?

问题一的答案:永远相信“输入” (variables)

onSuccess 回调提供了两个关键参数 (data, variables)data 是 API 的响应(输出),其格式由后端决定,可能为空或不含 ID。而 variables 是我们调用 mutate 时传入的参数(输入),由我们自己控制,绝对可靠

架构原则 1: 为了解耦我们的缓存逻辑与后端 API 的具体响应格式,我们应 永远优先使用 variables 作为构建 Query Key 的数据源。

问题二的答案:主动进行类型保证

JavaScript 是一种弱类型语言,这意味着一个 ID 可能以 1 (number) 的形式存在,也可能以 "1" (string) 的形式存在。但 TanStack QueryQuery Key 匹配是 严格区分类型 的。

架构原则 2: 在将 ID 传递给 Query Key 工厂函数前,我们必须 主动确保其类型与 useQuery 中使用的类型完全一致。最简单可靠的方法就是使用 Number() 进行转换。

2.3.2 黄金法则与最终实现

现在,我们将这两条黄金法则应用到实践中,直接写出健壮、可靠的最终代码。

文件路径: src/composables/useUpdateUser.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { updateUser } from '@/api/userService';
import { userKeys } from '@/queryKeys';

export const useUpdateUser = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: updateUser,
onSuccess: (data, variables) => {
// 黄金法则 1: 使用可靠的 variables 作为数据源。
const id = variables.id;

// 黄金法则 2: 主动保证类型一致性。
const numericId = Number(id);

// 增加一个防御性检查,防止意外情况。
if (isNaN(numericId)) {
console.error("无法验证该ID:", id);
return;
}

// 现在,我们用一个类型绝对正确的 ID 来进行缓存失效。
queryClient.invalidateQueries({ queryKey: userKeys.detail(numericId) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
};

同理,我们以同样的标准构建 useDeleteUser

文件路径: src/composables/useDeleteUser.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { deleteUserById } from '@/api/userService';
import { userKeys } from '@/queryKeys';

export const useDeleteUser = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: deleteUserById,
onSuccess: (data, deletedUserId) => {
// 黄金法则 1 & 2 应用
const numericId = Number(deletedUserId);

if (isNaN(numericId)) {
console.error(
"无法验证该ID:",
deletedUserId
);
return;
}

queryClient.invalidateQueries({ queryKey: userKeys.detail(numericId) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
};

通过从一开始就遵循这两条黄金法则,我们构建的 Mutation Hooks 不仅逻辑清晰,而且极其健壮。它们能够正确处理缓存同步,而不会受到后端响应格式或 JavaScript 类型模糊性的影响。


2.4. 脱胎换骨:一个“声明式”的 UserProfile 组件

现在是我们的收获时刻。我们已经构建了健壮、可复用的 Hooks,让我们用它们来重构 UserProfile.vue

文件路径: src/components/UserProfile.vue (重构后)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
<script lang="ts" setup>
// 1. 引入我们封装好的自定义 Hooks
import { useUserQuery } from "@/composables/useUserQuery";
import { useUpdateUser } from "@/composables/useUpdateUser";
import { useDeleteUser } from "@/composables/useDeleteUser";
import { ref } from "vue";

const userId = 1;

// 2. 声明式地获取数据和状态
const { data: user, isLoading, isError, error, isFetching } = useUserQuery(userId);

// 3. 声明式地获取更新能力(包含状态)
const {
mutateAsync: updateUser,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
isSuccess: isUpdateSuccess,
reset: resetUpdate,
} = useUpdateUser();

// 4. 声明式地获取删除能力(包含状态)
const {
mutate: deleteUser,
isPending: isDeleting,
isError: isDeleteError,
error: deleteError,
isSuccess: isDeleteSuccess,
reset: resetDelete,
} = useDeleteUser();

// 5. 事件处理函数只负责调用和交互逻辑
const handleUpdateName = async () => {
if (!user.value) return;
const newName = window.prompt("输入新名字:", user.value.name);
if (newName && newName !== user.value.name) {
try {
await updateUser({ id: user.value.id, name: newName });
// 可选:可在此处做成功提示
} catch (e) {
// 错误提示交给 UI 层
}
}
};

const handleDeleteUser = () => {
if (user.value && window.confirm(`确定要删除 ${user.value.name} 吗?`)) {
deleteUser(user.value.id);
}
};

</script>

<template>
<div class="user-profile">
<div class="header">
<h2>User Profile</h2>
<span v-if="isFetching" class="fetching-indicator">Updating...</span>
</div>

<div v-if="isLoading" class="loading-skeleton">
<p>Loading user data...</p>
</div>

<div v-else-if="isError" class="error">
An error has occurred: {{ error?.message }}
</div>

<div v-else-if="user" class="user-data">
<p><strong>ID:</strong> {{ user.id }}</p>
<p><strong>Name:</strong> {{ user.name }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>

<div class="user-actions" style="margin-top: 1rem">
<!-- Update Action Group -->
<div
class="action-group"
style="display: inline-block; margin-right: 1rem"
>
<button @click="handleUpdateName" :disabled="isUpdating">
{{ isUpdating ? "Updating..." : "Update Name" }}
</button>
<span v-if="isUpdateError" class="error" style="margin-left: 0.5rem">
{{ updateError?.message || "Update failed" }}
</span>
<span v-else-if="isUpdateSuccess" class="success" style="margin-left: 0.5rem">
更新成功
</span>
</div>

<!-- Delete Action Group -->
<div class="action-group" style="display: inline-block">
<button
@click="handleDeleteUser"
:disabled="isDeleting"
class="delete-button"
>
{{ isDeleting ? "Deleting..." : "Delete User" }}
</button>
<span v-if="isDeleteError" class="error" style="margin-left: 0.5rem">
{{ deleteError?.message || "Delete failed" }}
</span>
<span v-else-if="isDeleteSuccess" class="success" style="margin-left: 0.5rem">
删除成功
</span>
</div>
</div>
</div>
</div>
</template>

<style lang="scss" scoped>
.user-profile {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
background-color: white;
min-height: 150px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.fetching-indicator {
font-size: 0.8rem;
color: #3498db;
animation: pulse 1.5s infinite;
}
.loading-skeleton,
.error {
color: #888;
padding-top: 1rem;
}
.error {
color: #e74c3c;
}
.success {
color: #27ae60;
}
.delete-button {
background: #e74c3c;
color: #fff;
border: none;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.delete-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@keyframes pulse {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
</style>

对比一下重构前的代码,你会发现天壤之别。现在的 UserProfile 组件:

  • 高度声明式: 它只说“我需要用户数据”、“我需要更新用户的能力”,而不关心这些是如何实现的。
  • 职责清晰: 组件只负责 UI 渲染和触发事件,所有数据层的复杂性都被隐藏在 Composable 背后。
  • 代码简洁: <script> 部分的行数大大减少,逻辑一目了然。

本章总结:我们完成了一次至关重要的架构演进。通过将数据逻辑封装到自定义 Hooks (Composables) 中,我们不仅解决了组件臃肿的问题,还构建了一套可复用、高内聚的数据层。更重要的是,我们掌握了构建 健壮缓存失效策略 的两条黄金法则:依赖可靠的 variables保证 Query Key 类型一致性。这种 封装-调用 的模式,正是使用 TanStack Query 构建大型、专业应用的标准范式。


第三章:总结与下一步

摘要: 至此,我们已经完成了对 TanStack Query v5 核心知识体系的深度探索。在这篇指南中,我们不仅完成了一次从“命令式”到“声明式”数据管理的思想革命,更深入掌握了其作为现代前端数据层基石的全部核心机制——从 Query Keys 的设计哲学,到缓存与同步的精妙艺术,再到构建高内聚、可复用数据层的架构范式。您现在掌握的,是一套独立于任何UI框架的、纯粹的服务端状态管理能力。


3.1. 我们学到了什么?核心能力回顾

在这趟旅程中,我们为自己未来的架构师之路,打下了三块坚实的基石:

  1. 思想的转变:Server State 的独立宣言
    我们建立了一个至关重要的心智模型,清晰地将“客户端状态”(由 Pinia 管理)与“服务端状态”分离开来。我们不再将 isLoading, error 等状态散落在业务组件或 Store 的各个角落,而是将其收敛到 TanStack Query 这个专业的“服务端状态管理器”中,实现了职责的完美分离。

  2. 引擎的核心:掌控缓存与同步的艺术
    我们深入了 TanStack Query 的心脏,理解了其三大核心机制:

    • Query Keys: 学会了通过“查询键工厂”模式,设计出可预测、可维护的缓存标识体系。
    • staleTime & gcTime: 掌握了控制数据“新鲜度”与“生命周期”的艺术,能够在用户体验和应用性能之间取得精妙平衡。
    • Query Invalidation: 掌握了连接“读”与“写”操作的脉搏,能够通过设计精准的失效策略,构建出自动同步、状态一致的健壮应用。
  3. 架构的基石:高内聚、可复用的数据层
    我们实践了 Vue 生态中最优雅的逻辑复用模式——自定义 Hooks (Composables)。通过将所有 useQueryuseMutation 的逻辑封装起来,我们成功地将数据层与UI层解耦。这使得我们的组件变得更加纯粹、简洁,数据逻辑本身也获得了极高的复用性和可测试性。


3.2. 一个重要说明:关于我们亲手构建的 UI

在学习过程中,我们亲手实现了一个用户资料卡、一个文章列表。您可能会问:“在真实项目中,我们会这样写吗?”

答案是:通常不会

在大型的、追求效率的真实项目中,我们极少会从零开始构建基础的UI组件。我们会选择像 Naive UI, Element Plus 这样成熟、强大的组件库。

那么,我们为什么还要花时间用原生的 <div><li> 来实现这些功能呢?

我们的目标并非‘造轮子’,而是通过构建这些基础UI,能最直观、最无干扰地观察TanStack Query如何管理数据流和状态变化。 这好比学习驾驶时,教练会带我们在空旷的场地上练习,而不是第一天就上高速。我们亲手实现的UI,就是那个让我们能专注练习TanStack Query核心操控的“训练场”。

通过这个“训练场”,我们已经彻底掌握了 TanStack Query 的工作原理。现在,我们已经准备好离开训练场,奔赴真正的赛道。


3.3. 展望:下一步,为‘跑车’装上引擎

您现在手中掌握的,是一个与UI无关的、纯粹而强大的数据“引擎”。这个引擎可以驱动任何视图层,无论它是一个简单的 div,还是一个极其复杂的第三方组件。

这正是本篇指南的终点,也是您下一段旅程的起点。

在后续的 【组件库实战】 系列笔记中,我们将不再关心 <li><div>。届时,我们会直接拿出 Naive UIElement Plus 这样的“跑车”,并展示如何将我们今天打造的这个强大、独立的 TanStack Query 数据引擎,轻松地与之集成。

届时您会看到:

  • 如何将 usePaginatedQuery 返回的状态,无缝对接到 <n-data-table>loading, data 属性和 @update:page 事件上。
  • 如何将 useInfiniteQueryfetchNextPage 函数,直接传递给虚拟滚动组件的 on-load-more 回调。

您会发现,由于我们已经构建了完美的、解耦的数据层 Composables,集成工作将变得异常简单和愉悦。


3.4. 核心速查总结

分类关键项核心描述
核心思想状态分离(推荐) 严格区分客户端状态 (UI状态) 和服务端状态 (远程数据),分别交由 PiniaTanStack Query 管理。
核心 APIuseQuery用于读取或订阅服务端数据。返回包含 data, isLoading, isFetching 等状态的响应式对象。
核心 APIuseMutation用于写入或变更服务端数据 (C/U/D)。通过 mutatemutateAsync 触发。
核心 APIinvalidateQueries(数据同步) 使匹配的查询缓存失效,并自动触发 active 查询的重新获取。
核心概念Query Keys缓存的唯一标识。应使用层级化、可预测的结构来设计,最佳实践是使用 Query Key Factories
核心概念staleTime(用户体验) 数据保鲜期。在此时间内,数据被视为 fresh,不会触发后台刷新。
核心概念gcTime (v5)(性能优化) 内存回收期。inactive 状态的缓存在超过此时长后被清除。
架构模式自定义 Hooks(推荐) 封装 useQueryuseMutation 逻辑,实现数据层与 UI 层的分离,提高复用性和可测试性。