文档

TanStack Query 快速入门指南

了解如何在项目中使用 TanStack Query 进行状态管理和 API 数据获取,以及与 Hono.js API 的集成实践

TanStack Query 快速入门指南

TanStack Query 是一个强大的数据获取、缓存、同步和更新库,专为 React 应用程序设计。它帮助我们更好地管理服务端状态,减少样板代码,提供出色的用户体验。

为什么使用 TanStack Query?

  • 自动缓存: 智能缓存和重复请求去重
  • 后台更新: 自动在后台重新获取过期数据
  • 乐观更新: 提供乐观更新功能,让用户界面响应更快
  • 错误重试: 内置错误重试机制
  • 分页和无限查询: 原生支持分页和无限滚动
  • 窗口焦点重获取: 当窗口重新获得焦点时自动刷新数据

项目配置

1. Query Client 设置

我们的项目已经配置了 Query Client,位于 src/modules/shared/lib/query-client.ts

export function createQueryClient() {
	return new QueryClient({
		defaultOptions: {
			queries: {
				// 默认缓存时间设置
				staleTime: 2 * 60 * 1000, // 2分钟 - 数据被认为是新鲜的时间
				gcTime: 10 * 60 * 1000, // 10分钟 - 垃圾回收时间
				
				// 网络重试策略
				retry: (failureCount, error) => {
					// 对于认证错误不重试
					if (error instanceof Error && error.message.includes('401')) {
						return false;
					}
					// 最多重试2次
					return failureCount < 2;
				},
				retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
				
				// 重新获取策略
				refetchOnWindowFocus: true,
				refetchOnReconnect: true,
				refetchOnMount: true,
			},
			mutations: {
				// Mutation 重试策略
				retry: 1,
				retryDelay: 1000,
			},
		},
	});
}

2. 在应用中集成

在根组件中使用 QueryClientProvider:

'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { createQueryClient } from '@/lib/query-client'

function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => createQueryClient())

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

Query Keys 管理

我们使用统一的 Query Keys 管理策略,所有查询键都集中在 src/modules/shared/lib/api-hooks.ts 中:

export const queryKeys = {
	profile: () => ["profile"] as const,
	projects: (params?: { userId?: string }) => 
		params ? ["projects", params] : ["projects"] as const,
	notifications: {
		all: () => ["notifications"] as const,
		list: (page: number, limit: number) => ["notifications", "list", page, limit] as const,
		unreadCount: () => ["notifications", "unread-count"] as const,
	},
	bookmarks: {
		users: () => ["bookmarks", "users"] as const,
		projects: () => ["bookmarks", "projects"] as const,
		events: () => ["bookmarks", "events"] as const,
	},
	user: {
		registrations: () => ["user", "registrations"] as const,
		events: () => ["user", "events"] as const,
		interactiveUsers: (limit?: number) => 
			limit ? ["user", "interactive-users", limit] : ["user", "interactive-users"] as const,
	},
} as const;

使用 as const 确保 TypeScript 类型推断的准确性,这样可以获得更好的类型安全。

基本用法

1. useQuery - 数据查询

useQuery 用于获取和缓存数据:

// 基础用法
export function useProfileQuery() {
	return useQuery({
		queryKey: queryKeys.profile(),
		queryFn: async () => {
			const response = await fetch("/api/profile");
			if (!response.ok) {
				throw new Error("Failed to fetch profile");
			}
			const data = await response.json();
			return data.user;
		},
		staleTime: 5 * 60 * 1000, // 5分钟
		gcTime: 10 * 60 * 1000, // 10分钟
	});
}

// 在组件中使用
function ProfileComponent() {
  const { data: profile, isLoading, error, refetch } = useProfileQuery();

  if (isLoading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;

  return (
    <div>
      <h1>{profile?.name}</h1>
      <button onClick={() => refetch()}>刷新</button>
    </div>
  );
}

2. 带参数的查询

// 带参数的查询
export function useProjectsQuery(userId?: string) {
	return useQuery({
		queryKey: queryKeys.projects(userId ? { userId } : undefined),
		queryFn: async () => {
			const url = userId ? `/api/projects?userId=${userId}` : "/api/projects";
			const response = await fetch(url);
			if (!response.ok) {
				throw new Error("Failed to fetch projects");
			}
			const data = await response.json();
			return data.projects || [];
		},
		staleTime: 3 * 60 * 1000, // 3分钟
	});
}

// 使用
function ProjectsList({ userId }: { userId?: string }) {
  const { data: projects = [] } = useProjectsQuery(userId);
  
  return (
    <div>
      {projects.map(project => (
        <div key={project.id}>{project.title}</div>
      ))}
    </div>
  );
}

3. useMutation - 数据变更

useMutation 用于创建、更新、删除数据:

export function useMarkNotificationAsReadMutation() {
	const queryClient = useQueryClient();
	
	return useMutation({
		mutationFn: async (notificationId: string) => {
			const response = await fetch(`/api/notifications/${notificationId}/read`, {
				method: "POST",
			});
			if (!response.ok) {
				throw new Error("Failed to mark notification as read");
			}
			return response.json();
		},
		onSuccess: () => {
			// 更新相关查询的缓存
			queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all() });
			queryClient.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount() });
		},
	});
}

