☰
uniapp调接口全攻略:跨域、封装、并发一次讲透
2026/10/14 16:20:12 网站建设 项目流程

做了几年跨端开发,被问得最多的一个问题就是:uniapp到底怎么调取接口。这个问题听起来简单到翻一下文档就能解决,但实际上几乎每个人都会在某一步卡住——不是参数配错,就是真机上白屏,再不然是并发请求把数据覆盖了。这篇文章把我实际项目里调接口的整套方法整理出来,从基础用法到公共封装,从跨域到并发竞态,一次把链路讲透。

1. 为什么同样是调接口,H5、小程序、App表现三张脸

先看三个非常典型的翻车现场。

第一个场景,有位刚入门的开发者,本地用 H5 开发一切正常,接口随便调、数据随便渲染,可一打包丢到服务器上就白屏,打开控制台全是跨域报错。第二个场景,项目跑小程序,开发者工具里接口通通正常,真机预览却全部失败,一片 timeout。第三个场景,App 打成测试包之后,部分接口频繁超时,换一台手机又正常,查半天发现是明文 HTTP 被系统拦了。

这三个问题的根子在于:uniapp 本身只是一个跨端框架,它编译到不同平台后,真正发出网络请求的不是同一个东西。H5 走的是浏览器网络栈,小程序走的是宿主平台提供的请求 API,App 走的是原生网络库。三者对跨域、证书、Cookie、域名校验的处理逻辑完全不同。

我习惯用一句话概括:框架可以跨端,网络环境跨不了。所以你才会看到同一份接口代码,换一个端就换一种脾气。

1.1 三个运行环境的网络模型差异

H5 跑在浏览器里,受同源策略约束,前端代码访问不同源的接口时,浏览器会先发一个预检请求,服务端如果不返回正确的 CORS 响应头,请求直接被拦。小程序跑在微信、支付宝这类宿主 App 里,受限更明显,平台不让你随便请求任意域名,必须在后台配置合法域名,而且不校验 Cookie。App 端自由度最高,没有跨域、没有域名白名单,但 Android 和 iOS 都有系统级的网络安全策略,HTTP 明文、证书链不完整都会被卡。

下面这张表是我自己项目里常用的一张对照清单,每次排查问题先对着它定位,能省很多时间:

差异点H5小程序App
同源策略/CORS有无 CORS,但有域名白名单无
Cookie 机制可用不可用不可用
域名校验无必须配置合法域名无
HTTP 明文请求可用基本不可用Android/iOS 均有限制
证书完整性要求一般严格严格
并发能力浏览器同源并发限制平台并发限制系统网络栈

很多新手以为接口调不通是代码写错了,其实大部分真实场景是环境策略问题。搞清楚这张表,就搞懂了调接口的第一层逻辑:先看当前跑在什么环境里,再决定怎么排查。

2. uni.request 参数逐个拆解,文档没写透的细节

说完了环境差异,真正落实到代码层面,所有跨端请求的底层入口都是 uni.request。它长这样:

