ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

uni-app接口调用实战:从基础封装到跨端兼容,一篇讲透

uni-app接口调用实战:从基础封装到跨端兼容,一篇讲透

1. 从一次真实需求说起:为什么要专门写一篇接口调用的文章

上个月接手一个跨端管理系统的改造,业务方要求同一个产品覆盖微信小程序、iOS App 和 H5 三个入口。技术选型没有悬念,直接用 uniapp 一套代码三端复用。骨架搭起来很快,页面 UI 也顺利,但到了联调接口这一步,团队里几个人陆续卡住了:有的在浏览器里调试得好好的,一跑到小程序就报url not in domain list;有的人把 axios 的习惯直接搬进来,发现promise风格拿不到返回值;还有人被后端反问“你传的到底是不是 form 表单”,愣在当场说不出话。

这个场景想必很多搞 uniapp 的都经历过。uniapp 调取接口的方法,表面上看就是uni.request一个 API,但真正上手你会发现,这里面牵扯到请求封装、跨端差异、Content-Type 设置、证书域名校验、超时重试机制等等一系列细节。任何一个环节没处理好,轻则多花半天排查时间,重则线上版本直接翻车。

这篇文章就从实际开发的角度出发,把 uniapp 调取接口的完整链路捋一遍:从最基础的uni.request写法,到业务层封装思路,再到 H5、小程序、App 三端的差异化处理,最后附上我实际踩坑记录和排查清单。不管你是拿 uniapp 写过两三个页面的新手,还是已经在做跨端项目的中级开发者,这里都有你能直接抄走的方案。

2. 原生请求的十八般武艺:uni.request 的完整用法拆解

2.1 最基础的 GET 和 POST 调用

先把看家本领拿出来。uniapp 内置了uni.request,这就是默认的 HTTP 客户端,调用方式和微信小程序的wx.request几乎一致——如果你写过小程序,上手基本零成本。

一个最普通的 GET 请求长这样:

