uni-app 请求治理实战:用 RequestTask 解决快速切页数据错乱与重复提交

2026-09-19 0 765

这个问题的暴露方式很典型。

有个售后工单页面,顶部一排状态筛选:全部、待处理、处理中、已完成。做的时候挺顺利,点点点一切正常。上线之后客服那边反馈说,偶尔会看到列表和筛选条件对不上——明明点的是「已完成」,列表里却混着好几条待处理的单子。

一开始怀疑后端接口返回有问题,抓了包看,每个请求的数据都是对的。又怀疑是列表渲染的 key 有问题,检查了一遍也没毛病。后来客服描述得更具体了一点:她说这种情况一般出现在手快的时候,连着点两三个筛选,然后就串了。

到这里基本就确定了,是请求竞态

一、竞态是怎么发生的

把时间线拉出来看就很清楚了。

用户在 0 毫秒点了「待处理」,浏览器发出请求 A。80 毫秒后用户又点了「已完成」,发出请求 B。正常情况下 B 应该后到,先渲染的 A 被 B 的结果覆盖掉,没问题。

但如果 A 这个请求走的网络路径恰好比较慢,300 毫秒才回来,而 B 走了一条快路径 150 毫秒就回来了呢?那就变成 B 先渲染,然后把 A 的响应数据又盖了上去。用户看到的筛选按钮是「已完成」,列表却是「待处理」的内容。

这类问题的麻烦在于复现不稳定。网络好的时候永远测不出来,一到真实环境、弱网环境就频繁出现,测试同学很容易以为是偶发 bug 而忽略掉。

常见的土办法是加个 loading 遮罩,请求期间禁止点击。能用,但体验很糟——用户点不动就会以为卡死了,然后疯狂点击。而且遮罩挡得住手速,挡不住程序内部触发的并发请求,比如埋点上报和列表刷新同时发起。

更好的思路是:既然新的请求代表用户最新的意图,那就让旧的请求直接消失。不是等它回来再决定要不要用,而是从根上就别让它回来。

二、uni.request 其实一直有这个能力

很多人用 uni.request 只用了 success 和 fail 两个回调,忽略掉了一个细节:uni.request 会返回一个 RequestTask 对象。

const task = uni.request({
  url: 'https://api.example.com/orders',
  success(res) {}
})

task.abort()  // 请求直接从底层断掉

abort() 调用之后,底层的小程序网络层或者 H5 的 XHR 会直接把这次连接掐掉,不再等待响应。被掐掉的请求会走 fail 回调,errMsg 里带着类似 request:fail abort 的内容。

这一点是整套方案的地基。有了它,我们才能实现「新请求进来,旧请求立刻作废」这个语义。

不过这里有个坑要提前说清楚:不同端 abort 之后的表现不完全一样

  • 微信小程序:abort 之后 fail 回调会立即触发,errMsg 为 request:fail abort,complete 也会正常触发。
  • H5:本质是 XHR 的 abort,fail 回调同样会被调用,errMsg 可能是 request:fail abort。但如果请求已经完成了(响应体都接收完了),再调 abort 就没效果了,h和正常完成没有区别。
  • App 端:走的是原生网络库,abort 行为和小程序接近,但时机上会略有延迟。

因为 errMsg 的文案各端不一致、各版本也可能变,绝对不能靠匹配字符串来判断「是不是被主动取消」。正确做法是自己在外层维护一个标志位,调用 abort 之前先把它置上,fail 回调里检查这个标志位来区分是网络错误还是主动取消。这个细节后面代码里会体现。

三、把基础封装搭起来

先做一个最小可用的版本,把请求、取消、错误处理串起来。

// utils/request.js

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

// 在途请求表:key -> record,用于参数级去重
const inflight = new Map()

function hashKey(method, url, data) {
  let raw = ''
  if (data && typeof data === 'object') {
    raw = Object.keys(data)
      .sort()
      .map(k => `${k}=${data[k]}`)
      .join('&')
  } else if (data !== undefined && data !== null) {
    raw = String(data)
  }
  return `${method.toUpperCase()}::${url}::${raw}`
}

