ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue大文件上传实战:分片、秒传与断点续传全解析

Vue大文件上传实战:分片、秒传与断点续传全解析 干前端这些年文件上传是我觉得最容易被低估的功能之一。很多项目前期都以为“上传嘛input file 配个接口就完事”结果真到了大文件场景问题全冒出来了页面卡死、请求超时、服务器内存爆掉、用户等半天说失败。我最早接手这类需求的时候也走了不少弯路后来把 Vue 大文件上传整套逻辑从原理到代码完整跑通之后才发现核心其实就四个字分片、指纹。这篇是针对 Vue 项目里如何做好大文件上传的完整教程包含文件分片、整文件 hash 计算、秒传校验、断点续传、并发控制以及一套可以直接运行的 Node.js 后端示例代码。不管你是刚入门 Vue 的前端新人还是要在后台系统里实现上传模块的开发者对比这份教程就能自己搭出来。下面开始。1. 项目概述先把大文件上传的问题和需求聊透1.1 原生上传在真实场景里为什么撑不住很多人会觉得HTTP 协议本身对请求体大小没有硬性限制那是不是直接拿input typefile拿到 File 对象然后FormData.append(file, file)往后端一发就行了在小文件场景下确实没问题但文件一旦上到几百 MB 甚至几个 GB“直接整个文件提交”这条路几乎走不通。原因主要有几个首先是服务器内存压力。绝大多数后端框架在处理上传时都会先把请求体读入内存或临时文件如果你同时有几十个人上传大文件内存会瞬间爆炸。其次是网络稳定性一个 1GB 的文件传了 80%结果断网几秒钟就可能导致整个请求失败用户只能从头再来。再者是用户体验浏览器原生的上传事件只能拿到整体进度一旦中途失败没有续传机制产品体验会非常糟糕。所以大文件上传不是“能不能传”的问题而是“怎么传才稳、怎么传才能断点续传、怎么传才不拖垮服务端”的问题。1.2 需求拆解大文件上传到底要解决什么我做过好几个后台管理系统的上传模块把需求梳理下来通常绕不开这几点支持超过 1GB 的文件上传浏览器不能卡死。用户能看到真实的上传进度而不是一个只会转的 loading。网络波动或用户主动暂停后重新上传时不需要从头开始。如果服务端已经有相同文件直接标记“秒传”省去重复上传流量。并发上传多个分片提升速度但不能并发太高把后端打挂。这些需求单靠原生表单上传做不到必须用“分片上传 文件指纹 断点续传”这套组合方案来解决。1.3 方案大致长什么样整个方案的核心流程是这样的前端先把大文件切成多个几 MB 的小分片然后计算整个文件的唯一标识一般叫 hash先问一下后端“这个文件有没有传过、哪些分片已经传过了”接着把没传过的分片通过并发控制一个个传上去最后通知后端把分片按照顺序合并成一个完整的文件。这套流程既能实现秒传也能实现断点续传而且因为每个分片都是独立请求单次请求体很小服务器内存压力也小很多。2. 大文件上传的核心原理分片、秒传、断点续传2.1 分片上传完整流程先说分片。前端拿到 File 对象后会利用 Blob 的slice方法把文件切成若干个小块。比如一个 200MB 的文件每片 2MB最后会得到 100 个分片。每个分片单独作为一个请求上传到后端后端暂时把它们存在临时目录。等所有分片都传完了前端再发一个合并请求后端按照分片顺序把临时文件拼成完整文件然后清理临时分片。这个流程看似简单但有几个细节一定要处理好分片顺序、分片完整性、重复分片的覆盖策略。顺序靠分片下标 index 控制完整性靠后端对每个分片做大小校验重复分片则依赖“文件指纹 已上传列表”来判断。2.2 文件指纹hash的作用与计算方式分片上传只是第一步真正让方案变得好用的是文件指纹。什么是文件指纹就是利用 hash 算法把整个文件的内容计算出一段固定长度的摘要。只要文件内容变了哪怕只改了一个字节算出来的摘要也会完全不同。这样文件本身就可以作为唯一标识。我一般用 SparkMD5 这个库来做它对浏览器支持很好而且可以增量计算。增量计算的意思是我不需要把整个文件一次性读进内存而是配合前面分片逻辑每读取一个分片就 append 一次最后end()出结果。这样既省内存又能复用分片逻辑。但是这里有个非常关键的点对大文件做全量 hash 计算本身是 CPU 密集型任务。如果放在 Vue 组件的主线程里跑页面会明显卡顿用户滚动、点击都没反应。正确的做法是丢给 Web Worker 去算Worker 在后台线程算完再把结果传回主线程。这也是热词里有人提到“前端使用 worker 上传大文件”的原因哈希计算是 Worker 最典型的应用场景之一。2.3 分片大小怎么定分片大小不是越大越好也不是越小越好。分片太大单请求还是会占用较多内存失去分片的意义分片太小请求数量暴增网络握手开销就会拖慢整体速度后端还要处理大量小文件。我实战下来通常建议在 2MB 到 5MB 之间。普通项目用 2MB 分片文件特别大比如几个 GB时用 5MB 或 10MB 也可以具体看带宽和后端并发能力。大致估算一下2MB 分片1GB 文件也就是 512 个请求配合 3~5 个并发速度和稳定性比较均衡。2.4 并发数怎么选分片上传时如果前端把几百个分片一次性全部发出去浏览器会创建大量连接后端瞬间收到海量请求很容易把接口打崩。如果只做一个一个串行上传速度又太慢。所以需要一个并发池控制同时上传的分片数量。我一般控制在 3 到 5 个这个数量在大多数服务器上都不会产生明显压力。并发控制本质上就是一个任务队列指定同时执行的任务数量上限每当一个任务完成就从待执行列表里取下一个任务来补位。手写一个简单的asyncPool函数也不难后面代码部分我会直接给出实现。3. 技术选型与架构设计3.1 前端技术组件这套实现我用的前端技术栈是 Vue 3 Vite Element Plus Axios SparkMD5。如果你项目还在用 Vue 2核心逻辑一样只是组件写法上有些差异不影响整体方案。Vue 3组合式 API 写起来更清爽状态集中管理。Vite开发调试方便Worker 写法对原生模块支持友好。Axios负责请求支持上传进度事件和取消请求这也是我们做进度条和暂停功能的基础。Element Plus用现成的进度条组件展示上传百分比省去自己造轮子。SparkMD5计算文件指纹。3.2 后端选型与存储规划后端我用的是 Node.js Express Multer 来演示因为整套代码可以跟着前端一起跑不需要额外装其他语言环境。实际生产项目中换成 JavaSpring Boot、Go、PythonFastAPI都完全可以接口逻辑是通用的。存储规划上我习惯在后端配置两个目录一个临时目录专门放上传的分片一个文件目录放合并后的最终文件。目录按 hash 命名分片比如[hash]_[index]这样同一文件的分片在磁盘上天然集中方便后续校验和合并。合并完成后临时分片必须清理否则任务一多磁盘容易爆。3.3 为什么大文件的 hash 计算一定要丢给 Web Worker有些教程会直接把 SparkMD5 写在 Vue 组件里算 hash文件几 MB 时感觉不明显一旦文件上 GB主线程计算 hash 的时间可能长达十几秒甚至更久期间页面整个冻结。这不是用户体验问题而是严重的产品事故。丢给 Worker 之后主线程只负责发消息和收结果Worker 内部一边分片一边 append hash算完把结构返回。这样用户界面全程流畅进度条也能实时展示“正在计算文件指纹”的状态。我在项目里实测过一个 800MB 的视频文件2MB 分片在普通笔记本上算完 MD5 大约需要几秒到十几秒虽然不算快但页面不卡用户能接受。4. 前端实现Vue3 封装大文件上传组件4.1 文件分片与 hash 计算先定义基本的工具函数和 Worker。分片函数本身很简单关键是用 File 对象的slice方法。// utils/chunk.js export const CHUNK_SIZE 2 * 1024 * 1024 // 2MB export function createChunks(file, chunkSize CHUNK_SIZE) { const count Math.ceil(file.size / chunkSize) return Array.from({ length: count }, (_, i) { const start i * chunkSize const end Math.min(start chunkSize, file.size) return file.slice(start, end) }) }接下来是 Worker 内部的计算脚本。我把它放在public/hash.worker.js里使用 importScripts 引入 spark-md5。// public/hash.worker.js importScripts(/spark-md5.min.js) self.onmessage async (e) { const { file, chunkSize } e.data const spark new self.SparkMD5.ArrayBuffer() let cur 0 while (cur file.size) { const chunk file.slice(cur, cur chunkSize) const buffer await chunk.arrayBuffer() spark.append(buffer) cur chunkSize self.postMessage({ type: progress, percent: Math.min(100, Math.round((cur / file.size) * 100)), }) } self.postMessage({ type: done, hash: spark.end() }) }主线程里新建 Worker向它发送 File 对象和分片大小监听返回结果。这里要注意在 Vue 组件里使用new Worker(/hash.worker.js)时路径要放在 public 目录下这样打包后路径才能正确解析。export function calcFileHash(file, chunkSize CHUNK_SIZE) { return new Promise((resolve, reject) { const worker new Worker(/hash.worker.js) worker.postMessage({ file, chunkSize }) worker.onmessage (e) { if (e.data.type done) { worker.terminate() resolve(e.data.hash) } } worker.onerror reject }) }4.2 秒传与断点校验拿到 hash 之后先调用后端的 verify 接口告诉后端文件名和 hash后端返回这个文件是否已经存在以及已有的分片列表。// utils/upload.js import axios from axios export async function verifyFile({ filename, hash }) { const { data } await axios.post(/api/verify, { filename, hash }) return data }拿到needUpload和uploadedList之后就可以决定是直接提示秒传成功还是过滤掉已上传分片做断点续传。4.3 核心上传逻辑现在我把组件里的核心逻辑写出来。这里包含分片、hash、verify、过滤、并发上传、合并通知的完整链路。template div input typefile changehandleFileChange / el-progress :percentagepercent v-ifpercent 0 / el-button v-ifuploading clickpauseUpload暂停/el-button el-button v-ifpaused clickresumeUpload继续/el-button /div /template script setup import { ref } from vue import axios from axios import { CHUNK_SIZE, createChunks } from ../utils/chunk import { calcFileHash } from ../utils/hash import { verifyFile } from ../utils/upload const file ref(null) const hash ref() const chunks ref([]) const uploadedList ref([]) const percent ref(0) const uploading ref(false) const paused ref(false) const abortControllers new Map() let loadedChunkCount 0 const handleFileChange async (e) { file.value e.target.files[0] if (!file.value) return uploading.value true // 计算文件指纹 hash.value await calcFileHash(file.value) chunks.value createChunks(file.value) // 秒传 / 断点校验 const { needUpload, uploadedList: exists } await verifyFile({ filename: file.value.name, hash: hash.value, }) if (!needUpload) { percent.value 100 uploading.value false return } uploadedList.value exists loadedChunkCount exists.length await uploadChunks() } const uploadChunks async () { const tasks chunks.value .map((chunk, index) ({ chunk, index })) .filter((item) !uploadedList.value.includes(item.index)) .map((item) { const formData new FormData() formData.append(chunk, item.chunk) formData.append(hash, hash.value) formData.append(index, item.index) formData.append(filename, file.value.name) const controller new AbortController() abortControllers.set(item.index, controller) return { index: item.index, formData, controller, } }) await asyncPool(3, tasks, async (task) { try { await uploadSingleChunk(task.formData, task.controller) } finally { loadedChunkCount 1 percent.value Math.round((loadedChunkCount / chunks.value.length) * 100) } }) // 所有分片传完通知后端合并 await axios.get(/api/merge, { params: { hash: hash.value, filename: file.value.name }, }) uploading.value false } const uploadSingleChunk (formData, controller) { return axios.post(/api/upload, formData, { signal: controller.signal, }) } // 简单的并发池 const asyncPool async (poolLimit, tasks, iteratorFn) { const executing new Set() for (const task of tasks) { const promise Promise.resolve().then(() iteratorFn(task)) executing.add(promise) const clean () executing.delete(promise) promise.then(clean).catch(clean) if (executing.size poolLimit) { await Promise.race(executing) } } return Promise.all(executing) } const pauseUpload () { paused.value true abortControllers.forEach((controller) controller.abort()) abortControllers.clear() } const resumeUpload async () { paused.value false // 重新走 verify拿到已上传分片列表继续传剩余分片 const { needUpload, uploadedList: exists } await verifyFile({ filename: file.value.name, hash: hash.value, }) if (needUpload) { uploadedList.value exists loadedChunkCount exists.length await uploadChunks() } } /script这段代码基本把一个简化版的上传组件完整写出来了。暂停功能是通过AbortController中断当前未完成的分片请求实现的恢复的时候重新调用 verify后端返回已上传分片列表前端过滤掉之后再继续传就形成了断点续传。注意暂停时正在传的分片可能已经写入了后端临时目录但 verify 接口会如实返回所以不用担心重复上传问题。4.4 进度、暂停、恢复、取消进度这块我简单用“已上传分片数 / 总分片数”来算百分比。如果每个分片大小不固定建议改成累计已传字节数百分比更精确。Element Plus 自带的 el-progress 配合这个百分比就能展示。如果需求要求显示“当前正在上传第 x / 共 y 片”可以在每个分片的请求完成后更新一个名为 currentChunk 的 ref界面就能实时显示了。暂停、恢复、取消三者的关系是取消需要同时调用后端清理已上传分片暂停则是保留已上传分片后面恢复时续传。我在上面代码里做了暂停和恢复。如果要做取消可以在暂停基础上再调用一个后端清理接口删除临时目录中该 hash 下的所有分片文件。5. 后端实现Node.js 接收分片与合并5.1 接口设计后端我设计了三个接口逻辑比较清晰接口方法作用/api/verifyPOST秒传校验返回是否需要上传和已有分片列表/api/uploadPOST接收单个分片保存到临时目录/api/mergeGET合并临时目录里的所有分片为完整文件接下来分别实现。5.2 verify 接口秒传和断点查询verify 要做两件事先看最终文件目录里是否已经有同名 hash 的完整文件有就直接返回不需要上传没有的话去临时目录扫一圈把该 hash 下已存在的分片下标列表返回给前端。const express require(express) const multer require(multer) const fs require(fs) const path require(path) const app express() const BASE_DIR path.join(__dirname, uploads) const TEMP_DIR path.join(BASE_DIR, temp) const FILE_DIR path.join(BASE_DIR, files) ;[TEMP_DIR, FILE_DIR].forEach((dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) }) app.use(express.json()) app.post(/api/verify, (req, res) { const { hash } req.body const finalFile path.join(FILE_DIR, hash) if (fs.existsSync(finalFile)) { return res.json({ needUpload: false, uploadedList: [] }) } let uploadedList [] if (fs.existsSync(TEMP_DIR)) { uploadedList fs.readdirSync(TEMP_DIR) .filter((name) name.startsWith(${hash}_)) .map((name) Number(name.split(_)[1])) } res.json({ needUpload: true, uploadedList }) })5.3 upload 接口分片落盘upload 接口用 multer 接收 multipart 请求分片文件按[hash]_[index]命名保存。这里有个小细节如果同名分片已经存在直接覆盖其实也可以因为分片内容由 hash 保证一致性覆盖不会有问题。const storage multer.diskStorage({ destination: (req, file, cb) cb(null, TEMP_DIR), filename: (req, file, cb) { const { hash, index } req.body cb(null, ${hash}_${index}) }, }) const upload multer({ storage }) app.post(/api/upload, upload.single(chunk), (req, res) { if (!req.file) { return res.status(400).json({ message: chunk is required }) } res.json({ ok: true }) })5.4 merge 接口分片合并与清理merge 的时候按分片下标顺序读取临时文件依次写入最终文件。我建议用 stream 合成避免一次性把所有分片读进内存。合成完成后再清理临时分片。app.get(/api/merge, async (req, res) { const { hash, filename } req.query const ext path.extname(filename) const finalPath path.join(FILE_DIR, ${hash}${ext}) const chunkNames fs.readdirSync(TEMP_DIR) .filter((name) name.startsWith(${hash}_)) .sort((a, b) Number(a.split(_)[1]) - Number(b.split(_)[1])) if (chunkNames.length 0) { return res.status(500).json({ message: no chunk found }) } const writeStream fs.createWriteStream(finalPath) for (const name of chunkNames) { await new Promise((resolve, reject) { const readStream fs.createReadStream(path.join(TEMP_DIR, name)) readStream.on(error, reject) readStream.on(end, resolve) readStream.pipe(writeStream, { end: false }) }) } writeStream.end() writeStream.on(finish, () { // 合并完成清理临时分片 chunkNames.forEach((name) { fs.unlinkSync(path.join(TEMP_DIR, name)) }) res.json({ url: /files/${hash}${ext} }) }) }) app.listen(3000, () { console.log(server running at http://localhost:3000) })注意合并过程中前端如果发起了重复的 merge 请求可能出现两个线程同时写同一个文件的情况。生产环境建议加一个简单的状态锁或者让前端在合并完成后通过查询接口确认最终文件是否存在。6. 常见问题与排查技巧实录6.1 算出来的 hash 不一致这是初学者最容易踩的坑。SparkMD5 有几种不同的方法如果你在 Worker 里用的是SparkMD5.ArrayBuffer那读取分片时一定要用arrayBuffer()如果你在主线程里用的是SparkMD5的二进制字符串模式那读取方式也要统一。前后端如果都做 hash 校验更要保证两边算法一致。最简单的办法是统一使用 ArrayBuffer 读取。6.2 页面刷新后重传卡在 hash 计算一个很大的文件每次刷新都要重新算一遍 hash体验不好。我一般会把算好的 hash 存在浏览器本地上传完成后删除记录。这样页面刷新后再次选择同一文件可以直接从 localStorage 或 IndexedDB 里读 hash跳过 Worker 计算。注意存 hash 时要和文件的大小、名称绑定防止误用。6.3 后端合并失败或文件损坏文件损坏多数是分片顺序错了或者某个分片没传完整。排查时先确认 merge 时按 index 正确排序再确认每个分片的字节数是否和前端拆分时一致。我见过一个项目后端没有对分片大小做校验导致前端传了损坏的切片后端也照单全收最后合并出来的文件打不开。建议在 upload 接口里记录每个分片的大小merge 时对不上就返回错误。6.4 进度条不准确如果进度条是按“已上传分片数 / 总分片数”计算的分片大小不一时进度会跳。最简单的改进是按累计已上传字节数计算每次分片请求成功就把该分片的实际大小累加到 uploadedSize 里。还有一点hash 计算阶段也要给用户反馈否则文件选了之后界面就像卡死了一样。6.5 并发高导致服务器压力大并发数调得太高后端磁盘 IO 会被拖垮尤其当分片数量很多时。建议前端默认 3 个并发后端如果配置较强再调到 5。另外可以在 verify 阶段对请求做频率控制避免用户同一时间反复校验。6.6 还有什么值得注意的文件类型校验前端后端都要做前端做是为了体验后端做才是安全底线。上传接口一定要带鉴权大文件上传耗流量耗存储被刷一次代价很大。临时目录要定期清理防止用户传了一半放弃分片文件残留堆积。最后再分享一个小技巧如果公司里有现成的第三方存储服务比如 OSS、S3分片上传思路完全一样只是把后端的临时目录和合并逻辑换成云服务商提供的分片接口。原理懂了迁移到任何平台都特别快。
返回列表