【AI 编程实战】第 6 篇:告别复制粘贴 - 设计一个优雅的 HTTP 模块

每个项目都要和后端接口打交道,但很多人的 HTTP 封装要么过于简陋,要么复制粘贴一堆"祖传代码"。这篇文章以心动恋聊 小程序为例,展示如何和 AI 对话,一步步设计出优雅、实用、可维护的 HTTP 请求封装。

系列专栏【AI 编程实战:TRAE SOLO 全栈开发指南】

本篇主题:告别复制粘贴 - 设计一个优雅的 HTTP 模块

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:HTTP 封装的痛点

1.1 常见的"祖传代码"

很多项目的 HTTP 请求是这样写的:

typescript 复制代码
// 页面 A
uni.request({
  url: 'https://api.example.com/user/info',
  method: 'GET',
  header: {
    Authorization: 'Bearer ' + uni.getStorageSync('token'),
  },
  success: (res) => {
    if (res.statusCode === 200) {
      console.log(res.data);
    } else if (res.statusCode === 401) {
      uni.navigateTo({ url: '/pages/login/login' });
    } else {
      uni.showToast({ title: '请求失败', icon: 'none' });
    }
  },
  fail: () => {
    uni.showToast({ title: '网络错误', icon: 'none' });
  },
});

// 页面 B(复制粘贴,改个 url)
uni.request({
  url: 'https://api.example.com/chat/send',
  method: 'POST',
  header: {
    Authorization: 'Bearer ' + uni.getStorageSync('token'),
  },
  data: { message: 'hello' },
  success: (res) => {
    // 又是一堆重复的状态码判断...
  },
  fail: () => {
    // 又是一堆重复的错误处理...
  },
});

问题显而易见

  • 每个请求都要手动拼接 baseUrl
  • 每个请求都要手动读取 token
  • 每个请求都要重复写状态码判断
  • 每个请求都要重复写错误处理
  • 回调地狱,代码难以维护

1.2 我需要什么

我需要一个统一的 HTTP 封装

  • 自动拼接 baseUrl
  • 自动携带 token
  • 统一的错误处理
  • 支持 TypeScript 泛型
  • 简洁的调用方式

二、基础封装:Promise + 泛型

2.1 我的需求

txt 复制代码
我:项目需要封装 HTTP 请求,用的是 UniApp。
    要求:Promise 风格、自动处理错误、支持 TypeScript。

2.2 AI 询问细节

txt 复制代码
AI:好的,我来帮你设计 HTTP 封装。先确认几个问题:

🤔 问题:
1. 后端接口的响应格式是什么样的?
2. 需要支持哪些 HTTP 方法?(GET/POST/PUT/DELETE)
3. 错误提示用什么方式?(Toast/弹窗)
4. 有没有需要特殊处理的状态码?(如 401 未授权)

这些信息会影响封装的设计。

2.3 我补充信息

txt 复制代码
我:响应格式是 { code: 200, data: {...}, msg: '成功' }。
    需要 GET/POST/PUT/DELETE。
    错误用 Toast 提示就行。
    401 要跳登录页,其他错误显示 msg。

2.4 AI 给出执行计划

txt 复制代码
AI:明白了,我来设计封装结构。

📋 执行计划:

1. 创建 src/http/types.ts - 类型定义
2. 创建 src/http/http.ts - 核心请求函数
3. 创建 src/http/index.ts - 统一导出

📐 目录结构:
src/http/
├── index.ts      # 统一导出
├── types.ts      # 类型定义
└── http.ts       # 核心请求函数

🎯 设计要点:
1. 使用泛型 http<T>() 支持类型推导
2. 返回 Promise,支持 async/await
3. 状态码 2xx 视为成功(参考 axios)
4. 401 清除登录态并跳转
5. 其他错误显示 Toast

开始生成代码?
txt 复制代码
我:开始吧

2.5 AI 生成基础代码

typescript 复制代码
// src/http/types.ts
/**
 * 自定义请求配置
 */
export type CustomRequestOptions = UniApp.RequestOptions & {
  query?: Record<string, any>;
  /** 出错时是否隐藏错误提示 */
  hideErrorToast?: boolean;
};

