实现大文件上传全流程详解(补偿版本)

B站影视 韩国电影 2025-08-30 20:13 3

摘要:在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中断,今天就带大家从头到尾盘清楚其

之前分享了大文件上传的前端实现后,但是还有很多细节没有说明,隔了这么久又来考古一下 Orz.

在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中断,今天就带大家从头到尾盘清楚其中的技术细节。

先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题:

大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时秒传 :服务器已存在完整文件时,直接返回成 #技术分享功,无需重复上传断点续传 :刷新页面或上传中断后,仅上传未完成的分片,无需从头开始并发控制 :限制同时上传的分片数量,避免请求过多导致浏览器 / 服务器崩溃手动中断 :支持用户随时停止上传,且中断后已传分片不丢失

最终交互很简洁:一个文件选择框 + 上传中的中断按钮,但背后是一整套覆盖「上传前 - 上传中 - 上传后」的完整逻辑。

我们先从宏观视角梳理整个流程,再拆分成前端和后端的具体实现。整个过程可总结为「5 步走」,每一步都有明确的目标和技术要点:

用户选择文件 → 前端分片+算哈希 → 校验文件状态(秒传/断点续传) → 并发上传分片 → 后端合并分片

这是流程的起点,通过原生 获取用户选择的文件,在 onchange 事件中触发后续逻辑。

大文件上传演示

中断上传 import { ref } from "vue";// 上传状态管理 const isUploading = ref(false); // 是否正在上传 const abortControllers = ref(); // 存储所有请求的中断控制器const handleUpload = async (e) => { const file = e.target.files[0]; // 获取用户选择的单个文件 if (!file) return; // 未选文件则退出// 后续核心逻辑:分片、算哈希、校验... // (下文逐步展开) }; .upload-container { margin: 20px; } .file-input { margin-right: 10px; } .abort-btn { padding: 4px 8px; background: #ff4444; color: white; border: none; border-radius: 4px; }

大文件直接上传会触发超时,因此必须先「拆小」;而哈希值是实现「秒传」和「断点续传」的核心 —— 它是文件的唯一标识,用于告诉服务器 “这是哪个文件”。

用浏览器原生 API File.slice 按固定大小(这里设为 1MB)切割文件,得到多个 Blob 对象(即「分片」)。

运行