function toast(msg) {
  uni.showToast({ title: msg, icon: 'none', duration: 1800 })
}

export function request(options = {}) {
  const {
    url,
    method = 'GET',
    data,
    header = {},
    timeout = DEFAULT_TIMEOUT,
    dedupe = 'share',
    silent = false,
    withToken = true
  } = options

  const key = hashKey(method, url, data)

  // 参数完全一致且已有在途请求,直接复用
  if (dedupe === 'share' && inflight.has(key)) {
    return inflight.get(key).promise
  }

  // 参数一致但策略是替换,把旧的那个掐掉
  if (dedupe === 'replace' && inflight.has(key)) {
    inflight.get(key).abort('replaced')
  }

  let task = null
  let record = null
  let abortReason = null

  const promise = new Promise((resolve, reject) => {
    const finalHeader = {
      'Content-Type': 'application/json',
      ...header
    }

    if (withToken) {
      const token = uni.getStorageSync('token')
      if (token) {
        finalHeader.Authorization = `Bearer ${token}`
      }
    }

    task = uni.request({
      url: /^https?:///.test(url) ? url : BASE_URL + url,
      method: method.toUpperCase(),
      data,
      header: finalHeader,
      timeout,

      success(res) {
        const { statusCode, data: body } = res

        if (statusCode < 200 || statusCode >= 300) {
          const err = new Error(`HTTP ${statusCode}`)
          err.statusCode = statusCode
          err.response = body
          if (!silent) toast(`服务异常(${statusCode})`)
          reject(err)
          return
        }

        // 业务约定 code === 0 表示成功
        if (body && typeof body === 'object' && 'code' in body) {
          if (body.code === 0) {
            resolve(body.data)
          } else {
            const err = new Error(body.message || '业务处理失败')
            err.code = body.code
            err.biz = true
            if (!silent) toast(body.message || '操作失败')
            reject(err)
          }
          return
        }

        resolve(body)
      },

      fail(err) {
        // 关键:靠自己的标志位判断,不看 errMsg 文案
        if (abortReason) {
          const e = new Error('请求已取消')
          e.name = 'AbortError'
          e.reason = abortReason
          reject(e)
          return
        }
        if (!silent) toast('网络连接失败')
        reject(new Error(err.errMsg || '网络异常'))
      },

      complete() {
        inflight.delete(key)
      }
    })
  })

  // 防止主动取消引发的 unhandled rejection 噪音
  promise.catch(e => {
    if (e && e.name === 'AbortError') return
  })

  record = {
    key,
    promise,
    abort(reason = 'manual') {
      if (abortReason) return          // 已经取消过了,不重复触发
      abortReason = reason
      if (task && typeof task.abort === 'function') {
        task.abort()
      }
    }
  }

  if (dedupe === 'share' || dedupe === 'replace') {
    inflight.set(key, record)
  }

  promise.abort = record.abort
  return promise
}

export const http = {
  get:  (url, data, opts = {}) => request({ url, method: 'GET',    data, ...opts }),
  post: (url, data, opts = {}) => request({ url, method: 'POST',   data, ...opts }),
  put:  (url, data, opts = {}) => request({ url, method: 'PUT',    data, ...opts }),
  del:  (url, data, opts = {}) => request({ url, method: 'DELETE', data, ...opts })
}

用起来是这样:

import { http } from '@/utils/request'

const p = http.get('/orders', { status: 0 })

// 需要的时候可以主动取消
p.abort()

// 正常消费
p.then(list => {
  this.list = list
}).catch(e => {
  if (e.name === 'AbortError') return
  // 其他错误处理
})

有几个地方解释一下。

为什么把 abort 挂在 promise 上而不是单独返回?

因为调用方大多数时候只关心数据,不想每次都写两行。直接把 abort 挂到 promise 上,需要的时候 p.abort(),不需要的时候完全不用管,代码看起来干净很多。这个设计思路在一些老的请求库(比如 axios 的 CancelToken 时代)也出现过。

为什么要在 promise 上加一个空的 catch?

