Data Fetch
什麼是資料獲取庫?
現代 Web 應用程式頻繁地從伺服器獲取資料.在 Vue.js 生態系統中,Pinia Colada 和 TanStack Query 等庫提供了這個功能.
在本章中,我們將實現類似 Pinia Colada 的基本資料獲取功能,作為 chibivue-fetch.
為什麼需要庫?
簡單的資料獲取用 fetch 和 ref 似乎就足夠了:
// composables/useUser.ts
import { ref, onMounted } from "chibivue";
export function useUser(id: number) {
const user = ref(null);
const isLoading = ref(true);
const error = ref(null);
onMounted(async () => {
try {
const response = await fetch(`/api/users/${id}`);
user.value = await response.json();
} catch (e) {
error.value = e;
} finally {
isLoading.value = false;
}
});
return { user, isLoading, error };
}但是,這個實現有以下問題:
- 沒有快取:相同的資料會被多次獲取
- SSR 困難:無法將伺服器獲取的資料傳輸到客戶端
- 重複請求:相同組件多次掛載會導致重複請求
- 錯誤處理:重試和重新獲取的邏輯變得複雜
資料獲取庫解決了這些問題,並提供了宣告式的 API.
套件結構
chibivue-fetch 在 @extensions/chibivue-fetch 套件中提供.
@extensions/chibivue-fetch/src/
├── index.ts # 導出
├── queryCache.ts # QueryCache 實現(快取管理)
├── useQuery.ts # 資料獲取 hook
├── useMutation.ts # 資料變更 hook
└── types.ts # 類型定義Data State 模式
與 Pinia Colada 類似,chibivue-fetch 用三種狀態表示資料狀態:
type DataStateStatus = "pending" | "error" | "success";
type DataState<TData, TError> =
| { status: "pending"; data: undefined; error: null }
| { status: "error"; data: TData | undefined; error: TError }
| { status: "success"; data: TData; error: null };這種狀態模型可以清楚地追蹤資料狀態.
QueryCache
QueryCache 負責快取管理和 SSR 的狀態管理.
// queryCache.ts
export interface QueryCache {
install: (app: App) => void;
caches: Map<string, UseQueryEntry>;
options: Required<QueryCacheOptions>;
create: <TData>(key: EntryKey, options: UseQueryOptionsWithDefaults | null, ...) => UseQueryEntry;
ensure: <TData>(key: EntryKey, options: UseQueryOptionsWithDefaults) => UseQueryEntry;
fetch: <TData>(entry: UseQueryEntry) => Promise<DataState>;
refresh: <TData>(entry: UseQueryEntry) => Promise<DataState>;
invalidate: (entry: UseQueryEntry) => void;
invalidateQueries: (key?: EntryKey) => void;
remove: (entry: UseQueryEntry) => void;
track: (entry: UseQueryEntry, effect: EffectScope | object | null) => void;
untrack: (entry: UseQueryEntry, effect: EffectScope | object | null) => void;
setQueryData: <TData>(key: EntryKey, data: TData) => void;
getQueryData: <TData>(key: EntryKey) => TData | undefined;
prefetchQuery: <TData>(key: EntryKey, queryFn: (ctx: QueryContext) => Promise<TData>, options?: Partial<UseQueryOptionsWithDefaults>) => Promise<void>;
isStale: (entry: UseQueryEntry) => boolean;
}主要方法
ensure: 獲取或創建條目fetch: 執行查詢(總是執行)refresh: 刷新查詢(僅在 stale 或 error 時執行)invalidate: 使條目失效(標記為 stale)invalidateQueries: 使匹配鍵的條目失效track/untrack: 追蹤組件依賴關係setQueryData/getQueryData: 直接操作快取資料prefetchQuery: 預先獲取資料並儲存到快取
createQueryCache
import { createQueryCache } from "chibivue-fetch";
const queryCache = createQueryCache({
staleTime: 5000, // 預設的 stale time (5秒)
gcTime: 300000, // 預設的 GC time (5分鐘)
});
app.use(queryCache);useQuery
useQuery 是用於資料獲取的組合式函式.
// useQuery.ts
export interface UseQueryOptions<TData = unknown, TError = Error> {
key: EntryKey | EntryKeyFn;
query: (context: QueryContext) => Promise<TData>;
staleTime?: number;
gcTime?: number;
refetchOnMount?: boolean | "always";
initialData?: TData | (() => TData);
enabled?: boolean | Ref<boolean> | ComputedRef<boolean>;
retry?: number | boolean;
retryDelay?: number;
meta?: QueryMeta;
}
export interface UseQueryReturn<TData = unknown, TError = Error> {
state: ComputedRef<DataState<TData, TError>>;
asyncStatus: ComputedRef<AsyncStatus>;
data: ComputedRef<TData | undefined>;
error: ComputedRef<TError | null>;
status: ComputedRef<DataStateStatus>;
isPending: ComputedRef<boolean>;
isLoading: ComputedRef<boolean>;
isSuccess: ComputedRef<boolean>;
isError: ComputedRef<boolean>;
refresh: () => Promise<DataState<TData, TError>>;
refetch: () => Promise<DataState<TData, TError>>;
}選項
key: 查詢的唯一鍵(作為快取鍵使用)query: 獲取資料的非同步函式(接收{ signal })staleTime: 資料變為「stale(過期)」的時間gcTime: 保留未使用快取的期間(垃圾回收)enabled: 是否啟用查詢retry: 錯誤時的重試次數initialData: 初始資料
狀態
status: 當前狀態("pending"|"error"|"success")asyncStatus: 非同步狀態("idle"|"loading")isPending: 還沒有初始資料isLoading: 初次獲取中(isPending且asyncStatus === "loading")isSuccess: 獲取成功isError: 獲取失敗
refresh 和 refetch 的區別
refresh(): 僅在 stale 或 error 時獲取refetch(): 總是獲取(先使快取失效)
使用範例
import { useQuery } from "chibivue-fetch";
const { data, isLoading, error, refresh } = useQuery({
key: ["user", userId],
query: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then((res) => res.json()),
staleTime: 60000, // 1分鐘內使用快取
});useMutation
useMutation 是用於資料變更(POST,PUT,DELETE 等)的組合式函式.
// useMutation.ts
export interface UseMutationOptions<TData, TError, TVariables, TContext> {
mutation: (variables: TVariables) => Promise<TData>;
onMutate?: (variables: TVariables) => TContext | Promise<TContext>;
onSuccess?: (data: TData, variables: TVariables, context: TContext | undefined) => void | Promise<void>;
onError?: (error: TError, variables: TVariables, context: TContext | undefined) => void | Promise<void>;
onSettled?: (data: TData | undefined, error: TError | null, variables: TVariables, context: TContext | undefined) => void | Promise<void>;
}
export interface UseMutationReturn<TData, TError, TVariables> {
state: ComputedRef<DataState<TData, TError>>;
asyncStatus: ComputedRef<AsyncStatus>;
data: ComputedRef<TData | undefined>;
error: ComputedRef<TError | null>;
status: ComputedRef<DataStateStatus>;
isPending: ComputedRef<boolean>;
isLoading: ComputedRef<boolean>;
isSuccess: ComputedRef<boolean>;
isError: ComputedRef<boolean>;
variables: ShallowRef<TVariables | undefined>;
mutate: (variables: TVariables) => void;
mutateAsync: (variables: TVariables) => Promise<TData>;
reset: () => void;
}生命週期回調
onMutate: mutation 執行前呼叫(返回 context)onSuccess: 成功時呼叫onError: 錯誤時呼叫onSettled: 成功或錯誤後最後呼叫
使用範例
import { useMutation } from "chibivue-fetch";
const { mutate, isLoading, isSuccess } = useMutation({
mutation: (newUser) => fetch("/api/users", {
method: "POST",
body: JSON.stringify(newUser),
}).then((res) => res.json()),
onSuccess: (data) => {
console.log("User created:", data);
// 使快取失效以觸發重新獲取
queryCache.invalidateQueries(["users"]);
},
});
// 使用
mutate({ name: "John", email: "[email protected]" });快取的運作方式
Entry Key
key 作為快取鍵使用.陣列格式可以表示階層式的鍵:
// 簡單的鍵
key: ["users"]
// 階層式的鍵
key: ["users", userId]
// 包含物件的鍵
key: ["users", { status: "active", page: 1 }]具有相同 key 的查詢共享快取.鍵會被序列化為排序後的 JSON,因此物件屬性的順序不重要.
Stale Time 和 GC Time
← staleTime →|← refetch window →|← gcTime →|
fetch stale inactive gc
|-----------------|----------------------|-----|
data arrives data is stale data removed- staleTime: 資料保持「fresh」的期間.在此期間呼叫
refresh()不會重新獲取 - gcTime: 保留未使用快取的期間.組件卸載後,經過此期間快取會被刪除
// 1分鐘內不重新獲取,保留快取5分鐘
useQuery({
key: ["users"],
query: fetchUsers,
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
});依賴關係追蹤
與 Pinia Colada 類似,chibivue-fetch 追蹤每個查詢條目被哪些組件使用:
// 組件掛載時追蹤
onMounted(() => {
queryCache.track(entry, currentInstance);
});
// 組件卸載時取消追蹤
onUnmounted(() => {
queryCache.untrack(entry, currentInstance);
});當沒有依賴關係時,快取會在 gcTime 後被垃圾回收.
SSR 支援
chibivue-fetch 支援伺服器端渲染(SSR).
伺服器端:序列化狀態
// server.ts
import { createApp } from "chibivue";
import { renderToString } from "@chibivue/server-renderer";
import { createQueryCache, serializeQueryCache } from "chibivue-fetch";
import App from "./App.vue";
export async function render() {
// 為每個請求創建新實例
const queryCache = createQueryCache();
const app = createApp(App);
app.use(queryCache);
// 在伺服器端預先獲取資料
await queryCache.prefetchQuery(
["users"],
({ signal }) => fetch("http://api/users", { signal }).then((r) => r.json()),
);
const html = await renderToString(app);
// 序列化快取狀態
const queryState = JSON.stringify(serializeQueryCache(queryCache));
return { html, queryState };
}序列化格式
與 Pinia Colada 類似,我們使用相對時間戳進行序列化:
// UseQueryEntryNodeSerialized = [data, error, when (relative), meta]
{
'["users"]': [
[{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }], // data
null, // error
0, // when (relative: now - fetchTime)
undefined // meta
]
}相對時間戳可以處理伺服器和客戶端之間的時間差異.
嵌入 HTML
<!DOCTYPE html>
<html>
<head>
<script>
window.__QUERY_STATE__ = ${queryState};
</script>
</head>
<body>
<div id="app">${html}</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>客戶端:水合狀態
// main.ts (client)
import { createApp } from "chibivue";
import { createQueryCache, hydrateQueryCache } from "chibivue-fetch";
import App from "./App.vue";
const queryCache = createQueryCache();
const app = createApp(App);
app.use(queryCache);
// 使用伺服器狀態水合
if (window.__QUERY_STATE__) {
hydrateQueryCache(queryCache, window.__QUERY_STATE__);
}
app.mount("#app");
在 SSR 中,與 Store 類似,您必須注意 Cross-Request State Pollution. 在 render() 函式內呼叫 createQueryCache(),為每個請求創建新實例.
實用範例
響應式 Query Key
import { ref, computed } from "chibivue";
import { useQuery } from "chibivue-fetch";
const page = ref(1);
const filters = ref({ status: "active" });
const { data, isLoading } = useQuery({
// 函式格式用於動態鍵
key: () => ["users", { page: page.value, ...filters.value }],
query: ({ signal }) => fetchUsers(page.value, filters.value, signal),
});
// 當 page 或 filters 改變時自動重新獲取
function nextPage() {
page.value++;
}條件式查詢
const userId = ref<number | null>(null);
const { data: user } = useQuery({
key: () => ["user", userId.value],
query: ({ signal }) => fetchUser(userId.value!, signal),
// userId 為 null 時不執行查詢
enabled: computed(() => userId.value !== null),
});Mutation 後更新快取
const queryCache = getActiveQueryCache();
const { mutate: createUser } = useMutation({
mutation: (newUser) => api.createUser(newUser),
onSuccess: (createdUser) => {
// 方法1:使快取失效並重新獲取
queryCache.invalidateQueries(["users"]);
// 方法2:直接更新快取(樂觀更新)
const currentUsers = queryCache.getQueryData<User[]>(["users"]);
if (currentUsers) {
queryCache.setQueryData(["users"], [...currentUsers, createdUser]);
}
},
});錯誤處理和重試
const { data, error, refresh } = useQuery({
key: ["users"],
query: fetchUsers,
retry: 3, // 最多重試3次
retryDelay: 1000, // 1秒後重試
});
// 在組件中
if (error.value) {
// 顯示錯誤和重試按鈕
}使用 AbortController 取消
const { data } = useQuery({
key: ["users"],
query: async ({ signal }) => {
const response = await fetch("/api/users", { signal });
if (!response.ok) throw new Error("Failed to fetch");
return response.json();
},
});當查詢被取消時(例如,當新請求開始時),signal 會被 abort.
總結
chibivue-fetch 實現包括以下要素:
- QueryCache:集中式快取管理和依賴關係追蹤
- Data State 模式:
pending | error | success的三狀態模型 - useQuery:宣告式資料獲取 API
- useMutation:資料變更管理和生命週期回調
- 快取策略:通過 staleTime / gcTime 靈活控制
- SSR 支援:通過
serializeQueryCache()/hydrateQueryCache()傳輸狀態 - 響應式鍵:動態查詢鍵支援
- 錯誤處理:自動重試和狀態管理
- AbortController:請求取消支援
通過最小化實現 Pinia Colada 的核心功能,您可以理解資料獲取的運作方式.
