uni-app 断网后表单提交丢失?用拦截器加落盘队列重建请求层

2026-10-03 0 885

去年接了一个工程巡检类的 App,用户大部分时间待在信号极差的地方——地下室、隧道口、刚封顶的楼里。上线第一周就收到反馈:巡检单填完点提交,转了两圈圈提示失败,退出去再进来,数据没了,得重填。

翻了一下原来的代码,请求是这样写的:

uni.request({
  url: BASE + '/inspection/submit',
  method: 'POST',
  data: form,
  success: () => uni.showToast({ title: '提交成功' }),
  fail: () => uni.showToast({ title: '提交失败', icon: 'none' })
})

问题就出在这个 fail 上。弱网下 uni.request 默认 60 秒超时,用户在 20 秒的时候就把页面关了,这个回调根本没机会执行,表单数据随着页面卸载一起没了。就算回调执行了,除了弹个 toast 之外什么也没做,数据同样没落盘。

这篇把后来重写的那套请求层完整拆开讲。不复杂,但有几个地方不踩一遍是真的想不到。

一、先想清楚要解决哪些问题

在动手之前把需求列了一遍,一共四件事:

  • 网络断了或者超时,请求不能凭空消失,得先存下来。
  • 恢复网络之后自动补发,用户不用手动点重试。
  • 列表页那种同一秒内被触发的重复请求,只发一次就够了。
  • 页面同时打开好几个请求时,别一股脑全推出去把弱网堵死。

这四条对应四个模块:落盘队列、网络监听重放、请求合并、并发闸门。下面一个一个来。

二、addInterceptor 到底能干什么

很多人一看到「拦截器」三个字,第一反应就是把它当成 axios 的 interceptors,以为可以在这里统一重试、统一改返回值。这是个挺要命的误解,先把边界讲明白。

uni.addInterceptor('request', { ... }) 注册之后,每次调 uni.request 都会经过这几个钩子:

  • invoke(args):请求发出前调用,args 就是这次请求的参数对象,你可以改它。返回 false 能直接拦下这次请求不发出。
  • success(res):请求成功回调之前触发,返回 false 可以阻止后面的 success。
  • fail(err):失败回调之前触发。
  • complete(res):无论如何最后触发。

注意这里:它不能改变 uni.request 的返回值。你在拦截器里等不到结果,也没法把一次失败变成一次成功的重试。success 里就算你返回了别的东西,业务侧拿到的还是原始的 res。

所以正确的分工是:

  • 拦截器负责横向的、所有请求都要做的事——比如统一打 traceId、统计耗时、注入 token。
  • 重试、排队、落盘这些需要拿到结果的逻辑,放在自己封装的 request() 函数里。

搞清楚这点之后,代码结构就不会拧巴了。

三、并发闸门:让同时飞出去的请求不超过 N 个

弱网环境下并发不是越多越好。实测在地下车库里,同时发 5 个请求,5 个一起超时的概率比一个一个发高得多,因为带宽被平分,每个都卡在超时边缘。

写一个最简单的信号量:

// concurrency.js
const MAX_CONCURRENT = 3

let running = 0
const waiters = []

function acquire() {
  return new Promise((resolve) => {
    if (running < MAX_CONCURRENT) {
      running += 1
      resolve()
      return
    }
    waiters.push(resolve)
  })
}

function release() {
  running -= 1
  const next = waiters.shift()
  if (next) {
    running += 1
    // 异步调起,避免在同一个调用栈里递归展开
    setTimeout(next, 0)
  }
}

export { acquire, release }

为什么 release 里要包一层 setTimeout?因为释放和获取如果同步嵌套得太深,几十个排队的请求会形成一条很长的同步调用链,在某些基础库上会触发调用栈深度报警。包一层微任务就干净了。

另外 release() 必须放在 complete 里,不能只放 success 和 fail。原因下一节会说。

四、请求合并:同一秒内的重复提交只发一次

列表页下拉刷新、按钮被连点、页面 onShow 和 onLoad 都触发了同一个接口——这类重复请求在真机上比想象中常见得多。

思路是维护一个「正在飞行中的请求」的 Map,key 由 method + url + 序列化后的参数拼成:

// dedupe.js
const inflight = new Map()

// 关键点:把对象的 key 排好序再序列化,
// 否则 {a:1,b:2} 和 {b:2,a:1} 会算出两个不同的 key
function stableStringify(value) {
  if (value === null || typeof value !== 'object') {
    return JSON.stringify(value)
  }
  if (Array.isArray(value)) {
    return '[' + value.map(stableStringify).join(',') + ']'
  }
  const keys = Object.keys(value).sort()
  return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}'
}

function requestKey(options) {
  return [
    (options.method || 'GET').toUpperCase(),
    options.url,
    stableStringify(options.data || {})
  ].join('|')
}

function withDedupe(options, factory) {
  const key = requestKey(options)

  if (inflight.has(key)) {
    return inflight.get(key)
  }

  const task = Promise.resolve()
    .then(factory)
    .then(
      (res) => {
        inflight.delete(key)
        return res
      },
      (err) => {
        inflight.delete(key)
        throw err
      }
    )

  inflight.set(key, task)
  return task
}

