全国免费咨询:

13245491521

VR图标白色 VR图标黑色
X

中高端软件定制开发服务商

与我们取得联系

13245491521     13245491521

2025-09-03_实现大文件上传全流程详解(补偿版本)

您的位置:首页 >> 新闻 >> 行业资讯

实现大文件上传全流程详解(补偿版本) (??金石瓜分计划倒计时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 点击"阅读原文"了解详情~ 阅读原文

上一篇:2024-11-07_OpenAI o1强推理能提升安全性?长对话诱导干翻o1 下一篇:2019-08-19_一只鸡做主演的广告竟然这么好看!

TAG标签:

17
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设网站改版域名注册主机空间手机网站建设网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。
项目经理在线

相关阅读 更多>>

猜您喜欢更多>>

我们已经准备好了,你呢?
2022我们与您携手共赢,为您的企业营销保驾护航!

不达标就退款

高性价比建站

免费网站代备案

1对1原创设计服务

7×24小时售后支持

 

全国免费咨询:

13245491521

业务咨询:13245491521 / 13245491521

节假值班:13245491521()

联系地址:

Copyright © 2019-2025      ICP备案:沪ICP备19027192号-6 法律顾问:律师XXX支持

在线
客服

技术在线服务时间:9:00-20:00

在网站开发,您对接的直接是技术员,而非客服传话!

电话
咨询

13245491521
7*24小时客服热线

13245491521
项目经理手机

微信
咨询

加微信获取报价