Vue大文件上传实战:分片、断点续传与秒传原理及代码实现
2026/9/16 7:02:42 网站建设 项目流程

做前端时间久了你会发现一个规律:凡是涉及"上传"的需求,小文件随便写写就完了,一旦文件上了 1GB、甚至几个 GB,普通的上传方式就各种翻车。我之前接了一个内部系统的活,用户要传几十 GB 的监控录像,一开始项目经理觉得"不就是个上传吗",结果联调阶段天天被测试报 bug:传一半断网了要重新传、nginx 直接 413、后端内存爆掉。后来老老实实改成分片上传,问题才算真正解决。

这篇教程就围绕 Vue 大文件上传,把我从原理到示例代码的完整思路过一遍。适合刚接触大文件上传、想搞懂切片逻辑的人,也适合已经做过一版但被边界问题折磨的朋友。看完你能搞清楚三件事:大文件上传到底难在哪、分片/断点续传/秒传是怎么配合的、以及一套可以直接抄进 Vue 工程的代码长什么样。

1. 为什么大文件不能一把梭:三个绕不开的硬瓶颈

1.1 请求体限制和网关超时

先说最直观的:大多数上传场景走的都是普通 POST 请求,整个文件塞进请求体里一次发出去。问题是几乎所有服务端中间件都对请求体大小有限制。nginx 默认的client_max_body_size只有 1MB,后端框架也各有各的限制,你以为自己写好了上传接口,结果一发大文件直接 413,页面报错,用户一脸懵。

就算你把 nginx 和后端的限制都调大了,网关和反向代理的超时时间也是个大问题。一个 1GB 的文件,在普通家用宽带的 20Mbps 上传速率下,理论上就要传六七分钟,这期间如果代理超时时间设的是 60 秒,请求早就被掐断了。调大超时是一种办法,但这等于把风险往后挪:网络一抖动,整个请求就废了,而这个"废"的代价是整个文件重来。

1.2 断网重传:一次请求失败,全量重来

这是我实际项目里被测试追着打最多的情况。普通上传是一次性请求,只要中途网络断一下、或者后端服务重启了一下、甚至只是手机切了个 Wi-Fi,整个请求报错,用户就要从头再传一次。几个 GB 的文件传了 40 分钟突然失败重来,换谁都要骂人。

分片上传的优势就在这里:每个分片是独立的小请求,失败了只重传那一片,其他已经传成功的分片不用动。再加上"已传分片"的记录机制,用户关掉页面重新打开都能接着传,这就是"断点续传"的底层来源。

1.3 内存和线程占用:浏览器和后端都难受

还有一个容易忽略的点。浏览器读取一个大文件,如果直接通过 FormData 提交,整个文件会被读进内存(或者触发复杂的 multipart 组装流程);后端接收的时候,如果用的是框架里默认的 bodyParser,也可能把整个请求体缓冲到内存里。文件一上 GB,两边内存直接吃紧,多几个用户同时传,后端就可能 OOM。

分片以后,单次请求只有几 MB,内存压力小一个数量级。这也是分片上传能扛高并发的根本原因。所以切片的本质不是炫技,而是把"一个大请求"拆成"多个小请求",让每一个环节的资源和时间都处于可控范围。

2. 分片上传的核心原理:切片、指纹、并发、合并是怎么串起来的

2.1 先画一遍完整流程

分片上传的完整流程可以分成这么几步:

  1. 用户选择文件后,前端把文件按固定大小切成多个分片;
  2. 计算整个文件的唯一指纹(一般用 MD5),这个指纹就是后面所有接口的文件标识;
  3. 调用后端"校验接口",告诉后端文件指纹、文件名、分片总数,让后端判断:文件是否已经完整存在(存在就直接秒传成功)、有哪些分片已经传过(不完整则返回已传分片列表,用于断点续传);
  4. 前端把未传的分片逐个上传,每个分片都带上文件指纹和分片序号;
  5. 所有分片传完后,前端调用"合并接口",通知后端把所有分片按顺序合并成完整文件;
  6. 后端合并完成,返回文件地址,上传结束。

这套流程里最核心的技术点是三个:切片、指纹、并发控制。下面逐个说。

2.2 切片:Blob.slice 是把大文件切成小块的唯一姿势

浏览器里File对象继承自Blob,而Blob原生支持slice方法,可以直接从原文件里截取一段二进制数据,不占用整份拷贝,底层是引用原文件的片段。所以前端切片做起来非常简单:根据分片大小算出总片数,循环slice就能拿到所有分片。