export { withDedupe }

这里没用 .finally()。微信小程序基础库 2.10 以下对 Promise.prototype.finally 支持不完整,而项目里还有一批老安卓机的用户卡在旧基础库上,所以老老实实写了 then 的双分支。

五、重试:哪些错值得重试,退避怎么算

不是所有失败都该重试。用户 token 过期返回 401,你重试一百次还是 401,只是白白消耗流量。

// retry.js
// 这些状态码值得再试一次
const RETRYABLE_STATUS = [408, 429, 500, 502, 503, 504]

function isNetworkError(err) {
  // uni.request 网络层失败时,err 里通常只有 errMsg
  // 形如 "request:fail timeout" / "request:fail abort"
  const msg = (err && err.errMsg) || ''
  return /timeout|fail/i.test(msg)
}

function shouldRetry(err, attempt) {
  if (attempt >= 3) return false
  if (isNetworkError(err)) return true
  if (err && RETRYABLE_STATUS.includes(err.statusCode)) return true
  return false
}

// 指数退避 + 抖动,避免断网恢复瞬间所有请求一起冲上去
function backoffDelay(attempt) {
  const base = Math.min(1000 * Math.pow(2, attempt), 8000)
  const jitter = Math.random() * base * 0.3
  return Math.round(base + jitter)
}

export { shouldRetry, backoffDelay }

那个抖动(jitter)不是可选项。有一次做压力测试,20 个页面的补发请求在断网恢复的同一毫秒全部涌出,直接把服务端网关的 QPS 打到了限流阈值,结果全体 429,然后又全体退避,形成一个很规律的脉冲。加上随机抖动之后曲线就平了。

六、断网落盘:把没发出去的请求存到本地

重试三次还是失败,或者干脆 uni.getNetworkType 返回 none,这时候就得把它扔进持久化队列,等网络回来再补发。

// offline-queue.js
const QUEUE_KEY = 'offline_request_queue'
const MAX_QUEUE_SIZE = 50

function readQueue() {
  try {
    const raw = uni.getStorageSync(QUEUE_KEY)
    return raw ? JSON.parse(raw) : []
  } catch (e) {
    return []
  }
}

function writeQueue(list) {
  try {
    uni.setStorageSync(QUEUE_KEY, JSON.stringify(list))
  } catch (e) {
    // 存储写失败一般是被占满了,丢最老的一半再试一次
    const half = list.slice(Math.floor(list.length / 2))
    uni.setStorageSync(QUEUE_KEY, JSON.stringify(half))
  }
}

function enqueue(options) {
  const list = readQueue()
  // 队列满了就丢最老的,保证新数据一定进得去
  while (list.length >= MAX_QUEUE_SIZE) {
    list.shift()
  }
  list.push({
    url: options.url,
    method: options.method || 'POST',
    data: options.data || {},
    header: options.header || {},
    ts: Date.now()
  })
  writeQueue(list)
}

function dequeueAll() {
  const list = readQueue()
  writeQueue([])
  return list
}

export { readQueue, enqueue, dequeueAll }

几个细节值得说:

  • 只往队列里放写请求。GET 类的查询请求丢了就丢了,下次进页面自然会重新拉。把查询也塞进队列,补发的时候返回一堆过期数据,反而会覆盖掉新数据。
  • 队列长度必须封顶。微信小程序单个 key 的上限是 1MB,巡检表单里带图片 base64 的话,十几条就能撑爆。超过上限的部分直接 shift 丢掉。
  • 补发时按时间戳顺序来。同一张单子的「新增」和「修改」如果顺序反了,服务端状态就乱了。

七、拼起来:完整的 request 封装

// request.js
import { acquire, release } from './concurrency'
import { withDedupe } from './dedupe'
import { shouldRetry, backoffDelay } from './retry'
import { enqueue } from './offline-queue'

const BASE_URL = 'https://api.example.com'

function rawRequest(options, attempt = 0) {
  return new Promise((resolve, reject) => {
    uni.request({
      url: options.url.startsWith('http') ? options.url : BASE_URL + options.url,
      method: options.method || 'GET',
      data: options.data || {},
      header: {
        'Content-Type': 'application/json',
        ...(options.header || {})
      },
      timeout: options.timeout || 20000,
      success: (res) => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data)
        } else {
          reject({ statusCode: res.statusCode, data: res.data, errMsg: 'http error' })
        }
      },
      fail: (err) => {
        reject(err)
      }
    })
  })
}

async function request(options) {
  const isWrite = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(
    (options.method || 'GET').toUpperCase()
  )

  // 查询类请求直接走合并 + 重试,不走落盘
  if (!isWrite) {
    return withDedupe(options, () => sendWithRetry(options, false))
  }

  // 写请求:先合并,再排队
  return withDedupe(options, async () => {
    try {
      return await sendWithRetry(options, true)
    } catch (err) {
      // 重试穷尽仍然失败,落到本地队列
      enqueue(options)
      throw err
    }
  })
}

