如果你维护过一个跨端项目,大概率见过这种代码:页面里直接uni.request,地址写死一个http://192.168.x.x:8080,token 在 onLoad 里手动从 storage 取出来塞进 header,后端一调整错误码格式,你得翻十几个文件挨个改。我接手的一个项目就是这样,当时觉得"能用就行"。直到某次活动页在线上报了一批接口异常,排查到下午五点才定位——有个页面漏传了公共参数,导致所有请求没拿到正确数据。从那天起,我做的第一件事就是把 request 彻底封装一遍,把所有 API 请求收口到同一层。
这篇文章就来完整讲讲这套"封装 uni-app request、统一 API 接口请求"的落地经验:拦截器怎么设计、token 怎么注入、登录失效怎么处理、多环境怎么切换、API 怎么模块化管理,以及上传下载、并发刷新、取消请求这些扩展点。如果你在用 uni-app(uni-app x 同样适用,本质都是uni.request的跨端封装),或者正被散落的接口调用折磨,这篇可以直接拿来当方案参考。
1. 为什么必须封装 request 这一层——一次线上问题让我下的决心
1.1 不封装时最常见的三种乱象
我不止一次在别人的项目里看到如下三种代码。它们表面上是"能跑",实际全是隐患。
第一,baseURL 散落各处。A 页面写http://192.168.1.10:8080,B 页面写https://test-api.example.com,C 页面写在全局配置文件里。到了联调阶段,有人用测试环境,有人连本地后端,提交到同一个分支,排查问题时根本分不清哪个环境是哪次提交产生的。
第二,header 各写各的。有的页面从uni.getStorageSync读 token,有的页面在 App.vue 里缓存了一个全局变量,还有的页面把 token 写死在代码里。最典型的问题是:登录后某页面用的还是旧 token,后端返回 401,页面却没有跳转登录的逻辑,用户卡在一个假页面上。
第三,错误处理各写各的。有人用uni.showToast弹"网络错误",有人直接console.log然后假装无事发生,有人处理了超时,有人没处理。线上反馈"接口报错"时,连错误长什么样都说不清楚。
1.2 为什么不在页面里直接用 axios
有朋友问我:uni-app 的 H5 端不是可以用 axios 吗?直接用 axios 再包一层 Promise 不就行了?
这里有个关键前提:uni-app 要同时跑 H5、小程序和 App。axios 在浏览器环境依赖 XHR,在小程序端没有 XHR,必须靠适配器转换。适配器本身又是第三方依赖,一旦某个小程序平台升级了请求策略,就可能踩适配器的坑。相比之下,uni.request是官方跨端封装,它在内部处理了各端的差异,你传同样的参数,它自动适配底层实现。与其自己引 axios 再配适配器,不如在uni.request之上做一层你自己的封装。这样你只需要维护自己的这一层,不需要关心适配器的死活。
1.3 封装之后拿到的好处
封装完成后,收益立竿见影:
| 能力 | 封装前 | 封装后 |
|---|---|---|
| baseURL 切换 | 页面写死,靠人肉改 | 环境变量控制,编译期自动注入 |
| token 注入 | 每个页面手动塞 header | 请求拦截器统一注入 |
| 登录失效处理 | 各页面自行判断,大量遗漏 | 响应拦截器统一跳转登录 |
| 错误提示 | toast 文案五花八门 | 统一错误码映射,一处配置 |
| 新增接口 | 复制粘贴再改地址 | 只在 api 模块新增一个函数 |
这些收益不是玄学,后面每一节都会给出对应代码。你封装完回头看,会发现最值钱的不是少写了几行代码,而是所有的请求行为都变得可以预期、可以排查、可以统一修改。
2. 动手前必须想清楚的四件事:拦截器、环境、错误分类、职责边界
2.1 先定职责边界,别把 request 文件写成大杂烩
很多人在封装 uni.request 时,容易把登录逻辑、路由跳转、业务提示全塞进一个文件,最后 request.js 膨胀到一千多行。我的建议是先把职责画清楚:
- request 模块只负责:拼 URL、注入 header、发请求、收响应、判断错误类型、触达公共回调。
- 登录失效后的跳转动作,通过回调函数交给调用方注册,request 不直接操作页面路由。
- 业务上的提示文案(比如"该订单已关闭"),由业务侧在处理返回值时决定,不在拦截器里写死。
这样设计的目的很朴素:request 模块的可复用性最高。将来项目拆分,或者多个小程序共用一套请求层,你不用跟着业务逻辑一起搬。
2.2 拦截器机制:请求前干什么,响应后干什么
拦截器是封装的核心。我通常设计两个钩子:
beforeRequest(config):拿到即将发出的请求配置,可以修改 baseURL、追加 header、处理参数。afterResponse(response):拿到后端返回的原始数据,可以统一做数据解包、错误码判断。
拦截器不一定要做成 axios 那种注册多个拦截器的复杂机制,做成一进一出两个函数就够了。大多数项目不需要拦截器链,只需要在请求前干一件事、响应后干一件事。复杂化了反而增加维护成本。
2.3 环境切换:不要写死 IP,更不要写死环境判断
没有封装时,大家习惯在一个 config.js 里写:
const BASE_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:8080' : 'https://api.example.com'这样写也能用,但问题在于你无法通过一行配置决定编译环境,也无法为"测试环境""预发布环境"扩展。我的建议是用环境变量文件来管理。uni-app 基于 Vite 构建,天然支持.env文件机制:
# .env.development VITE_API_BASE_URL=https://dev-api.example.com # .env.test VITE_API_BASE_URL=https://test-api.example.com # .env.production VITE_API_BASE_URL=https://api.example.com代码里这样读:
const BASE_URL = import.meta.env.VITE_API_BASE_URL这样做的好处是,环境变量本身就是项目配置,团队成员不会因为本地调试而临时改代码里的地址。你只需要运行对应的编译命令,import.meta.env.MODE会自动匹配当前是 development 还是 production(test 这些自定义环境需要在 manifest 或打包脚本里声明)。实测下来,这是我最推荐的方式,没有之一。
2.4 错误分类:把"网络错误、HTTP 错误、业务错误"分开
封装前一定要和后端对齐错误约定。通常我会问三个问题:
- HTTP 状态码是否规范:4xx 表示客户端问题,5xx 表示服务端问题?
- 业务失败时 HTTP 状态码是多少:是 200 + 业务码,还是直接 4xx/5xx?
- 登录失效的标志是什么:HTTP 401,还是业务码 40101 之类?
真实项目里,这两个设计往往同时存在,所以封装必须对三类错误分别处理:
| 错误类型 | 判断依据 | 处理方式 |
|---|---|---|
| 网络错误 | uni.request的 fail 回调触发 | 提示网络异常、超时提示,支持重试按钮 |
| HTTP 错误 | statusCode 不是 2xx | 按状态码映射提示,401 触发登录失效回调 |
| 业务错误 | HTTP 200 但业务 code 非预期 | 交给业务方决定提示,request 层只透传 |
这样拆分后,每一层的处理逻辑都很纯。后端改错误码方式时,你只需要改响应拦截器中的映射表,页面代码基本不动。
2.5 封装前还要和后端对齐响应结构
我发现很多人封装 request 时忽略了一个前置条件——后端的响应结构必须统一。最理想的结构是:
{ "code": 0, "message": "success", "data": {} }如果后端接口有的返回{ status: 1, msg: 'ok', result: {} },有的返回{ code: 200, data: {} },你封装的拦截器会非常痛苦。所以动手前,一定要找后端 leader 对齐规范。如果历史项目里接口结构已经不统一,可以在响应拦截器里加一层"结构适配",先识别当前接口属于哪种结构再解包。这个适配层单独写成函数,别塞进主流程,方便后续逐步收敛。
3. 核心实现:一个带拦截器的 Promise 化请求模块
3.1 基础骨架:Promise 化 uni.request
uni.request本身是回调风格,我们需要包成 Promise,同时把 baseURL、timeout 和拦截器配置做成可注入项。下面是我项目里实际在用的基础骨架:
// utils/http.js class Request { constructor(options = {}) { this.baseUrl = options.baseUrl || '' this.timeout = options.timeout || 10000 this.beforeRequest = options.beforeRequest || null this.afterResponse = options.afterResponse || null this.handleError = options.handleError || null } request(config) { return new Promise((resolve, reject) => { let { url, method = 'GET', data = {}, header = {}, timeout = this.timeout, loading = false } = config // 请求拦截:可以在发出前统一处理 header、参数、签名等 if (this.beforeRequest) { const result = this.beforeRequest({ url, method, data, header, loading }) if (result === false) { // 拦截器返回 false,表示取消该请求 reject(new Error('request cancelled by interceptor')) return } // 拦截器可以返回修改后的配置 if (result && typeof result === 'object') { ({ url, method, data, header, timeout, loading } = { url, method, data, header, timeout, loading, ...result }) } } uni.request({ url: this.baseUrl + url, method, data, header, timeout, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { // HTTP 层成功,进入响应拦截 let responseData = res.data if (this.afterResponse) { responseData = this.afterResponse(responseData, res) } resolve(responseData) } else { // HTTP 层失败 this.handleHttpError(res) if (this.handleError) { this.handleError({ type: 'http', statusCode: res.statusCode, data: res.data }) } reject(res) } }, fail: (err) => { // 网络层失败 this.handleNetworkError(err) if (this.handleError) { this.handleError({ type: 'network', err }) } reject(err) } }) }) } }这段代码有几个设计点值得说明。第一,beforeRequest返回 false 可以取消请求,这是为了支持某些"未登录就直接不发请求"的场景。第二,拦截器可以返回修改后的配置,我用解构覆盖的方式合并,这样调用方传入的config始终是最终版。第三,HTTP 错误和网络错误都归一到一个handleError回调里,业务侧可以统一上报日志。
3.2 请求拦截:token 注入与公共 header
beforeRequest的典型实现是 token 注入。但这里有个坑:token 一定不要在模块加载时读一次存内存变量,而要每次请求前从 storage 读取。原因很简单,模块加载时可能还没有登录,token 是空的;或者用户登录后 storage 更新了,内存变量还停留在旧值。
const http = new Request({ baseUrl: BASE_URL, beforeRequest: (config) => { const token = uni.getStorageSync('token') if (token) { config.header['Authorization'] = `Bearer ${token}` } // 可以在这里统一追加公共参数,比如渠道、版本号等 config.header['X-App-Version'] = '1.0.0' return config }, // 响应拦截、错误处理见下文 })如果你觉得每次请求都读 storage 有性能开销,可以折中:登录成功后把 token 同时写入内存变量和 storage,但必须保证所有写 token 的地方都通过统一的方法(比如auth.js里的setToken)。我在项目里就吃过"直接改 storage 忘了同步内存变量"的亏,后来干脆每次从 storage 读,性能开销微乎其微,换来的是绝对不会出现 token 不同步的问题。
3.3 响应拦截:业务码判断与 401 处理
响应拦截是最容易"过度设计"的地方。有些人会把所有业务码都映射成 toast,结果后端加了新业务码,前端就得跟着发版。更合理的方式是:request 层只处理"该统一处理的错误"和"登录失效",其他业务错误由业务侧处理。
const http = new Request({ // ... afterResponse: (responseData) => { // 适配常用结构:有的接口返回 { code, message, data },有的返回 { status, msg, result } const code = responseData.code !== undefined ? responseData.code : responseData.status const data = responseData.data !== undefined ? responseData.data : responseData.result const message = responseData.message || responseData.msg || '请求成功' if (code === 0 || code === 200) { return data } if (code === 40101) { // 登录失效:触发统一回调,业务侧注册 this.onAuthExpired && this.onAuthExpired() return Promise.reject({ code, message }) } // 其他业务错误:抛给业务侧,由页面或模块决定提示文案 return Promise.reject({ code, message, data }) }, })再说说 HTTP 401 的处理。很多后端是 200 + 业务码表示登录失效,但也有后端用的是 HTTP 401。我通常在handleHttpError里统一判断:
// 在 Request 类中增加方法 handleHttpError(res) { const statusMessage = { 400: '请求参数错误', 401: '登录状态已失效,请重新登录', 403: '没有权限访问该资源', 404: '请求的资源不存在', 500: '服务器内部错误', 502: '网关错误', 503: '服务暂时不可用', 504: '网关超时' } const message = statusMessage[res.statusCode] || `请求失败(${res.statusCode})` uni.showToast({ title: message, icon: 'none', duration: 2000 }) if (res.statusCode === 401) { this.onAuthExpired && this.onAuthExpired() } }这里注意,onAuthExpired是通过外部注册进来的,不是 request 模块内部直接uni.navigateTo跳登录页。原因是 uni-app 里可能有多个入口需要处理登录过期,比如某些页面要跳转登录页,某些页面要弹出登录弹窗。做成回调让业务侧自己决定,request 模块就不用耦合具体的页面路径。
3.4 快捷方法与单例导出
骨架里的request(config)已经能用了,但调起来还是不够顺手。我会再补上get、post、put、delete四个快捷方法,并导出单例:
class Request { // ...上面代码 get(url, data, config = {}) { return this.request({ url, method: 'GET', data, ...config }) } post(url, data, config = {}) { return this.request({ url, method: 'POST', data, ...config }) } put(url, data, config = {}) { return this.request({ url, method: 'PUT', data, ...config }) } delete(url, data, config = {}) { return this.request({ url, method: 'DELETE', data, ...config }) } } export default http调用方式就变成了http.get('/user/info', { id: 1 })。代码里config可以传loading、timeout、header覆盖项,灵活度完全保留。
单例设计要说明一下:默认情况下全局只需要一个 Request 实例,但如果你的项目里需要对接"多个后端服务"(比如业务 API 和监控 API 是不同域名、不同鉴权方式),可以创建多个实例,每个实例有自己的拦截器。我遇到过把两个后端写进一个 request 实例然后通过判断 URL 前缀来分流的情况,代码非常混乱。不如直接两个实例来得干净。
4. API 接口的模块化管理:从散落调用到统一收口
4.1 api 目录怎么组织
request 层封装好之后,第二步就是让页面不再直接调用http.get('/user/info'),而是通过 api 模块统一暴露函数。我的目录结构是这样的:
src/ api/ index.js // 汇总导出 user.js // 用户相关接口 order.js // 订单相关接口 product.js // 商品相关接口 utils/ http.js // request 封装api/user.js的典型内容:
import http from '@/utils/http' export const login = (data) => { return http.post('/auth/login', data) } export const getUserInfo = (userId) => { return http.get('/user/info', { userId }) } export const updateUser = (data) => { return http.put('/user/update', data) } export const logout = () => { return http.post('/auth/logout') }api/index.js统一汇总,这样页面只从一处引入:
export * from './user' export * from './order' export * from './product'4.2 页面里如何优雅使用
页面里的调用方式变成:
import { getUserInfo, updateUser } from '@/api/user' async function loadUserInfo() { try { const info = await getUserInfo(userId) this.userInfo = info } catch (err) { // 业务错误在这里处理,request 层已经弹过网络/HTTP 错误提示 } }注意一个细节:我把所有接口函数都设计成返回 Promise,且只有 resolve 出业务数据(data字段),业务错误通过 reject 抛出。这样页面代码里拿到的永远是最干净的数据,不需要再拆一层res.data.data。这个体验上的改进,团队成员用过的都说舒服。
4.3 为什么要坚持模块化管理
很多人觉得接口函数只比直接调http多包了一层,没必要。但实际项目里,这个"多包一层"带来的收益非常大:
- 后端接口地址变更时,你只需要改 api 模块中的一行 URL,全局生效。
- 后端改了字段结构时,你可以只修改某个 api 模块里的返回数据转换,不影响页面。
- 需要 mock 时,可以直接替换 api 模块,页面完全无感。
- 团队协作时,接口清单就是 api 目录,新人看一遍就知道项目打通了哪些后端能力。
- 代码检索时,搜索"接口地址"只会出现在 api 目录,排查问题路径短很多。
这些看似是小事,但项目规模起来后,收口与否决定了你"半小时搞定环境切换"还是"一下午在全局搜索里迷路"。
4.4 多环境发布时的切换经验
环境切换除了 baseURL,还牵涉一个问题:后端可能区分"测试环境账号体系"和"生产环境账号体系"。我的经验是,baseURL 用环境变量管理,但登录逻辑不要写死环境判断。统一在api/auth.js里写登录请求,后端根据域名环境决定走哪套账号体系。前端唯一要注意的是:上线前确认当前构建产物里的 baseURL 确实是生产地址。我在某一次发版时就踩过环境变量没配对的坑,测试包打出来是生产地址、生产包打出来是测试地址,排查了半个多小时才意识到是.env文件命名不对。这里提醒你,Vite是按.env.${MODE}匹配的,MODE 值最好用小写,比如development、test、production,避免大小写混用导致匹配失败。
5. 最容易踩的四个坑与完整排查思路
5.1 坑一:token 拦截器读到旧值或读不到值
有位同事在某次联调时发现,登录成功后立刻发一个请求,后端仍然返回未登录。排查链路是这样的:
第一步,先在后端日志里看 Authorization 头,确认前端到底有没有带上 token。日志显示Authorization是空的。
第二步,前端在 beforeRequest 里打点,发现uni.getStorageSync('token')确实有值,说明不是读不到。
第三步,继续打点,发现 beforeRequest 执行时,header 对象里config.header['Authorization']赋值成功了,但此时uni.request已经发出去了——因为 baseURL 拼接时引用了模块加载时的旧BASE_URL,实际发出去的是另一个实例?不对,这只是猜测。
真正的根因:这位同事在 Request 类的 request 方法中,用了let { url, ... header } = config,但传给uni.request的header是解构后的新对象,而beforeRequest修改的也是这个对象,理论上没问题。问题出在他把拦截器注册写在了模块顶部,而http单例被多个 api 模块循环引用,模块初始化顺序导致beforeRequest还是旧函数,函数体内访问的 storage key 名也不对。解决方案很简单:不要在拦截器里写死 storage key,把它作为 Request 实例的配置项传入。
const http = new Request({ tokenKey: 'access_token', // 外部可配置 beforeRequest: function(config) { const token = uni.getStorageSync(this.tokenKey) // ... } })这个教训是:一旦拦截器里读不到预期的值,优先检查"拦截器函数本身是不是预期的版本",不要一上来怀疑请求流程。
5.2 坑二:H5 端开发环境的跨域问题
uni-app 跑 H5 时,浏览器会拦截跨域请求。我在开发环境遇到最多的现象是:请求发出去了,后端也收到并返回了,但前端浏览器报 CORS 错误,响应完全拿不到。很多人会去后端加跨域头,其实更推荐前端在开发环境用代理解决。
在 Vite 配置文件里加 server.proxy:
// vite.config.js export default { server: { proxy: { '/api': { target: 'https://dev-api.example.com', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } }同时开发环境的 baseURL 设置为/api开头,这样请求/api/user/info会被代理转发到https://dev-api.example.com/user/info。H5 端同源,浏览器不拦,小程序端和 App 端不受此配置影响,因为它们没有浏览器同源策略限制。
这里最容易被忽略的点是:代理只对devServer生效,打生产包时不能用/api这种相对路径,否则会 404。我见过有人把/api前缀留在生产环境,结果接口全部 404,排查了半天才知道是代理配置只在本地有用。正确的做法是:生产环境 baseURL 用完整域名,开发环境用/api前缀,中间加一层if (import.meta.env.DEV)判断。
5.3 坑三:header 与 content-type 的端兼容性问题
uni.request在不同端的 header 处理有细微差异。最典型的是Content-Type:
- 小程序端和 App 端在 POST 请求里如果不设置
Content-Type,默认可能是application/json。 - H5 端如果不设置,默认是表单格式。
我的建议是,在请求拦截器里显式统一设置:
if (method !== 'GET' && !header['Content-Type']) { header['Content-Type'] = 'application/json' }另一个兼容性问题是 header 的 key 命名。部分端(特别是小程序平台)要求 header key 不区分大小写,但实际调试时发现,有些自定义 header 用了大写、有些用了小写,后端取头时匹配不到。解决方案是:所有自定义 header 统一用小写,比如x-app-version,不要混用X-App-Version。这个统一约定要写进团队规范,否则新成员很容易踩坑。
还有一个容易忽略的细节:如果你传了Content-Type: application/x-www-form-urlencoded,后端通常期望数据格式是 URL-encoded 字符串,而你用 uni.request 的 data 传对象时,各端序列化结果不一样。为了统一,建议用 URLSearchParams 或手动拼接字符串,再放入 data。我遇到过小程序端表单提交正常、H5 端后端收不到字段的情况,就是这个原因。
5.4 坑四:多个请求并发时的 loading 计数问题
这是 loading 控制里最经典的坑。假设页面一次性发出三个请求,每个请求开始都uni.showLoading,第一个请求回来就uni.hideLoading,此时另外两个请求还在等待,loading 就被提前关掉了。用户看到的现象是:页面刷一下就没了,其实数据还没加载完。
解决方案是引入"loading 计数器":
let loadingCount = 0 function showLoading() { loadingCount++ uni.showLoading({ title: '加载中...', mask: true }) } function hideLoading() { loadingCount-- if (loadingCount <= 0) { uni.hideLoading() } }在 request 实例中,当配置项loading: true时才计数。如果请求设置为loading: false(比如静默刷新 token 的请求),则完全不触发 loading 逻辑。
这个坑的排查过程也很有意思。当时现象是"页面 loading 闪一下就消失",我一开始以为是uni.showLoading和uni.hideLoading调用顺序问题,打点后发现是并发请求导致计数失衡。后来在团队里推广了计数器方案,再没出现过类似问题。
6. 扩展能力:上传文件、并发刷新 token 与取消请求
6.1 uploadFile 的封装
uni.uploadFile是独立于uni.request的 API,但它也需要统一 header 和 baseURL。我会在 Request 实例上补一个upload方法:
upload(url, filePath, name = 'file', data = {}, config = {}) { return new Promise((resolve, reject) => { const header = {} if (this.beforeRequest) { const result = this.beforeRequest({ url, method: 'POST', data, header }) if (result === false) { reject(new Error('upload cancelled by interceptor')) return } if (result && typeof result === 'object') { Object.assign(header, result.header) } } uni.uploadFile({ url: this.baseUrl + url, filePath, name, formData: data, header, success: (res) => { // 上传接口通常返回字符串,这里做一次兼容 let responseData = res.data try { responseData = typeof res.data === 'string' ? JSON.parse(res.data) : res.data } catch (e) { // 保持原样 } if (this.afterResponse) { responseData = this.afterResponse(responseData, res) } resolve(responseData) }, fail: (err) => { this.handleNetworkError(err) reject(err) } }) }) }注意uni.uploadFile的success回调里res.data在小程序端是字符串,在 H5 端可能是对象,所以我在代码里做了一层 JSON.parse 兼容。这个细节很容易被遗漏,导致上传后拿不到返回的 data。
6.2 并发请求时统一刷新 token
当 token 过期时,可能会同时有多个请求在途,每个请求都返回 401,如果每个请求都跳登录或者每个请求都尝试刷新 token,就会造成混乱。更合理的方式是:让"刷新 token"这个过程只执行一次,其他请求在刷新完成后自动重新发起。
我采用的思路是:用一个isRefreshing布尔值和一段等待队列。
let isRefreshing = false const pendingQueue = [] async function refreshToken() { if (isRefreshing) { // 已有刷新流程在跑,当前请求排队等待 return new Promise((resolve, reject) => { pendingQueue.push({ resolve, reject }) }) } isRefreshing = true try { const newToken = await http.post('/auth/refresh', { refreshToken: uni.getStorageSync('refreshToken') }) uni.setStorageSync('token', newToken) // 释放队列中的请求 pendingQueue.forEach(({ resolve }) => resolve(newToken)) pendingQueue.length = 0 return newToken } catch (err) { pendingQueue.forEach(({ reject }) => reject(err)) pendingQueue.length = 0 throw err } finally { isRefreshing = false } }这个方案的核心是:第一个 401 触发 refreshToken,后续的 401 不再触发新的刷新请求,而是排队等待第一个刷新完成,然后用新 token 重新发起业务请求。这样才能避免"刷新 token 风暴"压垮后端。
注意,这里有个细节很容易踩坑:refreshToken 接口本身也可能返回 401(refreshToken 也过期了),此时队列里的所有请求都要 reject,并且要触发登录失效回调。我见过有人忘了处理这种情况,导致 refresh 接口无限重试,线上日志刷屏。
6.3 取消请求在搜索场景下的应用
uni.request返回的是一个RequestTask,支持abort()方法。取消请求最常见的场景是搜索框:用户输入关键词后马上按回车,结果输入到一半又改了,上一次的搜索请求还没返回,这时候应该取消它,避免旧结果覆盖新结果。
封装时我会在 Request 实例里维护一个Map,记录当前在途的可取消任务:
class Request { constructor() { // ... this.taskMap = new Map() } request(config) { return new Promise((resolve, reject) => { // ... const task = uni.request({ ... }) if (config.cancelKey) { // 同一个 cancelKey 对应上一次的请求,先取消旧的 const prevTask = this.taskMap.get(config.cancelKey) if (prevTask && prevTask.abort) { prevTask.abort() } this.taskMap.set(config.cancelKey, task) } // ... }) } }调用方在搜索时传cancelKey: 'search',下次搜索请求发出时自动取消上一次未完成的搜索请求。这个做法在搜索、筛选等高频场景下非常实用。
写在最后:这套方案用了快一年后的体会
如果你问我封装 request 最大的价值是什么,我的答案是:它把"请求这件小事"从业务代码里剥离出去了,让业务代码只关心数据和异常,不用关心请求细节。以前每次加一个接口,我要想半天 baseURL 怎么拼、header 怎么带、错误怎么弹;现在只需要在 api 模块里加一个函数,其他都是标准化流程。
最后再分享一个小习惯:我会在http.js里留一个console.log开关,开发环境打印每个请求的 url、method、耗时和状态码。这个日志在联调阶段帮了大忙,因为很多"前端拿不到数据"的问题,其实看一眼日志里的请求参数和返回状态就能定位,根本不需要上后端追日志。这个做法成本极低,收益却很大。你可以打开项目试试,应该也会喜欢上这种直观的排查方式。