// 在组件中使用
function NotificationItem({ notification }: { notification: Notification }) {
  const markAsReadMutation = useMarkNotificationAsReadMutation();
  
  const handleMarkAsRead = () => {
    markAsReadMutation.mutate(notification.id, {
      onSuccess: () => {
        console.log('通知已标记为已读');
      },
      onError: (error) => {
        console.error('标记失败:', error);
      }
    });
  };

  return (
    <div>
      <p>{notification.message}</p>
      <button 
        onClick={handleMarkAsRead}
        disabled={markAsReadMutation.isPending}
      >
        {markAsReadMutation.isPending ? '标记中...' : '标记为已读'}
      </button>
    </div>
  );
}

与 Hono.js API 集成

我们的后端使用 Hono.js 构建 RESTful API,所有 API 路由都在 src/server/routes/ 目录下。

1. API 路由结构

// src/server/app.ts - Hono 应用主文件
export const app = new Hono().basePath("/api");

// 挂载各种路由器
app.route("/", authRouter)
   .route("/", healthRouter)
   .route("/events", eventsRouter)
   .route("/user", userRouter)
   .route("/notifications", notificationsRouter)
   // ... 更多路由

2. 活动 API 集成示例

以活动 API 为例,展示完整的集成流程:

// 后端 API (src/server/routes/events.ts)
// GET /api/events - 获取活动列表
app.get("/", zValidator("query", getEventsSchema), async (c) => {
	const params = c.req.valid("query");
	const result = await getEvents(params);
	
	return c.json({
		success: true,
		data: result,
	});
});

// POST /api/events - 创建新活动
app.post("/", zValidator("json", eventSchema), async (c) => {
	const session = await auth.api.getSession({ headers: c.req.raw.headers });
	if (!session) {
		return c.json({ success: false, error: "Authentication required" }, 401);
	}
	
	const data = c.req.valid("json");
	const event = await createEvent({ ...data, organizerId: session.user.id });
	
	return c.json({ success: true, data: event });
});
// 前端 Query Hooks
export const queryKeys = {
	events: {
		all: () => ["events"] as const,
		list: (params: EventsParams) => ["events", "list", params] as const,
		detail: (id: string) => ["events", "detail", id] as const,
	},
} as const;

// 获取活动列表
export function useEventsQuery(params: EventsParams = {}) {
	return useQuery({
		queryKey: queryKeys.events.list(params),
		queryFn: async () => {
			const searchParams = new URLSearchParams();
			Object.entries(params).forEach(([key, value]) => {
				if (value !== undefined) {
					searchParams.append(key, String(value));
				}
			});
			
			const response = await fetch(`/api/events?${searchParams}`);
			if (!response.ok) {
				throw new Error("Failed to fetch events");
			}
			const data = await response.json();
			return data.data;
		},
		staleTime: 5 * 60 * 1000, // 5分钟缓存
	});
}

// 创建活动
export function useCreateEventMutation() {
	const queryClient = useQueryClient();
	
	return useMutation({
		mutationFn: async (eventData: CreateEventInput) => {
			const response = await fetch('/api/events', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify(eventData),
			});
			
			if (!response.ok) {
				const error = await response.json();
				throw new Error(error.error || 'Failed to create event');
			}
			
			return response.json();
		},
		onSuccess: () => {
			// 创建成功后使相关查询失效
			queryClient.invalidateQueries({ queryKey: queryKeys.events.all() });
		},
	});
}

3. 在组件中使用