因为 abort 之后的 promise 一定会 reject。如果调用方此刻正在等另一个更慢的请求,或者调用方是 fire-and-forget 类型的(比如埋点上报),这个 reject 就没人接,控制台里会刷一片 unhandled rejection 警告。加一个内部的 catch 把 AbortError 静默掉,不影响调用方另外挂自己的 catch。

为什么不用一个新的空 promise 或者 resolve 来处理 abort?

这是设计上的取舍。abort 应该被当作一个「异常终止」来对待,让调用方显式地知道这次请求没有结果。如果 resolve 一个空值,调用方拿到 undefined 会莫名其妙,反而更容易出问题。用 reject 加一个约定好的错误名,语义更清楚。

四、两种去重策略,对应两类业务场景

上面的代码里有个 dedupe 参数,我给了它两个取值,分别对应两种不同的业务需求。

share:同一份数据,同时只要一次

典型场景是详情页。一个页面上有多个模块需要用到用户信息,各自都去调 /user/profile。这时候不应该发三次请求,而是复用第一次的结果。

// 三个模块同时调用,实际只会发出一次网络请求
http.get('/user/profile')
http.get('/user/profile')
http.get('/user/profile')

这是 share 模式的默认行为。参数完全一致的时候,后两次调用拿到的都是第一次那个 promise 对象,resolve 的值也是同一个。

防重复提交本质上也是这个模式。用户手抖点两下提交按钮,参数完全一样,第二次调用会命中在途请求,直接复用第一次的结果,不会真的发两次。

不过要注意,防重复提交这块我还是建议在按钮层面也加一下禁用,因为在途请求的窗口期很短,一旦第一个请求回来了,第二次点击就又变成新请求了。请求层和 UI 层的两道防线是互补的,不是替代关系。

replace:同一份数据,只要最新的

典型场景就是文章开头说的筛选栏。参数不一样,但其实它们代表的是同一个数据源,只有最新的那一个有意义。

http.get('/orders', { status: 0 }, { dedupe: 'replace' })
http.get('/orders', { status: 1 }, { dedupe: 'replace' })

注意这里的关键点:replace 模式下并不是按 hashKey 去匹配的。上面这两个请求的参数不一样,hashKey 也不一样,按 key 是匹配不到的。

所以 replace 单独用还不够,得配合下面要说的 slot 机制才行。

五、slot 机制:让筛选栏不再打架

思路很简单:把一个请求和它代表的数据源绑定起来。同一个页面里,同一个数据源同时只允许有一个在途请求,新的进来就把旧的踢掉。

这个数据源的标识,我管它叫 slot(槽位)。

http.get('/orders', { status: this.status }, {
  slot: 'order-list'
})

不管参数怎么变,只要 slot 都是 order-list,那么后发起的请求就会取消先发起的那个。筛选栏连点几下也不会串了。

改一下刚才的封装,加上 slot 的支持。

// utils/request.js 的补充部分

// 页面作用域对象
export function createRequestScope() {
  const records = new Set()     // 这个页面上所有在途请求
  const slots = new Map()       // slot 名 -> 该槽位当前的请求

  return {
    add(record) {
      records.add(record)
    },
    remove(record) {
      records.delete(record)
      // 如果这个 record 还占着某个 slot,一并释放
      for (const [name, r] of slots) {
        if (r === record) slots.delete(name)
      }
    },
    occupy(slot, record) {
      const prev = slots.get(slot)
      if (prev && prev !== record) {
        prev.abort('slot-replaced')
      }
      slots.set(slot, record)
    },
    destroy(reason = 'scope-destroyed') {
      records.forEach(r => r.abort(reason))
      records.clear()
      slots.clear()
    },
    get size() {
      return records.size
    }
  }
}

然后 request 函数里加上 slot 的处理逻辑:

