资讯动态

Uniapp接口封装实战:从Promise化到Token刷新与请求队列

发布时间:2026/10/15 17:21:30 来源:尧图企业网站定制
1. 为什么你的Uniapp项目需要一层接口封装很多人写Uniapp项目习惯拿到接口就直接uni.request一把梭页面里到处是url:/api/user/login、method:POST一个项目写下来二三十个页面接口调用代码散落得到处都是。等后端要改接口前缀或者要统一加个token校验你就要全局搜索替换改到怀疑人生。这不是代码质量问题这是工程项目演进到一定规模后必然遇到的维护灾难。接口封装本质上就是给“前端业务代码”和“后端HTTP服务”之间加一层缓冲区。业务页面不直接和uni.request打交道而是面向一个自己定义的、语义清晰的函数调用。比如login(params)、getUserInfo()、submitOrder(data)页面只管调用底层怎么拼URL、怎么处理header、怎么解析返回结构、怎么处理错误码全部收敛到封装层。这层设计带来的收益是实打实的。先说最常见的一个场景后端返回的数据结构不统一。有的接口直接返回{code:0, data:{...}}有的返回{success:true, result:[...]}有的干脆把消息挂在msg和message两个字段上。前端如果到处直接用res.data.data早晚被这种混乱搞疯。封装层可以统一规整返回结构页面拿到手的永远是一个干净的Promise结果要么是处理好的业务数据要么是一个抛出的错误对象。再说团队协作。一个项目两三个人并行开发如果每个人都按自己的习惯调接口代码风格千奇百怪。有了统一封装后进来的人只需要看一遍api目录下的模块文档就知道所有接口怎么调用这比写十页开发文档都管用。多端适配也是Uniapp绕不开的痛点。同一套代码要跑微信小程序、App、H5各自的请求环境不一样。小程序里没有XMLHttpRequestApp里要注意证书校验H5要纠结跨域。这些差异如果散落在业务代码里每一处都是坑。集中在封装层处理一处适配处处生效。最后还有一层容易被忽视的价值接口封装是前后端联调效率的基石。Mock阶段你可以通过封装层瞬间把所有请求指向本地mock数据联调阶段切回真实环境只需要改一个配置变量。后端接口还没写好前端页面照样开发接口字段变了也只需要改对应模块的一个函数。这套能力不封装根本做不到。所以这次我打算把Uniapp接口封装这件事拆开揉碎从基础请求封装讲起到模块化管理、公共逻辑下沉、token刷新、并发去重、上传下载这些实际项目里一定会碰到的问题再到团队规范、目录组织、版本演进把一套能复用三五个项目的方案完整铺开。后面所有的代码都是我在实际项目里跑过、踩过坑、又优化过后的版本。2. 接口封装前的基础设计与思路拆解2.1 封装层到底该管哪些事先理清一个边界问题接口封装层不是越厚越好。有些封装喜欢把所有逻辑都塞进来包括复杂的业务判断、页面路由跳转、数据缓存策略结果封装层变成了一坨谁都不敢动的代码。我的原则是封装层只负责与HTTP请求生命周期相关的事情业务逻辑绝不进入这一层。具体来说一个健康Uniapp的接口封装层应该管下面这几件事请求的发起与取消uni.request实例的创建与销毁基础配置的注入baseURL、超时时间、公共header请求拦截登录态token注入、公共参数拼接、请求指纹标记响应拦截状态码校验、业务码分发、统一错误处理请求队列维护防重复请求、loading计数器、token并发刷新网络异常的处理断网提示、超时提示、请求重试策略外部环境切换开发、测试、生产环境域名映射而不应该管的事包括页面跳转、弹窗里的文案定制、某个业务模块特有的字段转换、页面级loading样式等。这些一旦卷进来封装层就失去了通用性变成业务代码的依附品换个项目就要大改。封装层的设计目标是“稳定可复用”业务代码的诉求是“快速可变化”这两者必须清晰分层。拿我之前某项目的真实经历举例。一开始同事图省事把接口返回的错误码直接和页面toast文案耦合在封装层里接口报错时根据后端code弹出不同的提示。刚开始看着挺方便后端改了几个错误码之后连code: 501代表什么、该弹什么文案这种事都要去找封装层业务页面反而不知道错误到底什么意思了。后来我把错误码映射全部拆分出去封装层只做一件事把错误对象往上抛页面自己决定怎么提示、要不要跳转。封装层瞬间轻了维护成本也降下来了。2.2 项目目录结构怎么规划封装之前先想好代码放哪。一个清晰的目录结构比封装函数本身更重要。我习惯在Uniapp项目的src或根目录取决于你用的是cli还是HBuilderX下建一个api目录里面再按业务模块划分子目录。标准的形态是这样src ├── api │ ├── modules │ │ ├── user.js │ │ ├── order.js │ │ ├── goods.js │ │ └── ... │ ├── core │ │ ├── request.js // 核心请求封装 │ │ ├── config.js // 环境与基础配置 │ │ ├── interceptors.js // 请求/响应拦截逻辑 │ │ ├── auth.js // token 相关处理 │ │ └── error.js // 错误码与统一错误处理 │ └── index.js // 统一导出 ├── utils │ ├── storage.js // 本地存储封装token 等 │ └── ... ├── pages └── ...这里有几个关键设计考量。core/request.js这层是与业务零耦合的底层请求器。它只知道发一个请求、带哪些参数、返回一个Promise。它不知道什么是登录、什么是订单、什么是商品。这样设计的好处是以后就算从uni.request迁移到axios比如Uniapp的Vue3Vite工程里也有人这么干也只换这一个文件业务代码一行不用动。core/config.js负责环境差异的收敛。开发环境连http://192.168.x.x:8080测试环境连https://test-api.xxx.com生产环境连https://api.xxx.com这些写死在各处就是灾难。config里做一个按条件编译的映射干净利落。api/modules/*.js这层是接口注册与出口。每个模块文件负责描述该业务域下的接口定义比如登录、登出、获取用户信息等。一个接口对应一个导出函数函数内部去调用request方法并把参数、路径、方法整理好。为了防止模块文件越写越肿我还习惯在api/index.js里做统一导出业务页面引用时只从index.js导入。页面里出现的形态永远是import { login, getUserInfo } from /api而不是直接去require某个深层的文件。这种收敛式的导出方式让全局搜索接口变得非常容易。2.3 Promise 化与回调地狱的告别Uniapp的uni.request本身是基于回调API的。写法大概是这样uni.request({ url: xxx, success: (res) {}, fail: (err) {} })这种写法在只有一两个请求时还能忍受一旦牵涉到“先登录、再拿token、再请求用户信息、再请求订单列表”这种串行依赖回调地狱就扑面而来。更麻烦的是success里的res结构在不同端上还不太一样你根本没法在业务层做统一处理。Promise化是接口封装最基础的一步也是后续所有高级能力拦截、去重、刷新的地基。核心思路很简单uni.request本身不变但它接收的参数用Promise包装成功时resolve失败时reject。const request (options) { return new Promise((resolve, reject) { uni.request({ url: options.url, method: options.method, data: options.data, header: options.header, timeout: options.timeout, success: (res) { resolve(res) }, fail: (err) { reject(err) } }) }) }不过这只是最粗糙的版本。真正落到项目里你还需要在resolve之前做响应拦截处理在reject之前做错误规范化——这两步才是封装的价值所在。后面第3节我会把完整且健壮的实现逐步展示出来。你可能还会问既然都用Promise了为什么不直接把async/await落在业务层当然要。封装层返回的本来就是Promise业务页面里直接const data await getUserInfo()拿到的就是业务数据本身清爽、直观、可读性极高。我把这列为封装的基本目标让业务代码看不到任何网络请求的影子。3. 核心实现从零写一个可上生产的请求封装3.1 基础配置与环境管理一切封装的起点是配置。我见过很多项目把baseURL散落在每个接口函数里或者写死在request函数内部环境切换时手动改代码这都不够优雅。合理的做法是利用Uniapp的条件编译能力让不同平台跑不同配置。// api/core/config.js const envMap { // #ifdef H5 development: https://dev-api.example.com, testing: https://test-api.example.com, production: https://api.example.com, // #endif // #ifndef H5 development: http://192.168.1.100:8080, testing: https://test-api.example.com, production: https://api.example.com // #endif } const currentEnv development // 手动切换或者接入环境变量机制 export const BASE_URL envMap[currentEnv] export const TIMEOUT 10000 // 默认超时 10s export const MAX_RETRY 2 // 请求失败自动重试次数注意这里H5和非H5环境分开写是因为开发阶段H5经常要处理跨域你需要在devServer里配代理而小程序和App对域名要求严格开发环境下配局域网IP更方便。配置集中管理还有个额外的好处上线时只要改currentEnv一个字段就完成了整套环境的切换。如果团队有出海或分区域部署的需求还可以在这个文件里扩展多套环境配置。我甚至见过有项目把配置做成动态获取的——启动时从本地配置文件读环境名再拉取远端配置中心那种属于更大体量的工程了这里不展开。3.2 请求核心Request函数的完整封装接下来是重头戏request.js。这一版是我在多个项目里反复打磨过的几乎每一条代码都有存在的理由。先看完整实现再逐段拆解。// api/core/request.js import { BASE_URL, TIMEOUT, MAX_RETRY } from ./config import { getToken, clearAuth } from ./auth import { pendingQueue, addPending, removePending } from ./queue import { handleError } from ./error import { store } from /store // 如果你的项目需要全局状态管理 let loadingCount 0 // 发起中的请求计数用来做全局 loading const showLoading () { loadingCount if (loadingCount 1) { uni.showLoading({ title: 加载中..., mask: true }) } } const hideLoading () { loadingCount-- if (loadingCount 0) { loadingCount 0 uni.hideLoading() } } const request (options {}) { // 1. 组装最终请求配置 const { url, method GET, data {}, header {}, loading false, // 是否展示全局 loading retry MAX_RETRY, // 剩余重试次数 skipAuth false, // 是否跳过携带 token } options // 2. 拼接完整地址支持传入完整链接也支持相对路径 const fullUrl /^https?:\/\//.test(url) ? url : BASE_URL url // 3. 请求头合并顺序默认header 自定义header const requestHeader { Content-Type: application/json, ...header } // 4. 如果不需要跳过鉴权且已有 token则自动携带 const token getToken() if (!skipAuth token) { requestHeader[Authorization] Bearer ${token} } // 5. loading 控制防止并发请求时 loading 闪烁 if (loading) { showLoading() } // 6. 请求指纹用于取消重复请求 const requestKey ${method}_${fullUrl}_${JSON.stringify(data)} addPending(requestKey) return new Promise((resolve, reject) { uni.request({ url: fullUrl, method, data, header: requestHeader, timeout: TIMEOUT, success: (response) { removePending(requestKey) if (loading) { hideLoading() } handleResponse(response, resolve, reject, retry, options) }, fail: (err) { removePending(requestKey) if (loading) { hideLoading() } handleNetworkError(err, reject) }, complete: () { removePending(requestKey) } }) }) } const handleResponse (response, resolve, reject, retry, options) { // HTTP 状态码错误 if (response.statusCode 200 || response.statusCode 300) { const error { source: response, code: response.statusCode, message: 请求失败(${response.statusCode}) } reject(error) return } // 业务层状态码校验 const { code, data, message } response.data || {} if (code 0) { resolve(data) return } // 全局拦截业务错误 handleBusinessError(code, message, reject) } export default request这段代码里藏着几个非常重要的设计决策。第一loading计数器的设计。很多人写全局loading喜欢在请求前uni.showLoading、在请求后uni.hideLoading。如果两个请求同时发出第一个先回来把loading关了第二个还在飞用户看到的就是loading一闪而过体验极其割裂。计数器方案可以保证只有所有请求都归零时才真正关闭loading。我在实际项目里遇到过这个问题的真实版本列表页和下拉刷新同时触发每次下拉刷新都会把页面loading搞消失加了计数器后这个问题彻底绝迹。第二请求指纹与重复请求取消。这里的addPending和removePending来自队列模块下一节讲。它做的事情是如果同一个接口、同样的参数在短时间内被重复发起比如用户连点两次提交按钮就取消后一个请求。这一个设计在表单提交场景里救过我好几次否则用户在弱网环境里连点两次按钮订单就重复创建了。第三skipAuth标记。登录接口不能携带当前用户的token因为此时token可能压根不存在刷新token的接口如果还带着旧token去请求会陷入死循环。所以在个别接口上留一个显式的“跳过鉴权”出口非常必要。第四handleBusinessError的收敛。后端业务错误比如code: 1001表示登录过期在这里统一分发页面层不做判断。但注意我这里没有把跳转登录页的逻辑写进request函数里而是在error.js统一处理第4节细说。3.3 请求队列防止重复提交重复请求是实际项目中的高频坑而且这个坑兼破坏订单数据、搞乱loading、浪费带宽三项“荣誉”。我用一个独立的queue.js模块来管这件事核心数据结构是Map。// api/core/queue.js const pendingMap new Map() export const addPending (key) { if (pendingMap.has(key)) { // 如果存在相同请求则取消前一个 const prevRequestTask pendingMap.get(key) if (prevRequestTask typeof prevRequestTask.abort function) { prevRequestTask.abort() } pendingMap.delete(key) } } export const removePending (key) { if (pendingMap.has(key)) { pendingMap.delete(key) } } export const clearPending () { pendingMap.forEach((task) { if (task typeof task.abort function) { task.abort() } }) pendingMap.clear() }执行流程是这样的请求发出前以method_url_data为key查一下Map发现已经有相同key的请求在pending状态时就把那个请求的uni.request任务实例找到并调用abort()取消然后当前请求继续发起同时把当前任务写入Map。请求完成时在complete生命周期里调用removePending把key清掉。这里有个技术细节uni.request的返回值在App、H5、小程序里都是一个RequestTask对象它身上有abort方法。但在Promise化封装里这个RequestTask对象没有直接暴露给外层所以我在内部用一个变量暂存它同时塞进Map里let task uni.request({ ... success() {}, fail() {}, complete() {} }) task requestTask注意时机uni.request是立即执行的但要把返回的RequestTask存下来再用于abort需要稍微处理一下。完整代码里这一步是const requestTask uni.request({...}) taskRef requestTask pendingMap.set(requestKey, requestTask)这个写法别小看等真遇到“秒杀按钮连点三次生成了三笔订单”的问题时你会感谢这个Map的。3.4 拦截器与错误码收敛Uniapp不像axios那样自带拦截器机制需要自己通过封装实现。我通常把“请求前处理”和“响应后处理”拆成两个独立的模块——interceptors.js和error.js这样职责更清晰。// api/core/interceptors.js import { getToken } from ./auth import { toast } from /utils/toast export const requestInterceptor (options) { // 在这里可以统一追加一些业务参数比如: // options.data { ...options.data, platform: mini, version: 1.0.0 } return options } export const responseInterceptor (response) { // 统一处理 HTTP 成功但业务失败的情况或者做数据格式整理 return response }看起来不起眼但拦截器里往往会沉淀一些后期才发现的公共逻辑。有一次我的项目接了一个第三方统计SDK需要在每个请求头上加一个设备指纹。没有拦截器的话要在每个api模块文件里改header几百个接口的修改量想想就头皮发麻。有了拦截器一行全局搞定。业务错误码的处理是另一个要点。前后端约定业务码时不可能永远规范。我见过code同时存在200、0、true三种情况的。封装层的价值就是屏蔽这些差异。我在error.js里维护一张映射表把后端的业务码统一转换成前端语义化的错误对象。// api/core/error.js import { clearAuth } from ./auth import { goLoginPage } from /router const ignoreCodes [10001, 10002] // 这些错误码不需要全局提示 export const handleBusinessError (code, message, reject) { // 登录态失效统一清理本地登录态并跳转登录页 if (code 401 || code 1001) { clearAuth() uni.showToast({ title: 登录已过期请重新登录, icon: none }) setTimeout(() goLoginPage(), 1500) } else if (!ignoreCodes.includes(code)) { uni.showToast({ title: message || 请求失败, icon: none }) } reject({ code, message, isBusinessError: true }) } export const handleNetworkError (err, reject) { const messageMap { timeout: 请求超时请检查网络后重试, fail: 网络异常请稍后再试, aborted: 请求已被取消 } let message messageMap[err.errMsg] || 网络异常请稍后再试 uni.showToast({ title: message, icon: none }) reject({ isNetworkError: true, message }) }这里有几个容易被忽略的重点。重点一登录过期的处理必须收敛。如果登录过期逻辑散落在各个页面每个页面各自跳转、各自toast那体验一定非常凌乱。统一在错误处理模块里做顺带把本地存储里的token等信息清理干净避免下次请求继续用脏token撞门。重点二有些错误码不需要全局弹窗。比如你在登录页输入了错误密码后端返回code: 2001这时候登录页自己有错误提示全局再弹一个就重复了。所以ignoreCodes是必要的它允许特定模块自行处理错误反馈。重点三网络错误的分类与映射。uni.request在不同端的fail回调参数不太一样errMsg字段有的是request:fail timeout有的是timeout。写分支判断时一定要做兼容不能想当然地只匹配精确字符串。我在某型号Android机子上就碰到过errMsg前缀带request:的情况没兼容这类细节导致用户看到的报错提示永远是笼统的“网络异常”。到这里一个基础的请求封装已经能跑起来了。接下来是模块化接口管理这是让整套架构真正变得好用的关键一步。4. 模块化接口管理从“散装”到“整洁”4.1 模块拆分与统一导出设计接口封装如果只有一个request.js那只是把uni.request换了个名字价值有限。真正的可维护性来自接口的模块化组织。我的做法是以业务域为边界切分模块。用户相关放进user.js订单相关放进order.js商品相关放进goods.js。每个文件内部只干一件事定义这个业务域下所有接口的调用函数。// api/modules/user.js import request from ../core/request export const login (data) request({ url: /api/user/login, method: POST, data, loading: true, skipAuth: true }) export const logout () request({ url: /api/user/logout, method: POST, loading: false }) export const getUserInfo () request({ url: /api/user/info, method: GET, loading: false }) export const updateAvatar (filePath) request({ url: /api/user/avatar, method: POST, loading: true, // 文件上传走单独逻辑见 4.2 节 upload: true, filePath })每个接口函数的命名要遵循两个原则动词开头get、post、update、remove语义直达getUserInfo比fetchUserData更直观。这样别人看代码时不需要点进函数体就能猜出它的大致作用。统一导出的入口文件// api/index.js import * as userApi from ./modules/user import * as orderApi from ./modules/order import * as goodsApi from ./modules/goods export const user userApi export const order orderApi export const goods goodsApi // 也可以直接展开导出看团队习惯我个人推荐命名空间式的导出方式业务层使用时import { user, order } from /api const info await user.getUserInfo() const list await order.getOrderList({ page: 1 })这样代码的归属感特别强。你看到user.xxx就知道这是用户模块的接口看到order.xxx就知道是订单模块。维护时顺着模块名找到对应文件再找到对应函数改起来非常顺手。而且import * as userApi这样的语法能保证模块文件里新增接口时导出文件不用跟着改减少了维护环节。4.2 文件上传与下载的特殊处理文件上传不能走普通的request逻辑因为uni.request本身不支持FormData的上传方式。虽然小程序有uni.uploadFileApp和H5也有各自的原生上传能力但它们的参数结构、回调事件并不完全一样。既然要做统一封装上传这块也得收敛到request逻辑里。我处理方案是在request函数里加一个upload分支检测到options.upload true时走uni.uploadFile。// api/core/request.js 中新增的分支 if (options.upload) { return uploadFile(options) } const uploadFile ({ url, filePath, name file, formData {}, header {} }) { const fullUrl /^https?:\/\//.test(url) ? url : BASE_URL url const token getToken() return new Promise((resolve, reject) { uni.uploadFile({ url: fullUrl, filePath, name, formData, header: { Authorization: Bearer ${token}, ...header }, success: (res) { const parseData typeof res.data string ? JSON.parse(res.data) : res.data const { code, data, message } parseData || {} if (code 0) { resolve(data) } else { handleBusinessError(code, message, reject) } }, fail: (err) { handleNetworkError(err, reject) } }) }) }这里有两个易踩的坑。第一个坑res.data在不同端的类型不一样。H5端和后端返回正常的JSON但小程序端在某些情况会把res.data序列化成字符串如果你直接res.data.code会拿到undefined。必须做一次安全转换typeof res.data string ? JSON.parse(res.data) : res.data。第二个坑name参数。后端接收文件字段名一般固定比如file。如果后端要求的是avatar你要在接口模块里显式传递name: avatar。不要假设所有上传接口字段名都一样后端团队的口味从来就不统一。下载的逻辑类似用的uni.downloadFile但要注意区分“文档类下载”和“图片/视频预览”。前者需要用uni.saveFile保存到本地后者直接uni.previewImage或uni.playVideo就行。4.3 环境切换与Mock数据联动前面配置里写了currentEnv development但实际工程里手动改代码终究不优雅。我推荐配合Uniapp的自定义环境变量或者package.json的scripts来做环境注入。如果你是cli创建的工程可以利用Vite的环境变量机制在api/core/config.js里这样读取// 仅适用于 cli 创建的工程 const envMap { development: /api, // Vite devServer 会自动代理到后端联调地址 production: https://api.example.com } export const BASE_URL envMap[import.meta.env.MODE || development]开发时你只需要在vite.config.js里配一个代理就能绕过H5端的跨域限制// vite.config.js server: { proxy: { /api: { target: http://192.168.1.100:8080, changeOrigin: true, // 这里不要把 /api 去掉后端接口恰好也带 /api 前缀时可以保持一致 } } }Mock数据联动就更直接了。开发环境时你可以在request.js最前面加一个短路条件// api/core/mock.js import { mockRoutes } from ./mockRoutes const enableMock true // 从 config 读取 export const mockAdapter (options) { const { url, method, data } options const mockItem mockRoutes.find(item item.url url item.method method) if (!mockItem) return null return new Promise((resolve) { setTimeout(() { resolve(typeof mockItem.result function ? mockItem.result(data) : mockItem.result) }, 300) // 模拟网络延迟 }) }然后在request函数最前面如果enableMock且命中mock路由直接返回mock结果不走真实请求。这么一来前端开发完全不受后端进度制约联调之前页面已经长好了体验非常丝滑。5. 进阶实战token刷新、并发重放与取消请求5.1 token自动刷新与请求重放这是很多项目从“能跑”走向“能上线”必须迈过的一道坎。token过期后自动刷新再用新token重新发送原请求这个能力如果没有用户会被频繁踢到登录页体验极差。设计思路是在响应拦截中发现业务码指示登录态失效此时不等于直接跳转登录页而是先尝试刷新token。如果刷新成功把当前请求重新放回队列如果刷新失败再走清空登录态、去登录页的兜底逻辑。// api/core/refresh.js let isRefreshing false let pendingRequests [] export const refreshToken () { return new Promise((resolve, reject) { if (!isRefreshing) { isRefreshing true // 调用后端刷新接口 const refreshToken getRefreshToken() if (!refreshToken) { reject(new Error(No RefreshToken)) return } request({ url: /api/auth/refresh, method: POST, data: { refreshToken }, skipAuth: true }).then((res) { isRefreshing false setAuth(res.token, res.refreshToken) // 把等待队列里的请求全部用新token重放 pendingRequests.forEach(cb cb(res.token)) pendingRequests [] resolve(res.token) }).catch((err) { isRefreshing false pendingRequests.forEach(cb cb()) pendingRequests [] clearAuth() reject(err) }) } else { // 刷新进行中把当前回调压入队列 pendingRequests.push((newToken) { if (newToken) { resolve(newToken) } else { reject(new Error(Refresh failed)) } }) } }) }这个模块最考验人的是并发场景。比如页面A和B同时发起请求两个都拿到401。如果不做并发控制就会同时触发两次刷新请求后端刷新接口一般还会让旧refreshToken失效两个刷新请求一前一后第二个必然失败用户被迫重新登录。队列方案的价值就在于第一个401触发了刷新后面的401不触发刷新而是把自己的回调挂到pendingRequests上等刷新结果回来后再挨个执行。这个模式其实就是请求重放的经典解法理解了它你就掌握了处理token刷新的所有关键点。然后在handleBusinessError里登录态失效的处理逻辑从“直接跳登录页”改成“先试刷新再决定跳不跳”// api/core/error.js 中增加处理 const handleInvalidToken async (reject) { try { const newToken await refreshToken() // 刷新成功重新发起原请求 // 这一步改造需要对 request 函数增加「等待刷新完成后再发请求」的能力 return } catch (e) { clearAuth() uni.showToast({ title: 登录已过期请重新登录, icon: none }) setTimeout(() goLoginPage(), 1500) reject(e) } }不过“刷新成功后再重放原请求”在Uniapp里没有axios那种现成的拦截器机制需要绕一下。我的做法是在request函数外层包一层requestWithAuthRetry它内部维护一个“是否是重放请求”的标志。一旦响应处理时看到401并且当前请求不是重放请求就独立发起刷新刷新完成后重新调用request。如果刷新失败再走兜底。这个模式实现起来不复杂但相当绕建议多看几遍上面refresh.js的代码再动手。5.2 取消未完成的请求除了防重复提交的“相同请求取消”还可能有“路由切换后所有未完成请求统统取消”的需求。比如一个列表页请求还没回来用户就切走了此时响应回来去setData一个不存在的页面就会在控制台报“Page not found”之类的警告。解决思路不复杂把队列的clearPending暴露出来在页面卸载时调用。// 页面 onUnload 里调用 onUnload(() { clearPending() })但如果每个页面都写这段代码又回到了“天下代码一般抄”的局面。有两个优化方向。第一个方向在mixins里统一挂载。做一个requestPageMixin.js公共的onUnload里调clearPending页面引入这个mixin即可。这种方式改动小侵入性低。// mixins/request.js export default { onUnload() { clearPending() } }第二个方向在request.js内部通过页面路由变化判断。Uniapp没有全局的路由守卫API但你可以通过uni.$emit/uni.$on做一个简单的发布订阅路由跳转时向请求层广播“页面切换了”请求层收到广播后清空pending队列。这样业务层完全无感适合项目复杂度更高的场景。我个人推荐第一种简单直接团队认知成本低。第二种虽然更“自动化”但需要你维护一套事件协议规模不够大的项目容易变成过度设计。5.3 请求重试与幂等控制请求失败自动重试这是一个很容易写错的功能。触发重试的场景要非常谨慎GET请求可以放心重试因为它是幂等的POST请求重试可能导致重复插入数据业务上可能是灾难。所以我的重试逻辑必须加两个前提请求方法必须能被安全重试默认只对GET生效必须显式传retry: true才启用// api/core/request.js 的完整路由中 const retryRequest (options, retryCount) { if (options.method ! GET || retryCount 0) return // 延迟 500ms 再重试避免网络抖动 setTimeout(() { request({ ...options, retry: retryCount - 1 }) }, 500) }网络错误断网、超时才触发重试业务错误绝对不触发。这一点务必写到代码注释里防止后来维护的人不懂乱加重试。我在某个管理后台项目中就踩过一个大坑详情页的GET请求在后端接口超时时重试3次看起来没问题后来加了删除接口也复用了这段重试逻辑后端偶发超时的情况下删除操作被执行了两遍直接把一条生产数据干没了。从那以后我重试的默认值永远是“只有GET且显式开启”打死不改。6. 常见问题与排查技巧实录6.1 请求成功但页面数据渲染不出来这大概是联调时遇到最多的“鬼故事”了。排查方向其实很固定。先确认网络层拿到什么。给request.js的success回调加一条临时的console.log(response)看状态码和返回结构。如果statusCode是200但页面没数据基本是数据层级拿错了。比如后端返回{code:0, data:{list:[...]}}而你在页面里直接用了res.data.list那必然取不到。再看是不是Promise链断了。异步函数里用了await但外层没加try/catch一旦请求reject后续代码全停摆页面白屏也没报错。所有await调用必须放在try/catch里这是Uniapp异步开发的基本素养。还有一类常见情况是微信小程序的缓存。开发工具上改动接口返回真机预览还是旧数据先在开发者工具中勾选“不校验合法域名”再清缓存重新编译。小程序端的缓存机制在某个版本之后变得相当“智能”经常把你的旧数据死死抱住不撒手。6.2 请求队列偶发失效重复请求还是发出去了明明加了addPending但重复请求还是会打出去。排查方向先看指纹key是否命中。我之前的key方案是${method}_${url}_${JSON.stringify(data)}但一个订单提交接口data里带了时间戳字段两次点击生成的data完全不一样key也就不一样队列自然拦不住。解决方案是支持自定义key在接口函数里显式指定export const createOrder (data) request({ url: /api/order/create, method: POST, data, loading: true, requestKey: create_order // 自定义指纹忽略内部可变参数 })另一个隐蔽的坑是complete回调里无条件removePending导致请求还没发起key就被清掉了。我这边的代码里complete里兜底清理没问题但要确保addPending和任务入库的时序正确不要在complete还没触发前就把Map里的key误删。6.3 H5跨域问题排查H5开发时最大的痛就是跨域。你在本地起的devServer是http://localhost:5173后端联调地址是http://192.168.1.100:8080浏览器默认会拦截跨域请求。如果你用的cli工程配置vite.config.js的proxy是最干净的方案。关键是代理转发的路径写对后端如果接受/api开头就不用重写路径如果后端不接受你要用rewrite把/api去掉。这两者的差异可以在一分钟内通过Network面板直观看到请求已经发出去了但路径是404那就不是跨域问题而是路径问题如果请求直接failed或者被浏览器拦截才是跨域。如果你用的HBuilderX创建的工程没有vite.config.js可以在manifest.json的h5配置里设置devServer的proxy。还有一条路是后端开启CORS但前后端谁改就涉及到协同能不动后端尽量不动。6.4 微信小程序生产环境请求失败开发工具里跑得好好的一上传正式版就白屏。九成问题出在合法域名。小程序管理后台的“开发设置-服务器域名”里必须把request合法域名配好而且必须是HTTPS。申请证书、配置Nginx转发这些是上线前必做的功课。另外注意unpkg或CDN资源的域名也要加到downloadFile合法域名里去否则图片/文件也会加载失败。小程序端遇到url not in domain list的报错别犹豫十有八九就是域名白名单漏了。6.5 常见问题速查表问题现象优先排查项解决方案请求失败状态码404路径拼接是否正确检查BASE_URL和相对路径是否重复带/api请求失败状态码500后端接口是否报错看后端日志别在前端死磕请求超时后端处理速度或网络环境调大TIMEOUT先确认后端是否慢token丢失本地存储是否被清检查auth.js的读写封装确认没有误存到内存重复请求请求指纹是否命中改用自定义requestKey上传接口H5失败跨域配置devServer代理或后端加CORS小程序请求无返回域名白名单到小程序后台配置request合法域名页面数据没渲染Promise链是否断裂检查await是否在try/catch里7. 团队协作与封装扩展建议7.1 接口文档与代码同步封装层沉淀下来之后接口定义就集中在api/modules里了。这时候可以顺手做一个自动生成接口文档的小脚本把每个接口函数处的JSDoc注释提取成Markdown文档直接同步给后端同事看。我自己的做法是给每个接口函数写JSDoc/** * 获取用户信息 * param {string} userId - 用户ID * returns {PromiseUserInfo} */ export const getUserInfo (userId) request({ url: /api/user/${userId}/info, method: GET })然后用一个简单的Node脚本扫描modules目录全局匹配param和returns标签生成文档。这样前后端约定的接口变更在代码提交时就能同步到文档不用每个人都去手动维护一份接口文档。这个脚本不复杂几十行Node代码就够了但带来的协作收益相当可观。7.2 防止封装层腐化的几条军规封装层写出来不难难的是三个月后它还保持整洁。根据我的经验想保持封装层的长期健康有几条纪律必须守住。第一绝不把业务字段透传到request层。比如页面id、面包屑路径这种业务字段不能塞进request函数的参数里。请求层关心的只有url、method、data、header、超时这些HTTP要素。如果发现某个业务参数全项目都在传那应该是公共请求头放进拦截器统一处理。第二每新增一个接口先看有没有类似的函数可以复用。十个高度相似的接口函数同时存在说明封装层少了“泛化”能力。宁可造一个getResourceList(type)也不复制粘贴十个getXxxList。第三慎用全局引用。封装层内部尽量不要直接import业务store、业务工具函数。api/core只依赖utils和store里的通用模块。如果哪一天某个页面组件被团队另一个项目复用接口层可以被整体抽走不用连带携带一堆业务依赖。7.3 从接口封装到工程化体系接口封装做到最后其实已经不只是一个文件、一层函数的问题它是整个前端工程基础能力的一部分。你会自然地发现请求封装好了之后权限控制、埋点上报、统一缓存、请求调度这些事情都有了挂靠点。比如权限控制可以在请求拦截里加路由鉴权埋点上报可以把请求时间、接口名、返回码批量发送统一缓存可以把GET请求的响应做本地存储提高二次进入页面的加载速度。我自己的项目里就是由接口封装这一层逐步长出了完整的“数据访问层”。现在新开一个功能页时团队的开发路径非常固定先定义好数据模型然后在api/modules里增加接口函数页面里直接调用——完全不必关心底层网络细节面对后端接口变更时也不至于手足无措。最后分享一点个人体会封装代码的复杂度应该“按需增长”。一开始只写一个request.js就够了不要一上来就堆token刷新、请求重放、mock适配这些重型机制。等真的碰到登录过期、重复提交、联调阻塞的问题再去逐个加。封装层的演进应该跟随项目的真实痛点而不是靠预判和想象。不然你写了一大堆能力项目里根本用不到代码反而成了团队的负担。先把基础打牢把模块拆干净让请求层成为一道清晰的门面项目跑过两三个迭代之后你会感谢当初在这个环节上花的时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