每个项目都要和后端接口打交道,但很多人的 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 关键收获
- 分层设计:拦截器、核心函数、API 定义各司其职
- 平台差异:H5 需要代理,小程序直接请求
- Token 处理:从 Storage 获取,不依赖 Store 初始化
- 类型安全:泛型 + 接口定义,获得完整的类型提示
9.3 下一篇预告
《【AI 编程实战】第 7 篇:登录系统不只是调接口 - 完整架构设计》
下一篇展示如何设计登录模块:
- 微信一键登录
- 手机号验证码登录
- 登录态维护与刷新
HTTP 封装不是"复制粘贴",而是根据项目需求逐步演进。 通过和 AI 对话,你可以理清每个设计决策的原因,而不是盲目照抄。
如果这篇文章对你有帮助,请点赞、收藏、转发!