实现大文件上传全流程详解(补偿版本)
(??金石瓜分计划倒计时2??天,速戳上图了解详情)之前分享了大文件上传的前端实现后,但是还有很多细节没有说明,隔了这么久又来考古一下 Orz.
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中断,今天就带大家从头到尾盘清楚其中的技术细节。
一、先看效果:我们要实现什么?先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题:
「大文件分片上传」:将文件切成固定大小的小片段分批上传,避免单次请求超时
「秒传」:服务器已存在完整文件时,直接返回成功,无需重复上传
「断点续传」:刷新页面或上传中断后,仅上传未完成的分片,无需从头开始
「并发控制」:限制同时上传的分片数量,避免请求过多导致浏览器 / 服务器崩溃
「手动中断」:支持用户随时停止上传,且中断后已传分片不丢失
最终交互很简洁:一个文件选择框 + 上传中的中断按钮,但背后是一整套覆盖「上传前 - 上传中 - 上传后」的完整逻辑。
二、全流程拆解:从选文件到合并我们先从宏观视角梳理整个流程,再拆分成前端和后端的具体实现。整个过程可总结为「5 步走」,每一步都有明确的目标和技术要点:
用户选择文件 → 前端分片+算哈希 → 校验文件状态(秒传/断点续传) → 并发上传分片 → 后端合并分片第一步:用户选择文件(前端触发)这是流程的起点,通过原生input type="file"获取用户选择的文件,在onchange事件中触发后续逻辑。
templatedivclass="upload-container" h2大文件上传演示/h2 input@change="handleUpload"type="file"class="file-input"/ !-- 上传中才显示中断按钮 -- button@click="abortUpload"v-if="isUploading"class="abort-btn" 中断上传 /button/div/template
scriptsetupimport{ ref }from"vue";
// 上传状态管理constisUploading =ref(false);// 是否正在上传constabortControllers =ref([]);// 存储所有请求的中断控制器
consthandleUpload=async(e) = {constfile = e.target.files[0];// 获取用户选择的单个文件if(!file)return;// 未选文件则退出
// 后续核心逻辑:分片、算哈希、校验...// (下文逐步展开)};/script
stylescoped.upload-container{margin:20px; }.file-input{margin-right:10px; }.abort-btn{padding:4px8px;background:#ff4444;color: white;border: none;border-radius:4px; }/style第二步:前端分片 + 计算文件哈希大文件直接上传会触发超时,因此必须先「拆小」;而哈希值是实现「秒传」和「断点续传」的核心 —— 它是文件的唯一标识,用于告诉服务器 “这是哪个文件”。
2.1 文件分片:把大文件切成小片段用浏览器原生 APIFile.slice()按固定大小(这里设为 1MB)切割文件,得到多个Blob对象(即「分片」)。
运行
// 分片大小:1MB(可根据需求调整,如5MB/10MB)constCHUNK_SIZE=1024*1024;
/*** 生成文件分片数组*@param{File}file- 用户选择的原始文件*@returns{Blob[]} 分片数组*/constcreateChunks= (file) = {letcur =0;// 当前切割位置letchunks = [];while(cur file.size) { // 从当前位置切割到「当前位置+分片大小」,最后一片可能不足1MB constblob = file.slice(cur, cur +CHUNK_SIZE); chunks.push(blob); cur +=CHUNK_SIZE; }returnchunks;};
// 示例:3.5MB 的文件会生成 4 个分片(1MB+1MB+1MB+0.5MB)2.2 计算文件哈希:生成唯一标识用spark-md5库计算文件哈希,但有个关键优化:「不读取整个文件」,而是抽样读取部分片段(首尾分片全量 + 中间分片抽样),既能保证哈希唯一性,又能大幅提升大文件的计算速度。
先安装依赖:
npm install spark-md5--save再实现哈希计算逻辑:
importsparkMD5from"spark-md5";
/*** 计算文件哈希值(抽样优化)*@param{Blob[]}chunks- 分片数组*@returns{Promisestring} 文件哈希值*/constcalHash= (chunks) = {returnnewPromise((resolve) ={constspark =newsparkMD5.ArrayBuffer();// 初始化MD5计算器constfileReader =newFileReader();// 用于读取Blob内容consttargets = [];// 存放抽样的片段(用于计算哈希)
// 抽样策略:首尾分片全量,中间分片取3个2字节片段(共6字节)chunks.forEach((chunk, index) ={if(index ===0|| index === chunks.length-1) { // 首尾分片:全量加入抽样 targets.push(chunk); }else{// 中间分片:取前2字节、中间2字节、后2字节 targets.push(chunk.slice(0,2)); targets.push(chunk.slice(CHUNK_SIZE/2,CHUNK_SIZE/2+2)); targets.push(chunk.slice(CHUNK_SIZE-2,CHUNK_SIZE)); }
// 读取抽样片段并计算哈希fileReader.readAsArrayBuffer(newBlob(targets));fileReader.onload=(e) ={spark.append(e.target.result);// 累加数据resolve(spark.end());// 生成最终哈希值(如:"a1b2c3d4e5") 「为什么抽样?」
如果是 1GB 的文件,全量读取计算哈希可能需要几秒甚至十几秒;抽样后仅读取几十字节,耗时可压缩到几百毫秒,用户几乎无感知。
第三步:校验文件状态(前后端配合)拿到文件哈希后,前端需要先向后端发「校验请求」,判断两个关键信息:
服务器是否已存在完整文件?(决定是否秒传)服务器是否有部分已上传的分片?(决定断点续传时要补传哪些分片)3.1 前端发起校验请求constfileHash =ref("");// 文件哈希值constfileName =ref("");// 原始文件名(用于取后缀)
/*** 向服务器校验文件状态*@returns{PromiseObject} 校验结果(shouldUpload: 是否需要上传, existChunks: 已上传分片列表)*/constverify=async() = {constres =awaitfetch("http://localhost:3000/verify", { method:"POST", headers: {"content-type":"application/json"}, body:JSON.stringify({ fileHash: fileHash.value, fileName: fileName.value, }),returnres.json();};
// 在handleUpload中调用校验consthandleUpload=async(e) = {constfile = e.target.files[0];if(!file)return;
fileName.value= file.name;constchunks =createChunks(file); fileHash.value=awaitcalHash(chunks);// 计算哈希
// 发起校验constverifyRes =awaitverify();if(!verifyRes.data.shouldUpload) { // 服务器已存在完整文件 → 秒传成功 alert("秒传成功!文件已存在"); return; }
// 需上传:进入分片上传环节(下文展开)awaituploadChunks(chunks, verifyRes.data.existChunks);};3.2 后端处理校验逻辑后端需要检查「完整文件」和「已上传分片」的存在性,返回给前端决策依据。
先初始化后端项目并安装依赖: # 1. 初始化 npm init -y
# 2. 安装依赖npminstall express cors multiparty fs-extra path --save再实现/verify接口:
constexpress =require("express");constpath =require("path");constfse =require("fs-extra");// 文件操作工具(比原生fs更易用)constcors =require("cors");constbodyParser =require("body-parser");
constapp =express();app.use(cors());// 解决跨域app.use(bodyParser.json());// 解析JSON请求体
// 上传根目录(所有分片和完整文件都存在这里)constUPLOAD_DIR= path.resolve(__dirname,"uploads");// 确保上传目录存在fse.ensureDirSync(UPLOAD_DIR);
/*** 提取文件名后缀(如:"test.pdf" → ".pdf")*@param{string}fileName- 原始文件名*@returns{string} 文件后缀*/constextractExt= (fileName) = {returnfileName.slice(fileName.lastIndexOf("."));};
// 校验接口:/verifyapp.post("/verify",async(req, res) = {const{ fileHash, fileName } = req.body;// 完整文件路径 = 上传目录 + 文件哈希 + 原文件后缀(确保文件名唯一)constcompleteFilePath = path.resolve(UPLOAD_DIR,`${fileHash}${extractExt(fileName)}`);
// 1. 检查完整文件是否存在 → 秒传逻辑if(fse.existsSync(completeFilePath)) { returnres.json({ status:true, data: {shouldUpload:false}// 无需上传 }
// 2. 检查已上传的分片 → 断点续传逻辑constchunkDir = path.resolve(UPLOAD_DIR, fileHash);// 分片临时目录(用文件哈希命名)constexistChunks = fse.existsSync(chunkDir) ?awaitfse.readdir(chunkDir)// 已上传的分片列表(如:["a1b2-0", "a1b2-1"]) : [];
res.json({ status:true, data: { shouldUpload:true,// 需要上传 existChunks: existChunks// 已上传的分片标识,供前端过滤 }});
// 启动服务器app.listen(3000,() ={console.log("服务器运行在 http://localhost:3000");});第四步:并发上传分片(前端核心)这是前端最复杂的环节,需要解决三个关键问题:
过滤已上传的分片(只传缺失的)控制并发请求数(避免请求爆炸)支持手动中断上传(用户可随时停止)4.1 过滤已上传的分片根据后端返回的existChunks(已上传分片标识列表),过滤掉不需要重新上传的分片,只生成待上传的FormData。
/*** 上传分片(核心函数)*@param{Blob[]}chunks- 所有分片数组*@param{string[]}existChunks- 已上传的分片标识列表*/constuploadChunks=async(chunks, existChunks) = { isUploading.value=true; abortControllers.value= [];// 清空历史中断控制器
// 1. 生成所有分片的基础信息(文件哈希、分片标识、分片数据)constchunkInfoList = chunks.map((chunk, index) =({ fileHash: fileHash.value, chunkHash:`${fileHash.value}-${index}`,// 分片标识:文件哈希-序号(确保唯一) chunk: chunk }));
// 2. 过滤已上传的分片 → 只保留待上传的constformDatas = chunkInfoList .filter(item=!existChunks.includes(item.chunkHash)) .map(item={ constformData =newFormData(); formData.append("filehash", item.fileHash); formData.append("chunkhash", item.chunkHash); formData.append("chunk", item.chunk);// 分片二进制数据 returnformData;
if(formDatas.length===0) { // 所有分片已上传 → 直接请求合并 mergeRequest(); return; }
// 3. 并发上传分片(下文展开)awaituploadWithConcurrencyControl(formDatas);};4.2 控制并发请求数用「请求池 +Promise.race」限制同时上传的分片数量(这里设为 6 个),避免请求过多导致浏览器 / 服务器压力过大。
/*** 带并发控制的分片上传*@param{FormData[]}formDatas- 待上传的FormData列表*/constuploadWithConcurrencyControl=async(formDatas) = {constMAX_CONCURRENT=6;// 最大并发数(可根据需求调整)letcurrentIndex =0;// 当前待上传的分片索引consttaskPool = [];// 存储当前正在执行的请求(请求池)
while(currentIndex formDatas.length) { // 为每个请求创建独立的中断控制器(AbortController) constcontroller =newAbortController(); const{ signal } = controller; abortControllers.value.push(controller);// 存入控制器列表
// 发起分片上传请求 consttask =fetch("http://localhost:3000/upload", { method:"POST", body: formDatas[currentIndex], signal: signal// 绑定中断信号 }) .then(res={ // 请求完成后,从请求池和控制器列表中移除 taskPool.splice(taskPool.indexOf(task),1); abortControllers.value= abortControllers.value.filter(c=c !== controller); return }) .catch(err={ // 捕获错误:区分「用户中断」和「其他错误」 if(err.name!=="AbortError") { console.error("分片上传失败:", err); // 可在这里加「错误重试」逻辑(如重试3次) } // 无论何种错误,都清理状态 taskPool.splice(taskPool.indexOf(task),1); abortControllers.value= abortControllers.value.filter(c=c !== controller);
taskPool.push(task);
// 当请求池满了,等待最快完成的一个请求再继续(释放并发名额) if(taskPool.length===MAX_CONCURRENT) { awaitPromise.race(taskPool); }
currentIndex++; }
// 等待所有剩余请求完成awaitPromise.all(taskPool);// 所有分片上传完成 → 请求合并mergeRequest();};4.3 手动中断上传用AbortController中断所有正在进行的请求,并清理状态,确保中断后下次上传能正常恢复。
/*** 中断上传(用户触发)*/constabortUpload= () = {if(!isUploading.value)return;
// 1. 中断所有正在进行的请求 abortControllers.value.forEach(controller={ controller.abort();// 调用中断方法,触发请求的AbortError
// 2. 清理状态 abortControllers.value= []; isUploading.value=false;
// 3. 通知用户alert("上传已中断,下次可继续上传");};第五步:后端接收分片并合并所有分片上传完成后,前端需要通知后端「合并分片」,后端按分片序号排序,用「流(Stream)」拼接成完整文件(避免内存溢出)。
5.1 后端接收分片(/upload 接口)用multiparty解析前端发送的FormData,将分片保存到临时目录(以文件哈希命名)。
// 后端:/upload 接口(接收分片)constmultiparty =require("multiparty");
app.post("/upload",(req, res) ={constform =newmultiparty.Form();// 解析FormData的工具
// 解析请求(fields:普通字段,files:文件字段) form.parse(req,async(err, fields, files) = { if(err) { console.error("分片解析失败:", err); returnres.status(400).json({status:false,message:"分片上传失败" }
// 提取字段 constfileHash = fields["filehash"][0];// 文件哈希 constchunkHash = fields["chunkhash"][0];// 分片标识 constchunkFile = files["chunk"][0];// 分片临时文件(multiparty生成的临时文件)
// 分片临时目录(如:uploads/a1b2c3) constchunkDir = path.resolve(UPLOAD_DIR, fileHash); // 确保临时目录存在 awaitfse.ensureDir(chunkDir);
// 目标路径:将分片从临时位置移动到临时目录 consttargetChunkPath = path.resolve(chunkDir, chunkHash); awaitfse.move(chunkFile.path, targetChunkPath);
// 响应前端:分片上传成功 res.json({status:true,message:"分片上传成功"});5.2 后端合并分片(/merge 接口)合并的核心是「按序号排序分片」+「用流拼接」,边读边写,避免一次性加载大文件到内存。
// 前端:请求合并分片的函数constmergeRequest=async() = {awaitfetch("http://localhost:3000/merge", { method:"POST", headers: {"content-type":"application/json"}, body:JSON.stringify({ fileHash: fileHash.value, fileName: fileName.value, size:CHUNK_SIZE// 分片大小(用于计算写入位置) }),
// 合并完成后的清理 isUploading.value=false;alert("文件上传完成!");};
// 后端:/merge 接口(合并分片)app.post("/merge",async(req, res) = {const{ fileHash, fileName,size:CHUNK_SIZE} = req.body;// 完整文件路径(上传目录 + 文件哈希 + 后缀)constcompleteFilePath = path.resolve(UPLOAD_DIR,`${fileHash}${extractExt(fileName)}`);// 分片临时目录constchunkDir = path.resolve(UPLOAD_DIR, fileHash);
// 检查分片目录是否存在(防止恶意请求)if(!fse.existsSync(chunkDir)) { returnres.status(400).json({status:false,message:"分片目录不存在" }
// 1. 读取所有分片并按序号排序constchunkPaths =awaitfse.readdir(chunkDir); chunkPaths.sort((a, b) ={ // 从分片标识中提取序号(如:"a1b2-0" → 0) returnparseInt(a.split("-")[1]) -parseInt(b.split("-")[1]);
// 2. 用流拼接分片(边读边写,低内存占用)constmergePromises = chunkPaths.map((chunkName, index) ={ returnnewPromise((resolve) ={ constchunkPath = path.resolve(chunkDir, chunkName); constreadStream = fse.createReadStream(chunkPath);// 分片读流 constwriteStream = fse.createWriteStream(completeFilePath, { start: index *CHUNK_SIZE,// 写入起始位置(精确到字节) end: (index +1) *CHUNK_SIZE// 写入结束位置
// 分片读取完成后:删除分片文件 + resolve readStream.on("end",async() = { awaitfse.unlink(chunkPath);// 删除单个分片 resolve();
// 管道流:将分片内容写入完整文件 readStream.pipe(writeStream);
// 3. 等待所有分片合并完成awaitPromise.all(mergePromises);// 4. 删除分片临时目录(合并完成后清理)awaitfse.remove(chunkDir);
// 响应前端:合并成功 res.json({status:true,message:"文件合并成功"});「为什么用流?」
如果直接用fs.readFile读取所有分片内容再拼接,1GB 的文件会占用 1GB 内存,可能导致服务器内存溢出;而流操作(createReadStream/createWriteStream)是边读边写,内存占用始终很低(仅几 KB/MB)。
三、核心难点与解决方案总结大文件上传的核心痛点已在方案中解决,这里整理成表格,方便大家快速回顾:
核心难点解决方案代码关键位置大文件哈希计算慢抽样读取片段(首尾全量 + 中间分片抽样)calHash函数并发请求过多导致崩溃用「请求池 + Promise.race」限制并发数uploadChunks函数用户需要手动中断上传用AbortController中断请求 + 清理状态abortUpload函数分片合并顺序错乱按分片序号排序,用流按固定位置写入后端/merge接口的排序逻辑刷新页面后需从头上传校验时返回已上传分片,前端过滤后再上传前端filter逻辑 + 后端/verify接口大文件合并内存溢出用流(Stream)边读边写,避免全量加载后端/merge接口的流操作四、最后大文件上传看似复杂,拆解后其实是「分片→校验→上传→合并」四个核心步骤,每个步骤解决一个具体问题。这套方案用 Vue+Express 实现,代码简洁易懂,可直接作为项目基础版本,再根据实际需求扩展优化。
实际开发中,还需要结合业务场景补充异常处理(如文件大小限制、格式校验)、日志监控(上传失败告警)等功能。如果大家在实践中遇到问题,欢迎在评论区交流。
AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding
点击"阅读原文"了解详情~
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线