const CHUNK_SIZE = 1024 * 1024;const createChunks = (file) => { let cur = 0; let chunks = ; while (cur

用 spark-md5 库计算文件哈希,但有个关键优化:不读取整个文件 ,而是抽样读取部分片段(首尾分片全量 + 中间分片抽样),既能保证哈希唯一性,又能大幅提升大文件的计算速度。

先安装依赖:

npm install spark-md5 --save

再实现哈希计算逻辑:

import sparkMD5 from "spark-md5";const calHash = (chunks) => { return new Promise((resolve) => { const spark = new sparkMD5.ArrayBuffer; const fileReader = new fileReader; const targets = ;chunks.forEach((chunk, index) => { if (index === 0 || index === chunks.length - 1) { targets.push(chunk); } else { 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(new Blob(targets)); fileReader.onload = (e) => { spark.append(e.target.result); resolve(spark.end); }; }); };

为什么抽样? 如果是 1GB 的文件,全量读取计算哈希可能需要几秒甚至十几秒;抽样后仅读取几十字节,耗时可压缩到几百毫秒,用户几乎无感知。

const fileHash = ref("")const fileName = ref("")/** * 向服务器校验文件状态 * @returns {Promise} 校验结果(shouldUpload: 是否需要上传, existChunks: 已上传分片列表) */ const verify = async => { const res = await fetch("http://localhost:3000/verify", { method: "POST", headers: { "content-type": "application/JSON" }, body: JSON.stringify({ fileHash: fileHash.value, fileName: fileName.value, }), }) return res.json }// 在 handleUpload 中调用校验 const handleUpload = async (e) => { const file = e.target.files[0] if (!file) returnfileName.value = file.name const chunks = createChunks(file) fileHash.value = await calHash(chunks)// 发起校验 const verifyRes = await verify if (!verifyRes.data.shouldUpload) { // 服务器已存在完整文件 → 秒传成功 alert("秒传成功!文件已存在") return }// 需上传:进入分片上传环节(下文展开) await uploadChunks(chunks, verifyRes.data.existChunks) }

后端需要检查「完整文件」和「已上传分片」的存在性,返回给前端决策依据。

npm init -y

# 2. 安装依赖npm install express cors multiparty fs-extra path

再实现 /verify 接口:

const express = require("express");const path = require("path");const fse = require("fs-extra");const cors = require("cors");const bodyParser = require("body-parser");const app = express; app.use(cors); app.use(bodyParser.json);const UPLOAD_DIR = path.resolve(__dirname, "uploads");fse.ensureDirSync(UPLOAD_DIR);const extractExt = (fileName) => { return fileName.slice(fileName.lastIndexOf(".")); };app.post("/verify", async (req, res) => { const { fileHash, fileName } = req.body; // 完整文件路径 = 上传目录 + 文件哈希 + 原文件后缀(确保文件名唯一) const completeFilePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`);// 1. 检查完整文件是否存在 → 秒传逻辑 if (fse.existsSync(completeFilePath)) { return res.json({ status: true, data: { shouldUpload: false } // 无需上传 }); }const chunkDir = path.resolve(UPLOAD_DIR, fileHash); const existChunks = fse.existsSync(chunkDir) ? await fse.readdir(chunkDir) : ;res.json({ status: true, data: { shouldUpload: true, // 需要上传 existChunks: existChunks // 已上传的分片标识,供前端过滤 } }); });app.listen(3000, => { console.log("服务器运行在 http://localhost:3000"); });

这是前端最复杂的环节,需要解决三个关键问题:

过滤已上传的分片(只传缺失的)控制并发请求数(避免请求爆炸)支持手动中断上传(用户可随时停止)

根据后端返回的 existChunks (已上传分片标识列表),过滤掉不需要重新上传的分片,只生成待上传的 FormData 。

/** * 上传分片(核心函数) * @param {Blob} chunks - 所有分片数组 * @param {string} existChunks - 已上传的分片标识列表 */const uploadChunks = async (chunks, existChunks) => { isUploading.value = true abortControllers.value = // 1. 生成所有分片的基础信息(文件哈希、分片标识、分片数据) const chunkInfoList = chunks.map((chunk, index) => ({ fileHash: fileHash.value, chunkHash: `${fileHash.value}-${index}`, // 分片标识:文件哈希-序号(确保唯一) chunk: chunk }))// 2. 过滤已上传的分片 → 只保留待上传的 const formDatas = chunkInfoList .filter(item => !existChunks.includes(item.chunkHash)) .map(item => { const formData = new FormData formData.append("filehash", item.fileHash) formData.append("chunkhash", item.chunkHash) formData.append("chunk", item.chunk) return formData })if (formDatas.length === 0) { // 所有分片已上传 → 直接请求合并 mergeRequest return }// 3. 并发上传分片(下文展开) await uploadWithConcurrencyControl(formDatas) }

用「请求池 + Promise.race 」限制同时上传的分片数量(这里设为 6 个),避免请求过多导致浏览器 / 服务器压力过大。

/** * 带并发控制的分片上传 * @param {FormData} formDatas - 待上传的FormData列表 */const uploadWithConcurrencyControl = async (formDatas) => { const MAX_CONCURRENT = 6 let currentIndex = 0 const taskPool = while (currentIndex { // 请求完成后,从请求池和控制器列表中移除 taskPool.splice(taskPool.indexOf(task), 1) abortControllers.value = abortControllers.value.filter(c => c !== controller) return res }) .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) { await Promise.race(taskPool) }currentIndex++ }// 等待所有剩余请求完成 await Promise.all(taskPool) // 所有分片上传完成 → 请求合并 mergeRequest }

用 AbortController 中断所有正在进行的请求,并清理状态,确保中断后下次上传能正常恢复。

/** * 中断上传(用户触发) */const abortUpload = => { if (!isUploading.value) return// 1. 中断所有正在进行的请求 abortControllers.value.forEach(controller => { controller.abort })// 2. 清理状态 abortControllers.value = isUploading.value = false// 3. 通知用户 alert("上传已中断,下次可继续上传") }

所有分片上传完成后,前端需要通知后端「合并分片」,后端按分片序号排序,用「流(Stream)」拼接成完整文件(避免内存溢出)。

用 multiparty 解析前端发送的 FormData ,将分片保存到临时目录(以文件哈希命名)。

const multiparty = require("multiparty");app.post("/upload", (req, res) => { const form = new multiparty.Form;form.parse(req, async (err, fields, files) => { if (err) { console.error("分片解析失败:", err); return res.status(400).json({ status: false, message: "分片上传失败" }); }const fileHash = fields["filehash"][0]; const chunkHash = fields["chunkhash"][0]; const chunkFile = files["chunk"][0];const chunkDir = path.resolve(UPLOAD_DIR, fileHash); await fse.ensureDir(chunkDir);const targetChunkPath = path.resolve(chunkDir, chunkHash); await fse.move(chunkFile.path, targetChunkPath);res.json({ status: true, message: "分片上传成功" }); }); });

合并的核心是「按序号排序分片」+「用流拼接」,边读边写,避免一次性加载大文件到内存。

const mergeRequest = async => { await fetch("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("文件上传完成!"); };app.post("/merge", async (req, res) => { const { fileHash, fileName, size: CHUNK_SIZE } = req.body; const completeFilePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`); const chunkDir = path.resolve(UPLOAD_DIR, fileHash);if (!fse.existsSync(chunkDir)) { return res.status(400).json({ status: false, message: "分片目录不存在" }); }const chunkPaths = await fse.readdir(chunkDir); chunkPaths.sort((a, b) => { return parseInt(a.split("-")[1]) - parseInt(b.split("-")[1]); });const mergePromises = chunkPaths.map((chunkName, index) => { return new Promise((resolve) => { const chunkPath = path.resolve(chunkDir, chunkName); const readStream = fse.createReadStream(chunkPath); const writeStream = fse.createWriteStream(completeFilePath, { start: index * CHUNK_SIZE, end: (index + 1) * CHUNK_SIZE });readStream.on("end", async => { await fse.unlink(chunkPath); resolve; });readStream.pipe(writeStream); }); });await Promise.all(mergePromises); await fse.remove(chunkDir);res.json({ status: true, message: "文件合并成功" }); });

为什么用流? 如果直接用 fs.readFile 读取所有分片内容再拼接,1GB 的文件会占用 1GB 内存,可能导致服务器内存溢出;而流操作( createReadStream / createWriteStream )是边读边写,内存占用始终很低(仅几 KB/MB)。

大文件上传看似复杂,拆解后其实是「分片→校验→上传→合并」四个核心步骤,每个步骤解决一个具体问题。这套方案用 Vue+Express 实现,代码简洁易懂,可直接作为项目基础版本,再根据实际需求扩展优化。

实际开发中,还需要结合业务场景补充异常处理(如文件大小限制、格式校验)、日志监控(上传失败告警)等功能。如果大家在实践中遇到问题,欢迎在评论区交流。

--- 如果您觉得这篇文章对您有帮助,欢迎点赞和收藏,大家的支持是我继续创作优质内容的动力也希望您能在 我的主页 找到更多对您有帮助的内容。

致敬每一位赶路人

来源:墨码行者

相关推荐