uni-app 大文件分片上传实战:一套代码统一小程序、H5、App 的断点续传与秒传

2026-10-08 0 508

上周接到一个需求:用户要把手机里录的视频传上来,最大的一个 480MB。第一版我直接用 uni.uploadFile 传整包,结果是这样的:进度条走到 60% 卡住,再过 30 秒报「网络请求失败」,用户返回上一页重传,又从头开始。测试机换成 4G 网络之后成功率大概只有三成。

这不是 uni-app 的问题,任何平台的单次上传到了这个体积都会出事。要解决就得拆成三件事:文件切成分片、分片能重传、重传时知道哪些已经传过了。麻烦的地方在于,uni-app 要跑在小程序、H5、App 三端,而这三端连「怎么从文件里读出第 3 个分片的字节」这件事,做法都不一样。

这篇文章把我最后落地的方案完整写出来。代码是按可运行的标准写的,中间那些让我卡住的地方会专门标出来。

为什么 uni.uploadFile 撑不住大文件

先把它的短板列清楚,不然改完还是不知道改了什么。

  • 一次性发送,没有分片概念。中断就是全部重来。
  • 进度回调粒度很粗,小程序端 onProgressUpdate 上报的 loaded 偶尔会跳变,做不了精确的并发进度合并。
  • 小程序端有单次请求体积上限,虽然各平台数值不同,但大文件必然踩到。
  • 没有重试机制。网络抖一下就整个失败,超时时间也不好调。

还有一个容易被忽略的点:上传失败时你拿不到「传到哪了」这个信息。服务端可能已经收到 90% 的数据,但客户端一无所知,只能从头再来。分片方案的价值不只是把大改小,更在于让「传输进度」变成一个可持久化、可恢复的状态。

先把整体流程定下来

分片上传的流程不复杂,但每一步的顺序不能乱,特别是秒传和断点续传必须在切分之前完成:

  • 拿到文件,计算文件指纹(hash)。
  • 拿指纹去问服务端:这个文件是不是已经有了?有就秒传结束。
  • 服务端返回已经收到的分片序号列表。
  • 本地算出「总共有哪些分片」,减掉服务端已收的,再减掉本地记录里传完的,剩下的才是本次要传的。
  • 把待传分片丢进并发池,逐个上传,每个成功就记一笔。
  • 全部分片到齐后,通知服务端合并。

注意第三步和第四步的区别。服务端只看得到它收到的,本地记录看得到「客户端认为传完了但服务端没落库」的。两个都要用,取交集之外的补传。只依赖服务端列表的话,每次失败重试都得重新问一遍接口;只依赖本地记录的话,用户清一次缓存就白传了。

文件指纹:别在大文件上算全量 MD5

第一个要做的决定是:hash 怎么算。

教科书做法是整文件跑一遍 MD5。在 H5 上用一个 480MB 的文件试了一下,PC 浏览器大概 8 秒,中端安卓机 40 秒以上。用户点了上传之后转一分钟圈,这个体验直接劝退。而且这么做的收益只有秒传和断点续传两处,性价比太低了。

抽样指纹的做法

我最后用的方案是抽样:只取文件的开头 2MB、末尾 2MB,加上文件大小和最后修改时间,一起算 MD5。

// utils/fingerprint.js
import SparkMD5 from 'spark-md5'

const SAMPLE_SIZE = 2 * 1024 * 1024

/**
 * 对「头部 + 尾部 + 元信息」做抽样指纹
 * 计算量固定在 4MB 左右,跟文件多大没关系
 */
export function sampleFingerprint(meta) {
  const { size, name, lastModified } = meta
  const spark = new SparkMD5.ArrayBuffer()
  // 把元信息也喂进去,避免两个同尺寸文件撞在一起
  const seed = `${name}|${size}|${lastModified}`
  spark.append(new TextEncoder().encode(seed))
  return {
    spark,
    appendChunk(buffer) {
      spark.append(buffer)
    },
    end() {
      return spark.end()
    }
  }
}

调用方负责读头尾两段二进制喂给它。这样算出来的值不是文件内容的真哈希,而是一个「文件身份标识」。对秒传来说够用,因为我们只需要判断「是不是同一个文件的上传行为」,而不是「两个文件内容是否完全一致」。

这个取舍要跟服务端说清楚。不要用抽样指纹去校验文件完整性,它是身份标识,不是校验和。真正的内容校验交给服务端在合并完成后自己做,或者干脆不做。