这里有个细节:File.slice在不同浏览器里的参数名曾经有差异(webkitSlice、mozSlice),但现代浏览器都支持slice了,老项目兼容到 IE 才需要 polyfill,现在基本可以放心直接用。

分片大小怎么定?后面会专门讲,通常取 5MB 到 20MB 比较常见。太小会导致请求数过多、HTTP 往返开销大;太大会失去分片的意义。

2.3 指纹:整个文件算一个 MD5,干什么用

每个分片本身并没有身份,我们把一堆分片发给后端,后端怎么知道这些分片属于同一个文件、以及拼起来顺序对不对?答案就是文件指纹。

前端把整个文件读一遍,用 SparkMD5 这类库算出一个 MD5 值。这个值相当于文件的"身份证号"——内容相同的文件,MD5 一定相同。后端拿到指纹后,可以拿它做三件事:

  • 用它作为分片存储的目录名,把同一个文件的所有分片放在一起;
  • 检查指纹是否已存在且分片齐全,实现秒传;
  • 检查哪些分片已经上传过,实现断点续传。

算 MD5 的代价是必须把文件完整读一遍。几个 GB 的文件在浏览器里算 MD5,主线程会被 FileReader 的读操作卡住,页面直接失去响应。所以进阶做法是把算哈希的活儿丢给 Web Worker,文章后面会单独说。

2.4 并发控制:不是越快越好,是"够用且不崩"

分片上传天然支持并发,因为切片之间互不依赖。但并发数不是越大越好:浏览器对同一域名的并发连接数是有限制的(HTTP/1.1 通常是 6 个左右),而且前端同时把太多分片读进内存发起请求,也会带来内存压力。

通常的做法是维护一个"并发池",限制同时执行的请求数,比如 3 到 6 个。核心逻辑不复杂:用一个Set存放当前正在执行的请求,满了就await Promise.race,等其中任意一个完成后腾出位置,再塞进下一个。核心代码在下一章直接给出。

3. Vue 里可复用的上传组件:完整示例代码与逐段解析

3.1 组件结构和数据定义

我用 Vue 3 + Composition API 的写法,Vue 2 的 Options API 逻辑也可以平移,核心都在那堆函数里。先看组件的模板:

<template> <div class="uploader"> <input type="file" accept="*/*" :disabled="uploading" @change="handleFileChange" /> <div v-if="file" class="info"> <span>文件名:{{ file.name }}</span> <span>大小:{{ formatSize(file.size) }}</span> </div> <div class="progress" v-if="file && !uploaded"> <div class="progress-bar" :style="{ width: progress + '%' }"></div> <span>{{ progress.toFixed(1) }}%</span> </div> <div class="actions"> <button :disabled="!file || uploading" @click="startUpload">开始上传</button> <button :disabled="!uploading" @click="pauseUpload">暂停</button> </div> </div> </template>

我特意加了暂停按钮,这个后面讲断点续传时会用到。状态数据在 script 里是这样:

import { ref, computed } from 'vue' import axios from 'axios' import SparkMD5 from 'spark-md5' const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB const MAX_CONCURRENCY = 3 const file = ref(null) const chunks = ref([]) const fileHash = ref('') const uploading = ref(false) const uploadedChunks = ref(new Set()) // 已成功上传的分片 index 集合 const settledCount = ref(0) // 已结束请求的分片数 const totalProgress = ref(0) const progress = computed(() => totalProgress.value) function formatSize(size) { if (size < 1024) return size + 'B' if (size < 1024 * 1024) return (size / 1024).toFixed(1) + 'KB' if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + 'MB' return (size / 1024 / 1024 / 1024).toFixed(2) + 'GB' }

3.2 创建分片与计算指纹

选中文件后,第一步就是切片并算哈希:

function handleFileChange(e) { file.value = e.target.files[0] chunks.value = createChunks(file.value) fileHash.value = '' uploadedChunks.value = new Set() settledCount.value = 0 totalProgress.value = 0 } function createChunks(file, chunkSize = CHUNK_SIZE) { const list = [] let cur = 0 while (cur < file.size) { const end = Math.min(cur + chunkSize, file.size) list.push({ index: list.length, file: file.slice(cur, end), size: end - cur }) cur = end } return list } function calculateHash(chunks) { return new Promise((resolve, reject) => { const spark = new SparkMD5.ArrayBuffer() let index = 0 const total = chunks.length function next() { const reader = new FileReader() reader.onload = (e) => { spark.append(e.target.result) index++ if (index < total) { next() } else { resolve(spark.end()) } } reader.onerror = reject reader.readAsArrayBuffer(chunks[index].file) } next() }) }