/**
 * 通用响应格式(与后端约定)
 */
export interface IResponse<T = any> {
  code: number;
  data: T;
  msg: string;
}
typescript 复制代码
// src/http/http.ts
import type { CustomRequestOptions } from './types';

/**
 * 核心请求函数
 */
export function http<T>(options: CustomRequestOptions) {
  return new Promise<IResData<T>>((resolve, reject) => {
    uni.request({
      ...options,
      dataType: 'json',
      // 响应成功
      success(res) {
        // 状态码 2xx 视为成功
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data as IResData<T>);
        } else if (res.statusCode === 401) {
          // 401 未授权 → 跳转登录
          reject(res);
        } else {
          // 其他错误 → 显示提示
          if (!options.hideErrorToast) {
            uni.showToast({
              icon: 'none',
              title: (res.data as IResData<T>).msg || '请求错误',
            });
          }
          reject(res);
        }
      },
      // 网络错误
      fail(err) {
        uni.showToast({
          icon: 'none',
          title: '网络错误,换个网络试试',
        });
        reject(err);
      },
    });
  });
}

/**
 * GET 请求
 */
export function httpGet<T>(
  url: string,
  query?: Record<string, any>,
  options?: Partial<CustomRequestOptions>,
) {
  return http<T>({
    url,
    query,
    method: 'GET',
    ...options,
  });
}

/**
 * POST 请求
 */
export function httpPost<T>(
  url: string,
  data?: Record<string, any>,
  options?: Partial<CustomRequestOptions>,
) {
  return http<T>({
    url,
    data,
    method: 'POST',
    ...options,
  });
}
txt 复制代码
AI:基础封装完成。使用方式:

// 调用示例
import { httpGet, httpPost } from '@/http'

// GET 请求
const res = await httpGet<UserInfo>('/api/user/info')
console.log(res.data.username)  // 有类型提示

// POST 请求
const res = await httpPost<LoginResult>('/api/auth/login', {
  username: 'test',
  password: '123456',
})

现在可以测试一下基础功能。

三、请求拦截器:自动拼接 URL 和 Token

3.1 我发现问题

txt 复制代码
我:请求发出去了,但是 URL 不对。
    我写的是 /api/user/info,但请求的是 http://localhost/api/user/info。
    应该是 https://api.xindong.com/api/user/info 才对。

3.2 AI 解释并添加拦截器

txt 复制代码
AI:需要添加请求拦截器,自动拼接 baseUrl。

🔍 UniApp 的拦截器机制:
uni.addInterceptor('request', {
  invoke(options) {
    // 请求发出前执行
    options.url = baseUrl + options.url
  }
})

我来创建拦截器文件...
typescript 复制代码
// src/http/interceptor.ts
import { getEnvBaseUrl } from '@/utils';

// 从环境变量获取 baseUrl
const baseUrl = getEnvBaseUrl();

const httpInterceptor = {
  // 请求发出前
  invoke(options: CustomRequestOptions) {
    // 1. 拼接 baseUrl(非 http 开头的才拼接)
    if (!options.url.startsWith('http')) {
      options.url = baseUrl + options.url;
    }

    // 2. 设置超时时间
    if (options.timeout == null) {
      options.timeout = 60000; // 60 秒
    }
  },
};

export const requestInterceptor = {
  install() {
    uni.addInterceptor('request', httpInterceptor);
    uni.addInterceptor('uploadFile', httpInterceptor); // 上传也拦截
  },
};
txt 复制代码
AI:在 main.ts 中注册拦截器:

// main.ts
import { requestInterceptor } from '@/http'

requestInterceptor.install()

现在请求会自动拼接 baseUrl 了。

3.3 我追问:Token 也要自动加

txt 复制代码
我:baseUrl 好了。但每个接口都要登录后才能调用,
    Token 能不能也自动加上?不想每个请求都手动写。

3.4 AI 添加 Token 处理

txt 复制代码
AI:在拦截器中添加 Token 自动携带。