三端计算 hash 的差异

spark-md5 需要 ArrayBuffer。H5 端从 File 对象切一段丢给 FileReader 就行,很直接。小程序和 App 端没有 FileReader,得用 FileSystemManager.readFile 读指定位置和长度。

// 读取一段二进制并返回 ArrayBuffer
function readRange(fp, start, length) {
  return new Promise((resolve, reject) => {
    uni.getFileSystemManager().readFile({
      filePath: fp,
      // 关键:encoding 必须给空字符串,
      // 否则拿到的是 string,spark-md5 会报错
      encoding: '',
      position: start,
      length,
      success: res => resolve(res.data),
      fail: reject
    })
  })
}

这里有个我自己踩过的细节:如果 encoding 不写,微信默认按 utf8 读,返回的是字符串。spark-md5 的 ArrayBuffer 版本收到字符串会抛异常,报错信息还特别含糊。写空字符串才走二进制通道。

另外,position 和 length 这两个参数需要较新的基础库版本支持。老版本上它们会被忽略,readFile 会把整个文件读出来,480MB 的文件直接把内存打爆。上线前一定要在 uni.getSystemInfoSync() 里检查一下 SDK 版本,低于门槛就退化成「只传整包」或者提示用户升级微信。

读取分片:三端最不统一的一层

这是整个方案里最需要花时间的地方。三端读文件的 API 完全不同,但读取之后的用途是一样的,所以我把它抽象成了一个 reader 对象。

H5:Blob.slice,一行搞定

// #ifdef H5
function createH5Reader(file) {
  return {
    size: file.size,
    kind: 'blob',
    // 返回的是 Blob,可以直接塞进 FormData
    read(index, chunkSize) {
      const start = index * chunkSize
      const end = Math.min(start + chunkSize, file.size)
      return Promise.resolve(file.slice(start, end))
    }
  }
}
// #endif

H5 是唯一一个不需要落地临时文件的平台,Blob 对象直接就能当表单字段发出去。这一点在后面上传环节会体现得很明显。

小程序与 App:FileSystemManager 加临时文件

小程序和 App 拿到的都是路径(tempFilePath),不是文件对象。要用的时候得先把某一段读出来,落成一个临时文件,然后才能交给 uploadFile。

// #ifndef H5
function createPathReader(filePath, size) {
  const fs = uni.getFileSystemManager()
  const tempFiles = []

  return {
    size,
    kind: 'path',

    // 读出第 index 个分片的 ArrayBuffer
    read(index, chunkSize) {
      const start = index * chunkSize
      const length = Math.min(chunkSize, size - start)
      return new Promise((resolve, reject) => {
        fs.readFile({
          filePath,
          encoding: '',
          position: start,
          length,
          success: res => resolve(res.data),
          fail: reject
        })
      })
    },

    // 把 ArrayBuffer 落成临时文件,返回路径
    materialize(buffer, index) {
      const target = `${getTempRoot()}/chunk_${Date.now()}_${index}`
      return new Promise((resolve, reject) => {
        fs.writeFile({
          filePath: target,
          data: buffer,
          success: () => {
            tempFiles.push(target)
            resolve(target)
          },
          fail: reject
        })
      })
    },

    // 上传结束后统一回收
    cleanup() {
      tempFiles.forEach(p => {
        try { fs.unlinkSync(p) } catch (e) {}
      })
      tempFiles.length = 0
    }
  }
}
// #endif

临时目录的获取方式两端不同,用一个小的兼容函数处理:

function getTempRoot() {
  // #ifdef MP-WEIXIN
  return wx.env.USER_DATA_PATH
  // #endif
  // #ifdef APP-PLUS
  return plus.io.convertLocalFileSystemURL('_doc/upload_tmp')
  // #endif
}

注意 App 端的目录要先创建,否则 writeFile 会失败。这行代码我第一版漏了,表现是前两个分片正常,后面全部报错,排查了半天才发现是目录不存在导致写入失败——但失败回调里给的错误信息完全没有提目录的事。

上传分片:条件编译的两条路

读取方式统一之后,上传阶段又分成了两派。

H5 端拿到的是 Blob,最可靠的做法是直接用 XMLHttpRequest 加 FormData,不绕 uni.uploadFile。因为 uni.uploadFile 在 H5 端接受的是 filePath,Blob 得先转成 blob URL,多一次转换就多一处可能出错的地方,而且blob URL 忘了 revoke 会泄漏内存。既然都要写,不如直接写 XHR。