uni.request({ url: 'https://api.example.com/v1/user/info', method: 'GET', data: { userId: '10086' }, success: (res) => { // 注意这里是 res.data,不是 res console.log('请求成功', res.data) }, fail: (err) => { // 网络异常、超时、域名不存在等情况走这里 console.log('请求失败', err.errMsg) } })

POST 请求的差异只在两个地方:method改成'POST',同时显式声明请求头里的Content-Type:

uni.request({ url: 'https://api.example.com/v1/login', method: 'POST', header: { 'Content-Type': 'application/json' }, data: { account: 'admin', password: '123456' }, success: (res) => { if (res.statusCode === 200) { console.log('登录成功', res.data) } } })

这里有一个新接触 uniapp 的人最容易搞混的点:success回调里拿到的res是完整响应对象,里面包含了statusCode、header、data、errtMsg等字段。你要的业务数据在res.data里面,而不是res本身。后端返回了什么,res.data就是什么——如果后端返回的是 JSON 字符串,res.data甚至可能是一个字符串,需要你再JSON.parse一次,这跟浏览器里的fetch、axios自动解析 JSON 的行为不一样。

2.2 method、data、header、timeout 等关键参数逐个说

uni.request可配置的参数,常用的没那么多,但每个都有注意点。

url是请求地址,开发的时候你可能图省事写成相对路径/api/login,H5 端配合 devServer 代理没问题,但打包到小程序和 App 上必挂。因为小程序运行环境里根本没有“当前站点host”这种概念,必须写完整的https://地址。我建议从第一天写项目起,所有接口地址都拼成一个完整的绝对 URL,后面省心很多。

method的可选值有GET、POST、PUT、DELETE、OPTIONS、HEAD等,和 HTTP 方法一一对应。需要注意,uni.request默认值就是GET,所以很多初学者写了 POST 逻辑却忘记改 method,请求发了,后端拿到却是一脸懵。每次写完请求,先检查 method 再提交代码。

data是发送到后端的参数,类型可以是对象,也可以直接给字符串。当Content-Type是application/json时,对象会被序列化成 JSON 字符串;当Content-Type是application/x-www-form-urlencoded时,对象会被序列化成key=value&key2=value2的格式。这块后面第三节再展开,因为它是跨端联调里的大坑。

header是个普通对象,塞请求头用的。除了Content-Type,最常见的还有Authorization和token——有的后端要Authorization: Bearer xxx,有的后端自定义了一个token字段,按对方文档填就行。

timeout单位是毫秒,默认 60000(小程序端一部分基础库版本默认是 60000,H5 端略不同)。像上传图片这类长任务你可以加到 30000,但登录、列表这种接口建议设成 8000 或 10000,不然后端假死的时候用户会对着页面发呆十秒钟。

responseType默认是text,如果需要后端返回二进制,把它设成arraybuffer。做文件下载、图片流渲染的时候才会用到,日常接口不用管。

success、fail、complete三个回调覆盖了三种情况:业务请求成功、网络层失败、无论成功失败都要执行的逻辑。比如请求时要先uni.showLoading,那就在complete里uni.hideLoading,不管结果是成功还是失败都能关掉弹窗,避免页面卡死一个转圈。

2.3 返回值最大痛点:uni.request 本身不支持 Promise

用习惯了fetch、axios的人,上手uni.request会很难受:它没有 promise,不支持async/await,只能在success回调里一层一层往下写。两个接口有依赖关系就得嵌套回调,三四个接口串起来代码就是“回调地狱”。

我们做个简单统计,一个列表页可能先要拿到用户信息,根据用户信息里的权限码判断请求哪个数据接口,再拿数据渲染。用原生的写法:

uni.request({ url: 'https://api.example.com/user/info', method: 'GET', success: (userRes) => { const permissions = userRes.data.permissions let apiUrl = 'https://api.example.com/list/default' if (permissions.includes('admin')) { apiUrl = 'https://api.example.com/list/admin' } uni.request({ url: apiUrl, method: 'GET', success: (listRes) => { // 到这里代码已经是两层嵌套了 this.list = listRes.data } }) } })

这还只是两层,实际项目里加上 loading、错误处理、token 刷新,代码会越来越乱。解决方案是手动包一层 Promise,把uni.request变成可await的版本。这也是后面讲请求封装的最基础一步。

3. 进阶实战:把请求模块化,代码瞬间清爽

3.1 动手封装一个 Promise 版本的基础请求函数

这一节直接给能落地的代码。我们构建一个request.js文件,先做最基础的一层封装:

// utils/request.js const request = (options) => { return new Promise((resolve, reject) => { uni.request({ url: options.url, method: options.method || 'GET', data: options.data || {}, header: options.header || {}, timeout: options.timeout || 10000, success: (res) => { // HTTP 状态码 2xx 视为成功,其他交给 reject if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data) } else { reject(res) } }, fail: (err) => { reject(err) } }) }) } export default request

封装完之后,原来的嵌套代码可以改写成这样:

import request from '@/utils/request.js' async function getList() { const userInfo = await request({ url: 'https://api.example.com/user/info', method: 'GET' }) const permissions = userInfo.permissions const apiUrl = permissions.includes('admin') ? 'https://api.example.com/list/admin' : 'https://api.example.com/list/default' const listData = await request({ url: apiUrl, method: 'GET' }) this.list = listData }

逻辑清晰多了吧。这里有个微妙的点:resolve、reject之后,调用方通过await拿到的返回值到底是什么,取决于你在封装函数里怎么处理。我上面的写法是直接resolve(res.data),把后端真正的数据抛出去,业务层拿到就直接用,不需要再剥一层壳。如果你在后端约定的结构里还有code、data、message三层,也可以在这一层先做一个统一判断:

// 后端返回结构约定:{ code: 0, data: {...}, message: 'ok' } success: (res) => { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data) } else { reject(res.data) } }

这样业务侧连code === 0都不用判断,永远只拿业务数据。如果后端报错,直接走catch,错误对象里带message可以直接弹给用户。

3.2 全局拦截器和认证信息注入的几种常规方案

项目一旦上了规模,每个接口都要带 token,每个 401 都要统一跳登录页。把这些逻辑全部塞进unk层其实是错的。最合理的做法是把拦截逻辑写进封装函数里,而不是让每个页面去处理。

改造一下request.js——增加请求前处理、响应后处理两个钩子:

// utils/request.js // 读缓存中的登录态 const getToken = () => { const token = uni.getStorageSync('token') return token ? `Bearer ${token}` : '' } const request = (options) => { // 请求前统一处理,比如加 token const header = { 'Content-Type': 'application/json', ...(options.header || {}) } const token = getToken() if (token) { header.Authorization = token } return new Promise((resolve, reject) => { uni.request({ url: options.url, method: options.method || 'GET', data: options.data || {}, header, timeout: options.timeout || 10000, success: (res) => { if (res.statusCode === 401) { // 登录态失效:统一清理缓存并跳转登录页 uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) reject({ code: 401, message: '登录已过期' }) return } if (res.statusCode >= 200 && res.statusCode < 300) { if (res.data.code === 0) { resolve(res.data.data) } else { // 业务错误统一弹 toast,页面不需要再加判断 uni.showToast({ title: res.data.message || '请求失败', icon: 'none' }) reject(res.data) } } else { reject(res) } }, fail: (err) => { // 统一处理网络异常 uni.showToast({ title: '网络异常,请检查网络设置', icon: 'none' }) reject(err) } }) }) }

这个版本的好处是:业务开发者不需要在任何页面里写uni.showToast,接口失败自动提示;不需要在每个页面判断登录态,401 自动跳登录页;也不需要手动塞 token,统一在header注入。当然,如果你想给某些接口单独跳过 token,可以在 options 里加一个标记位灵活控制,这里不展开。

要提醒一点:uni.addInterceptor也能拦截uni.request,从官方能力上看更“正统”,但我实测下来,在部分安卓真机上对于uni.request的拦截存在兼容问题,表现为 promise 链有时不执行。如果你的项目一定要用,务必在真机上多测几个版本。我的建议是优先采用“包一层函数”的方案,它对所有端都是天然兼容的,因为本质上你就是在调用一个普通函数。

3.3 按业务模块拆分的 API 层设计

封装好request.js之后,再往前走一步:不要在每个页面里直接写request({ url: '...' }),而是把接口按业务模块拆分到独立文件里,形成“一个模块一个 js”的结构。

举个例子,用户相关的接口放进api/user.js:

// api/user.js import request from '@/utils/request.js' export const login = (account, password) => { return request({ url: 'https://api.example.com/v1/login', method: 'POST', data: { account, password } }) } export const getUserInfo = () => { return request({ url: 'https://api.example.com/v1/user/info', method: 'GET' }) } export const updateUserProfile = (data) => { return request({ url: 'https://api.example.com/v1/user/profile', method: 'PUT', data }) }

订单模块放在api/order.js,商品模块放在api/product.js。页面里这么用:

import { login } from '@/api/user.js' async function handleLogin() { try { const userInfo = await login('admin', '123456') uni.setStorageSync('token', userInfo.token) } catch (e) { // 错误已经被统一 toast 过了,这里只需要处理业务逻辑 } }

这样做的价值分成两层:第一层,接口路径不再散落在每个页面里,后端换地址只改一个文件;第二层,页面代码只关心“登录”这个动作,不关心它是怎么请求的,维护成本大幅降低。团队里如果有人专门维护接口层,对页面开发者来说就是透明的:你只需要知道调用哪个函数、传什么参数、返回什么结构。

4. 跨端环境下的隐性差异:H5、小程序、App 各有各的脾气

4.1 H5 端跨域问题从原理到解决方案

H5 跑在浏览器里,就绕不开浏览器的同源策略。你本地启动的 uniapp 开发服务器是localhost:5173,后端接口在https://api.example.com,协议不同、端口不同,就是跨域。

浏览器看到跨域请求,会先发一个OPTIONS预检请求,后端如果不回Access-Control-Allow-Origin,真实请求就不会发出。你打开浏览器开发者工具的 Network 面板,会看到一堆CORS error。

最省事的开发期方案,是在 uniapp 项目根目录的manifest.json里配置 H5 的 devServer 代理。找到h5.devServer字段,加上 proxy 配置:

{ "h5": { "devServer": { "proxy": { "/api": { "target": "https://api.example.com", "pathRewrite": { "^/api": "/v1" } } } } } }

这样页面里的url写成/api/login,请求经过 devServer 转发到目标后端,浏览器感知不到跨域,开发期调试非常顺手。需要注意,/api和/v1的路径映射要跟后端实际路由对齐,否则你看到的就是 404。

生产环境上线 H5 时,devServer 不再生效,常见做法是让运维在 nginx 里配反向代理,把/api/前缀的请求全部转发到后端服务。后端开 CORS 也是一种方案,但如果你有多个前端应用消费同一套接口,统一走 nginx 反代反而更可控。

顺带一提,如果你在小程序端的代码里用的是绝对 URL,H5 端跨域问题被 nginx 代理解决后,前端基本不需要做任何按端区分的条件编译,这也是我把“所有请求统一写绝对地址”视为最佳实践的原因。

4.2 小程序端的域名校验和开发工具配置

小程序是异类中的异类。它不存在浏览器的 CORS 概念,请求直接由客户端发出,但微信强约束了请求域名:所有接口域名必须是 HTTPS 且配置在小程序后台的“request 合法域名”列表里。

大部分新手在小程序开发者工具里跑通了一个 HTTP 接口,真机预览却发现请求全军覆没,报错信息自带xxx is not in domain list的字眼。原因就是工具自带“不校验合法域名”开关,真机上这个开关不存在了。

我处理此类问题的优先级是这样:在开发阶段,开发者工具右上角“详情 → 本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,保证开发效率和真机调试;提审前,把线上接口域名提前配置到微信公众平台的“开发管理 → 服务器域名 → request 合法域名”里,保存后一般一分钟内生效。如果你的域名是新的,还没备案或没配 HTTPS 证书,那后端要先把这一套补齐,否则没法上小程序。

另一件小事是 TLS 版本。微信要求 TLS 1.2 及以上,如果你后端服务器用的是比较老的配置,客户端请求可能直接握手失败。在浏览器上看接口是好的,但在小程序里errMsg显示request:fail,这种情况优先检查服务器 TLS 配置,而不是前端代码。

4.3 App 端的网络权限和证书细节

App 端跑的是原生网络栈,在 Android 上要注意AndroidManifest.xml里有没有INTERNET权限。uniapp 默认打包模板一般会带上,但如果你是用离线打包或者自定义的 manifest,漏掉INTERNET权限的后果就是所有的网络请求全部失败,而且fail回调里的错误信息经常让人摸不着头脑,提示的是“网络异常”而不是“权限缺失”。

iOS 端更折磨人的是 ATS(App Transport Security)。iOS 默认禁止不安全的 HTTP 连接,你的接口如果是 HTTP 明文,在 iOS 的真机上会被拦下来。开发阶段你可以临时在manifest.json配置允许任意加载(NSAllowsArbitraryLoads设为 true),但这是治标不治本的方案——App Store 审核时如果看到你开了全局 HTTP 豁免,可能会以“违规收集用户信息”、“不安全传输”的理由打回。过审版本还是要上 HTTPS 证书,实在有特殊场景必须 HTTP,也要限定在特定域名而不是全局放开。

这部分的调试经验是:App 短真机联调时,如果请求一直失败,用 PC 上的抓包软件先确认请求到底有没有发出去、后端到底有没有响应。如果根本没发出去,大概率是权限或证书问题;如果发出去了但没有响应,那是后端或网络环境问题。不要一上来就在前端代码里盲目加header或者改data格式。

5. Content-Type 引发的惨案:json 还是 form,跨端各有讲究

5.1 application/json 与 x-www-form-urlencoded 的区别

这是接口联调时最容易“死得不明不白”的地方。后端工程师习惯用 Postman 调试,他们可能测通了 JSON 格式,也可能测通的是表单格式,但前端一旦传错格式,后端的参数解析逻辑就会拿不到值,报错信息往往是“参数缺失”或者Required String parameter 'xxx' is not present。

application/json对应的传参方式,是把参数序列化成 JSON 字符串放入请求体。现在的主流后端框架,只要控制器方法用@RequestBody接收对象,就是这个格式。

application/x-www-form-urlencoded则是传统的表单提交格式,参数长这样:title=test&content=hello。后端用@RequestParam或request.getParameter接收。

uni.request的默认行为需要明确一下:如果data传的是一个对象,且header没设置Content-Type,uni-app 在不同平台的默认值可能不同。H5 端默认可能是application/json,小程序端也是 json,但我碰到过个别第三方组件或自定义基座改变了默认值的场景。为了不出幺蛾子,跨端项目里必须在header里显式写明Content-Type。

当后端要求x-www-form-urlencoded时,如果data传了对象,uni-app 一般会帮我们序列化成查询字符串,但我更推荐自己拼好字符串:

// 方式一:传对象,依赖框架序列化 uni.request({ url: 'https://api.example.com/v1/form', method: 'POST', header: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: { title: 'hello', content: 'world' } }) // 方式二:手动拼接字符串 const body = `title=${encodeURIComponent('hello')}&content=${encodeURIComponent('world')}` uni.request({ url: 'https://api.example.com/v1/form', method: 'POST', header: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: body })

方式二看起来麻烦,但它绕开不同端对对象序列化处理不一致的问题。我在部分安卓机型上确实碰到过对象传表单格式时字段顺序被打乱、中文编码出错的现象,手动拼字符串后世界清净了。

5.2 常见的“明明传了却拿不到”的排查思路

遇到后端说“我没收到参数”,不要急着跟后端争论,先按以下顺序自查。

第一步,打开开发者工具或抓包工具,看请求体里的具体内容。如果是 json 格式,确认花括号里的字段名跟后端@RequestBody的实体字段严格一致,注意大小写和下划线分割差异。很多后端是 Java 风格,字段叫userName,前端传了username,看起来差不多,实际对不上。

第二步,确认Content-Type和后端接收方式匹配。后端如果是 key-value 方式接收,你传 json 就识别不了;后端如果是@RequestBody接收,你传表单也直接报错。这一步对不齐,后面全是白搭。

第三步,检查数据有没有被二次处理。比如data里套了一层{ data: {...} },后端实体没这层结构,自然解析失败。或者是data传了FormData对象,跨端兼容不一致,部分端直接变成[object Object]。

第三点特别想多说一句:uniapp 里FormData在 App 端和 H5 端的表现差异极大,如果你是为了传文件而用FormData,建议直接改用uni.uploadFile,那是官方封装好的上传组件,能处理multipart/form-data,比手动构造FormData可靠得多。

6. 高频问题排查与避坑经验速查

6.1 典型报错与解决方案对照

把我在项目里碰到过的高频问题整理成了一份速查表,方便你直接定位问题:

报错现象可能的根因解决动作
request:fail网络断开、域名无法解析、证书错误抓包确认请求是否到达服务器;检查 WiFi 和 4G 切换测试
url not in domain list小程序 request 合法域名未配置去小程序后台添加域名;开发工具勾选“不校验合法域名”
跨域 CORS 报错H5 端同源策略拦截开发期配置 devServer proxy;上线用 nginx 反代或后端开 CORS
后端收不到参数Content-Type 不匹配核对后端接收方式是 json 还是表单,显式声明 header
返回数据是字符串responseType是 text如果需要 JSON,建议在封装层统一JSON.parse并做容错
请求超时后端响应慢或 timeout 设置过短区分接口类型设置合理 timeout;排查后端慢查询
App 端请求全部失败Android 缺网络权限,或 iOS ATS 限制 HTTP检查 manifest 配置的权限;iOS 开发期临时允许 HTTP,线上必须 HTTPS
401 循环跳转登录401 拦截逻辑没有区分单次请求和并发请求加一个标记位,防止多个请求同时 401 时重复跳转

6.2 五个最容易踩但网上不太有人提的坑

坑一:success回调里拿statusCode判错位置。有人把业务成功的判断写成了if (res.data.statusCode === 200),但res.data是后端返回的数据,后端的业务结构里大概率没有statusCode,于是永远进不了成功分支。正确写法是res.statusCode。

坑二:请求地址里带空格和中文没编码。URL 里有中文参数,在 H5 端浏览器会自动编码,但是在小程序端不同版本的编码行为不一致,最稳妥的方式是自己提前encodeURIComponent。我建议所有 query 参数都手动编一次,不要依赖框架。

坑三:并发请求的 loading 处理。如果页面上同时发出三个请求,每个请求complete里都执行uni.hideLoading,第一个请求回来就把 loading 关了,后面的请求还在飞,用户体验像抽搐一样。解决方案是加一个计数器,请求发起时++,结束时--,只有归零才隐藏 loading。这也解释了为什么很多成熟项目都会在封装层做并发控制。

坑四:token 过期后,并发请求进来全部 401,结果同时发起了多次“重新登录”跳转。处理办法是:第一次遇到 401 时设置一个全局标记,后续请求直接排队等待登录完成,再统一重放。这个逻辑在封装函数里实现要花点心思,但效果直接决定体验是否正常。

坑五:uni.request的url长度限制在不同端不一样。小程序端 URL 长度上限有限,如果你把一长串参数全拼在 query 里,到了真机可能被截断,后端就收不到完整参数。凡是参数多的接口,优先走 POST body,而不是 GET query。

6.3 一键产出可用代码:我的最终版 request.js 模板

前面讲了很多细节,这里直接把一个适合大多数中后台项目的request.js完整版贴出来,你复制过去改下 baseURL 就能用:

// utils/request.js const BASE_URL = 'https://api.example.com' const getToken = () => uni.getStorageSync('token') || '' const request = (options) => { let url = options.url // 传相对路径时自动拼接完整地址,兼容多端 if (!/^https?:\/\//.test(url)) { url = BASE_URL + url } const header = { 'Content-Type': 'application/json', ...(options.header || {}) } const token = getToken() if (token) { header.Authorization = token } return new Promise((resolve, reject) => { uni.request({ url, method: options.method || 'GET', data: options.data || {}, header, timeout: options.timeout || 15000, success: (res) => { if (res.statusCode === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) reject({ code: 401, message: '登录已过期' }) return } if (res.statusCode >= 200 && res.statusCode < 300) { // 约定后端成功码为 0 if (res.data && res.data.code === 0) { resolve(res.data.data) } else { uni.showToast({ title: (res.data && res.data.message) || '请求失败', icon: 'none' }) reject(res.data || res) } } else { uni.showToast({ title: `服务异常[${res.statusCode}]`, icon: 'none' }) reject(res) } }, fail: (err) => { uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' }) reject(err) } }) }) } export default request

要注意,如果你接入的后端成功码不是0,比如是200或者是"success",把res.data.code === 0那个判断改成对应规则即可。另外这个模板里Content-Type默认写了 json,遇到需要表单提交的接口,可以在调用侧覆盖:

request({ url: '/user/login', method: 'POST', header: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: 'account=admin&password=123456' })

header 里的同名键会覆盖默认值,这一层灵活度是封装后最实用的部分之一。

7. 写在最后:我对 uniapp 接口调用的几点体会

用 uniapp 调取接口,说难不难,说简单也不简单。核心的 API 就一个uni.request,但它背后绑定了跨端兼容、请求规范、业务分层等一系列工程化问题。我个人做了几个跨端项目后的体会是:尽早把请求层封装统一起来,比再多的“页面里临时调一下”都重要。封装的好处不在于少写几行代码,而在于你能在一次改动里修复所有页面共性的问题——比如统一加 token、统一处理 401、统一规范错误提示。这些习惯到项目后期会产生复利效应,维护成本差距非常明显。

最后再分享一个小技巧:调试接口时,不要只在浏览器里验证,定期用小程序开发者工具、App 自定义基座跑一遍真实环境。跨端项目最大的风险永远是“某个端单独出问题”,而这个问题往往要到真机联调阶段才暴露。把这种多端验证变成每天的例行操作,你会少熬很多个深夜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进