⚠️ 注意:Token 从 Storage 获取,不从 Store 获取。
原因:拦截器执行时,Store 可能还没初始化。
typescript 复制代码
// src/http/interceptor.ts(更新)
const httpInterceptor = {
  invoke(options: CustomRequestOptions) {
    // 1. 拼接 baseUrl
    if (!options.url.startsWith('http')) {
      options.url = baseUrl + options.url;
    }

    // 2. 设置超时时间
    if (options.timeout == null) {
      options.timeout = 60000;
    }

    // 3. 添加 Token(从 Storage 获取)
    const token = uni.getStorageSync('token');
    if (token) {
      options.header = {
        ...options.header,
        Authorization: `Bearer ${token}`,
      };
    }
  },
};
txt 复制代码
AI:现在所有请求会自动带上 Token。

请求头示例:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

如果没有 Token(未登录),就不会添加这个头。

四、Query 参数:自动拼接到 URL

4.1 我发现 query 参数没生效

txt 复制代码
我:GET 请求传了 query 参数,但后端没收到。

    httpGet('/api/chat/list', { page: 1, pageSize: 10 })

    请求的 URL 还是 /api/chat/list,没有 ?page=1&pageSize=10

4.2 AI 修复 Query 处理

txt 复制代码
AI:query 参数需要手动拼接到 URL。我来添加处理逻辑。
typescript 复制代码
// src/http/tools/queryString.ts
/**
 * 将对象转换为 URL 查询字符串
 */
export function stringifyQuery(query: Record<string, any>): string {
  const pairs: string[] = [];

  for (const key in query) {
    const value = query[key];
    if (value === undefined || value === null) continue;

    if (Array.isArray(value)) {
      // 数组:key=1&key=2
      value.forEach((v) => {
        pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
      });
    } else {
      pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
    }
  }

  return pairs.join('&');
}
typescript 复制代码
// src/http/interceptor.ts(更新)
import { stringifyQuery } from './tools/queryString';

const httpInterceptor = {
  invoke(options: CustomRequestOptions) {
    // 0. 处理 query 参数
    if (options.query) {
      const queryStr = stringifyQuery(options.query);
      if (options.url.includes('?')) {
        options.url += `&${queryStr}`;
      } else {
        options.url += `?${queryStr}`;
      }
    }

    // 1. 拼接 baseUrl
    // ...

    // 2. 设置超时时间
    // ...

    // 3. 添加 Token
    // ...
  },
};
txt 复制代码
AI:现在 query 参数会自动拼接到 URL:

httpGet('/api/chat/list', { page: 1, pageSize: 10 })
// 实际请求:https://api.xindong.com/api/chat/list?page=1&pageSize=10

httpGet('/api/search?type=all', { keyword: '你好' })
// 实际请求:https://api.xindong.com/api/search?type=all&keyword=%E4%BD%A0%E5%A5%BD

五、多平台适配:H5 代理与平台标识

5.1 我发现 H5 有跨域问题

txt 复制代码
我:小程序正常,但 H5 调试时报跨域错误:
    Access-Control-Allow-Origin...

5.2 AI 解释并添加代理配置

txt 复制代码
AI:H5 开发时需要配置代理来解决跨域。

🔍 解决方案:
1. Vite 配置代理:开发时请求 /api → 转发到后端
2. 拦截器判断:H5 开发环境用代理前缀,其他环境用 baseUrl

我来修改配置...
typescript 复制代码
// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api.xindong.com',
        changeOrigin: true,
      },
    },
  },
});
typescript 复制代码
// src/http/interceptor.ts(H5 适配)
const httpInterceptor = {
  invoke(options: CustomRequestOptions) {
    // ... query 处理

    // 拼接 URL(区分平台)
    if (!options.url.startsWith('http')) {
      // #ifdef H5
      if (JSON.parse(__VITE_APP_PROXY__)) {
        // H5 开发环境:使用代理前缀
        options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url;
      } else {
        // H5 生产环境:使用 baseUrl
        options.url = baseUrl + options.url;
      }
      // #endif

      // #ifndef H5
      // 小程序/App:直接使用 baseUrl
      options.url = baseUrl + options.url;
      // #endif
    }

    // ... 其他处理
  },
};
txt 复制代码
AI:
✅ H5 开发:/api/user → 代理到 https://api.xindong.com/api/user
✅ H5 生产:/api/user → https://api.xindong.com/api/user
✅ 小程序:/api/user → https://api.xindong.com/api/user