// #ifdef H5
function uploadChunk(blob, formData, onProgress, getAbort) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    const fd = new FormData()
    Object.keys(formData).forEach(k => fd.append(k, formData[k]))
    fd.append('chunk', blob)

    xhr.upload.onprogress = e => {
      if (e.lengthComputable) onProgress(e.loaded)
    }
    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText))
      else reject(new Error(`HTTP ${xhr.status}`))
    }
    xhr.onerror = () => reject(new Error('network'))
    xhr.ontimeout = () => reject(new Error('timeout'))
    xhr.timeout = 60000

    getAbort(() => xhr.abort())
    xhr.send(fd)
  })
}
// #endif

小程序和 App 端只能用 uni.uploadFile,把临时文件路径传进去。

// #ifndef H5
function uploadChunk(tempPath, formData, onProgress, getAbort) {
  return new Promise((resolve, reject) => {
    const task = uni.uploadFile({
      url: UPLOAD_CHUNK_URL,
      filePath: tempPath,
      name: 'chunk',
      formData,
      success: res => {
        try {
          const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
          if (res.statusCode >= 200 && res.statusCode < 300) resolve(body)
          else reject(new Error(`HTTP ${res.statusCode}`))
        } catch (e) {
          reject(e)
        }
      },
      fail: err => reject(new Error(err.errMsg || 'upload fail'))
    })

    task.onProgressUpdate(e => onProgress(e.loaded))
    getAbort(() => task.abort())
  })
}
// #endif

两边的函数签名故意做成一样,这样上层的调度逻辑不用写条件编译。这是我在这份代码里最满意的一处设计,后面加任何新平台,只需要再加一个同签名的实现。

并发池与重试

分片不能一个个传,太慢;也不能一次全丢出去,小程序对并发请求数有硬限制,超了会直接失败。我实测下来 3 个并发是比较稳的,4G 网络下能跑满带宽,弱网时也不至于把连接池占死。

// utils/pool.js
export function createPool(limit) {
  let active = 0
  const queue = []

  function next() {
    if (active >= limit || queue.length === 0) return
    active++
    const task = queue.shift()
    task().finally(() => {
      active--
      next()
    })
  }

  return function enqueue(fn) {
    return new Promise((resolve, reject) => {
      queue.push(() => fn().then(resolve, reject))
      next()
    })
  }
}

export function sleep(ms) {
  return new Promise(r => setTimeout(r, ms))
}

/**
 * 退避重试:第 n 次失败等 n * base 毫秒
 * 不用指数退避是因为分片请求本身很快,
 * 指数退避会让最后一个分片等太久
 */
export async function withRetry(fn, times = 3, base = 800) {
  let lastError
  for (let i = 0; i < times; i++) {
    try {
      return await fn()
    } catch (err) {
      lastError = err
      if (i < times - 1) await sleep(base * (i + 1))
    }
  }
  throw lastError
}

关于重试次数,有个实际的经验:不要对「服务端明确返回的错误」重试。比如分片校验失败、文件已被删除,这类错误重试十次也没用,只是白白拖慢整体进度。我在 withRetry 外面加了一层判断,只有网络类错误才进重试循环。

断点续传的状态该存在哪

每个分片成功后,往本地记录里写一笔。key 用文件指纹,value 是已完成的分片索引数组。

// utils/progress.js
const KEY_PREFIX = 'upload_progress:'

export function loadProgress(hash) {
  try {
    const raw = uni.getStorageSync(KEY_PREFIX + hash)
    return raw ? JSON.parse(raw) : { done: [] }
  } catch (e) {
    return { done: [] }
  }
}

export function saveProgress(hash, doneSet) {
  try {
    uni.setStorageSync(KEY_PREFIX + hash, JSON.stringify({
      done: Array.from(doneSet),
      updatedAt: Date.now()
    }))
  } catch (e) {
    // 存储满了就放弃记录,不影响本次上传
  }
}

export function clearProgress(hash) {
  try { uni.removeStorageSync(KEY_PREFIX + hash) } catch (e) {}
}

这里有几个细节值得说。

第一,存储要加时间戳并定期清理。用户传了一半退出,这个 key 就留在本地了。如果一直不清,半年后 storage 里全是过期记录。我的做法是启动时扫一遍,超过 7 天的直接删掉。

