小程序接口封装实战:分层设计、拦截器与错误处理
1. 小程序请求接口的封装思路与整体设计做过小程序的人都知道原生wx.request用起来不算难但一旦项目里接口数量超过十个代码里到处都是重复的url、header、loading、错误处理维护起来就是一场灾难。我接手过一个已经迭代了半年的小程序项目光是wx.request的调用就散落在三十多个页面里改一个公共参数要全局搜索替换漏一处就出线上问题。所以接口封装这件事不是“要不要做”的问题而是“什么时候做”的问题。接口封装要解决的核心问题其实就四个统一基地址和公共参数、统一请求/响应拦截、统一错误处理、统一 loading 和 token 管理。把这四件事做好了业务代码里调用接口就变成一行的事页面逻辑会干净很多。整体设计上我采用的是“分层封装”的思路从下往上大致分三层。最底层是请求核心层负责真正发起wx.request处理超时、重试、基础配置中间是拦截器层负责请求前加 token、加签名响应后判断业务状态码、统一弹错误提示最上层是业务接口层按模块把接口聚合成一个个函数比如userApi.login()、orderApi.getList()页面只跟这一层打交道。为什么这么分层因为小程序和传统 Web 前端不太一样它没有 axios 那种成熟的库可以直接用很多能力要自己搭。分层之后每一层的职责单一改基地址只动核心层改 token 逻辑只动拦截器加接口只动业务层互不干扰。这个结构在项目从小做到大的过程中优势会越来越明显。还有一个关键决策是用 Promise 还是回调。原生wx.request是回调式的写多了就是回调地狱。我强烈建议用 Promise 包一层配合async/await业务代码可读性会提升一个档次。虽然小程序基础库早就支持 Promise 了但自己封装一层能更好地控制错误流向这点后面会详细讲。2. 核心细节解析与实操要点2.1 基地址与公共参数的管理基地址千万不要硬编码在各个页面里。我的做法是在核心层维护一个配置对象把baseUrl、超时时间、默认 header 都放进去。开发、测试、生产三套环境用不同的基地址通过一个环境变量或者构建时的常量来切换。const config { baseUrl: https://api.example.com, timeout: 10000, defaultHeader: { content-type: application/json } }这里有个细节很多人会忽略content-type的选择。小程序默认是application/json但如果你要传表单或者上传文件就得改成application/x-www-form-urlencoded或multipart/form-data。我一般会在请求方法里根据method和是否有文件来自动判断而不是让业务层每次手动指定。公共参数这块常见的比如version、platform、timestamp这些可以在拦截器里统一拼到 header 或 query 上。但要注意不是所有接口都需要公共参数比如某些第三方回调接口硬塞参数反而会出问题。所以我会在请求配置里留一个skipCommonParams的开关默认 false特殊接口手动打开。2.2 请求拦截与 token 注入token 管理是接口封装里最容易出 bug 的地方。我见过不少项目把 token 存在globalData里页面里到处读结果登录态过期了还在发请求用户看到一堆报错。我的做法是token 统一存在storage里拦截器每次请求前从 storage 读一次。为什么不用内存变量因为小程序冷启动后内存变量会丢storage 更可靠。读的时候做个缓存避免频繁同步读取影响性能。function getToken() { if (tokenCache) return tokenCache tokenCache wx.getStorageSync(token) || return tokenCache }登录态过期怎么处理我的方案是在响应拦截器里统一判断。如果后端返回的状态码表示未登录比如 401 或业务码 10001就清除本地 token跳转到登录页并且把当前页面路径记下来登录成功后自动跳回去。这个体验细节很多项目都没做用户登录完发现回到了首页还得重新找刚才的页面很烦。注意跳转登录页时要做防抖因为可能同时有多个请求返回未登录状态不加控制会连续跳转多次小程序会报错。2.3 响应拦截与错误分类响应拦截器是整个封装里逻辑最重的部分。我一般把错误分成三类网络层错误请求没发出去或超时、HTTP 层错误状态码非 200、业务层错误HTTP 200 但业务码表示失败。网络层错误通常是用户网络问题提示“网络异常请检查网络”就够了。HTTP 层错误比如 500、404属于服务端问题提示“服务繁忙”并上报日志。业务层错误最复杂因为每个后端的业务码规范不一样有的用code: 0表示成功有的用code: 200还有的用success: true。我的建议是在封装层做一次归一化把后端的各种返回格式统一成{ code, data, message }的结构。这样业务层拿到的永远是同一种格式不用关心后端怎么定义的。归一化的逻辑写在响应拦截器里根据后端实际返回做适配。function normalizeResponse(res) { const { statusCode, data } res if (statusCode ! 200) { return { code: statusCode, data: null, message: 网络请求失败 } } // 根据后端约定适配 if (typeof data.code ! undefined) { return { code: data.code, data: data.data, message: data.message } } return { code: 0, data: data, message: ok } }2.4 loading 的显示与隐藏策略loading 这块看似简单其实坑不少。最常见的错误是每个请求都showLoading请求结束hideLoading结果并发请求时第一个请求结束就把 loading 关了后面的请求还在飞用户以为卡死了。我的解决方案是维护一个请求计数器。每发起一个请求计数加一每结束一个请求计数减一只有计数归零时才真正hideLoading。这样并发请求时 loading 会一直显示到最后一个请求结束。let loadingCount 0 function showLoading() { if (loadingCount 0) { wx.showLoading({ title: 加载中, mask: true }) } loadingCount } function hideLoading() { loadingCount-- if (loadingCount 0) { loadingCount 0 wx.hideLoading() } }但不是所有请求都需要 loading。比如轮询接口、后台静默刷新显示 loading 反而干扰用户。所以我会在请求配置里加一个showLoading选项默认 true特殊接口传 false。3. 完整实操过程与核心代码实现3.1 搭建请求核心层先建一个request.js文件把核心逻辑放进去。这个文件对外暴露一个request函数接收配置对象返回 Promise。const config { baseUrl: https://api.example.com, timeout: 10000 } let loadingCount 0 let tokenCache function getToken() { if (tokenCache) return tokenCache tokenCache wx.getStorageSync(token) || return tokenCache } function showLoading() { if (loadingCount 0) { wx.showLoading({ title: 加载中, mask: true }) } loadingCount } function hideLoading() { loadingCount-- if (loadingCount 0) { loadingCount 0 wx.hideLoading() } } function request(options) { const { url, method GET, data {}, header {}, showLoading: needLoading true, skipToken false } options if (needLoading) showLoading() return new Promise((resolve, reject) { const finalHeader { content-type: application/json, ...header } if (!skipToken) { const token getToken() if (token) { finalHeader[Authorization] Bearer ${token} } } wx.request({ url: config.baseUrl url, method, data, header: finalHeader, timeout: config.timeout, success(res) { const result normalizeResponse(res) if (result.code 0) { resolve(result.data) } else if (result.code 401 || result.code 10001) { handleUnauthorized() reject(result) } else { if (needLoading) { wx.showToast({ title: result.message, icon: none }) } reject(result) } }, fail(err) { wx.showToast({ title: 网络异常, icon: none }) reject(err) }, complete() { if (needLoading) hideLoading() } }) }) }这段代码里有几个关键点值得展开说。第一complete里统一 hideLoading保证无论成功失败都会关闭 loading不会出现 loading 卡死的情况。第二token 注入放在请求前且用skipToken开关控制登录接口本身不需要 token。第三错误提示用showToast而不是showModal因为 toast 不打断用户操作体验更轻。3.2 实现响应归一化与未登录处理normalizeResponse和handleUnauthorized这两个函数是核心层的另一半。function normalizeResponse(res) { const { statusCode, data } res if (statusCode ! 200) { return { code: statusCode, data: null, message: 服务繁忙 } } if (data typeof data.code ! undefined) { return { code: data.code, data: data.data ! undefined ? data.data : data, message: data.message || data.msg || } } return { code: 0, data, message: ok } } let isRedirecting false function handleUnauthorized() { if (isRedirecting) return isRedirecting true wx.removeStorageSync(token) tokenCache const pages getCurrentPages() const currentPage pages[pages.length - 1] const redirectUrl currentPage ? currentPage.route : wx.navigateTo({ url: /pages/login/index?redirect${encodeURIComponent(redirectUrl)}, complete() { setTimeout(() { isRedirecting false }, 1000) } }) }isRedirecting这个标志位很关键。前面提过并发请求时可能多个请求同时返回未登录没有这个标志位会连续跳转多次小程序会报“navigateTo 频繁调用”的错。加个 1 秒的冷却时间既防抖又不会影响正常登录流程。3.3 封装业务接口层核心层搭好后业务层就简单了。按模块建文件比如api/user.js、api/order.js每个接口一个函数。// api/user.js import { request } from ../utils/request export function login(data) { return request({ url: /user/login, method: POST, data, skipToken: true, showLoading: true }) } export function getUserInfo() { return request({ url: /user/info, method: GET }) } export function updateProfile(data) { return request({ url: /user/profile, method: PUT, data }) }页面里调用就非常清爽了import { login, getUserInfo } from ../../api/user async onLogin() { try { const res await login({ username: this.data.username, password: this.data.password }) wx.setStorageSync(token, res.token) const userInfo await getUserInfo() this.setData({ userInfo }) } catch (e) { console.error(登录失败, e) } }对比一下原生写法原来要写wx.request的完整配置、手动处理 loading、手动判断状态码、手动弹错误提示现在一个await login(...)就搞定了。这就是封装的价值。3.4 参数计算与超时设置超时时间设多少合适我一般设 10 秒。为什么不是 5 秒或 30 秒5 秒对于弱网环境太短用户还没加载完就报错了30 秒又太长用户等得不耐烦。10 秒是个平衡点覆盖了绝大多数正常请求。如果是上传大文件这种特殊接口可以在请求配置里单独把 timeout 调大比如 60 秒。export function uploadFile(filePath) { return request({ url: /upload, method: POST, data: { filePath }, timeout: 60000, showLoading: true }) }这里要注意wx.request的 timeout 参数在部分低版本基础库上不生效所以核心层里最好再加一层setTimeout兜底超时后主动 reject。虽然稍微麻烦点但能保证超时逻辑在所有机型上一致。4. 常见问题与排查技巧实录接口封装上线后我陆陆续续踩过不少坑这里整理成速查表方便对照排查。问题现象可能原因排查思路解决方案loading 不消失并发请求计数错乱检查 showLoading/hideLoading 是否成对用计数器统一管理complete 里必调 hideLoadingtoken 过期后疯狂跳登录页并发请求同时返回未登录看控制台是否连续 navigateTo加 isRedirecting 标志位防抖请求头 content-type 不对业务层手动覆盖了默认值打印最终 header 确认核心层统一设置业务层不传 header后端返回格式不统一不同接口 code 字段不一致抓包对比各接口返回归一化函数做适配业务层只认统一格式弱网下请求超时无提示timeout 未生效真机弱网测试加 setTimeout 兜底主动 reject上传文件失败content-type 是 json检查上传接口 header上传接口单独设 multipart/form-data除了表格里的问题还有几个经验性的坑值得单独说。第一个坑storage 同步读取的性能问题。早期我在拦截器里每次都wx.getStorageSync(token)接口一多页面明显卡顿。后来加了内存缓存tokenCache只在登录/登出时更新缓存性能好了很多。这个优化在小程序里很实用因为 storage 同步 API 确实有性能开销。第二个坑错误提示重复弹。并发请求同时失败时会连续弹好几个 toast用户看到的就是一闪一闪的。我的做法是在核心层加一个 toast 节流500 毫秒内只弹一次。let lastToastTime 0 function showErrorToast(message) { const now Date.now() if (now - lastToastTime 500) return lastToastTime now wx.showToast({ title: message, icon: none }) }第三个坑登录接口本身走了 token 注入。登录接口不需要 token但如果忘了加skipToken: true拦截器会带上一个过期的旧 token后端可能直接拒绝。这个 bug 很隐蔽因为本地测试时 storage 里没 token不会触发只有用户登出再登录时才复现。第四个坑页面卸载后请求才返回。用户在请求还没回来时就返回了上一页这时候setData会报“页面已卸载”的警告。解决办法是在页面onUnload里把未完成的请求 abort 掉或者用标志位判断页面是否还存活。我一般用后者简单可靠。onLoad() { this._alive true this.fetchData() }, onUnload() { this._alive false }, async fetchData() { const data await getList() if (!this._alive) return this.setData({ list: data }) }第五个坑真机调试和开发者工具表现不一致。开发者工具里请求很快真机上弱网环境可能超时。所以接口封装做完后一定要在真机上用弱网模式测一遍重点看 loading、超时提示、错误处理是否正常。我习惯用手机热点限速来模拟弱网比工具里的模拟更真实。最后分享一个我个人的习惯给每个请求加一个唯一的 requestId在拦截器里打印日志时带上。这样排查线上问题时能快速定位是哪个请求出的错尤其是并发场景下日志不会混在一起。这个 requestId 可以用时间戳加随机数生成成本很低但排查效率提升明显。接口封装这件事做一次能管很久。前期多花两个小时把结构搭好后面几百个接口都能受益。我现在的习惯是新项目初始化第一件事就是把 request 封装好然后再开始写业务这样后面基本不会因为接口问题返工。