function EventsList() {
  const [params, setParams] = useState<EventsParams>({
    page: 1,
    limit: 20,
    type: 'HACKATHON'
  });
  
  const { 
    data: eventsData, 
    isLoading, 
    error, 
    refetch 
  } = useEventsQuery(params);
  
  const createEventMutation = useCreateEventMutation();
  
  const handleCreateEvent = (eventData: CreateEventInput) => {
    createEventMutation.mutate(eventData, {
      onSuccess: () => {
        toast.success('活动创建成功!');
        refetch(); // 可选:手动刷新列表
      },
      onError: (error) => {
        toast.error(`创建失败: ${error.message}`);
      }
    });
  };

  if (isLoading) return <div>加载活动列表...</div>;
  if (error) return <div>加载错误: {error.message}</div>;

  return (
    <div>
      <div className="events-grid">
        {eventsData?.events?.map(event => (
          <EventCard key={event.id} event={event} />
        ))}
      </div>
      
      <CreateEventButton 
        onSubmit={handleCreateEvent}
        isLoading={createEventMutation.isPending}
      />
    </div>
  );
}

高级功能

1. 条件查询

function useUserProfile(userId?: string) {
  return useQuery({
    queryKey: ["user", "profile", userId],
    queryFn: () => fetchUserProfile(userId!),
    enabled: !!userId, // 只有当 userId 存在时才执行查询
  });
}

2. 乐观更新

export function useToggleEventLikeMutation() {
	const queryClient = useQueryClient();
	
	return useMutation({
		mutationFn: async ({ eventId, isLiked }: { eventId: string; isLiked: boolean }) => {
			const response = await fetch(`/api/events/${eventId}/like`, {
				method: isLiked ? 'DELETE' : 'POST',
			});
			if (!response.ok) throw new Error('操作失败');
			return response.json();
		},
		onMutate: async ({ eventId, isLiked }) => {
			// 取消相关的查询以避免覆盖乐观更新
			await queryClient.cancelQueries({ queryKey: queryKeys.events.detail(eventId) });
			
			// 获取当前数据快照
			const previousEvent = queryClient.getQueryData(queryKeys.events.detail(eventId));
			
			// 乐观更新
			queryClient.setQueryData(queryKeys.events.detail(eventId), (old: any) => ({
				...old,
				isLiked: !isLiked,
				likesCount: isLiked ? old.likesCount - 1 : old.likesCount + 1,
			}));
			
			// 返回上下文对象,包含撤销信息
			return { previousEvent, eventId };
		},
		onError: (err, variables, context) => {
			// 如果出错,回滚到之前的状态
			if (context?.previousEvent) {
				queryClient.setQueryData(
					queryKeys.events.detail(context.eventId),
					context.previousEvent
				);
			}
		},
		onSettled: (data, error, variables) => {
			// 无论成功还是失败,都重新获取数据确保数据一致性
			queryClient.invalidateQueries({ 
				queryKey: queryKeys.events.detail(variables.eventId) 
			});
		},
	});
}

3. 无限查询(分页加载)

export function useInfiniteNotifications() {
	return useInfiniteQuery({
		queryKey: queryKeys.notifications.all(),
		queryFn: async ({ pageParam = 1 }) => {
			const response = await fetch(`/api/notifications?page=${pageParam}&limit=20`);
			if (!response.ok) throw new Error('Failed to fetch notifications');
			return response.json();
		},
		initialPageParam: 1,
		getNextPageParam: (lastPage, pages) => {
			// 如果还有更多数据,返回下一页页码
			if (lastPage.data.hasNextPage) {
				return pages.length + 1;
			}
			return undefined; // 没有更多数据
		},
	});
}

// 在组件中使用无限查询
function InfiniteNotificationsList() {
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteNotifications();

  if (status === 'pending') return <div>加载中...</div>;
  if (status === 'error') return <div>错误: {error.message}</div>;

  return (
    <div>
      {data.pages.map((group, i) => (
        <div key={i}>
          {group.data.notifications.map(notification => (
            <NotificationItem key={notification.id} notification={notification} />
          ))}
        </div>
      ))}
      
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? '加载中...'
          : hasNextPage
          ? '加载更多'
          : '没有更多了'}
      </button>
    </div>
  );
}

4. 并行查询

function UserDashboard({ userId }: { userId: string }) {
  // 多个查询并行执行
  const profileQuery = useProfileQuery();
  const projectsQuery = useProjectsQuery(userId);
  const notificationsQuery = useUnreadNotificationsCountQuery();
  
  // 检查所有查询的状态
  const isLoading = profileQuery.isLoading || projectsQuery.isLoading || notificationsQuery.isLoading;
  const hasError = profileQuery.error || projectsQuery.error || notificationsQuery.error;
  
  if (isLoading) return <div>加载用户数据...</div>;
  if (hasError) return <div>加载出错</div>;

  return (
    <div>
      <UserProfile profile={profileQuery.data} />
      <ProjectsList projects={projectsQuery.data} />
      <NotificationBadge count={notificationsQuery.data} />
    </div>
  );
}