这里有几个细节值得说明。

第一,FileReader 一次只读一个分片,读完一个再读下一个,避免一次性把所有分片都载入内存。第二,SparkMD5.ArrayBuffer支持增量追加数据,正好配合分片读取,性能比直接把整个文件一次性读进来要好。第三,如果你算完哈希发现进度条卡了很久,说明文件太大、主线程被读操作占住了,后面会讲用 Web Worker 优化。

3.3 并发上传控制器

算完哈希以后,先校验,再上传。核心是下面这个并发控制器:

async function startUpload() { if (!file.value || uploading.value) return uploading.value = true fileHash.value = await calculateHash(chunks.value) // 1. 先向后端校验,看秒传和已传分片情况 const checkRes = await axios.post('/api/check', { fileHash: fileHash.value, fileName: file.value.name, totalChunks: chunks.value.length }) if (checkRes.data.data.uploaded) { // 文件已完整存在,直接秒传成功 uploading.value = false totalProgress.value = 100 return } // 2. 合并后端记录和 localStorage 记录,作为"已传分片"集合 const localUploaded = getLocalUploaded(fileHash.value) uploadedChunks.value = new Set([ ...(checkRes.data.data.uploadedChunks || []), ...localUploaded ]) // 3. 只挑未传的分片放入并发池 const pool = new Set() const pending = chunks.value.filter(c => !uploadedChunks.value.has(c.index)) for (const chunk of pending) { if (pool.size >= MAX_CONCURRENCY) { await Promise.race(pool) } const task = uploadChunk(chunk).then(() => { uploadedChunks.value.add(chunk.index) settledCount.value++ saveLocalUploaded(fileHash.value, uploadedChunks.value) }).catch((err) => { // 单片失败,重试一次 return uploadChunk(chunk).then(() => { uploadedChunks.value.add(chunk.index) settledCount.value++ saveLocalUploaded(fileHash.value, uploadedChunks.value) }) }).finally(() => { pool.delete(task) totalProgress.value = (settledCount.value / chunks.value.length) * 100 }) pool.add(task) } await Promise.all(pool) // 4. 全部传完,调合并接口 await axios.post('/api/merge', { fileHash: fileHash.value, fileName: file.value.name, totalChunks: chunks.value.length }) uploading.value = false } async function uploadChunk(chunk) { const formData = new FormData() formData.append('file', chunk.file) formData.append('fileHash', fileHash.value) formData.append('chunkIndex', chunk.index) formData.append('totalChunks', chunks.value.length) return axios.post('/api/upload', formData, { timeout: 120000 }) }

注意pool.delete(task)的写法:task是在const声明之后才被赋值的,但由于finally回调是异步执行的,等它真正跑起来时task早就赋值完毕了,所以闭包引用没有任何问题。这个写法在社区里也很常见,用来在Promise.race的等待中精确移除已经结束的请求。

3.4 进度条:按分片完成数算,还是按字节数算

进度的计算有两种口径:按分片个数算,和按已上传字节数算。分片大小相同的情况下,两者基本等价,但更精确的做法是按字节数,因为实际网络传输中每个分片的耗时差异很大。刚才的示例里我按分片个数计算,胜在简单;如果你要精确到小数位,可以记录每个分片的size,然后:

const uploadedBytes = [...uploadedChunks.value].reduce((sum, idx) => { return sum + chunks.value[idx].size }, 0) totalProgress.value = (uploadedBytes / file.value.size) * 100

如果你的产品要求显示每个分片的实时网速,那还要配合 axios 的onUploadProgress事件,拿到当前分片的loadedtotal再做累计。这属于锦上添花,核心逻辑不复杂,只是要注意onUploadProgress高频率触发时怎么节流更新进度条,不然 UI 频繁刷新会掉帧。

4. 断点续传与秒传的落地:localStorage 配合后端校验

4.1 断点续传到底断在哪、续在哪

断点续传的前提是"前端知道哪些分片已经传成功了"。这需要两层记录。

一层是后端的记录。每次分片上传成功,后端把分片信息写进存储(数据库、Redis,哪怕是文件系统里的一个列表都行)。下次前端带着 fileHash 来校验,后端返回已传分片列表。这是最可靠的记录。

另一层是前端的本地记录。把已传分片的 index 集合存进 localStorage,key 可以设计成upload_${fileHash}。这样即使后端没做记录(有些临时方案或 mock 后端),或者用户关掉页面又重新打开,前端也能跳过已传分片。我在示例里用了一个简单的 localStorage 封装:

function getLocalUploaded(fileHash) { const key = `upload_${fileHash}` const data = localStorage.getItem(key) return data ? JSON.parse(data) : [] } function saveLocalUploaded(fileHash, uploadedSet) { localStorage.setItem(`upload_${fileHash}`, JSON.stringify([...uploadedSet])) } function clearLocalUploaded(fileHash) { localStorage.removeItem(`upload_${fileHash}`) }

注意:localStorage 的可存储空间有限(一般 5MB),如果你的分片数非常多(比如几万片),存 index 数组可能超限。这种情况建议只存"最后一个连续完成的 index"或者用 IndexedDB,但绝大多数场景index数组都够用。

4.2 秒传的实现:后端到底怎么判断

秒传不是前端魔法,关键在后端。前端调用/api/check时带过去 fileHash,后端做的事很简单:

  1. 查一下这个 fileHash 对应的完整文件是否已经存在;
  2. 存在就直接返回uploaded: true,前端都不用上传任何分片,直接提示上传成功;
  3. 不存在就看有哪些分片已经传过,返回uploadedChunks列表。

所以前端拿到 check 结果后,只需要上传缺失的分片。秒传的存储成本由后端承担,但它的价值巨大:同一份文件被多人多次上传时,服务器几乎零费用返回成功,对网盘类产品是刚需。

这里有个注意事项:MD5 校验不是绝对安全,理论上有碰撞可能,而且"内容相同"不等于"文件合法"。业务层面如果对文件有安全要求,后端还要对合并后的文件做二次扫描,别把秒传当成安全绕过通道。

4.3 暂停与继续:顺序上传模式下的最简单实现

上面的并发池代码里塞暂停逻辑,会让代码变得很绕。如果你的项目对并发要求不高,可以用一个更直白的顺序上传模式,暂停起来非常清爽:

let paused = false async function uploadSequential(chunks, fileHash) { const uploadedSet = new Set(getLocalUploaded(fileHash)) for (let i = 0; i < chunks.length; i++) { if (paused) break if (uploadedSet.has(chunks[i].index)) continue try { await uploadChunk(chunks[i]) uploadedSet.add(chunks[i].index) saveLocalUploaded(fileHash, uploadedSet) } catch (err) { // 失败不中断,重试一次后再失败就停下 await uploadChunk(chunks[i]) uploadedSet.add(chunks[i].index) saveLocalUploaded(fileHash, uploadedSet) } } if (!paused) { await axios.post('/api/merge', { fileHash, fileName: file.value.name, totalChunks: chunks.length }) } } function pauseUpload() { paused = true }

继续上传时,把paused置回 false,重新调uploadSequential,已经从 localStorage 读出的uploadedSet会跳过已传分片,实现"续传"。这个模式虽然比并发慢,但逻辑清晰、错误处理直观,适合中小文件、对速度不敏感的场景。想要并发又想要优雅暂停,可以研究一下 p-limit 库配合信号量中断,不过那属于另一个话题了。

5. 后端接口约定与联调清单:前端写得再欢也得有人接

5.1 三个接口的职责划分

前端代码再好,后端不配合也白搭。我跟后端联调时,一般会把接口约定写成下面这种表格,双方照着签:

接口方法入参返回
/api/checkPOSTfileHash, fileName, totalChunks{ uploaded: boolean, uploadedChunks: number[] }
/api/uploadPOSTfile(表单), fileHash, chunkIndex, totalChunks{ ok: true }
/api/mergePOSTfileHash, fileName, totalChunks{ url: string }

这里有个容易被忽略的约定:totalChunks必须由前端传,因为后端并不知道这个文件总共有多少片;merge 的时候也建议把totalChunks传过去,方便后端校验分片是否收齐,避免前端漏传导致合并出半个文件。

5.2 分片合并的顺序问题

后端收到的是乱序到达的分片,它的存储目录里分片可能是 0、2、1、4、3 这样的顺序。合并时必须按chunkIndex升序读取分片再拼接,绝不能按文件修改时间或名字排序拼。我在实际项目里见过一次线上事故:后端按分片文件的 inode 时间排序合并,结果视频传到一半就花屏,查了半天才发现是合并顺序错了。

另外,合并时一般用流式写入(Node 的createWriteStream、Java 的FileChannel、Go 的os包都能处理),千万别用 readFile 把整个文件读进内存再 write,大文件分分钟炸内存。

5.3 文件重名的处理

分片存储的目录建议用 fileHash 命名,比如uploads/${fileHash}/下面放所有分片。这样的话,不同用户上传同名但内容不同的文件,也不会互相覆盖。合并后的最终文件名可以用${fileHash}_${originalName}保存,既保留原名,又避免重名冲突。如果业务需要保留原始文件名给用户下载,那就在数据库里多存一个字段,别拿原名直接当磁盘文件名。

还有一点:如果后端做了定时清理,建议清理规则以"最后活跃时间"为准,而不是以创建时间为准。因为断点续传的用户可能隔了一天才继续传,过早清理会把已传的分片删掉,前面的功夫全白费。

6. 我实测踩过的坑:并发数、分片大小、哈希耗时和异常恢复

6.1 并发数选多少合适

并发数我是从 6 调到 3 再调到 6 的。第一次做的时候以为并发越高越好,直接开了 10 个,结果自家测试服务器上传时带宽被打满,其他接口全卡死。后来压测发现,对于后端处理能力一般的情况,3~5 个并发是比较稳的区间;如果后端在云上、带宽充足,6~8 个也能跑。关键是做压测,别拍脑袋。

要注意浏览器 HTTP/1.1 对同一域名的并发连接限制一般是 6,如果业务里有其他请求也打到同一个服务,前端包一层代理域名或者升级 HTTP/2 可以缓解,但这些属于架构层面的事,做上传组件时先守住"别超过 6"这条线就行。

6.2 分片大小怎么选

分片大小我试过 1MB、5MB、10MB、20MB。结论是:1MB 分片数为 1000 个(1GB 文件),请求数量太多,浏览器和服务器都累;20MB 分片在弱网环境下单片失败重传成本高。5MB 到 10MB 是主流选择。

再结合一个因素:后端如果对单次请求体有 10MB 限制,那就选 8MB 以内;如果有 50MB 限制,选 20MB 也能跑。总之分片大小要跟后端限制对齐,别前端切得很开心、后端一接就 413。

6.3 哈希计算的卡顿与 Web Worker 优化

拖一个 5GB 的文件进来,主线程算 MD5,页面会卡到让人怀疑电脑死机。因为 FileReader 的readAsArrayBuffer会不断占用主线程。优化方案是把读文件和算哈希放进 Web Worker:

// worker.js importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js') self.onmessage = function (e) { const { chunks } = e.data const spark = new self.SparkMD5.ArrayBuffer() let index = 0 function next() { const reader = new FileReader() reader.onload = (ev) => { spark.append(ev.target.result) index++ if (index < chunks.length) { next() } else { self.postMessage({ hash: spark.end() }) } } reader.readAsArrayBuffer(chunks[index].file) } next() }

主线程里这样调用:

const worker = new Worker('/worker.js') worker.postMessage({ chunks: chunks.value }) worker.onmessage = (e) => { fileHash.value = e.data.hash worker.terminate() }

注意 Web Worker 里无法直接操作 DOM,但File是可以在postMessage里传递的(结构化克隆),所以 chunks 数组传进去没问题。这一个改动,就能让大文件在上传前期的"卡死"阶段变成顺滑的等待。很多团队讨论的"前端使用 worker 上传大文件"就是这个思路,它优化不止哈希,后续分片的读取和预处理同样可以搬到 worker 里做,当然那属于更深的性能优化,先把哈希这关过了收益最大。

6.4 其他几个高频异常

  • 413 Request Entity Too Large:先查网关和后端限制,再查分片大小是否超了后端单请求限制。
  • 网络中断导致单片上传失败:加上失败重试机制,指数退避重试三次,三次还失败就停下来提示用户,别让它无限重试。
  • 合并后文件损坏:优先检查后端的合并顺序,其次检查是否有分片遗漏。
  • localStorage 存满:分片数量大时不存全量数组,改成增量记录或 IndexedDB。
  • 秒传误判:前端算错哈希或者后端存储碰巧有相同哈希文件,导致用户传了新文件却秒传成旧文件。排查时先对比两个文件的 MD5 是否一致。

这个方案在我手上的项目里稳定跑了一年多,几十 GB 的录像文件没再出过传一半丢了的投诉。中间只改过两次:一次是并发数从 6 降到 4,另一次是给后端加了一个临时存储清理任务。如果你也是第一次接大文件上传的需求,别急着上各种花哨功能,先把切片、合并、断点续传这三板斧打磨稳,后面的优化都是顺手的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询