现在 H5 跨域问题解决了。

5.3 我追问:还需要加平台标识

txt 复制代码
我:后端说需要知道请求来自哪个平台(小程序/H5/App),
    方便做数据统计。能加到请求头里吗?

5.4 AI 添加平台标识

txt 复制代码
AI:在请求头添加平台信息。
typescript 复制代码
// src/utils/platform.ts
// #ifdef MP-WEIXIN
export const platform = 'mp-weixin';
// #endif
// #ifdef H5
export const platform = 'h5';
// #endif
// #ifdef APP-PLUS
export const platform = 'app';
export const isApp = true;
// #endif
typescript 复制代码
// src/http/interceptor.ts(添加平台标识)
import { platform, isApp } from '@/utils/platform';
import { clientSource, clientChannel } from '@/config/product';

const httpInterceptor = {
  invoke(options: CustomRequestOptions) {
    // ... 前面的处理

    // 添加请求头
    const header = {
      platform, // 平台标识
      ...options.header,
    } as Record<string, any>;

    // 客户端来源和渠道
    header['X-Client-Source'] = String(clientSource);
    header['X-Client-Channel'] = String(clientChannel);

    // App 平台:附加版本信息
    if (isApp && typeof plus !== 'undefined') {
      const version = plus.runtime.version;
      if (version) {
        header['X-App-Version'] = String(version);
      }
    }

    options.header = header;

    // 添加 Token
    const token = uni.getStorageSync('token');
    if (token) {
      options.header.Authorization = `Bearer ${token}`;
    }
  },
};
txt 复制代码
AI:现在每个请求都会带上这些头信息:

platform: mp-weixin
X-Client-Source: 1
X-Client-Channel: xd_mp
Authorization: Bearer xxx(如果有 Token)

App 还会额外带上版本号。后端可以据此做统计分析。

六、快捷方法:http.get/post 链式调用

6.1 我想要更简洁的 API

txt 复制代码
我:现在用起来还是有点繁琐,能不能像 axios 那样:

    http.get('/api/user')
    http.post('/api/login', data)

    这样更简洁

6.2 AI 添加链式调用

txt 复制代码
AI:给 http 函数挂载快捷方法。
typescript 复制代码
// src/http/http.ts(完整版)
import type { CustomRequestOptions } from './types';

/**
 * 核心请求函数
 */
export function http<T>(options: CustomRequestOptions) {
  return new Promise<IResData<T>>((resolve, reject) => {
    uni.request({
      ...options,
      dataType: 'json',
      success(res) {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data as IResData<T>);
        } else if (res.statusCode === 401) {
          reject(res);
        } else {
          if (!options.hideErrorToast) {
            uni.showToast({
              icon: 'none',
              title: (res.data as IResData<T>).msg || '请求错误',
            });
          }
          reject(res);
        }
      },
      fail(err) {
        uni.showToast({
          icon: 'none',
          title: '网络错误,换个网络试试',
        });
        reject(err);
      },
    });
  });
}

/** GET 请求 */
export function httpGet<T>(
  url: string,
  query?: Record<string, any>,
  header?: Record<string, any>,
  options?: Partial<CustomRequestOptions>,
) {
  return http<T>({ url, query, method: 'GET', header, ...options });
}

/** POST 请求 */
export function httpPost<T>(
  url: string,
  data?: Record<string, any>,
  query?: Record<string, any>,
  header?: Record<string, any>,
  options?: Partial<CustomRequestOptions>,
) {
  return http<T>({ url, data, query, method: 'POST', header, ...options });
}

/** PUT 请求 */
export function httpPut<T>(
  url: string,
  data?: Record<string, any>,
  query?: Record<string, any>,
  header?: Record<string, any>,
  options?: Partial<CustomRequestOptions>,
) {
  return http<T>({ url, data, query, method: 'PUT', header, ...options });
}

/** DELETE 请求 */
export function httpDelete<T>(
  url: string,
  query?: Record<string, any>,
  header?: Record<string, any>,
  options?: Partial<CustomRequestOptions>,
) {
  return http<T>({ url, query, method: 'DELETE', header, ...options });
}

