ARTICLE DETAIL

资讯详情

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

Vue大文件跨平台上传DEMO制作:分片、断点续传与秒传

Vue大文件跨平台上传DEMO制作:分片、断点续传与秒传 Vue大文件跨平台上传的DEMO怎么制作这个问题我猜不是只有你一个人想问。大多数人对上传功能的认知是从“input post”开始的真去做大文件传输才发现普通方式传一个500MB的视频进度条动不动卡住传一半断个网又要从头再来根本没法在生产环境用。今天想把一个能跑的DEMO完整讲明白从环境配置、切片上传、并发控制到断点续传、秒传和Worker优化全部给出可以直接抄的代码和踩坑记录。这篇文章适合刚接触Vue不久、想独立做上传功能的新手也适合正在给现有项目加“大文件能力”的开发者参考。1. 方案设计先想清楚要解决什么问题1.1 传统上传为什么扛不住大文件很多人一开始觉得上传大文件不就是把文件塞进FormData然后axios.post吗这种做法在小文件上没毛病但文件一大问题全出来了。首先浏览器和服务器都要处理一个超大请求体。前端如果直接用file对象放进FormData浏览器可能在内存里缓冲整个文件页面滚动都会掉帧。服务端更惨Nginx/Node默认body限制只有1MB左右就算你把限制调高服务器内存也会被多用户的大请求迅速打满。其次网络是脆弱的。一个500MB的文件传了80%时切换Wi-Fi或者服务器超时整个请求就没了。HTTP一次请求要么成功要么失败断掉没有中间状态只能用户手动重新选文件再来一遍。最后体验非常有限。虽然XMLHttpRequest有upload.onprogress事件但大文件的整体进度受网络波动影响很大看起来就是在“99%卡住”和“1%刷新”之间反复横跳。所以大文件上传的核心需求不是“上传”而是要把上传这件事拆成很多个可重试的小任务。这就是分片上传的出发点。1.2 分片上传的核心思路分片上传本质上就是“把一整箱搬家拆成一箱一箱搬”。文件还是那个文件但前端调用file.slice()把它切成几百分每个分片单独发起HTTP请求全部传完后服务端再按顺序合并。这样做有几个直接的好处单分片体积小比如2MB一个失败重试只重传2MB而不是整个文件。可以控制并发数比如同时传4个分片既不把连接打满又能充分利用带宽。可以记录哪些分片已经成功下次继续传剩下的断点续传就是这么来的。可以提前算整个文件的Hash服务器发现hash相同就直接“秒传”。这个思路是所有网盘、视频平台的通用底座。你看到的那些“极速上传”“秒传”功能背后就是这个机制再加上一层文件去重。1.3 跨平台到底指什么标题里的“跨平台”需要拆开理解。我们的DEMO基于是浏览器端标准Web APIFile、Blob、FormData、XMLHttpRequest在Windows、macOS、Linux下的Chrome/Edge/Firefox表现基本一致这是一层跨平台。第二层是桌面应用。Electron或Tauri里的WebView同样支持这些API前端组件代码可以直接复用只是桌面端可能还会借助Node能力读写本地文件但那属于进阶玩法。第三层是移动端。iOS Safari和Android Chrome对Blob、FormData的支持都很成熟只是内存限制和锁屏策略要求我们调整分片大小和并发数。所以这个DEMO虽然默认跑在浏览器里但只要遵循标准API设计天然就具备跨平台的底子。2. 环境准备先搭一个能跑的Vue3项目2.1 Node与Vue环境配置开始写代码前先把环境收拾利索。这里我默认你已经有Node.js建议版本不低于18。命令行先跑一下node -v npm -v如果npm安装依赖特别慢尤其是国内网络环境可以把源切到npmmirrornpm config set registry https://registry.npmmirror.com很多人卡在“vue安装及环境配置”这一步其实大部分是环境变量没配对Node安装时没有勾选“Add to PATH”或者装了多个Node版本导致vue命令找不到。用现代Vite脚手架的话其实根本不需要全局vue-cli一个npm create vue就够。创建项目npm create vuelatest vue-big-file-upload-demo按提示选择Vue3、Vite、TypeScript与否都可以为了DEMO简洁我用JavaScript默认配置。项目创建完进入目录并安装基础依赖cd vue-big-file-upload-demo npm install npm i axios spark-md5axios用来发上传请求spark-md5用来算文件指纹。2.2 项目目录与路由准备我们不需要复杂的工程结构但为了后面扩展建议按模块分开。大致这样src/ api/upload.js # 封装上传相关接口 workers/hash.js # Web Worker里算文件Hash utils/chunk.js # 切片工具 components/UploadPanel.vue # 上传面板组件 views/Home.vue # 页面容器如果你已经用了Vue Router就在路由表里加一条/upload指向Home.vue路由配置不是这个DEMO的重点我就不展开讲了。需要提醒的是很多人会把所有逻辑塞进一个巨大的组件里先能跑但维护两三周后会想哭。按上面的目录拆至少逻辑是清楚的。2.3 Vite代理配置前后端分离开发时前端和上传接口大概率不在同一个端口浏览器同源策略会拦住跨域请求。最简单的方式是Vite配置代理在vite.config.js里加server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }这样前端发请求统一用/api/...Vite在开发阶段帮你转发到后端规避CORS。后端如果在3000端口就不用再做跨域中间件了。3. 核心实现上传组件从0到13.1 文件选择与基础切片页面模板里放一个简单的文件选择框和进度展示区域input typefile changehandleFileChange / div v-ifprogressVisible div{{ progress }}%/div /div核心切片逻辑在utils/chunk.js里一个文件切成多片export const CHUNK_SIZE 2 * 1024 * 1024; // 默认2MB一片 export function createChunks(file) { const chunks []; let index 0; for (let cur 0; cur file.size; cur CHUNK_SIZE) { chunks.push({ index, start: cur, end: Math.min(file.size, cur CHUNK_SIZE), blob: file.slice(cur, Math.min(file.size, cur CHUNK_SIZE)) }); index; } return chunks; }file.slice()返回的是一个Blob对象它并不是把文件内容复制一份更像是给原始文件做了一个“区间视图”。所以哪怕切出一千个2MB的切片内存里也不是真的要放2GB数据这一点对大文件特别关键。分片大小怎么选我的习惯是2MB到5MB之间。一个1GB的文件2MB一片是512个请求这个数量对浏览器和后端都算可控。如果切成1MB请求数翻倍握手、响应、日志占用的开销反而会拖慢速度如果切到10MB断点续传的粒度又太粗传一半断了损失的传输量更多。3.2 用Worker计算文件Hash切片后要算出整个文件的Hash这个Hash用来做秒传和断点续传。计算方式是把所有分片按顺序喂给spark-md5增量计算。但这有一个大坑如果直接在主线程用FileReader读取所有分片算Hash一个2GB的文件可能让页面卡死几十秒。因为FileReader和spark的append都是同步阻塞UI的其实FileReader是异步读但量大、分段多时主线程依然会被频繁调度进度和点击都会受影响。最优解是Web Worker也是你搜“前端使用worker上传大文件”时最常看到的东西。Worker文件src/workers/hash.js这样写import SparkMD5 from spark-md5; self.onmessage async (e) { const { chunks } e.data; const spark new SparkMD5.ArrayBuffer(); for (let i 0; i chunks.length; i) { const buffer await chunks[i].blob.arrayBuffer(); spark.append(buffer); self.postMessage({ type: progress, percent: Math.round(((i 1) / chunks.length) * 100) }); } self.postMessage({ type: done, hash: spark.end() }); };主线程调用时把切片数组传进去export function calcHash(chunks) { return new Promise((resolve, reject) { const worker new Worker( new URL(../workers/hash.js, import.meta.url), { type: module } ); worker.postMessage({ chunks }); worker.onmessage (e) { if (e.data.type progress) { hashProgress.value e.data.percent; } if (e.data.type done) { resolve(e.data.hash); worker.terminate(); } }; worker.onerror (err) { reject(err); worker.terminate(); }; }); }注意几个细节spark-md5的append顺序必须是严格的文件顺序第0片、第1片、第2片这样排。不能因为并发快就同时appendHash结果会错。Worker里读分片用的arrayBuffer()不是所有旧浏览器都支持现在主流浏览器和Electron环境基本没问题。如果不用Vite的模块化Worker老项目里通常会写importScripts(/spark-md5.min.js)也可以但要注意spark-md5的作用域和使用方式。算Hash期间页面主线程已经解放用户可以继续滚动、点击不会再卡成白屏。这也是整个DEMO体验提升最大的一步。3.3 并发池控制上传分片准备好、Hash算完了接下来就是上传。如果512个切片一次性全发出去浏览器会同时建立几百个连接带宽马上打满请求互相争抢进度反而变慢甚至触发防火墙或网关的连接数限制。所以必须做并发控制。一个比较稳妥的方式是写一个并发池函数。可以参考这套通用实现async function asyncPool(poolLimit, tasks, iteratorFn) { const ret []; const executing []; for (const item of tasks) { const p Promise.resolve().then(() iteratorFn(item)); ret.push(p); if (poolLimit tasks.length) { const e p.then(() { executing.splice(executing.indexOf(e), 1); }); executing.push(e); if (executing.length poolLimit) { await Promise.race(executing); } } } return Promise.all(ret); }上传单个分片的方法async function uploadOne(chunk) { const form new FormData(); form.append(chunk, chunk.blob); form.append(hash, fileHash.value); form.append(index, chunk.index); const res await axios.post(/api/upload/chunk, form); return res.data; }然后组装整个上传流程const chunks createChunks(file); fileHash.value await calcHash(chunks); const tasks chunks.map((chunk) () uploadOne(chunk)); const poolLimit 4; await asyncPool(poolLimit, tasks, (task) task());并发数设多少合适我在浏览器里实测下来4到6是甜点区。太多反而因为TCP拥塞和内存压力拖慢整体时间移动端建议降到2以内。3.4 断点续传与秒传落地有了Hash秒传和断点续传就是“服务端状态同步”的问题了。上传前先向后端要一个check接口const checkRes await axios.post(/api/upload/check, { hash: fileHash.value, filename: file.name, size: file.size });后端返回下面两个字段uploaded布尔值为true说明服务器已经有完整文件直接跳过上传调merge秒传。uploadedChunks数字数组比如[0, 1, 3, 5]表示哪些分片已经存在。前端拿到后过滤掉已上传的分片只传剩下的const remainingChunks chunks.filter( (chunk) !checkRes.data.uploadedChunks.includes(chunk.index) );全部传完之后调merge接口await axios.post(/api/upload/merge, { hash: fileHash.value, filename: file.name, chunkTotal: chunks.length, size: file.size });这里有一个容易懵的点页面刷新后File对象没了怎么断点续传答案是断点续传不依赖前端保存文件对象只需要保存Hash和文件元数据。用户刷新后重新选中同一个文件因为内容相同计算出的Hash也相同check接口就会告诉你“服务器上哪些分片已经在了”。前端过滤掉这些分片从断点继续就行。如果还加了localStorage存文件元信息甚至可以在用户重新选文件时自动定位到上次的进度体验更顺。秒传的逻辑更简单服务端发现相同Hash根本不需要传内容前端直接调merge服务器把已有的文件复制一份或在数据库里加一条引用记录瞬间完成。3.5 暂停、继续与取消DEMO级别还能顺手做一下暂停和继续。最粗糙的做法是加一个全局paused标志每次并发池准备发起新请求前检查如果暂停就死等但已经在途的请求没法停。更好的是用AbortControllerconst controller new AbortController(); async function uploadOne(chunk) { const form new FormData(); form.append(chunk, chunk.blob); form.append(hash, fileHash.value); form.append(index, chunk.index); const res await axios.post(/api/upload/chunk, form, { signal: controller.signal }); return res.data; }暂停时controller.abort()继续时新起一个Controller重新跑。注意abort会导致正在上传的分片可能没传完继续前一定要重新调用check以服务端记录为准不要用本地“我以为发出去了”的状态。很多网盘上传组件还提供了“取消上传并删除临时分片”的功能那个需要后端再补一个删除接口不是必须项但如果你想做得完整可以在merge之前暴露一个delete接口把hash目录清掉。4. 配套后端没有服务端就没有上传4.1 上传接口如何设计前端做得再花哨后端不配合也白搭。这个DEMO需要三个HTTP接口我按最常见的REST风格设计方法路径用途请求关键字段返回字段POST/api/upload/check检查文件是否存在、已传哪些分片hash, filename, sizeuploaded, uploadedChunksPOST/api/upload/chunk接收单分片file, hash, indexcodePOST/api/upload/merge合并全部分片hash, filename, chunkTotalcode, url为什么需要这些字段具体解释一下hash是服务器存分片的文件夹名这样才能把属于同一个文件的分片放一起index用于确定合并顺序chunkTotal用于判断分片是否到齐filename用于合并后生成完整的文件名。4.2 写一个极简Node后端为了能跑通DEMO我用Express配合multiparty写一个极简后端。先装依赖npm i express multipartyserver.js核心代码const express require(express); const multiparty require(multiparty); const path require(path); const fs require(fs); const app express(); const DIR path.resolve(__dirname, uploads); app.post(/api/upload/chunk, (req, res) { const form new multiparty.Form(); form.parse(req, (err, fields, files) { const hash fields.hash[0]; const index Number(fields.index[0]); const chunkFolder path.join(DIR, hash); fs.mkdirSync(chunkFolder, { recursive: true }); const chunkPath path.join(chunkFolder, String(index).padStart(6, 0)); fs.copyFileSync(files.file[0].path, chunkPath); res.json({ code: 0 }); }); }); app.post(/api/upload/check, (req, res) { let body ; req.on(data, (c) (body c)); req.on(end, () { const { hash } JSON.parse(body); const chunkFolder path.join(DIR, hash); let uploadedChunks []; if (fs.existsSync(chunkFolder)) { uploadedChunks fs .readdirSync(chunkFolder) .map((name) parseInt(name, 10)); } res.json({ uploaded: false, uploadedChunks }); }); }); app.post(/api/upload/merge, (req, res) { let body ; req.on(data, (c) (body c)); req.on(end, () { const { hash, filename, chunkTotal } JSON.parse(body); const chunkFolder path.join(DIR, hash); const outputFile path.join(DIR, ${hash}-${filename}); const writeStream fs.createWriteStream(outputFile); let count 0; const appendNext () { if (count chunkTotal) { writeStream.end(); fs.rmSync(chunkFolder, { recursive: true, force: true }); return res.json({ code: 0, url: outputFile }); } const chunkPath path.join(chunkFolder, String(count).padStart(6, 0)); if (!fs.existsSync(chunkPath)) { return res.status(500).json({ code: 1, msg: missing chunk ${count} }); } const reader fs.createReadStream(chunkPath); reader.pipe(writeStream, { end: false }); reader.on(end, () { count; appendNext(); }); }; appendNext(); }); }); app.listen(3000, () { console.log(upload server running at 3000); });几个关键点padStart(6, 0)是为了排序安全。如果直接存1,2,3...10服务器readdir默认字典序会变成1,10,2合并顺序就错了。copyFileSync而不是直接移动files.file[0].path是因为multiparty在请求结束后会清理临时文件。合并用的是流式写入对大文件不会一次性撑爆内存。这只是DEMO没有做鉴权、没有限制文件类型、没有处理非法路径生产环境不能直接这么用。4.3 联调时怎么验证启动后端node server.js启动前端npm run dev打开页面选一个几百MB的文件Chrome DevTools切到Network面板你会看到请求分批地向上传每个请求FormData里都有hash和index。传完后去服务器uploads目录看一眼合并出来的文件能不能正常打开可以用校验工具对比Hash是否和源头文件一致md5sum 原文件 上传后的文件如果一致说明从切片到合并整条链路是通的。5. 常见问题与避坑手册5.1 网关和服务器限制虽然分片上传避开了“单文件大小”问题但你的Nginx或后端服务器如果对body大小有限制还是会报413 Request Entity Too Large。因为单个分片本身的body是几MB默认1MB的Nginx一样会挡。开发时如果报413先看Nginx的client_max_body_size设为10G或者至少大于你的分片大小。另外上传时间长的时候Nginx默认proxy_read_timeout可能只有60秒大文件上传往往超过这个时间中途被断掉后你会在前端看到socket hang up之类的错误。调大超时时间或者干脆关闭代理直连对象存储。5.2 进度条不动或不准进度条不动最常见的两个原因一是主线程被Hash计算卡住了要用Worker二是你计算进度的依据是“已发出请求数”但请求正在排队或者失败重试中进度当然不更新。以服务端确认为准是唯一靠谱的做法。每个分片上传返回成功才累计一个分片计入进度。这样即使中途失败进度回退也是真实状态不会出现“100%了但文件还打不开”的尴尬。5.3 Worker和Hash计算的坑如果你把spark-md5的append操作放在多个并发异步任务里最终Hash多半是错的。记住spark.append的顺序必须严格和文件分片顺序一致。但如果分片数量上千一个个串行读Buffer确实慢。一个优化思路是并发读取分片但按顺序临时保存ArrayBuffer等前面的都读完了再按顺序append。这个可以把读磁盘和算Hash的时间部分重叠不过DEMO阶段串行就够了别过度设计。还有Vite里创建Worker如果路径写错页面直接报“failed to fetch dynamically imported module”通常用new URL(../workers/hash.js, import.meta.url)就能避开相对路径解析问题。5.4 并发数不是越大越好真有人把并发数设成100结果进度条比并发4还慢。原因是TCP连接数过多会导致带宽分配不均、服务端文件描述符耗尽、路由器转发队列堆积。加上每个HTTP请求都有握手和响应的固定开销请求越多有效吞吐率反而降低。我测试过不同并发下的上传时间4到6是性价比最高的区间。移动端尤其要保守并发2就很稳。还要配合浏览器的并发连接限制同一个域名下HTTP/1.1通常最多6个连接你开100个并发也只是在排队没有意义。5.5 移动端上传的几个特殊问题手机浏览器上传大文件时如果用户切到后台ASystem会挂起WebView分片请求中断。这时候要做的不是强行后台传而是提示用户保持屏幕常亮或者保存当前进度下次打开自动续传。手机内存也比不上的桌面。分片大小可以调小一点比如1MB每片这样每个Blob的arrayBuffer读取时内存峰值更低。并发数也建议降到2避免多个2MB请求同时读入内存造成OOM。5.6 关于生产环境的几点经验这个DEMO跑通之后往生产走还有几件事要补给临时目录做定时清理防止上传失败留下的僵尸分片占满磁盘。合并完成后校验所有分片是否都到齐宁可返回500也不要生成损坏文件。如果要接对象存储OSS或S3可以把“接收分片”这一步改为前端直接传分片到对象存储后端只负责下发STS临时凭证和触发合并回调这样带宽压力完全不在自己的服务器上。文件名和扩展名一定要做白名单校验这是安全底线。我没法在文章里把所有生产细节都写满但你把上面这几条加到自己的Checklist里至少不会在项目上线后被刀。最后分享一个实操感受当时我跑通这个DEMO后把并发从1调到4再调大分片大小反复对比了几十组数据最终得出“2MB分片加4并发”最适合我手头项目的结论。你如果自己动手做也建议用控制变量法多测几组不要照搬网上的参数。上传这种事本地网络环境、服务器配置、文件类型都会影响最优参数跑出自己项目的曲线才算真正掌握了大文件上传。
返回列表