有同学问到相关的问题,抽空简单回答一下
在现代 Web 应用中,大文件上传早已不是「锦上添花」的功能,而是电商、视频平台、企业网盘等产品的基础能力 。尤其在动辄数百 MB 或数 GB 的文件场景下,直传方式几乎不可用 ------ 超时、内存溢出、网络中断,统统会让用户体验崩溃。
本文将带你实现一个工业级大文件上传方案 ,覆盖 分片上传、断点续传、秒传、并发控制、错误重试、手动中断 等核心功能,并进一步扩展到上传进度可视化、服务端安全校验、云存储对接等更贴近生产的场景。
一、目标拆解:我们要解决什么问题?
对照真实业务,我们需要的功能清单如下:
- 分片上传:避免超时,突破请求大小限制
- 秒传:服务端已存在文件 → 直接返回成功
- 断点续传:上传中断后,仅补传缺失分片
- 并发控制:避免同时发起过多请求压垮浏览器/服务端
- 错误重试:网络波动时自动重试几次,减少用户干预
- 手动中断:用户可随时暂停/取消上传
- 上传进度可视化:进度条+速率提示,增强体验
- 云存储对接:与 S3/OSS/MinIO 等无缝兼容
- 安全与校验:防止恶意上传 & 文件完整性保证
二、上传全流程拆解
大文件上传可抽象为 6 个阶段:
- 文件选择(File API 获取文件对象)
- 分片 + 哈希计算(生成唯一标识)
- 文件校验(秒传 / 断点续传判断)
- 分片上传(含并发控制、错误重试、中断机制)
- 合并分片(服务端流式拼接)
- 上传完成校验(文件完整性验证、存储持久化)
接下来分前后端分别展开。
三、前端实现(Vue 3)
1. 文件选择与状态管理
ini
<template>
<div class="upload-container">
<input type="file" @change="handleFileSelect" />
<button v-if="isUploading" @click="abortUpload">中断上传</button>
<div v-if="progress > 0">
上传进度:{{ progress }}%
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
const isUploading = ref(false);
const progress = ref(0);
const abortControllers = ref([]);
const fileInfo = ref({});
</script>
2. 分片与哈希优化
和之前一样,我们按 1MB 分片,但要注意:
- 大文件(>500MB)建议 5MB/10MB,减少请求数
- 抽样 + 全量混合计算哈希,保证唯一性与速度
ini
import SparkMD5 from "spark-md5";
const CHUNK_SIZE = 5 * 1024 * 1024;
function createChunks(file) {
const chunks = [];
let cur = 0;
while (cur < file.size) {
chunks.push(file.slice(cur, cur + CHUNK_SIZE));
cur += CHUNK_SIZE;
}
return chunks;
}
async function calHash(chunks) {
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const targets = [];
chunks.forEach((chunk, i) => {
if (i === 0 || i === 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));
}
});
return new Promise((resolve) => {
reader.readAsArrayBuffer(new Blob(targets));
reader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};
});
}
3. 校验文件状态(秒传/断点续传)
javascript
async function verifyFile(fileHash, fileName) {
const res = await fetch("http://localhost:3000/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileHash, fileName }),
});
return res.json();
}
4. 分片上传(并发 + 重试 + 中断)
这是前端最复杂的部分:
- 并发池:限制并发数(如 6 个)
- 错误重试:失败的分片可重试 3 次
- 中断机制 :统一用
AbortController
ini
async function uploadWithPool(formDatas, retries = 3) {
const MAX_CONCURRENT = 6;
let index = 0;
const pool = [];
async function request(formData, attempt = 1) {
const controller = new AbortController();
abortControllers.value.push(controller);
try {
await fetch("http://localhost:3000/upload", {
method: "POST",
body: formData,
signal: controller.signal,
});
} catch (err) {
if (attempt < retries && err.name !== "AbortError") {
return request(formData, attempt + 1); // 自动重试
}
throw err;
}
}
while (index < formDatas.length) {
const task = request(formDatas[index]);
pool.push(task);
if (pool.length >= MAX_CONCURRENT) {
await Promise.race(pool);
pool.splice(pool.indexOf(await Promise.race(pool)), 1);
}
index++;
}
await Promise.all(pool);
}
5. 上传进度条
利用 XMLHttpRequest
或 fetch + stream
,实时监听上传进度。
ini
function uploadChunk(formData) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "http://localhost:3000/upload");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
progress.value = Math.round((e.loaded / e.total) * 100);
}
};
xhr.onload = () => resolve();
xhr.onerror = () => reject("网络错误");
xhr.send(formData);
});
}
四、后端实现(Express)
1. 文件校验(/verify)
ini
app.post("/verify", async (req, res) => {
const { fileHash, fileName } = req.body;
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`);
if (fse.existsSync(filePath)) {
return res.json({ shouldUpload: false });
}
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
const existChunks = fse.existsSync(chunkDir) ? await fse.readdir(chunkDir) : [];
res.json({ shouldUpload: true, existChunks });
});
2. 分片接收(/upload)
ini
app.post("/upload", (req, res) => {
const form = new multiparty.Form();
form.parse(req, async (err, fields, files) => {
const fileHash = fields.filehash[0];
const chunkHash = fields.chunkhash[0];
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
await fse.ensureDir(chunkDir);
await fse.move(files.chunk[0].path, path.resolve(chunkDir, chunkHash));
res.json({ status: true });
});
});
3. 合并分片(/merge)
ini
app.post("/merge", async (req, res) => {
const { fileHash, fileName, size } = req.body;
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${extractExt(fileName)}`);
const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
const chunkFiles = await fse.readdir(chunkDir);
chunkFiles.sort((a, b) => parseInt(a.split("-")[1]) - parseInt(b.split("-")[1]));
for (let i = 0; i < chunkFiles.length; i++) {
const chunkPath = path.resolve(chunkDir, chunkFiles[i]);
await pipeStream(chunkPath, fse.createWriteStream(filePath, { start: i * size }));
await fse.unlink(chunkPath);
}
await fse.remove(chunkDir);
res.json({ status: true, message: "合并完成" });
});
function pipeStream(path, writeStream) {
return new Promise((resolve) => {
const readStream = fse.createReadStream(path);
readStream.on("end", resolve);
readStream.pipe(writeStream);
});
}
五、工程化可扩展方向
- 进度持久化:将分片完成状态存入 localStorage,刷新后可继续上传。
- 云存储对接:后端仅签发预签名 URL,分片直传 S3/OSS,减少服务端压力。
- 安全校验:限制文件类型、大小;在合并时校验哈希一致性。
- 大规模优化:上传日志采集,失败自动上报告警。
- 秒传优化 :可在数据库维护
fileHash → 文件路径
映射。
六、总结
- 分片 → 校验 → 上传 → 合并 → 校验 是大文件上传的黄金流程
- 并发池、错误重试、进度条 是提升用户体验的关键
- 云直传 + 安全校验 是生产环境必须的扩展
大文件上传从「玩具版」到「工程级」,关键就在于:细节补偿 + 可扩展性设计。