uni.request({ url: 'https://example.com/api/login', data: { username: 'admin', password: '123456' }, method: 'POST', timeout: 15000, header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer xxx' }, success(res) { // res.statusCode HTTP状态码 // res.data 服务器返回的数据 // res.header 响应头 }, fail(err) { // 网络层错误,不是HTTP错误 }, complete() {} })

光看这段代码,很多刚接触的人会觉得很简单,无非就是传参、回调。但文档里没写透的细节,恰恰是项目里最容易翻车的地方。

2.1 几个必须注意的参数细节

第一个细节是 method 必须大写。uni.request 对请求方法有严格约定,写小写会出现部分平台不识别的情况。我见过有人写成 'post',H5 端碰巧能用,小程序真机上直接报错。所以建议在封装层统一转成大写,后面会说到。

第二个细节是 data 的编码方式,它跟 header 里的 Content-Type 有直接关系。当 Content-Type 是 application/json 时,data 对象会被序列化成 JSON 字符串发送,这是默认行为。如果接口要求的是表单格式,需要把 Content-Type 改成 application/x-www-form-urlencoded,此时 uni.request 会把 data 对象转成 query string 形式。还有少数后端要的是纯文本,那就得自己用 JSON.stringify 处理后直接传字符串。

我踩过一个很典型的坑:后端同学说他接收的是 form 格式,我左看右看都是 JSON,最后发现对方接口框架默认只解析表单,前端默认发的却是 JSON,两边各说各话,浪费了一下午。

第三个细节是 statusCode 的判断。success 回调不代表业务成功,它只代表请求发出去了并且拿到了响应,哪怕服务端返回 500 或 404,也会走 success。所以正确写法是先判断 res.statusCode 是否在 200 到 300 之间,再处理业务数据。

success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { // HTTP层正常,处理业务 } else { // 服务端返回了错误状态 } }

fail 回调跟 success 完全不同,它只在网络层出错时触发,比如断网、超时、DNS 解析失败、证书错误。前端拿到 fail 时,是拿不到任何 HTTP 状态码的。所以错误处理要分两层:网络错误看 err,业务错误看业务 code。

2.2 requestTask:很多人忽略的返回对象

uni.request 调用后返回的是一个 requestTask 对象,它身上有个 abort 方法,可以中断请求。这个能力在处理“用户快速切换搜索条件”“下拉刷新频繁触发”等场景时非常有用。通常的做法是把 requestTask 存起来,下一次请求前把上一次 abort 掉。

let task = null function search(keyword) { if (task) { task.abort() } task = uni.request({ url: 'https://example.com/api/search', data: { keyword }, success(res) { // handle } }) }

还有一个参数 dataType,默认是 json,意思是服务端返回的文本会自动做 JSON 解析。如果后端返回的不是合法 JSON,这里会解析失败。需要拿原始文本时,把 dataType 设为 text。另外像图片验证码、文件流这类二进制数据,要用 responseType 指定为 arraybuffer,配合 uni.request 下载后转成 base64 显示。

3. 工程化第一步:把请求全部收进一个公共模块

前面讲的都是最原始的写法,直接写在页面里确实能跑,但项目一旦超过十个页面,问题就全来了。我自己早期做过一个商城类跨端项目,那时候每写一个页面就复制一段 uni.request,token 参数手动拼,错误弹窗各自写,登录失效的跳转逻辑散落得到处都是。后来测试环境切生产环境,光是改接口域名就全局搜索替换了三十多处。

那次之后我彻底明白,调接口这件事必须做一层公共封装,把共性逻辑全部收拢到一个文件里。

3.1 公共请求模块解决的问题

公共封装至少要解决四件事:统一 baseURL、统一注入 token、统一处理 HTTP 状态和业务错误、统一做登录失效跳转。有了这一层,页面里调接口就变成非常干净的调用,不用关心 token 从哪里来、401 怎么处理、loading 怎么控制。

先看一下我很常用的一套完整封装,基于 Promise 实现:

// utils/request.js import config from './config.js' const pendingTasks = new Map() export default function request(options) { return new Promise((resolve, reject) => { const token = uni.getStorageSync('token') const requestId = options.requestId || `${options.url}_${Date.now()}` const task = uni.request({ url: `${config.baseURL}${options.url}`, data: options.data || {}, method: (options.method || 'GET').toUpperCase(), timeout: options.timeout || config.timeout || 30000, header: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.header || {}) }, success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { const body = res.data if (body && body.code === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/index' }) reject(body) return } resolve(body) } else { reject({ statusCode: res.statusCode, data: res.data }) } }, fail(err) { reject(err) }, complete() { pendingTasks.delete(requestId) } }) pendingTasks.set(requestId, task) }) } request.cancel = function(requestId) { if (pendingTasks.has(requestId)) { pendingTasks.get(requestId).abort() pendingTasks.delete(requestId) } } export default request

配套的 config.js 可以这么写:

// utils/config.js const config = { baseURL: 'https://example.com/api', timeout: 30000 } export default config

我在项目里还会按环境拆 baseURL,开发环境、测试环境、生产环境各一个,通过编译环境变量切换,页面代码完全不用动。

封装之后,页面里的接口调用就变成这样:

import request from '../../utils/request.js' async function fetchList() { const data = await request({ url: '/goods/list', data: { page: 1 } }) list.value = data.list }

干净、清晰、每个页面不用再重复造轮子。

3.2 拦截器的两种写法对比

除了自己在函数里封装,uni 官方还提供了 addInterceptor,可以在全局拦截 request 调用。我在几个老项目里用过这种方案,效果也不错:

uni.addInterceptor('request', { invoke(args) { args.header = args.header || {} const token = uni.getStorageSync('token') if (token) { args.header.Authorization = `Bearer ${token}` } return args }, success(res) { if (res.statusCode === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/index' }) } } })

两种写法怎么选,我的经验是:新项目直接自己封装一层,老项目不想改所有页面就用 addInterceptor。自己封装的优点是可以同时管理 requestTask 做取消、定制错误结构、加防重逻辑;addInterceptor 的优点是不侵入原有代码,适合快速补一刀。实际工程里两者也经常同时用,addInterceptor 做全局逻辑,自定义封装做业务增强,互不冲突。

4. 跨域、域名白名单与证书:一条完整的环境适配链路

这一块是“真机就挂、调试就通”这类灵异问题的重灾区。很多人以为代码没问题就万事大吉,实际上环境配置才是最容易卡人的地方。

4.1 H5 开发时如何绕开跨域

H5 本地开发有个常见现象:用开发工具内置的浏览器调试,接口一切正常,换到系统 Chrome 打开本地页面,控制台全是 CORS 报错。原因是内置浏览器没有跨域限制,而真实浏览器有。

解决办法是给本地开发服务器配代理。uni-app 用 Vite 编译 H5 时,可以在项目根目录的 vite.config.js 里配置 server.proxy:

// vite.config.js import { defineConfig } from 'vite' import uni from '@dcloudio/vite-plugin-uni' export default defineConfig({ plugins: [uni()], server: { proxy: { '/api': { target: 'https://example.com', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })

这样本地请求 /api/login 就会转发到 https://example.com/login,浏览器看到的是同源请求,跨域问题从根上消失。生产环境同样要处理,最省事的办法是让后端开放 CORS,或者由 Nginx 做反向代理,把 /api 路径转发到后端服务,前端只访问同域地址:

location /api/ { proxy_pass https://example.com/api/; proxy_set_header Host example.com; }

我的习惯是:开发环境用代理,测试环境开 CORS,生产环境走同域 Nginx,三层分开,互不影响。

4.2 小程序的域名白名单必须提前搞定

小程序端最让人崩溃的体验是:开发者工具里勾了“不校验合法域名”,所以怎么测都正常,等到真机预览才发现所有请求全部失败。原因就是真实的小程序环境强制校验域名,不在合法域名名单里的接口一律拒绝。

这个必须在管理后台提前配好:开发管理、开发设置、服务器域名,把正式环境接口域名填进 request 合法域名。要注意两点:一是合法域名不能带路径,只能填协议加域名;二是平台要求 HTTPS,HTTP 域名在小程序端基本无解。如果测试环境和生产环境是不同域名,建议都配上,避免切换环境时又要重新走审核流程。

我在一个项目里吃过亏,临上线才发现测试域名和生产域名不在同一份白名单里,真机切生产环境直接瘫痪,临时加域名又赶上了平台审核周期的尾巴,上线时间硬生生往后拖了一天。从那以后,新项目配置域名这件事我都是提需求时第一优先级处理。

4.3 App 端的明文流量与证书校验

App 端没有跨域和域名白名单问题,但系统层面的限制依然存在。Android 9 及以上默认禁止 HTTP 明文流量,如果接口还是 http:// 开头,请求会直接被系统拦掉,报错看起来像网络不通。iOS 的 ATS 机制类似,默认也只放行 HTTPS。

临时调试时可以放宽,比如 Android 在 AndroidManifest.xml 里给 application 加 android:usesCleartextTraffic="true",iOS 在 Info.plist 里配置 ATS 例外。但正式包我建议尽早切 HTTPS,别把调试配置带到线上。还有一个容易忽略的点是证书链要完整,有些证书在 Chrome 里能正常访问是因为浏览器有容错机制,但小程序和 App 的校验严格得多,证书链缺了中间证书,表现就是真机请求失败,本地调试碰巧正常。

5. 并发、重复点击与竞态:接口调用的进阶考验

很多人功能做完了,接口也能通,但一遇到并发场景就出 bug。这一节讲的是接口调用里最容易出暗坑的地方,也是区分“会调接口”和“会调好接口”的分水岭。

5.1 搜索竞态:后发先至的数据覆盖

搜索框是最典型的竞态场景。用户输入“手机”,然后改成“笔记本电脑”,前端连续发出两个请求。如果第一个请求响应比第二个慢,最后渲染在页面上的可能反而是“手机”的结果,哪怕用户最终想要的是“笔记本电脑”。

问题根源在于 HTTP 请求的响应顺序不可控,网络快慢由服务端处理时间和链路决定。解决办法有两个方向:一是给请求编号,只有最后一次发出的请求才允许更新页面;二是直接取消上一次请求。

先看编号方案:

let searchSeq = 0 function handleSearch(keyword) { const currentSeq = ++searchSeq request({ url: '/search', data: { keyword } }) .then((data) => { if (currentSeq === searchSeq) { pageList.value = data.list } }) }

核心逻辑就是每次搜索自增一个序号,响应回来时对比当前序号,如果不一致就丢弃。这个方案实现简单,也不依赖 abort,兼容性最好。

再看取消方案,适合接口本身比较耗时、想省流量的场合:

function handleSearch(keyword) { request.cancel('search_task') request({ url: '/search', data: { keyword }, requestId: 'search_task' }).then((data) => { pageList.value = data.list }) }

每次发起新请求前,先把上一次的搜索请求 abort 掉,再发新的。这样能保证只有最后一个请求生效,同时省掉无效响应占用的资源。

5.2 重复点击与请求防重

竞态要防,重复请求也要防。比如提交订单按钮,用户手快连点两次,前端就会发出两个一模一样的请求。如果后端没做幂等处理,订单可能就被创建两份。

前端最直接的做法是按钮加一个 submitting 状态:

let submitting = false async function submitOrder() { if (submitting) return submitting = true try { await request({ url: '/order/create', method: 'POST', data: orderData }) uni.showToast({ title: '提交成功' }) } finally { submitting = false } }

对于更通用的场景,可以在封装层做相同请求的合并。判断依据是请求方法、URL、参数三者一致,如果同一个请求还在 pending 中,就直接复用上一次的 Promise,不发新请求:

const pendingPromises = new Map() function requestWithDedupe(options) { const key = `${options.method}_${options.url}_${JSON.stringify(options.data || {})}` if (pendingPromises.has(key)) { return pendingPromises.get(key) } const promise = request(options).finally(() => { pendingPromises.delete(key) }) pendingPromises.set(key, promise) return promise }

这个方案在列表页重复进详情、按钮连点、轮询接口意外重叠等场景都能兜住。需要注意 key 的生成要把 data 序列化进去,否则不同参数会被误合并。

5.3 Loading 与并发请求的冲突

全局 loading 和并发请求也有一个经典矛盾。多个请求同时发出,各自在 success 和 fail 里 hideLoading,第一个完成的请求会把 loading 关掉,后面的请求还在飞,页面却已经没有了加载态。

解决办法是给 loading 加一个计数器:

let loadingCount = 0 function showGlobalLoading() { loadingCount++ uni.showLoading({ title: '加载中' }) } function hideGlobalLoading() { loadingCount-- if (loadingCount === 0) { uni.hideLoading() } }

每次 show 都加一,每次 hide 都减一,当计数归零时才真正关闭。这属于很小的一个细节,但没有它,并发接口多的时候 UI 状态会非常奇怪。

6. 多端差异实测清单与收尾心得

接口调用的知识,纸上聊再多也不如真机跑一遍。这一章把多端差异整理成一份自检清单,顺便分享几个靠经验踩出来的小习惯。

6.1 多端差异对照表

这份清单是我每接一个新端都会过一遍的,建议直接收藏:

检查项H5小程序App
接口发送方式uni.requestuni.requestuni.request
跨域处理CORS 或代理域名白名单系统网络策略
Token 存放localStorage / 内存uni.setStorageSyncuni.setStorageSync
Cookie 是否可用可用不可用不可用
自定义 Header基本不限制部分字段受限基本不限制
证书链要求宽松严格严格
并发限制同源并发限制平台并发限制系统网络栈

另一个容易忽视的点是 Header 字段大小写。某些小程序真机上对 header 的 key 会做大小写归一化,前端传 Authorization,结果真机收到的可能是 authorization,后端框架如果不区分大小写就没事,一旦区分就出现“开发工具正常、真机 401”的诡异现象。遇到这种情况,让后端的 token 解析逻辑大小写不敏感,或者前端同时传两个字段。

6.2 错误处理的统一策略

不管业务复杂程度如何,我建议前端约好后端统一返回结构,最常用的就是 { code, message, data } 这种格式。code 为 0 或 200 表示成功,非 0 表示业务失败,401 统一表示登录失效。

在公共封装里可以加一层业务拦截:

function handleBusiness(body) { if (body.code === 0 || body.code === 200) { return body.data } if (body.code === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/index' }) throw new Error('登录已过期') } uni.showToast({ title: body.message || '请求失败', icon: 'none' }) throw new Error(body.message || '请求失败') }

这样页面里调接口就只需要关注成功数据,失败情况统一在封装层消化。其他常见状态码的处理思路是:404 检查路径,500 让后端看日志,504 大概率是网关超时,可以提示用户稍后重试。

6.3 几个能救命的小经验

最后说几个实际项目里攒下来的经验,不一定写在哪篇文档里,但真的能救命。

第一,接口地址不要散落在页面里。我见过太多项目直接在页面里拼接完整 URL,导致切换环境时全局搜索替换。正确的做法是统一放在 api 模块里,每个接口一个函数,页面只 import 函数。

第二,请求日志要低调。联调阶段建议在封装层打印 method、url、params、response,方便定位问题,但生产环境要把日志关掉或者只保留错误日志,避免敏感信息刷屏。

第三,大文件下载不要用 uni.request。下载用 uni.downloadFile,上传用 uni.uploadFile,它们有自己的进度回调和处理链路,用 uni.request 传大文件容易把内存顶爆,且无法展示进度条。

第四,调试工具和真机行为不一致是常态。遇到接口异常,先按环境策略检查一遍域名、证书、明文流量、CORS,再回头看代码逻辑。很多时候不是代码问题,而是环境没放行。

第五,封装层建议保留一个关闭全面板的逃生舱。线上如果出现接口大面积故障,至少能通过一个开关快速停止所有 loading 和相关跳转,给后端争取修复时间,这个设计在早期项目里救过我一次。

接口调取这件事,看起来是入门第一课,实际上牵扯了跨端差异、网络策略、工程封装、并发控制好几层东西。把这些链路理清楚,uniapp 的网络层才算真正掌握。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询