// 挂载快捷方法,支持 http.get() 风格
http.get = httpGet;
http.post = httpPost;
http.put = httpPut;
http.delete = httpDelete;
txt 复制代码
AI:现在有两种调用风格:

// 风格一:独立函数
import { httpGet, httpPost } from '@/http'
const res = await httpGet<UserInfo>('/api/user')

// 风格二:链式调用
import { http } from '@/http'
const res = await http.get<UserInfo>('/api/user')
const res = await http.post<LoginResult>('/api/login', { username, password })

两种风格效果一样,看个人喜好选择。

七、最终代码:完整的 HTTP 封装

经过多轮对话,最终的 HTTP 封装模块:

7.1 目录结构

txt 复制代码
src/http/
├── index.ts          # 统一导出
├── types.ts          # 类型定义
├── http.ts           # 核心请求函数
├── interceptor.ts    # 请求拦截器
└── tools/
    └── queryString.ts  # URL 参数处理

7.2 核心代码

typescript 复制代码
// src/http/interceptor.ts
// 完整的请求拦截器

import type { CustomRequestOptions } from './types';
import { getEnvBaseUrl } from '@/utils';
import { platform, isApp } from '@/utils/platform';
import { stringifyQuery } from './tools/queryString';
import { clientSource, clientChannel } from '@/config/product';

const baseUrl = getEnvBaseUrl();

const httpInterceptor = {
  invoke(options: CustomRequestOptions) {
    // 1. 处理 query 参数
    if (options.query) {
      const queryStr = stringifyQuery(options.query);
      options.url += options.url.includes('?') ? `&${queryStr}` : `?${queryStr}`;
    }

    // 2. 拼接 baseUrl(区分平台)
    if (!options.url.startsWith('http')) {
      // #ifdef H5
      if (JSON.parse(__VITE_APP_PROXY__)) {
        options.url = import.meta.env.VITE_APP_PROXY_PREFIX + options.url;
      } else {
        options.url = baseUrl + options.url;
      }
      // #endif
      // #ifndef H5
      options.url = baseUrl + options.url;
      // #endif
    }

    // 3. 设置超时时间
    if (options.timeout == null) {
      options.timeout = 60000;
    }

    // 4. 添加请求头
    const header = {
      platform,
      ...options.header,
    } as Record<string, any>;

    header['X-Client-Source'] = String(clientSource);
    header['X-Client-Channel'] = String(clientChannel);

    // 5. App 平台附加版本信息
    if (isApp && typeof plus !== 'undefined' && plus?.runtime) {
      const version = plus.runtime.version;
      if (version) {
        header['X-App-Version'] = String(version);
      }
    }

    options.header = header;

    // 6. 添加 Token
    const token = uni.getStorageSync('token');
    if (token) {
      options.header.Authorization = `Bearer ${token}`;
    }
  },
};

export const requestInterceptor = {
  install() {
    uni.addInterceptor('request', httpInterceptor);
    uni.addInterceptor('uploadFile', httpInterceptor);
  },
};

7.3 使用示例

typescript 复制代码
// API 定义
// src/api/user.ts
import { httpGet, httpPost } from '@/http';
import type { UserInfo, LoginParams, LoginResult } from 'shared-types';

/** 获取用户信息 */
export function getUserInfo() {
  return httpGet<UserInfo>('/api/user/info');
}

/** 登录 */
export function login(params: LoginParams) {
  return httpPost<LoginResult>('/api/auth/login', params);
}

/** 更新用户信息 */
export function updateUserInfo(data: Partial<UserInfo>) {
  return httpPost<UserInfo>('/api/user/update', data);
}
typescript 复制代码
// 页面中使用
import { getUserInfo, login } from '@/api/user';

// 获取用户信息
const handleGetUser = async () => {
  const res = await getUserInfo();
  if (res.code === 200) {
    console.log(res.data.username); // 有完整的类型提示
  }
};

// 登录
const handleLogin = async () => {
  const res = await login({ username: 'test', password: '123456' });
  if (res.code === 200) {
    uni.setStorageSync('token', res.data.token);
  }
};

八、核心经验:HTTP 封装的最佳实践

8.1 分层设计

