刚接手项目的时候,我在一个用了uni-app的中台业务里碰到一个特别诡异的线上反馈:用户点“提交申请”按钮之后,按钮进入loading状态,然后就再也没恢复过来。页面没崩、没白屏、也没有报错弹窗,就是所有操作都像被冻住了一样,用户只能杀掉App重进。最头疼的是这个Bug在测试环境怎么点都复现不了,功能也完全正常,但只要用户手机网络稍微慢一点,必现。
后来我一条条排查才发现,这个“卡死”根本不是主线程阻塞,而是页面代码里的await挂在一个永远pending的Promise上——也就是Promise悬挂。而罪魁祸首,恰恰是我在防重复请求逻辑里偷偷埋下的雷。这个过程踩了不少坑,也把uni-app各端下的表现摸了一遍,今天把完整的思路、代码和排查方法整理出来,希望对正在被类似问题折磨的同学有用。
1. Promise悬挂的本质:为什么请求没回来页面就僵住了
1.1 一个最容易被忽略的Promise陷阱
很多人在用uni.request的时候,都会习惯性地封装一层Promise,让调用方可以用async/await去写业务逻辑。这本身没问题,问题出在很多人封装的时候只处理了成功和失败两条路,却漏了“中间状态”和“异常状态”。
看一段非常典型的错误写法:
function http(options) { return new Promise((resolve, reject) => { uni.request({ ...options, success: (res) => { if (res.statusCode === 200) { resolve(res.data); } // 坑来了:statusCode不是200的时候,这里什么都不做 // 既不resolve也不reject // 于是这个Promise永远处于pending状态 }, fail: (err) => { reject(err); } }); }); }如果你只是请求后端接口,后端一定会返回状态码,那这段代码大部分时间是“看起来正常”的。但一旦遇到后端网关超时、服务端返回500、或者接口做了统一封装后业务层返回的code非200,这时候promise就挂住了。调用方的写法往往是:
const result = await http({ url: '/api/xxx' }); setData({ data: result.data });await之后那行代码永远不会执行,loading永远不会被关掉。这就是用户视角的“页面卡死”。
我常说,Promise这玩意儿很像一份快递单:你下单的时候承诺一定会收到,但快递员可以打电话告诉你送到了,也可以打电话告诉你丢件了。最怕的是快递员既不打电话,也不更新物流,你的await就一直傻等。而Promise悬挂,就是这种“既没送到也没丢件”的状态。
1.2 网络请求与生命周期交错产生的“幽灵等待”
还有一种更隐蔽的悬挂,发生在请求发出之后、页面已经被销毁的情况下。uni-app页面有onLoad、onShow、onUnload等生命周期,很多业务代码在onLoad里发请求,请求回来后用this.setData去更新页面。正常流程没问题,但用户快速返回上一个页面,当前页面已经onUnload了,这时候请求的回调才回来。
在Web端这顶多算是回调执行了但页面DOM已经不存在,顶多报个警告。但在小程序端,这个回调可能会触发“Component is not found in path”的警告,而在App端的某些版本下,这个回调会异常中断,导致调用方外围的try/catch捕获不到,外层resolve一直不执行。
如果你在页面里还用了类似这样的代码:
onLoad() { this.loadData(); }, async loadData() { const res = await myRequest(); if (res.code === '0') { this.setData({ list: res.data }); } }当页面实例已经被销毁时,this.setData这行甚至可能连调用都不算数,后续代码仍然在跑,但真正更新UI的路径已经断了。表现出来就是:业务逻辑看起来“执行完了”,但页面却没有任何变化,用户怎么刷新都没反应。
1.3 先区分:是“真卡死”还是“看起来卡死”
排查这类问题第一件事就是分清楚到底是哪种卡死。因为很多开发同学一听到页面卡死,第一反应是死循环、内存泄漏,往性能优化方向跑了半天,方向错了。
所谓“真卡死”,是主线程被同步代码占住,比如一个while(true)死循环,或者超大列表的同步渲染,此时页面上任何点击、滑动、按钮响应全部无响应,手机系统甚至弹“继续等待/关闭应用”的对话框。这种情况用性能工具一抓就能看到JS线程长时间100%占用。
而“看起来卡死”,是主线程是空闲的,点击按钮也有响应,但页面UI停留在某个中间状态,比如loading一直转、按钮一直灰着,或者像是被一层透明遮罩锁住了。这种情况下CPU占用是正常的,你甚至可以在console里正常打日志、切换tab。
Promise悬挂导致的基本都是第二种:UI停在某个await生效的中间状态。判断方法也很简单,卡死状态下打开调试器,执行几条JS语句,如果还能执行,那99%是Promise悬挂而不是死循环。
2. 触发场景复盘:重复点击、请求竞态与拦截器“吞掉”的reject
2.1 前端防抖防御的局限性
很多团队在解决“重复请求”问题时,第一反应就是加防抖。按钮点击后置一个标记:
let submitting = false; async function onSubmit() { if (submitting) return; submitting = true; try { await submitRequest(); } finally { submitting = false; } }这个方案可以挡住“同一按钮的连续点击”,但它挡不住所有场景。比如同一个页面有多个入口都能触发同一接口的请求,又比如用户在页面A发起请求后,立刻跳转页面B,再从页面B返回到页面A,这时候防抖标记已经被重置了,而之前的请求还Pending着。以及一个非常常见的场景:多个业务组件挂在同一个页面里,组件A和组件B分别触发同一个下拉刷新数据接口,防抖标记如果放在组件内部,互相之间根本管不住。
在我看来,防抖只是操作层面的兜底,不是请求层面的治理。要治本还得在请求层解决“同一时刻同一请求只发一次”的问题。
2.2 拦截器合并请求时留下的悬挂隐患
如果你们项目里用了uni.addInterceptor来统一处理请求,这一步其实很容易埋雷。我当时就在拦截器里加了请求合并逻辑:发现当前有一个相同URL且相同参数、相同请求方式的请求正在Pending,就不再发新请求,而是直接把这个新调用绑定到那个已有请求的Promise上。
思路是对的,问题出在实现上。我当时是这么写的:
if (currentRequestMap.has(key)) { // 直接复用,避免重复请求 return currentRequestMap.get(key); }看起来没毛病,但被复用的那个Promise如果因为前面说的“非2xx状态码没有reject”而悬挂,那么后面所有复用它的调用方全部一起悬挂。这就像一架飞机出了故障,除了首架飞机上的乘客,后续所有买了同一架航班转票的人都上不了天。
所以请求去重本身不会导致悬挂,导致悬挂的是被合并的那个Promise的完整性。你必须在源头保证这个被合并且被所有人共享的Promise,要么最终resolve,要么最终reject,绝对不能卡在中间。
还有一个很典型的细节:你用uni.request的requestTask执行abort后,fail回调会被触发,如果你在fail里做了判断,遇到abort错误直接return而不是reject,那么该Promise悬挂。可如果同时还有其他并发请求正在复用这个Promise,它们也会一起永久等待。
2.3 页面卸载后回调仍在执行的竞态问题
另一个高频场景是下拉刷新和触底加载。列表页用户上拉加载更多,然后快速切走,再切回来,页面onShow又触发一次加载,此时上一次请求的响应可能还没回来。等到多个响应陆续返回,代码里基于某个lastPage字段做累加时,由于响应顺序不确定,列表内容可能错乱甚至重复。如果此时页面loading状态和请求数量没有对应管理,就会出现请求明明完成了,loading却还挂着、页面看起来像是卡死的状态。
这里其实牵扯到“竞态”控制:前端发送多个请求,后端的返回顺序不一定和发送顺序一致。如果你的代码逻辑是“谁先返回就处理谁”,那么后来返回的旧数据就会覆盖新数据,这是数据错乱层面的问题。而页面卡死层面的问题则是:多个请求并发时,你把所有请求都绑定在一个loading状态上,只要有一个请求没结束loading就永远转圈。
3. 关键一役:给uni.request装一个“请求签名+去重+超时”三层保险
3.1 请求签名的设计
先说什么是请求签名。这个“签名”不是加密签名,而是给每个请求生成一个唯一标识,用来判断“这个请求是不是和另一个请求是同一个请求”。最简单的方式是把请求方法、URL、请求参数拼接成一个字符串,然后做一次hash。
function generateRequestKey(config) { const { url, method = 'GET', data = {} } = config; // 这里用JSON.stringify把data转成字符串 // 注意:如果data里有动态字段,比如时间戳,需要排除 const dataStr = JSON.stringify(data || {}); return `${method}_${url}_${dataStr}`; }这里有一个细节很多人会忽略:如果每次请求带的时间戳字段不同,那么两次“业务上相同”的请求会被误判成不同请求,因为参数串不一样。所以在做签名之前,要先把动态字段排除掉或者规范化,比如删除data里的timestamp、nonce之类的字段再生成签名。
签名设计的目的不是唯一性越强越好,而是“业务层面是否重复”的判定越准越好。你需要结合自己的业务场景去定义哪些数据字段会变化但不影响请求的业务含义。
3.2 并发去重的核心逻辑
有了签名,就可以在请求层做并发去重。核心思路是维护一个进行中的请求Map,key是签名,value是对应的Promise。每次发请求前先查Map,如果发现相同签名已经在Pending,就直接返回已有的Promise,不再发起新HTTP请求。
const pendingMap = new Map(); function dedupeRequest(key) { if (pendingMap.has(key)) { return pendingMap.get(key); } const promise = doRequest(); pendingMap.set(key, promise); promise.finally(() => { pendingMap.delete(key); }); return promise; }这样做的收益很直接:同一时刻无论页面触发多少次相同请求,真正发出去的只有一次,所有调用方共享同一个结果,既不重复消费服务器资源,也天然规避了响应顺序错乱问题。而且只要doRequest内部本身是“一定会resolve或reject”的,就不存在悬挂。
我之前踩过的坑是只在业务组件里做了防重复,没有在请求层做全局去重,结果出现两个组件各发各的请求、各拿各的返回值、最后互相覆盖列表数据。把去重放到请求层之后,这类问题直接消失了。
3.3 超时兜底:Promise层一定要有自己的超时机制
很多人以为uni.request有timeout参数,设置之后请求超时就会走fail回调,Promise会被reject,就没问题了。实际上uni.request的timeout参数在某些平台上有差异,比如H5端默认60秒,小程序端默认是60秒,App端超时时间在某些版本上并不完全受timeout参数控制。
为了万无一失,我建议在Promise层面再包一层超时兜底。因为Promise链上除了uni.request本身,还有可能有人为的延迟、队列等待、权限校验等额外逻辑。这些逻辑中任何一个环节卡住,uni.request自身的timeout都管不到。
超时兜底可以用Promise.race,也可以自己写一个包装函数,在指定时间内没有resolve/reject就主动reject。
function withTimeout(promise, timeoutMs = 15000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`请求超时,已超过 ${timeoutMs}ms`)); }, timeoutMs); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); } ); }); }这个包装函数虽然简单,但它能保证“任何Promise都不会无限期pending”。它不能替代业务层面的去重,但和去重配合,相当于给整个请求体系上了双保险。
4. 实战:完整封装一套可防悬挂的request工具
4.1 核心代码实现
接下来是我在实际项目里用的一套相对完整的封装,包含签名、去重、超时、任务取消和常用拦截器。你可以根据自己项目的情况裁剪使用。
先定义请求配置和签名:
// utils/request.js let requestTaskList = []; // 所有进行中的uni.request任务 const pendingMap = new Map(); // 进行中请求的Promise function normalizeData(data) { // 排除字段 const { timestamp, nonce, ...rest } = data || {}; return rest; } function generateRequestKey(config) { const { url, method = 'GET', data = {} } = config; const dataStr = JSON.stringify(normalizeData(data)); return `${method}_${url}_${dataStr}`; } function addTask(task) { if (task) { requestTaskList.push(task); } } function removeTask(task) { requestTaskList = requestTaskList.filter((item) => item !== task); } function abortAllRequest() { requestTaskList.forEach((task) => { try { task.abort(); } catch (e) { // 已经结束的任务可能抛异常,忽略 } }); requestTaskList = []; }封装uni.request为Promise,注意fail里对abort错误的特殊处理,避免因为abort导致reject又被上层误判为真正失败:
function httpRequest(options) { return new Promise((resolve, reject) => { let task; try { task = uni.request({ ...options, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { const response = options.transformResponse ? options.transformResponse(res.data) : res.data; resolve(response); } else { // 关键点:非2xx状态码必须reject const error = new Error(`HTTP ${res.statusCode}`); error.code = res.statusCode; error.data = res.data; reject(error); } }, fail: (err) => { // 主动取消触发的fail,单独处理,避免误判 if (err && err.errMsg && /abort/.test(err.errMsg)) { const cancelError = new Error('request canceled'); cancelError.canceled = true; reject(cancelError); } else { reject(err); } } }); addTask(task); } catch (error) { reject(error); } // 这里不能用finally删除task,因为finally会在promise settle时执行 // 而resolve/reject之后task已经结束,移除没问题 // 但用promise + removeTask要放在then里 }); }然后是对外暴露的request方法,集成去重和超时:
function request(options = {}) { const { timeout = 15000, dedupe = true } = options; let promise; if (dedupe) { const key = generateRequestKey(options); if (pendingMap.has(key)) { // 直接复用进行中的同一请求 return pendingMap.get(key); } promise = httpRequest(options); pendingMap.set(key, promise); promise.finally(() => { pendingMap.delete(key); }); } else { promise = httpRequest(options); } if (timeout > 0) { promise = withTimeout(promise, timeout); } return promise; }这样封装之后,页面里的调用方式变成:
// pages/demo/index.js import { request, abortAllRequest } from '../../utils/request'; Page({ onLoad() { this.loadData(); }, onUnload() { // 离开页面时取消该页面发起的请求 abortAllRequest(); }, async loadData() { try { const res = await request({ url: '/api/list', method: 'GET', data: { page: 1 } }); this.setData({ list: res.data }); } catch (err) { if (err.canceled) return; // 被取消的不需要提示 uni.showToast({ title: err.message || '加载失败', icon: 'none' }); } } });这里有个需要注意的地方:abortAllRequest()在页面onUnload调用时,会把该页面还没结束的任务全部abort掉。触发fail回调后会走reject,但err.canceled等于true的会被业务层忽略,不会弹错。这就在生命周期层面从根本上消除了“页面已销毁但回调才回来”的竞态问题。
4.2 页面级的防悬挂接入指南
封装好request工具之后,还需要在页面层统一接入。我一般会在页面根组件、列表页等高频发请求的场景里做三件事:
第一,在onUnload里调用abortAllRequest,清除该页面所有未完成的请求。对于不关心取消的场景,也可以在onHide里做,这样用户切走页面后请求自动取消,切回来再重新拉数据,避免数据陈旧。
第二,在page的data里默认把加载状态设为true,在请求的finally里统一关闭loading。不要在每个catch分支里手动去关,因为一旦某个catch分支忘了关,就只有等下一次请求才能恢复正常。
async loadData() { this.setData({ loading: true }); try { const res = await request({ url: '/api/list' }); this.setData({ list: res.data }); } catch (err) { if (err.canceled) return; uni.showToast({ title: '加载失败', icon: 'none' }); } finally { this.setData({ loading: false }); } }第三,对于需要用户确认的提交类请求,除了按钮的loading状态,还可以配合请求层的去重,确保即使是多个入口同时触发同一个提交接口,也只有一个请求到后端。这一点是单独靠前端按钮防抖做不到的。
再补充一个小技巧:如果想要更细粒度的取消,比如只取消某个请求而不影响页面其他请求,可以给request增加一个signal参数,类似AbortSignal。每次页面onUnload生成一个新signal,请求和取消都绑定在这一个signal上:
class RequestCanceler { constructor() { this.onCancel = null; this.canceled = false; } cancel() { this.canceled = true; if (this.onCancel) this.onCancel(); } bindToRequest(promise) { promise.catch((err) => { if (err && err.canceled) this.canceled = true; }); } }不过这个方案要和封装里的task管理机制结合起来,代码复杂度会高很多,不是所有项目都需要。如果你的页面请求切换非常频繁,建议先用abortAllRequest这种粗粒度方案,够用且简单,避免过度设计。
5. 多端差异与真机避坑:H5、小程序、App行为不完全一样
5.1 requestTask.abort在不同端的表现差异
封装的时候我一度以为abort在各端表现应该一致,后来在真机上一测,发现差距还是比较大的。
在H5端,uni.request实际上底层是XMLHttpRequest,abort调用后,XHR会触发abort事件,进而走向fail回调,errMsg里带“abort”字样,然后我们对fail里的abort做特殊处理,这没问题。
在微信小程序端,requestTask.abort()也是可用的,但小程序对abort触发fail回调的时机有细微差别。我遇到的情况是,在请求发出的极短时间内立刻调用abort,可能fail不触发,只有请求真正发出去之后abort才会触发fail回调。为了应对这个问题,我在abortAllRequest里调用task.abort()后,还同步把对应pendingMap里的Promise果断reject掉,不能依赖fail回调来reject。换句话说,取消动作本身应该在封装层被当作一次reject,而不是等待底层abort事件来触发。
在App端,uni-app的安卓端底层用的是okhttp或者原生的网络库,有的版本上,task.abort()确实会触发fail,有的版本上fail里拿到的errMsg并不是“abort”字样,而是别的,比如“request:fail errors”。如果代码里只用正则判断abort,可能识别不到,就会把取消误判成真错误,弹一个“请求失败”的提示。这是非常影响体验的。
我的对策是:在封装层的reject错误对象上统一挂一个标记,只要是取消主动触发,不管底层errMsg长什么样,只要业务层引入了取消机制,这个错误对象就带上canceled=true。业务层统一判断err.canceled,不依赖字符串匹配。
5.2 真机上“界面卡死”但网络层已完成的特殊case
另一个真机上的坑,和请求本身没关系,但很容易误诊成Promise悬挂。
App端有时候会出现一种情况:请求已经完成,网络层日志也打印了响应,但页面就是一直卡在loading状态。我排查了好久,最终发现是代码里setData的数据量太大,或者setData里带了某个表达式,渲染层在做同步渲染时被卡住了,JS线程一度长时间占用,页面表现就是“看起来死锁”。
在H5端,大列表setData通常顶多卡顿一下,但在App端低端机上,一个几百条数据的列表直接setData,渲染压力是很大的。如果加载数据的接口返回的是一个超大JSON对象,你直接存在data里,再绑定到模板上,模板里还跑了复杂的计算函数,那页面确实会卡很久。
所以当你排查“页面卡死”时,别只盯着Promise。看一眼network面板,如果请求已经在几百毫秒内返回了,而页面卡顿发生在数据返回之后的一两秒内,那应该优先怀疑渲染层压力,而不是请求悬挂。
5.3 顺带排掉那个极易误判的扩展程序报错
热词里有一条“uncaught (in promise) error: could not establish connection. receiving end d”,这在uni-app的H5端调试时非常常见,但它其实不是你项目代码的问题。
这个报错一般来自浏览器扩展程序之间的消息通信。如果你开发时开着一些翻译类、笔记类、广告拦截类扩展,页面在请求外部资源时,扩展程序注入的内容脚本尝试和background通信失败,会把错误冒泡到Promise里,控制台就会打出一大片“Uncaught (in promise)”的红字。很多同学看到就以为是自己的请求Promise出了问题,查了半天发现无从下手。
这个情况在Chrome无痕模式或者禁用所有扩展之后会消失。遇到这种报错,先别慌,点开错误堆栈,确认不是自己写的调用链,再去扩展管理里临时禁用扩展试试。
6. 当事故已经发生:一套完整的排查与恢复步骤
6.1 复现与定位
如果你遇到了“用户反馈页面卡死,测试环境复现不了”的情况,我的排查路线一般是这样:
第一步,先复现。有条件就让反馈用户提供手机型号、系统版本、网络环境(Wi-Fi/移动网络/弱网)。很多时候弱网是必现条件,可以用开发者工具里的Network throttling模拟弱网,或者手机上开飞行模式再关掉制造一个网络抖动窗口。
第二步,看控制台有没有报错。H5端按F12,小程序端打开vConsole,App端连上HBuilderX的真机调试日志。重点看有没有“Uncaught (in promise)”以及报错堆栈来源。如果报错指向你封装Promise的函数内部,那大概率是resolve/reject被吞了。
第三步,在疑似卡住的await链路前后打日志。比如在请求发出前打印“开始请求”,在finally中打印“请求结束”。如果一个请求的“开始请求”日志出现了,但“请求结束”始终没出现,基本就可以锁定这个请求对应的Promise悬挂了。
第四步,检查pendingMap。如果你用了去重队列,在卡死状态下打印一下pendingMap的size,看看里面还留着哪些key,这些key对应的请求就是还没settle的。每个key都去排查为什么没settle:是真的没返回,还是返回了但被代码吞了reject。
// 临时调试代码 console.log(pendingMap);只要能看到pendingMap里堆积了请求,那悬挂点就找到了。
6.2 修复后的验证清单
修复完这个Bug之后,我会重新过一遍验证清单,确保不会在别的地方翻车:
- 在弱网环境下连续快速点击提交按钮10次,确认按钮loading只出现一次,请求只发了一个,页面不会卡死。
- 在请求返回前立即返回上一个页面,再重新进入,确认没有旧请求的响应来覆盖新页面数据。
- 打开控制台,确认无“Uncaught (in promise)”。
- 杀掉网络请求的服务端接口(让请求超时),确认页面在超时时间到达后能自动恢复loading状态,并弹出错误提示。
- 在App端和微信小程序端分别真机验证abort后的fail回调,确认不会把取消误判成请求失败。
- 检查Modal、Loading遮罩在finally里有没有被统一关闭。
我自己的习惯是,开发阶段把所有请求的超时调成2秒到3秒,故意制造一种“随时可能超时”的环境,这样只要代码里有任何悬挂隐患,都会在开发阶段暴露出来。到了上线前,再把超时调回正常业务的10秒或者15秒。这个习惯帮我提前拦住了不少类似问题。
最后再分享一个小技巧:在请求封装里,给每一个request统一挂一个最后兜底,不管是什么原因,只要在发起后一段时间内没settle,就直接reject。这个兜底时间可以设得比正常超时长一点,比如正常超时15秒,兜底就设30秒。它不会影响正常业务的等待时间,但它是一道最底层的安全网,能确保线上任何特殊网络环境下,Promise都不可能永远pending。这个兜底加上去之后,我再也没收到过“页面卡死”的复现报告。