Skip to content
第 137 / 250 章前端⏱ 10 分钟阅读

第 137 章:HTTP 与请求封装

学习目标

  • 掌握 axios 在 Vue 3 项目中的封装
  • 学会拦截器、错误处理、Token 刷新
  • 理解请求缓存、并发控制、取消请求
  • 实现完整的 HTTP 客户端

一、为什么需要封装

二、基础封装

2.1 安装

bash
pnpm add axios

2.2 简单实例

typescript
// api/http.ts
import axios from 'axios';

const http = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
});

export default http;

三、类型化

3.1 响应类型

typescript
interface ApiResponse<T = unknown> {
  code: number;
  data: T;
  message: string;
}

interface PageData<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
}

3.2 泛型请求

typescript
async function getUser(id: number): Promise<User> {
  const res = await http.get<ApiResponse<User>>(`/users/${id}`);
  return res.data.data;
}

3.3 简化返回

typescript
// 拦截器解包,直接返回 data
http.interceptors.response.use((res) => {
  return res.data.data;  // 直接返回业务数据
});

// 使用
const user = await http.get<User>('/users/1');
// user 就是 User 类型,无须再 .data

四、拦截器

4.1 请求拦截

typescript
http.interceptors.request.use(
  (config) => {
    // 添加 token
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }

    // 添加时间戳
    config.headers['X-Request-Time'] = Date.now().toString();

    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

4.2 响应拦截

typescript
http.interceptors.response.use(
  (response) => {
    const { code, data, message } = response.data;

    if (code === 0) {
      return data;
    }

    if (code === 401) {
      // 跳转登录
      router.push('/login');
    }

    showMessage(message || '请求失败');
    return Promise.reject(new Error(message));
  },
  (error) => {
    if (error.response?.status === 401) {
      // 未授权
    } else if (error.response?.status === 500) {
      showMessage('服务器错误');
    }
    return Promise.reject(error);
  }
);

4.3 拦截器分类

typescript
// http.ts
const instance = axios.create();

// 通用拦截器
instance.interceptors.request.use(addAuth);
instance.interceptors.response.use(handleResponse);

// 业务侧添加自定义
instance.interceptors.request.use(customLogic);

五、Token 刷新

5.1 简单版本

typescript
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];

http.interceptors.response.use(
  (response) => response,
  async (error) => {
    const { config, response } = error;

    if (response?.status === 401 && !config._retry) {
      if (!isRefreshing) {
        isRefreshing = true;
        config._retry = true;

        try {
          const newToken = await refreshToken();
          localStorage.setItem('token', newToken);

          refreshSubscribers.forEach((cb) => cb(newToken));
          refreshSubscribers = [];

          config.headers.Authorization = `Bearer ${newToken}`;
          return http(config);
        } catch (refreshError) {
          // 跳转登录
          router.push('/login');
          return Promise.reject(refreshError);
        } finally {
          isRefreshing = false;
        }
      } else {
        return new Promise((resolve) => {
          refreshSubscribers.push((token) => {
            config.headers.Authorization = `Bearer ${token}`;
            resolve(http(config));
          });
        });
      }
    }

    return Promise.reject(error);
  }
);

5.2 抽离 composable

typescript
// composables/useAuth.ts
export function useAuth() {
  const router = useRouter();

  async function refreshToken() {
    const refreshToken = localStorage.getItem('refresh-token');
    const res = await http.post<{ token: string }>('/auth/refresh', {
      refreshToken
    });
    return res.token;
  }

  function logout() {
    localStorage.removeItem('token');
    router.push('/login');
  }

  return { refreshToken, logout };
}

六、错误处理

6.1 错误类型

typescript
interface RequestError {
  code: string;
  message: string;
  status?: number;
  data?: unknown;
}

function handleError(error: any): RequestError {
  if (axios.isAxiosError(error)) {
    return {
      code: error.code || 'AXIOS_ERROR',
      message: error.message,
      status: error.response?.status,
      data: error.response?.data
    };
  }

  if (error instanceof Error) {
    return { code: 'UNKNOWN', message: error.message };
  }

  return { code: 'UNKNOWN', message: String(error) };
}

6.2 错误日志

typescript
http.interceptors.response.use(
  (res) => res,
  (error) => {
    const info = handleError(error);

    console.error('[HTTP Error]', {
      url: error.config?.url,
      method: error.config?.method,
      status: info.status,
      message: info.message
    });

    // 上报到监控
    if (import.meta.env.PROD) {
      reportError(info);
    }

    return Promise.reject(error);
  }
);

6.3 用户提示

typescript
// composables/useMessage.ts
export function useMessage() {
  function error(message: string) {
    ElMessage.error(message);
  }

  function success(message: string) {
    ElMessage.success(message);
  }

  return { error, success };
}

七、请求缓存

7.1 简单缓存

typescript
const cache = new Map<string, { data: any; expire: number }>();

async function getWithCache<T>(url: string, ttl = 60000): Promise<T> {
  const cached = cache.get(url);
  if (cached && cached.expire > Date.now()) {
    return cached.data;
  }

  const data = await http.get<T>(url);
  cache.set(url, { data, expire: Date.now() + ttl });
  return data;
}

7.2 缓存策略

typescript
// SWR 风格
async function fetchSWR<T>(url: string): Promise<T> {
  const cached = cache.get(url);
  if (cached) {
    fetch(url).then((data) => cache.set(url, { data, expire: Date.now() + 60000 }));
    return cached.data;
  }
  return fetch(url);
}

7.3 取消请求