第二,setStorageSync 要包 try-catch。小程序的 storage 有总量上限,写满之后是抛异常而不是静默失败。分片多的文件,这个数组可能有好几百个元素,不包住的话上传到一半会莫名崩掉。

第三,不要用 setStorageSync 记录实时进度。它是同步 API,每个分片成功都写一次会阻塞主线程,进度条会明显卡顿。我改成每 500ms 或者每完成 10 个分片才落一次盘,中间状态放在内存里。

秒传的接口契约

秒传这块最容易出问题的地方不是客户端,是前后端对「同一个文件」的判断标准。我在项目里定了一个明确的契约:

// 响应示例
// 1. 秒传命中
{ "code": 0, "data": { "exists": true, "url": "https://cdn.example.com/v/abc.mp4" } }

// 2. 需要续传,服务端返回已收到的分片
{ "code": 0, "data": { "exists": false, "uploaded": [0, 1, 2, 5, 7] } }

// 3. 全新上传
{ "code": 0, "data": { "exists": false, "uploaded": [] } }

客户端拿到 uploaded 之后,和本地记录取并集,剩下的才是待传列表。这个逻辑只有几行,但一定要写对:

const totalChunks = Math.ceil(reader.size / CHUNK_SIZE)
const allIndexes = Array.from({ length: totalChunks }, (_, i) => i)

const serverDone = new Set(checkRes.data.uploaded || [])
const localDone = new Set(loadProgress(hash).done)

const pending = allIndexes.filter(
  i => !serverDone.has(i) && !localDone.has(i)
)

// 进度条的起点要把已经传过的分片算进去,
// 否则会出现「明明传了 80% 却从 0 开始」的观感问题
let loadedBytes = (totalChunks - pending.length) * CHUNK_SIZE
loadedBytes = Math.min(loadedBytes, reader.size)

最后那两行是我后来补的。第一版没做这个处理,用户重连之后进度条从 0% 开始爬,虽然实际只传了剩下的部分,但看着像是要重头来一次,用户体验上很别扭。

进度、取消与临时文件回收

进度合并要注意,并发上传时每个分片都在报自己的 loaded,直接累加会重复计算。我的做法是维护一个 Map<index, loaded>,每次某个分片上报时更新对应的值,然后对整个 Map 求和。这样某个分片重试时,它的旧值会被新值覆盖,不会累加出错。

const chunkLoaded = new Map()

function updateProgress(index, loaded) {
  chunkLoaded.set(index, Math.min(loaded, CHUNK_SIZE))
  let sum = 0
  chunkLoaded.forEach(v => { sum += v })
  const percent = Math.min(100, ((loadedBytes + sum) / reader.size * 100)).toFixed(1)
  emit('progress', percent)
}

取消这块,要维护一个 abort 回调集合。每个分片开始上传时把自己的 abort 函数注册进来,结束后移除。

const aborts = new Set()
let cancelled = false

function getAbort(fn) {
  if (cancelled) { fn(); return }
  aborts.add(fn)
}

function cancelAll() {
  cancelled = true
  aborts.forEach(fn => { try { fn() } catch (e) {} })
  aborts.clear()
}

取消之后记得走一次 cleanup,把已经落地的临时文件删掉。小程序的临时文件不会自动清理,一个 480MB 的文件按 2MB 切片,会产生 240 个临时文件,加上源文件本身,一次失败的上传能吃掉接近 1GB 的存储空间。用户手机上出现「存储空间不足」的时候,你很难联想到是上传功能干的。

把主流程串起来

上面所有零件拼在一起,主体不到 80 行:

export async function uploadFile(file) {
  const reader = await createReader(file)
  const hash = await computeHash(reader)

  // 1. 秒传检查
  const check = await request('/upload/check', {
    method: 'POST',
    data: { hash, size: reader.size, name: file.name }
  })
  if (check.data.exists) {
    clearProgress(hash)
    return { url: check.data.url, instant: true }
  }

  // 2. 计算待传分片
  const totalChunks = Math.ceil(reader.size / CHUNK_SIZE)
  const serverDone = new Set(check.data.uploaded || [])
  const localDone = new Set(loadProgress(hash).done)
  const pending = []
  for (let i = 0; i < totalChunks; i++) {
    if (!serverDone.has(i) && !localDone.has(i)) pending.push(i)
  }

  // 3. 并发上传
  const doneSet = new Set(serverDone)
  const enqueue = createPool(MAX_CONCURRENCY)

  await Promise.all(pending.map(index => enqueue(async () => {
    if (cancelled) throw new Error('cancelled')

    return withRetry(async () => {
      const buffer = await reader.read(index, CHUNK_SIZE)
      let payload = buffer
      let tempPath = null

      // 小程序/App 需要先把 ArrayBuffer 落地成文件
      if (reader.kind === 'path') {
        tempPath = await reader.materialize(buffer, index)
        payload = tempPath
      }

      await uploadChunk(payload, {
        hash,
        index,
        total: totalChunks
      }, loaded => updateProgress(index, loaded), getAbort)

      doneSet.add(index)
      if (doneSet.size % 10 === 0 || doneSet.size === totalChunks) {
        saveProgress(hash, doneSet)
      }
    })
  })))

  // 4. 通知合并
  const result = await request('/upload/merge', {
    method: 'POST',
    data: { hash, name: file.name, total: totalChunks }
  })

  clearProgress(hash)
  reader.cleanup?.()
  return { url: result.data.url, instant: false }
}

服务端要配合的四个接口

客户端写得再顺,服务端接口设计不对也是白搭。按我的经验,四个接口的语义要定死:

  • POST /upload/check:入参 hash、size、name。返回 exists 和 uploaded 数组。这个接口必须快,建议在 Redis 里用 set 存分片索引,不要每次查数据库。
  • POST /upload/chunk:入参 hash、index、total,文件字段名为 chunk。落盘到 tmp/{hash}/{index}。要幂等,同一个 index 重复提交直接覆盖,不要报错。
  • POST /upload/merge:按 index 顺序合并,合并完做一次完整性自检,然后转存到正式存储。
  • DELETE /upload:用户取消时调用,清掉服务端半成品。这个接口很多人不做,结果服务端磁盘被一堆永远合并不了的碎片占满。

还有一个坑:hash 作为目录名之前一定要做校验。它来自客户端,如果直接拼进路径,传入 ../../etc 这种东西就出事了。只允许十六进制字符,长度固定,这一条必须写死在服务端。

我踩过的六个坑

一、encoding 不设空字符串。前面提过了,readFile 默认按 utf8 读,拿到字符串,spark-md5 直接抛异常。

二、position 和 length 在老基础库上被忽略。表现是第一次读分片就内存暴涨然后闪退。上线前做版本判断,或者干脆用一个 5MB 的测试文件试一下 readFile 带 position 的结果对不对。

三、临时文件不回收。一次失败的上传能留下几百个文件,几百 MB 的空间。cleanup 要放在 finally 里,不能只在成功路径上调用。

四、并发数设成 10。看起来更快,实际上小程序的 uploadFile 并发上去之后错误率明显升高,而且失败重试会把队列堵死。3 到 5 是最舒服的区间。

五、onProgressUpdate 的 loaded 是单个分片的,不是整体的。第一次收到 2MB 的时候我直接拿它当总进度用,进度条瞬间冲到 100% 然后回到 5%。必须自己维护 Map 求和。

六、切片的时机错了。如果先切片再算指纹,那算指纹的时候就得把所有分片读一遍,等于白白多读一次完整文件。正确顺序是先算指纹再切片。

最后

这套方案上线之后,同一个 480MB 视频在 4G 网络下的上传成功率从三成提到了九成以上,失败的那部分基本都是用户主动退出。断点续传的命中率在测试环境大概 40%,也就是说有四成的上传是从一半开始接着传的,这部分原本全是白费。

代码量比我想的大,核心加适配大概 500 行。但如果一开始就知道三端在文件 API 上差异在哪,其实两天能写完。真正花时间的是那些「回调不报错但结果不对」的地方——encoding、position 失效、临时文件残留,都是这类。它们不会在开发机上暴露,只在用户的旧手机和弱网环境里出现。

如果你的项目里也有大文件上传,建议先把「读取分片」这一层抽象出来单独测。这一层对了,上面所有逻辑就都是纯 JS,跑在哪都一样。

uni-app 大文件分片上传实战:一套代码统一小程序、H5、App 的断点续传与秒传
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 大文件分片上传实战:一套代码统一小程序、H5、App 的断点续传与秒传 https://www.taomawang.com/web/uniapp/2910.html

常见问题

相关文章

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

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