Chapter 07

数据请求:Fetch、Axios 与 React Query

从原生 Fetch 到 Axios 拦截器,再到 TanStack Query 的服务器状态管理

1. Fetch API

fetch 是浏览器内置的网络请求 API,返回 Promise,无需安装任何依赖。但它有一个陷阱:即使服务器返回 4xx / 5xx 错误,fetch 本身不会 throw——只有网络完全断开时才会 reject。 必须手动检查 response.ok

// 基本用法:GET 请求
async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);

  // ❌ 不能假设 response 就是成功!
  // response.ok 为 true 表示状态码 200-299
  if (!response.ok) {
    throw new Error(`请求失败:${response.status} ${response.statusText}`);
  }

  const data = await response.json();  // 解析 JSON
  return data;
}

// POST 请求:发送 JSON 数据
async function createUser(userData: CreateUserDto) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
    },
    body: JSON.stringify(userData),
  });

  if (!response.ok) throw new Error('创建用户失败');
  return response.json();
}

封装通用请求函数

// lib/request.ts — 封装 fetch,统一处理错误和请求头
const BASE_URL = import.meta.env.VITE_API_URL;

async function request<T>(path: string, options?: RequestInit): Promise<T> {
  const token = localStorage.getItem('token');
  const response = await fetch(BASE_URL + path, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
      ...options?.headers,
    },
  });

  if (response.status === 401) {
    localStorage.removeItem('token');
    window.location.replace('/login');  // Token 过期,跳转登录
  }

  if (!response.ok) {
    const err = await response.json().catch(() => ({}));
    throw new Error(err.message ?? `HTTP ${response.status}`);
  }

  return response.json();
}

2. Axios

Axios 是目前最流行的 HTTP 请求库,相比原生 fetch 有几个重要优势:自动转换 JSON(无需 .json())、4xx/5xx 自动抛出错误、强大的拦截器系统、支持请求取消、Node.js 和浏览器双端兼容。

特性FetchAxios
内置/安装浏览器内置需安装 npm i axios
自动解析 JSON需手动 .json()自动,存在 response.data
错误处理需手动检查 response.ok4xx/5xx 自动 throw
拦截器请求/响应拦截器
请求取消AbortController内置 CancelToken / AbortController
超时配置需配合 AbortController直接设置 timeout
import axios from 'axios';

// 创建 axios 实例(推荐,方便配置和复用)
const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 10000,   // 10 秒超时
  headers: {
    'Content-Type': 'application/json',
  },
});

// 使用:比 fetch 更简洁
const { data: user } = await api.get<User>(`/users/${id}`);
const { data: newUser } = await api.post<User>('/users', userData);
await api.delete(`/users/${id}`);

3. 请求拦截器与响应拦截器

拦截器是 Axios 的核心特性,可以在请求发出前或响应到达后统一处理逻辑,避免在每个请求中重复代码。

// lib/axios.ts — 配置拦截器
import axios from 'axios';

const api = axios.create({ baseURL: '/api' });

// ── 请求拦截器:每个请求发出前执行 ──
api.interceptors.request.use(
  (config) => {
    // 自动附加 JWT Token
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// ── 响应拦截器:每个响应到达前执行 ──
api.interceptors.response.use(
  (response) => {
    return response;  // 成功响应直接返回
  },
  (error) => {
    const status = error.response?.status;

    if (status === 401) {
      // Token 失效,清除并跳转登录
      localStorage.removeItem('token');
      window.location.replace('/login');
    }

    if (status === 403) {
      console.error('权限不足');
    }

    // 统一错误提示(结合 toast 库)
    const message = error.response?.data?.message ?? '网络错误,请重试';
    showErrorToast(message);

    return Promise.reject(error);
  }
);

export default api;

4. 什么是 React Query(TanStack Query)

前端状态分为两类:

服务器状态的挑战在于:数据可能过期、多个组件可能请求同一份数据、需要缓存避免重复请求、需要在后台静默刷新、需要处理加载/错误状态。React Query(正式名 TanStack Query)专门为解决这些问题而生。

💡
React Query 的核心能力:自动缓存(相同 queryKey 的请求复用数据)、后台自动刷新(窗口重新聚焦时)、自动重试(失败后最多重试3次)、请求去重(同时发出的相同请求只发一次)。
# 安装 TanStack Query v5
npm install @tanstack/react-query

# 可选:开发者工具(在浏览器中查看缓存状态)
npm install @tanstack/react-query-devtools

5. React Query 基础:useQuery

// main.tsx — 配置 QueryClient,包裹整个应用
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,   // 1分钟内数据视为新鲜,不重新请求
      retry: 2,               // 失败后最多重试2次
    },
  },
});

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);
// components/UserList.tsx — 使用 useQuery 获取数据
import { useQuery } from '@tanstack/react-query';
import api from '../lib/axios';

type User = { id: number; name: string; email: string };