typescript
const controller = new AbortController();

http.get('/users', { signal: controller.signal });

// 取消
controller.abort();

八、并发控制

8.1 全部完成

typescript
const [users, posts] = await Promise.all([
  http.get('/users'),
  http.get('/posts')
]);

8.2 限制并发

typescript
async function limit<T>(tasks: Array<() => Promise<T>>, max: number): Promise<T[]> {
  const results: T[] = [];
  const executing: Promise<unknown>[] = [];

  for (const task of tasks) {
    const p = task().then((res) => {
      results.push(res);
    });

    executing.push(p);

    if (executing.length >= max) {
      await Promise.race(executing);
    }
  }

  return Promise.all(results).then(() => results);
}

8.3 失败重试

typescript
async function retry<T>(
  fn: () => Promise<T>,
  retries: number = 3,
  delay: number = 1000
): Promise<T> {
  try {
    return await fn();
  } catch (e) {
    if (retries <= 0) throw e;
    await new Promise((r) => setTimeout(r, delay));
    return retry(fn, retries - 1, delay * 2);
  }
}

九、文件上传

9.1 单文件

vue
<template>
  <input type="file" @change="handleUpload" />
</template>

<script setup lang="ts">
async function handleUpload(e: Event) {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (!file) return;

  const formData = new FormData();
  formData.append('file', file);

  const res = await http.post('/upload', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (e) => {
      const progress = (e.loaded / e.total!) * 100;
      console.log(`上传进度: ${progress}%`);
    }
  });

  console.log('上传成功:', res);
}
</script>

9.2 多文件

typescript
const files = ref<FileList | null>(null);

async function uploadMultiple() {
  if (!files.value) return;

  const formData = new FormData();
  Array.from(files.value).forEach((file) => {
    formData.append('files', file);
  });

  await http.post('/upload/multiple', formData);
}

9.3 进度

typescript
http.post('/upload', formData, {
  onUploadProgress: (progressEvent) => {
    const percent = (progressEvent.loaded / progressEvent.total!) * 100;
    uploadProgress.value = percent;
  }
});

十、文件下载

typescript
async function downloadFile(url: string, filename: string) {
  const res = await http.get(url, { responseType: 'blob' });

  const blob = new Blob([res.data]);
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = filename;
  link.click();
  URL.revokeObjectURL(link.href);
}

十一、请求封装

11.1 通用请求

typescript
// api/http.ts
class HttpClient {
  get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return this.request<T>({ ...config, method: 'GET', url });
  }

  post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    return this.request<T>({ ...config, method: 'POST', url, data });
  }

  put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    return this.request<T>({ ...config, method: 'PUT', url, data });
  }

  delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    return this.request<T>({ ...config, method: 'DELETE', url });
  }

  private async request<T>(config: AxiosRequestConfig): Promise<T> {
    const res = await http.request<ApiResponse<T>>(config);
    return res.data;
  }
}

export default new HttpClient();

11.2 业务封装

typescript
// api/user.ts
import http from './http';

export const userApi = {
  list: (params?: ListParams) => http.get<PageData<User>>('/users', { params }),
  get: (id: number) => http.get<User>(`/users/${id}`),
  create: (data: CreateUserDTO) => http.post<User>('/users', data),
  update: (id: number, data: UpdateUserDTO) => http.put<User>(`/users/${id}`, data),
  delete: (id: number) => http.delete(`/users/${id}`)
};

十二、TypeScript 高级

12.1 路径参数

typescript
// 工具类型
type ReplaceParams<T extends string> = T extends `${infer Before}:${infer Param}/${infer After}`
  ? `${Before}${string}/${After}`
  : T extends `${infer Before}:${infer Param}`
  ? `${Before}${string}`
  : T;

function get<T extends string>(path: ReplaceParams<T>): Promise<any> {
  return http.get(path);
}

get('/users/:id');  // OK

12.2 接口类型推

typescript
async function request<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
  return http.request<ApiResponse<T>, ApiResponse<T>>({ url, ...config }).then((res) => res.data);
}

十三、测试

13.1 Mock axios

typescript
import { vi } from 'vitest';
import axios from 'axios';

vi.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

beforeEach(() => {
  mockedAxios.create.mockReturnValue(mockedAxios);
});

it('fetch user', async () => {
  mockedAxios.get.mockResolvedValue({ data: { code: 0, data: { id: 1, name: 'Tom' } } });

  const user = await userApi.get(1);
  expect(user).toEqual({ id: 1, name: 'Tom' });
});

13.2 MSW

typescript
import { setupServer } from 'msw/node';
import { rest } from 'msw';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(ctx.json({ code: 0, data: [] }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

十四、推荐库

特点
axios主流、功能丰富
ofetch现代化、轻量
ky浏览器 fetch 封装
alova轻量级请求库
Vue Query服务端状态

十五、本章小结

概念关键
拦截器请求添加 token、响应处理错误
类型化泛型 T,直接返回数据
Token 刷新拦截器 + 队列
错误处理统一错误类型 + 提示
缓存Map + TTL
取消AbortController
并发Promise.all / limit

动手练习

  1. 基础封装:封装 axios 实例,统一拦截器
  2. Token 刷新:实现 401 自动刷新逻辑
  3. 错误处理:实现统一的错误提示与上报
  4. 文件上传:实现带进度条的文件上传
  5. 缓存策略:为 GET 请求添加短期缓存

推荐阅读


下一章第 138 章:用户认证与权限

本站基于 VitePress 构建 · 由 Codebook 团队维护