uni-app中Promise悬挂导致页面卡死的排查与修复实战
发布时间:2026/9/10 4:07:05 锦皓数字建站

刚接手项目的时候我在一个用了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. 触发场景复盘重复点击、请求竞态与拦截器“吞掉”的reject2.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 并发去重的核心逻辑有了签名就可以在请求层做并发去重。核心思路是维护一个进行中的请求Mapkey是签名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实际上底层是XMLHttpRequestabort调用后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长什么样只要业务层引入了取消机制这个错误对象就带上canceledtrue。业务层统一判断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小程序端打开vConsoleApp端连上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。这个兜底加上去之后我再也没收到过“页面卡死”的复现报告。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。