function UserList() {
  const {
    data: users,     // 请求成功的数据
    isPending,       // 首次加载中(没有缓存数据)
    isError,         // 是否出错
    error,           // 错误对象
    isFetching,      // 后台重新获取中(有缓存但正在刷新)
  } = useQuery({
    queryKey: ['users'],          // 唯一缓存键,建议用数组
    queryFn: () => api.get<User[]>('/users').then(r => r.data),
    // enabled: isLoggedIn,        // 条件查询:false 时暂停请求
  });

  if (isPending) return <div>加载中...</div>;
  if (isError) return <div>出错了:{error.message}</div>;

  return (
    <ul>
      {/* isFetching 时显示顶部加载指示器 */}
      {isFetching && <div className="loading-bar">刷新中</div>}
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

动态 queryKey

// queryKey 中包含变量,当变量变化时自动重新请求
const { data: user } = useQuery({
  queryKey: ['user', userId],       // userId 变化时重新 fetch
  queryFn: () => api.get<User>(`/users/${userId}`).then(r => r.data),
  enabled: !!userId,                 // userId 为空时不发请求
});

// 带分页的查询
const { data } = useQuery({
  queryKey: ['users', { page, pageSize, filter }],
  queryFn: () => fetchUsers({ page, pageSize, filter }),
});

6. useMutation

useQuery 用于读数据(GET),useMutation 用于写数据(POST/PUT/DELETE)。Mutation 不会自动执行,需要手动调用 mutate()

import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreateUserForm() {
  const queryClient = useQueryClient();

  const createUserMutation = useMutation({
    mutationFn: (newUser: CreateUserDto) =>
      api.post<User>('/users', newUser).then(r => r.data),

    // 创建成功后使缓存失效,触发列表重新请求
    onSuccess: (createdUser) => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
      console.log('创建成功:', createdUser);
    },

    onError: (error) => {
      console.error('创建失败:', error.message);
    },
  });

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      createUserMutation.mutate({ name: '新用户', email: 'test@example.com' });
    }}>
      <button disabled={createUserMutation.isPending}>
        {createUserMutation.isPending ? '创建中...' : '创建用户'}
      </button>
    </form>
  );
}

乐观更新(Optimistic Updates)

乐观更新指:不等服务器响应,先立刻更新 UI,若失败再回滚。这让用户感觉操作"瞬间完成",提升体验(如点赞、待办勾选)。

const toggleTodoMutation = useMutation({
  mutationFn: (todo: Todo) =>
    api.patch(`/todos/${todo.id}`, { completed: !todo.completed }),

  // onMutate:mutation 执行前调用,适合乐观更新
  onMutate: async (todo) => {
    // 取消正在进行的 refetch(防止覆盖乐观数据)
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    // 保存旧数据(用于回滚)
    const previousTodos = queryClient.getQueryData(['todos']);
    // 立即更新缓存(乐观)
    queryClient.setQueryData(['todos'], (old: Todo[]) =>
      old.map((t) => t.id === todo.id ? { ...t, completed: !t.completed } : t)
    );
    return { previousTodos };
  },
  onError: (err, todo, context) => {
    // 失败,回滚到旧数据
    queryClient.setQueryData(['todos'], context?.previousTodos);
  },
});

7. 缓存策略:staleTime 与 gcTime

// 用户信息不常变,保鲜5分钟
const { data: profile } = useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  staleTime: 5 * 60 * 1000,   // 5分钟内不重新请求
  gcTime: 10 * 60 * 1000,    // 10分钟后从内存清除
});

// 实时通知,保鲜时间为0(每次聚焦都刷新)
const { data: notifications } = useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  staleTime: 0,
  refetchInterval: 30000,  // 每30秒自动轮询
});

// 手动使缓存失效(通常在 mutation 成功后调用)
queryClient.invalidateQueries({ queryKey: ['users'] });

// 精确匹配缓存键
queryClient.invalidateQueries({
  queryKey: ['user', userId],
  exact: true,
});

8. 错误边界(Error Boundary)

React 的错误边界(Error Boundary)是一种特殊的类组件,可以捕获子组件树中的 JavaScript 错误,展示备用 UI,防止整个应用崩溃。目前只有类组件才能作为错误边界(因为需要 componentDidCatch 生命周期)。

⚠️
错误边界无法捕获:事件处理器中的错误(用 try/catch)、异步代码(setTimeout/fetch)、服务端渲染错误。
// 手动实现错误边界(类组件)
import { Component, type ReactNode, type ErrorInfo } from 'react';

interface Props { children: ReactNode; fallback: ReactNode; }
interface State { hasError: boolean; }

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };   // 更新 state 触发备用 UI 渲染
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    reportToSentry(error, info);  // 上报错误到监控平台
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

// 推荐使用 react-error-boundary 库(功能更完整)
// npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';

function App() {
  return (
    <ErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div>
          <p>出错了:{error.message}</p>
          <button onClick={resetErrorBoundary}>重试</button>
        </div>
      )}
    >
      <UserList />
    </ErrorBoundary>
  );
}

9. 加载状态 UI 方案

方案实现方式体验复杂度
Loading SpinnerisPending ? <Spinner/> : <Data/>一般,有"内容闪动"最低
Skeleton Loading骨架屏占位(与真实布局相同)好,减少布局偏移感中等
Suspense + lazyReact.lazy + Suspense + 流式渲染最好,代码自动分割较高
// 方案一:Skeleton Loading(骨架屏)
function UserCardSkeleton() {
  return (
    <div className="skeleton-card">
      <div className="skeleton-avatar"></div>
      <div className="skeleton-text"></div>
      <div className="skeleton-text short"></div>
    </div>
  );
}

// 方案二:React.lazy + Suspense(路由级懒加载)
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <Suspense fallback={<div>页面加载中...</div>}>
      <Dashboard />
    </Suspense>
  );
}