上周接到一个需求:用户要把手机里录的视频传上来,最大的一个 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,跑在哪都一样。