export function request(options = {}) {
  const {
    url,
    method = 'GET',
    data,
    header = {},
    timeout = DEFAULT_TIMEOUT,
    dedupe = 'share',
    slot = null,           // 新增:该请求占用的槽位名
    silent = false,
    withToken = true
  } = options

  const key = hashKey(method, url, data)
  const scope = currentScope()

  // 1. 相同参数的请求去重
  if (dedupe === 'share' && inflight.has(key)) {
    return inflight.get(key).promise
  }
  if (dedupe === 'replace' && inflight.has(key)) {
    inflight.get(key).abort('replaced')
  }

  // 2. 同一槽位只保留最新
  if (slot && scope) {
    const prev = scope.getSlot(slot)
    if (prev) prev.abort('slot-replaced')
  }

  // ... 中间 uni.request 的部分和上一版一样 ...

  record = {
    key,
    promise,
    slot,
    abort(reason = 'manual') {
      if (abortReason) return
      abortReason = reason
      if (task && typeof task.abort === 'function') {
        task.abort()
      }
    }
  }

  if (scope) {
    scope.add(record)
    if (slot) scope.occupy(slot, record)
  }

  if (dedupe === 'share' || dedupe === 'replace') {
    inflight.set(key, record)
  }

  promise.abort = record.abort
  return promise
}

complete 回调里也要做清理:

complete() {
  inflight.delete(key)
  if (scope) scope.remove(record)
}

这样一套下来,页面上就可以这么写了:

async loadOrders() {
  try {
    const list = await http.get('/orders', {
      status: this.status,
      page: this.page
    }, {
      slot: 'order-list'
    })
    this.list = list
  } catch (e) {
    if (e.name === 'AbortError') {
      // 被更新的请求顶掉了,正常现象,直接忽略
      return
    }
    // 其他错误
  }
}

被取消的请求会在 catch 里拿到 AbortError,直接 return 就行。这样旧请求的结果永远不会污染新数据。

这里有个细节值得提一下:分页加载和筛选不能共用一个 slot。因为「加载第 2 页」和「筛选切换」是两种不同的语义,前者想要追加,后者想要替换。共用一个 slot 的话,用户滑到底部触发加载更多,就会把当前页的请求取消掉,列表直接空白。正确做法是给分页单独用另一个 slot,或者分页请求干脆不设 slot,只设 dedupe: 'share' 防止重复触发。

六、把请求绑到页面上,页面走了全部掐掉

光有 slot 还不够。用户从列表页点进详情页,列表页的请求如果不取消,会一直消耗流量和电量。虽然这些请求回来之后更新的是已经离屏的页面,用户看不到,但内存和电量的浪费是实打实的。

而且,如果用户从列表页点进详情页,又从详情页返回列表页,此时列表页可能残留着几个旧请求,它们回来后把新加载的数据盖掉,会出现类似「返回后数据变旧」的现象。

所以需要把每个请求和发起它的那个页面绑定起来,页面一销毁,它发出去的所有请求一起取消。

// utils/request.js

function topPage() {
  const pages = getCurrentPages()
  return pages.length ? pages[pages.length - 1] : null
}

function currentScope() {
  const page = topPage()
  if (!page) return null
  return page._reqScope || null
}

export function abortPageRequests(page, reason = 'page-destroy') {
  const target = page || topPage()
  if (target && target._reqScope) {
    target._reqScope.destroy(reason)
  }
}

然后在 main.js 里挂一个全局 mixin,给每个页面实例在 onLoad 时创建 scope,onUnload 时销毁:

// main.js(Vue 3 版本)
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRequestScope } from '@/utils/request'

export function createApp() {
  const app = createSSRApp(App)

  app.mixin({
    onLoad() {
      if (!this._reqScope) {
        this._reqScope = createRequestScope()
      }
    },
    onUnload() {
      if (this._reqScope) {
        this._reqScope.destroy('page-unload')
        this._reqScope = null
      }
    }
  })

  return { app }
}

Vue 2 的项目里换成 Vue.mixin({...}),结构完全一致。

有个容易被忽略的点:onHide 要不要也取消?

我倾向于不取消,但可以对长时间在后台的页面做特殊处理。原因是用户可能只是切了个微信去回消息,几秒钟就回来了,这时候把列表请求取消掉,回来之后页面还是空的,体验反而更差。

如果确实需要处理长时间后台的情况,可以在 onHide 时记个时间戳,onShow 时判断间隔超过一定阈值(比如 5 分钟)再刷新数据。

七、各端差异和几个踩过的坑

1. abort 之后的 Promise 一定要处理