层级 职责 文件
拦截器层 URL 拼接、Token、请求头 interceptor.ts
核心层 请求发送、响应处理、错误处理 http.ts
API 层 具体接口定义,参数/返回值类型 api/*.ts
业务层 页面/组件调用 API pages/*.vue

8.2 设计要点

typescript 复制代码
// ✅ 推荐:Token 从 Storage 获取
const token = uni.getStorageSync('token');

// ❌ 不推荐:Token 从 Store 获取
const userStore = useUserStore();
const token = userStore.token; // 拦截器执行时 Store 可能未初始化
typescript 复制代码
// ✅ 推荐:使用泛型,获得类型提示
const res = await httpGet<UserInfo>('/api/user');
console.log(res.data.username); // 有类型提示

// ❌ 不推荐:不使用泛型
const res = await httpGet('/api/user');
console.log(res.data.username); // any 类型,没有提示
typescript 复制代码
// ✅ 推荐:API 层统一定义
// api/user.ts
export function getUserInfo() {
  return httpGet<UserInfo>('/api/user/info');
}

// 页面调用
import { getUserInfo } from '@/api/user';
const res = await getUserInfo();

// ❌ 不推荐:页面直接调用 http
import { httpGet } from '@/http';
const res = await httpGet('/api/user/info'); // URL 分散在各处

8.3 错误处理策略

typescript 复制代码
// 全局错误处理(在 http.ts 中)
if (res.statusCode === 401) {
  // 清除登录态,跳转登录
  uni.removeStorageSync('token');
  uni.navigateTo({ url: '/pages/login/login' });
}

// 局部错误处理(在业务代码中)
try {
  const res = await login(params);
  // 成功处理
} catch (error) {
  // 特殊错误处理(如表单验证失败)
}

// 静默请求(不显示错误提示)
const res = await httpGet('/api/check', {}, {}, { hideErrorToast: true });

九、总结:从祖传代码到优雅设计

9.1 迭代过程回顾

阶段 需求 成果
基础封装 Promise + 错误处理 http.ts 核心函数
请求拦截 baseUrl + Token interceptor.ts
参数处理 Query 自动拼接 queryString.ts
多平台适配 H5 代理 + 平台标识 条件编译 + 请求头
体验优化 链式调用 http.get/post 快捷方法

9.2 关键收获

  1. 分层设计:拦截器、核心函数、API 定义各司其职
  2. 平台差异:H5 需要代理,小程序直接请求
  3. Token 处理:从 Storage 获取,不依赖 Store 初始化
  4. 类型安全:泛型 + 接口定义,获得完整的类型提示

9.3 下一篇预告

《【AI 编程实战】第 7 篇:登录系统不只是调接口 - 完整架构设计》

下一篇展示如何设计登录模块:

  • 微信一键登录
  • 手机号验证码登录
  • 登录态维护与刷新

HTTP 封装不是"复制粘贴",而是根据项目需求逐步演进。 通过和 AI 对话,你可以理清每个设计决策的原因,而不是盲目照抄。

如果这篇文章对你有帮助,请点赞、收藏、转发!

相关推荐
weibkreuz8 小时前
收集表单数据@10
开发语言·前端·javascript
hboot8 小时前
别再被 TS 类型冲突折磨了!一文搞懂类型合并规则
前端·typescript
在西安放羊的牛油果8 小时前
浅谈 import.meta.env 和 process.env 的区别
前端·vue.js·node.js
鹏北海8 小时前
从弹窗变胖到 npm 依赖管理:一次完整的问题排查记录
前端·npm·node.js
布列瑟农的星空8 小时前
js中的using声明
前端
薛定谔的猫28 小时前
Cursor 系列(2):使用心得
前端·ai编程·cursor
用户904706683578 小时前
后端问前端:我的接口请求花了多少秒?为啥那么慢,是你慢还是我慢?
前端
深念Y8 小时前
仿B站项目 前端 4 首页 顶层导航栏
前端·vue·ai编程·导航栏·bilibili·ai开发
dragonZhang8 小时前
基于 Agent Skills 的 UI 重构实践:从 Demo 到主题化界面的升级之路
前端·ai编程·claude
王林不想说话8 小时前
提升工作效率的Utils
前端·javascript·typescript