最佳实践

1. 错误处理

// 全局错误处理
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error: any) => {
        // 不重试认证错误
        if (error?.status === 401) return false;
        // 不重试客户端错误(4xx)
        if (error?.status >= 400 && error?.status < 500) return false;
        // 其他错误最多重试2次
        return failureCount < 2;
      },
    },
  },
});

// 组件级错误处理
function ComponentWithErrorHandling() {
  const { data, error, isError } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
  });

  if (isError) {
    // 根据错误类型显示不同的错误信息
    if (error.message.includes('401')) {
      return <div>请先登录</div>;
    }
    if (error.message.includes('403')) {
      return <div>权限不足</div>;
    }
    return <div>加载失败: {error.message}</div>;
  }

  return <div>{/* 渲染数据 */}</div>;
}

2. 加载状态管理

function SmartLoadingComponent() {
  const { data, isLoading, isFetching, isPreviousData } = useQuery({
    queryKey: ['data', page],
    queryFn: () => fetchData(page),
    keepPreviousData: true, // 保持之前的数据直到新数据加载完成
  });

  return (
    <div>
      {/* 首次加载显示骨架屏 */}
      {isLoading && <SkeletonLoader />}
      
      {/* 有数据时显示内容 */}
      {data && (
        <div>
          {/* 数据内容 */}
          <DataContent data={data} />
          
          {/* 后台刷新指示器 */}
          {isFetching && !isLoading && (
            <div className="refresh-indicator">刷新中...</div>
          )}
          
          {/* 分页加载时显示之前的数据 */}
          {isPreviousData && (
            <div className="loading-overlay">加载新页面...</div>
          )}
        </div>
      )}
    </div>
  );
}

3. 查询依赖

// 依赖查询:只有当用户信息加载完成后才获取用户的项目
function UserProjectsPage() {
  const { data: user } = useProfileQuery();
  
  const { data: projects } = useQuery({
    queryKey: ['projects', user?.id],
    queryFn: () => fetchUserProjects(user!.id),
    enabled: !!user?.id, // 只有当用户ID存在时才执行查询
  });

  return <div>{/* 渲染内容 */}</div>;
}

4. 缓存管理

// 手动更新缓存
function updateCacheExample() {
  const queryClient = useQueryClient();
  
  // 设置查询数据
  queryClient.setQueryData(['user', userId], newUserData);
  
  // 使查询失效(触发重新获取)
  queryClient.invalidateQueries({ queryKey: ['events'] });
  
  // 预获取数据
  queryClient.prefetchQuery({
    queryKey: ['events', { page: 2 }],
    queryFn: () => fetchEvents({ page: 2 }),
  });
  
  // 移除查询数据
  queryClient.removeQueries({ queryKey: ['temp-data'] });
}

调试工具

在开发环境中启用 React Query DevTools:

import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

确保只在开发环境中包含 DevTools,避免在生产环境中暴露调试信息。

性能优化

1. 选择性订阅

// 只订阅需要的数据字段
const { data: userName } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  select: (data) => data.name, // 只选择用户名
});

2. 查询键工厂函数

// 使用工厂函数生成复杂的查询键
export const createQueryKeyWithParams = (
	key: string | string[],
	params: Record<string, string | number>,
) => {
	return [
		...(Array.isArray(key) ? key : [key]),
		Object.entries(params)
			.reduce((acc, [key, value]) => {
				acc.push(`${key}:${value}`);
				return acc;
			}, [] as string[])
			.join("_"),
	] as const;
};

// 使用
const queryKey = createQueryKeyWithParams("events", { 
  type: "HACKATHON", 
  organizationId: "123" 
});

总结

TanStack Query 为我们的应用提供了强大的数据管理能力:

  1. 与 Hono.js API 完美集成:通过统一的错误处理和响应格式
  2. 智能缓存策略:减少不必要的网络请求
  3. 优秀的用户体验:加载状态、错误处理、乐观更新
  4. 类型安全:配合 TypeScript 提供完整的类型推断
  5. 可维护性:集中化的查询键管理和复用的 Hook

通过遵循这些最佳实践,你可以构建出高性能、用户体验良好的现代 React 应用程序。

相关文档