前面提到过,abort 会让 promise reject。如果调用方没有 catch,会污染控制台。有两种处理方式:封装里加内部 catch(上面已经做了),或者约定调用方必须处理 AbortError。

两种都行,但千万不能两边都不管。我见过有项目因为这个问题,控制台里刷了几千条 unhandled rejection,把真正的问题全都埋住了。

2. 重复调用 abort 没有意义

一个请求已经被取消了,再调一次 abort,小程序端不会有任何反馈,H5 端可能抛个异常。上面代码里用 if (abortReason) return 做了保护,这是个必要的防御。

3. 在 H5 端,同步代码里调 abort 可能来不及

有次写了这么一段:

const p = http.get('/slow-endpoint')
p.abort()

本意是发出去立刻就取消掉,实际上在 H5 端请求已经发出去了,abort 也确实调用了,但 XHR 的 abort 需要在网络栈里排队处理。看起来是取消了,只是后端可能已经收到了请求。所以不要指望用 abort 做「撤销业务操作」,它只是客户端层面的取消。

4. 网络错误和被取消要严格区分

因为两者都会走 fail 回调。如果代码里靠 errMsg.indexOf('abort') 来判断,在不同端会出各种幺蛾子。上面代码里用 abortReason 这个闭包变量来标记,是最稳妥的做法。

5. 上传和下载是另一套 API

uni.uploadFileuni.downloadFile 同样返回 task 对象,也支持 abort,需要单独封装一遍。它们的错误处理逻辑和请求不太一样,但取消机制可以复用同一套 scope 和 slot 结构。这个工作本文不展开了。

6. 别把 scope 存在 data 里

这是个 Vue 使用者容易犯的错误。scope 里包含 Set 和 Map 这种不可序列化的结构,放到 data 里会被 Vue 的响应式系统包一层,性能会掉得很难看。正确做法是挂在组件实例上作为一个普通属性,用 this._reqScope 访问。

八、完整版的一个小优化

上面拼出来的代码已经能跑了,但还有两个可以优化的小点。

第一,给请求加一个统一的前置钩子,方便做一些全局事情,比如加上时间戳防缓存、加上设备信息、或者记录埋点:

// 在 uni.request 之前,finalHeader 组装完之后
const beforeSend = request.beforeSend
if (typeof beforeSend === 'function') {
  const extra = beforeSend({ url, method, data, header: finalHeader })
  if (extra) Object.assign(finalHeader, extra)
}

第二,把常用模块的 API 抽到一个独立的 api 目录下,页面只关心「调用哪个方法」,不关心 URL 和参数结构:

// api/order.js
import { http } from '@/utils/request'

export function fetchOrderList(params, options = {}) {
  return http.get('/orders', params, {
    slot: 'order-list',
    ...options
  })
}

export function submitOrder(payload) {
  return http.post('/orders', payload, {
    dedupe: 'share',   // 防重复提交
    silent: false
  })
}

页面里就变成:

const list = await fetchOrderList({ status: this.status })

清爽很多,而且 slot 的命名策略集中在一处,改起来方便。

九、收尾

这套方案不是银弹。它解决的是「同一数据源、同一用户意图下多次请求互相干扰」这一类问题。如果业务本身就是需要多个请求同时并发、结果合并展示的,那就要另外设计,比如用 Promise.all 配上取消信号。

但从实际项目经验来看,页面上大部分数据加载都属于前面那种模式——用户想看的是「最新状态」,中间过程无所谓。想清楚这一点,剩下的就是工程实现的细节了。

回头看,整个方案的核心其实就一句话:把「取消」这件事,从调用方的自觉行为,变成框架层面的默认行为。页面上写代码的时候不需要每次都想着「万一用户手快怎么办」,框架自己会处理。这才是让这类 bug 从根上消失的办法,比到处加防抖、加遮罩要可靠的 多。

uni-app 请求治理实战:用 RequestTask 解决快速切页数据错乱与重复提交
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 请求治理实战:用 RequestTask 解决快速切页数据错乱与重复提交 https://www.taomawang.com/web/uniapp/2786.html

常见问题

相关文章

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

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