async function sendWithRetry(options, allowOfflineSave) {
  await acquire()
  try {
    let attempt = 0
    // eslint-disable-next-line no-constant-condition
    while (true) {
      try {
        return await rawRequest(options, attempt)
      } catch (err) {
        attempt += 1
        if (!allowOfflineSave || !shouldRetry(err, attempt)) {
          throw err
        }
        await new Promise((r) => setTimeout(r, backoffDelay(attempt)))
      }
    }
  } finally {
    // 一定要在 finally 里释放,否则一次中断就会永久占掉一个名额
    release()
  }
}

export default request

配套的网络恢复监听放在 App 启动的地方,比如 App.vue 的 onLaunch 里:

// App.vue 的 script 部分
import { dequeueAll } from '@/utils/offline-queue'
import request from '@/utils/request'

let flushing = false

async function flushOfflineQueue() {
  if (flushing) return
  flushing = true

  const list = dequeueAll()

  for (const job of list) {
    try {
      await request({ url: job.url, method: job.method, data: job.data, header: job.header })
    } catch (e) {
      // 补发又失败了,先放回去,剩下的下次再说
      enqueue(job)
      break
    }
  }

  flushing = false
}

export default {
  onLaunch() {
    uni.getNetworkType({
      success: (res) => {
        if (res.networkType !== 'none') flushOfflineQueue()
      }
    })

    uni.onNetworkStatusChange((res) => {
      if (res.isConnected) flushOfflineQueue()
    })
  }
}

注意 flushOfflineQueue 里那个 break。补发到一半又断网了,如果继续往下循环,后面每一条都要走一遍超时重试,用户会在后台白白耗掉几十秒的电。断一条就停下来,等下一次网络事件再继续。

八、最后用拦截器收个尾

上面所有业务逻辑都不需要拦截器,那拦截器用来干嘛?用来做那些「所有请求都该有,但跟业务无关」的事。比如给每个请求打一个 traceId,方便后端排查问题:

// interceptor.js
function uuid() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = (Math.random() * 16) | 0
    const v = c === 'x' ? r : (r & 0x3) | 0x8
    return v.toString(16)
  })
}

export function setupInterceptor() {
  uni.addInterceptor('request', {
    invoke(args) {
      args.header = args.header || {}
      if (!args.header['X-Trace-Id']) {
        args.header['X-Trace-Id'] = uuid()
      }
    }
  })
}

之所以把 traceId 放在拦截器而不是封装函数里,是因为项目里还有一些历史代码和第三方 SDK 直接调 uni.request,走不到封装函数,但拦截器会全部兜住。如果只靠封装函数,那些漏网之鱼就没法追踪了。

九、几个我确实踩过的坑

坑 1:fail 回调拿不到原始请求参数

uni.request 的 fail(err) 里,err 基本只有一个 errMsg,没有 url,没有 data。你必须在外层的闭包里记住 options,才能知道是哪个请求失败了。uni.addInterceptor 的 fail 钩子也一样,它拿到的 err 是合并后的结果,别指望从里面翻出请求体。

坑 2:拦截器只对 request 生效

uni.uploadFile、uni.downloadFile、uni.connectSocket 都要单独注册拦截器。巡检单里带照片上传,第一版上线后照片上传的 traceId 全是空的,就是因为这个。

坑 3:release 忘了放在 finally 里

第一版的 release() 写在 while 循环外面的正常路径上,一旦 throw 出去就永远不执行。跑了两周之后用户反馈「用久了之后所有请求都卡住不动」,重启 App 才能恢复。查了半天才发现是并发名额被泄漏完了。这个坑非常隐蔽,因为它在测试阶段根本复现不出来,得连着失败几十次才会触发。

坑 4:JSON.stringify 的属性顺序会毁掉去重

列表页的参数对象如果是从不同地方拼出来的,{ page: 1, size: 20 } 和 { size: 20, page: 1 } 的序列化结果不一样,去重直接失效。stableStringify 里那次 sort() 不能省。

坑 5:setStorageSync 不是免费的

最开始把 enqueue 写在了每次请求失败的回调里,断网时几十个请求同时失败,同步写存储把主线程卡住了,页面肉眼可见地掉帧。后来改成用一个内存数组缓存,加 300ms 防抖再落盘,才算顺过来。

十、写在最后

这套东西加起来大概两百行,不算复杂,但把「请求消失」这个最要命的问题从根上堵住了。上线之后线上反馈的丢单率从每周十几单降到了零,虽然中间也补了几个小版本修上面那些坑。

如果你的项目也跑在信号不稳定的场景里,建议先把「失败后数据落到哪里」这一件事做扎实,剩下的重试、并发、去重都可以慢慢加。反过来先做花哨的,最后丢的还是数据。

uni-app 断网后表单提交丢失?用拦截器加落盘队列重建请求层
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 断网后表单提交丢失?用拦截器加落盘队列重建请求层 https://www.taomawang.com/web/uniapp/2855.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务