前端大文件上传全解析:从基础到高级,循序渐进掌握核心实现
一、前言:为什么需要大文件上传专属方案?
在前端开发中,文件上传是常见需求,但大文件(几十MB/几百MB/几GB,如视频、安装包、大型数据集) 直接用原生方式上传会遇到一系列致命问题:
- 请求超时:服务器默认有请求超时限制(如30s),大文件上传耗时远超阈值,直接上传失败;
- 易中断:网络波动、页面刷新、浏览器关闭都会导致上传中断,且中断后只能重新上传;
- 无精细化反馈:用户无法感知上传进度,体验极差;
- 服务器压力大:单个大文件请求占用大量带宽和内存,高并发场景下服务器极易拥堵;
- 无法暂停/续传:大文件上传到99%中断,只能从头开始,极度浪费资源。
而传统原生表单/简单AJAX上传仅适用于小文件(几KB/几MB),核心代码如下(仅作对比,大文件禁用):
<!-- 原生表单上传(小文件专用) -->
<form action="/api/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<button type="submit">上传</button>
</form>
<!-- 原生AJAX上传(小文件专用) -->
<script>
const input = document.querySelector('input[type="file"]');
input.onchange = async (e) => {
const file = e.target.files[0];
const formData = new FormData();
formData.append('file', file);
// 大文件上传:大概率超时/中断
const res = await fetch('/api/upload', { method: 'POST', body: formData });
console.log(await res.json());
};
</script>
针对大文件上传的痛点,行业内的核心解决思路是「分而治之」:将单个大文件拆分为多个固定大小的小分片,分批次上传到服务器,服务器接收完所有分片后,再将分片合并为原始大文件。在此基础上,可扩展出断点续传、秒传等高级功能,形成一套完整的大文件上传解决方案。
本文将从基础分片上传入手,逐步进阶到断点续传、秒传,搭配前端(原生JS/Vue3)+ 后端(Node.js+Express) 可运行案例,循序渐进带领大家掌握大文件上传的核心实现,所有案例代码均可直接运行测试。
二、基础核心:分片上传(大文件上传的基石)
分片上传是所有大文件上传方案的基础,也是最核心的环节,掌握这部分逻辑,才能理解后续所有高级功能。
2.1 分片上传核心原理
- 文件拆分:通过浏览器原生
File.slice()方法,将大文件拆分为固定大小的小分片(如1MB/片),每片都是独立的Blob对象; - 分片标识:为每个分片设置唯一标识(如「文件唯一key-分片索引」),确保服务器能按顺序合并;
- 分批次上传:逐个/并行上传所有分片,每个分片对应一个独立的HTTP请求;
- 服务器合并:前端检测到所有分片上传完成后,发送「合并请求」,服务器按分片索引顺序将所有分片合并为原始大文件;
- 清理分片:服务器合并完成后,删除临时存储的分片文件,释放磁盘空间。
2.2 关键API:File.slice()(文件拆分核心)
浏览器原生提供File.slice(start, end)方法,专门用于截取文件的指定部分,不会修改原文件,返回一个新的Blob对象(可直接作为FormData的上传参数)。
- 参数说明:
start:截取的起始位置(单位:字节,从0开始);end:截取的结束位置(单位:字节,不包含该位置);
- 分片大小计算:假设分片大小为1MB(1024*1024字节),第
i个分片的起始位置为i * 1024 * 1024,结束位置为(i+1) * 1024 * 1024,最后一个分片的结束位置取文件总大小(避免超出); - 简单示例:将文件拆分为1MB分片
const file = document.querySelector('input[type="file"]').files[0];
const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB/片
const chunkCount = Math.ceil(file.size / CHUNK_SIZE); // 总分片数(向上取整)
// 遍历拆分所有分片
for (let i = 0; i < chunkCount; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size); // 最后一片取文件末尾
const chunk = file.slice(start, end); // 拆分出第i个分片
console.log(`第${i+1}片,大小:${(chunk.size/1024).toFixed(2)}KB`);
}
2.3 分片上传完整实现(前端+后端可运行案例)
为了让大家快速上手,案例采用前端原生JS(无框架依赖,易理解)+ 后端Node.js+Express(极简版,无数据库,仅做分片接收和合并),核心实现「文件选择、分片拆分、串行上传、进度反馈、服务器合并」。
2.3.1 环境准备(后端)
- 新建文件夹,命名为
big-file-upload,进入文件夹执行npm init -y初始化项目; - 安装后端依赖:
npm i express cors multer fs-extra(cors处理跨域,multer处理文件上传,fs-extra增强文件操作); - 新建后端文件
server.js,新建前端文件index.html,项目结构如下:
big-file-upload/
├── server.js # 后端代码
├── index.html # 前端代码
├── package.json # 项目配置
└── uploads/ # 自动生成,存储分片和最终文件
├── chunks/ # 临时存储分片
└── files/ # 存储合并后的最终文件
2.3.2 前端实现(原生JS,带上传进度)
核心功能:文件选择、分片拆分、串行上传分片、实时更新上传进度、发送合并请求,代码注释详细,可直接复制到index.html:
拓展文档:
FormData.append()方法
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>大文件分片上传(基础版)</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.upload-container { width: 600px; margin: 50px auto; }
.file-input { padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 4px; cursor: pointer; }
.progress { margin: 20px 0; height: 24px; border: 1px solid #409eff; border-radius: 12px; overflow: hidden; }
.progress-bar { height: 100%; background: #409eff; transition: width 0.3s ease; width: 0; }
.progress-text { text-align: center; margin-top: 8px; color: #666; }
.result { margin-top: 10px; padding: 8px; border-radius: 4px; color: #fff; display: none; }
.success { background: #67c23a; }
.error { background: #f56c6c; }
</style>
</head>
<body>
<div class="upload-container">
<input type="file" class="file-input" id="fileInput" />
<div class="progress">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="progress-text" id="progressText">上传进度:0%</div>
<div class="result" id="resultBox"></div>
</div>
<script>
// 全局配置
const config = {
chunkSize: 1 * 1024 * 1024, // 1MB/片
baseUrl: 'http://localhost:3000/api' // 后端接口地址
};
// 获取DOM元素
const fileInput = document.getElementById('fileInput');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const resultBox = document.getElementById('resultBox');
// 初始化:文件选择事件
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
// 重置上传状态
resetUploadState();
// 执行分片上传
await uploadByChunk(file);
};
// 重置上传状态
function resetUploadState() {
progressBar.style.width = '0%';
progressText.innerText = '上传进度:0%';
resultBox.style.display = 'none';
resultBox.className = 'result';
}
// 分片上传核心函数
async function uploadByChunk(file) {
try {
// 1. 计算文件基础信息
const fileName = file.name;
const fileSize = file.size;
const chunkCount = Math.ceil(fileSize / config.chunkSize); // 总分片数
const fileKey = `${Date.now()}-${fileName}`; // 简易文件唯一key(进阶版用MD5)
// 2. 串行上传所有分片
for (let i = 0; i < chunkCount; i++) {
const start = i * config.chunkSize;
const end = Math.min(start + config.chunkSize, fileSize);
const chunk = file.slice(start, end); // 拆分当前分片
// 构建上传FormData
const formData = new FormData();
formData.append('file', chunk, `${fileKey}-${i}`); // 分片名:fileKey-索引
formData.append('fileKey', fileKey);
formData.append('chunkIndex', i);
formData.append('chunkCount', chunkCount);
formData.append('originalName', fileName);
// 上传当前分片
await fetch(`${config.baseUrl}/upload-chunk`, {
method: 'POST',
body: formData
});
// 3. 更新上传进度
const progress = Math.ceil(((i + 1) / chunkCount) * 100);
progressBar.style.width = `${progress}%`;
progressText.innerText = `上传进度:${progress}%`;
}
// 4. 所有分片上传完成,发送合并请求
const mergeRes = await fetch(`${config.baseUrl}/merge-chunk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileKey, originalName: fileName, chunkCount })
});
const mergeData = await mergeRes.json();
if (mergeData.code === 200) {
showResult(`上传成功!文件访问地址:http://localhost:3000${mergeData.data.filePath}`, 'success');
} else {
throw new Error(mergeData.msg || '文件合并失败');
}
} catch (err) {
showResult(`上传失败:${err.message}`, 'error');
console.error('分片上传异常:', err);
}
}
// 显示上传结果
function showResult(text, type) {
resultBox.innerText = text;
resultBox.className = `result ${type}`;
resultBox.style.display = 'block';
}
</script>
</body>
</html>
2.3.3 后端实现(Node.js+Express)
核心功能:配置跨域、接收分片并临时存储、处理合并请求(按索引合并分片)、托管上传文件为静态资源,代码注释详细,可直接复制到server.js:
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const app = express();
const PORT = 3000;
// 全局中间件配置
app.use(cors()); // 允许跨域请求
app.use(express.json()); // 解析JSON格式请求体
app.use(express.urlencoded({ extended: true })); // 解析表单格式请求体
// 1. 配置multer,临时存储分片文件(存储到uploads/chunks目录)
const chunkStorage = multer.diskStorage({
destination: (req, file, cb) => {
const chunkDir = path.join(__dirname, 'uploads/chunks');
fs.ensureDirSync(chunkDir); // 确保目录存在,不存在则自动创建
cb(null, chunkDir);
},
filename: (req, file, cb) => {
// 保持前端传递的分片名(fileKey-索引),方便后续合并
cb(null, file.originalname);
}
});
// 配置multer只接收单个文件,字段名为file
const uploadChunk = multer({ storage: chunkStorage }).single('file');
// 2. 接口1:接收分片上传
app.post('/api/upload-chunk', uploadChunk, (req, res) => {
try {
// 只需返回成功状态,无需返回额外数据
res.json({ code: 200, msg: '分片上传成功' });
} catch (err) {
res.status(500).json({ code: 500, msg: '分片上传失败', error: err.message });
}
});
// 3. 接口2:合并分片为原始文件
app.post('/api/merge-chunk', async (req, res) => {
const { fileKey, originalName, chunkCount } = req.body;
try {
// 定义文件存储路径
const targetFileDir = path.join(__dirname, 'uploads/files');
fs.ensureDirSync(targetFileDir); // 确保最终文件目录存在
const targetFilePath = path.join(targetFileDir, originalName); // 合并后的文件路径
const chunkDir = path.join(__dirname, 'uploads/chunks'); // 分片存储目录
// 按索引顺序合并分片(核心:必须按顺序,否则文件损坏)
for (let i = 0; i < chunkCount; i++) {
const chunkFilePath = path.join(chunkDir, `${fileKey}-${i}`); // 单个分片路径
if (!fs.existsSync(chunkFilePath)) {
throw new Error(`第${i}个分片不存在,合并失败`);
}
// 追加写入分片到目标文件(不存在则创建,存在则追加)
await fs.appendFile(targetFilePath, await fs.readFile(chunkFilePath));
// 删除已合并的临时分片,释放磁盘空间
await fs.unlink(chunkFilePath);
}
// 合并成功,返回文件访问路径
const filePath = `/uploads/files/${originalName}`;
res.json({
code: 200,
msg: '文件合并成功',
data: { filePath }
});
} catch (err) {
res.status(500).json({ code: 500, msg: '文件合并失败', error: err.message });
}
});
// 4. 托管上传目录为静态资源,允许前端直接访问上传的文件
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// 启动服务器
app.listen(PORT, () => {
console.log(`大文件上传服务器启动成功,地址:http://localhost:${PORT}`);
});
2.3.4 运行测试
- 启动后端:在项目根目录执行
node server.js,控制台输出「大文件上传服务器启动成功」即为正常; - 运行前端:直接用浏览器打开
index.html文件; - 测试上传:选择一个大文件(如100MB视频、50MB压缩包),可看到上传进度实时更新,上传完成后会显示文件访问地址,点击地址可直接预览/下载文件。
2.4 分片上传基础优化:并行上传(提升上传速度)
上述案例采用串行上传(逐个上传分片),优点是逻辑简单、不易出错,但缺点是上传速度慢(带宽利用率低)。实际开发中可改为并行上传(同时上传多个分片),充分利用网络带宽,提升大文件上传速度。
2.4.1 并行上传核心思路
- 限制最大并行数(如3-5个),避免并行数过多导致浏览器请求超限或服务器压力过大;
- 维护一个「待上传分片索引」,每完成一个分片上传,立即上传下一个,保持并行数稳定;
- 用
Promise收集所有上传请求,确保所有分片上传完成后再发送合并请求。
2.4.2 前端并行上传改造(替换原uploadByChunk函数)
仅需修改前端的分片上传逻辑,后端代码无需任何改动,核心改造后的uploadByChunk函数如下:
// 分片上传核心函数(并行版,最大并行数3)
async function uploadByChunk(file) {
try {
const fileName = file.name;
const fileSize = file.size;
const chunkCount = Math.ceil(fileSize / config.chunkSize);
const fileKey = `${Date.now()}-${fileName}`;
const MAX_PARALLEL = 3; // 最大并行上传数
let currentIndex = 0; // 当前待上传分片索引
let completedCount = 0; // 已完成分片计数(用于进度)
let uploadPromiseList = []; // 存储并行上传的Promise
// 并行上传单个分片的函数
const uploadSingleChunk = async (i) => {
const start = i * config.chunkSize;
const end = Math.min(start + config.chunkSize, fileSize);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append("file", chunk, `${fileKey}-${i}`);
formData.append("fileKey", fileKey);
formData.append("chunkIndex", i);
formData.append("chunkCount", chunkCount);
formData.append("originalName", fileName);
await fetch(`${config.baseUrl}/upload-chunk`, {
method: "POST",
body: formData,
});
// 单个分片上传完成,更新进度(使用 completedCount 而不是 currentIndex)
const progress = Math.ceil((++completedCount / chunkCount) * 100);
progressBar.style.width = `${progress}%`;
progressText.innerText = `上传进度:${progress}%`;
};
// 启动并行上传
while (currentIndex < chunkCount) {
// 启动当前分片上传,加入Promise列表
const promise = uploadSingleChunk(currentIndex);
uploadPromiseList.push(promise);
currentIndex++; // 递增分片索引
// 当Promise列表达到最大并行数,等待其中一个完成后再继续
if (uploadPromiseList.length >= MAX_PARALLEL) {
await Promise.race(uploadPromiseList);
// 移除已完成的Promise
uploadPromiseList = uploadPromiseList.filter(
(p) => p.status !== "fulfilled",
);
}
}
// 等待所有剩余分片上传完成
await Promise.all(uploadPromiseList);
// 发送合并请求(和串行版一致)
const mergeRes = await fetch(`${config.baseUrl}/merge-chunk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileKey,
originalName: fileName,
chunkCount,
}),
});
const mergeData = await mergeRes.json();
if (mergeData.code === 200) {
showResult(
`上传成功!文件访问地址:http://localhost:3000${mergeData.data.filePath}`,
"success",
);
} else {
throw new Error(mergeData.msg || "文件合并失败");
}
} catch (err) {
showResult(`上传失败:${err.message}`, "error");
console.error("分片上传异常:", err);
}
}
三、进阶功能1:断点续传(中断后无需重新上传)
分片上传解决了大文件一次性上传易中断的问题,但如果上传到50%时中断(如网络断开、页面刷新),串行/并行上传都需要重新上传所有分片,依然浪费资源。断点续传就是为了解决这个问题——中断后再次上传同一文件时,先查询服务器「已上传的分片」,只上传「未完成的分片」。
3.1 断点续传核心原理
- 文件唯一标识:为每个文件生成唯一且不变的标识(最优选择是文件MD5),即使文件名相同,只要文件内容不同,MD5就不同,确保文件标识的唯一性;
- 分片状态记录:服务器端存储每个文件的「已上传分片索引」(如用数据库/文件记录:文件MD5 → 已上传分片数组);
- 断点检测:文件上传前,前端先向服务器发送「检测请求」,携带文件MD5,服务器返回该文件已上传的分片索引;
- 按需上传:前端根据服务器返回的已上传分片,跳过已完成的分片,只上传未完成的分片,完成后发送合并请求。
3.2 关键技术:前端计算文件MD5(大文件友好版)
MD5是文件的「数字指纹」,文件内容不变,MD5就不变,是文件唯一标识的最佳选择。前端计算大文件MD5时,不能直接一次性读取整个文件(会导致浏览器卡顿、主线程阻塞),需采用分片读取文件的方式计算,结合轻量MD5库SparkMD5实现(高效、无依赖)。
3.2.1 引入SparkMD5
可通过CDN直接引入,在前端index.html的<head>标签中添加:
<!-- 引入SparkMD5,用于计算文件MD5 -->
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
3.2.2 前端分片计算文件MD5(非阻塞)
核心思路:将文件拆分为2MB的小分片,通过FileReader逐片读取为ArrayBuffer,传入SparkMD5进行计算,完成后返回文件MD5,不会阻塞浏览器主线程,实现代码如下:
/**
* 分片计算文件MD5(大文件友好,非阻塞)
* @param {File} file - 待计算MD5的文件
* @returns {Promise<string>} - 文件MD5字符串
*/
function calculateFileMD5(file) {
return new Promise((resolve, reject) => {
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB/片,计算MD5的分片和上传分片可不同
const spark = new SparkMD5.ArrayBuffer(); // 初始化SparkMD5
const fileReader = new FileReader(); // 用于读取文件分片
let currentIndex = 0; // 当前读取的分片索引
// 读取下一个分片
const readNextChunk = () => {
const start = currentIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
fileReader.readAsArrayBuffer(file.slice(start, end));
};
// 分片读取完成回调
fileReader.onload = (e) => {
try {
spark.append(e.target.result); // 将分片数据加入MD5计算
currentIndex++;
// 判断是否读取完所有分片
if (currentIndex * CHUNK_SIZE < file.size) {
readNextChunk(); // 继续读取下一个分片
} else {
const md5 = spark.end(); // 完成MD5计算,获取最终MD5值
resolve(md5);
}
} catch (err) {
reject(err);
}
};
// 读取文件失败回调
fileReader.onerror = (err) => {
reject(new Error(`文件MD5计算失败:${err.message}`));
};
// 开始读取第一个分片
readNextChunk();
});
}
// 使用示例:
// const file = document.querySelector('input[type="file"]').files[0];
// const fileMD5 = await calculateFileMD5(file);
// console.log('文件MD5:', fileMD5);
3.3 断点续传完整实现(基于分片上传改造)
断点续传是在分片上传的基础上进行改造,核心新增3个环节:前端计算文件MD5、前端发送断点检测请求、后端记录并返回已上传分片。本次改造采用lowdb(轻量JSON数据库,无需安装MySQL/MongoDB,易上手)存储文件和分片状态,前后端代码均做最小化改造,确保可直接运行。
3.3.1 后端改造:安装lowdb+新增接口+记录分片状态
- 安装lowdb依赖:
npm i lowdb@1.0.0(指定版本,避免兼容性问题); - 改造
server.js,核心修改点:- 引入lowdb,初始化JSON数据库,用于记录「上传中文件」(已上传分片)和「已完成文件」;
- 新增
/api/check-chunk接口,处理前端的断点检测请求,返回已上传分片; - 修改
/api/upload-chunk接口,分片上传成功后,记录该分片索引到数据库; - 修改
/api/merge-chunk接口,文件合并成功后,将文件从「上传中」移到「已完成」,并清理分片记录。
改造后的完整server.js代码如下(新增/修改部分有注释标注):
const express = require("express");
const cors = require("cors");
const multer = require("multer");
const fs = require("fs-extra");
const path = require("path");
// 修复:lowdb CommonJS 正确导入方式(适配低版本Node.js)
const low = require("lowdb");
const FileSync = require("lowdb/adapters/FileSync");
const app = express();
const PORT = 3000;
// 全局中间件配置
app.use(cors()); // 允许跨域
app.use(express.json()); // 解析JSON请求体
app.use(express.urlencoded({ extended: true })); // 解析表单请求体
// 修复:初始化lowdb数据库(FileSync 同步适配器,无顶级await问题)
const dbFile = path.join(__dirname, "db.json"); // 数据库文件路径
const adapter = new FileSync(dbFile); // 同步文件适配器(适合小数据量,无需异步)
const db = low(adapter); // 初始化db实例
// 设置数据库默认数据(首次运行自动创建db.json)
db.defaults({
uploadingFiles: [], // 上传中文件:[{ fileMD5, uploadedChunks: [] }]
finishedFiles: [], // 已完成文件:[{ fileMD5, originalName, filePath }]
}).write(); // 同步写入默认配置
// multer分片存储配置(无修改,保持原有逻辑)
const chunkStorage = multer.diskStorage({
destination: (req, file, cb) => {
const chunkDir = path.join(__dirname, "uploads/chunks");
fs.ensureDirSync(chunkDir); // 确保分片目录存在
cb(null, chunkDir);
},
filename: (req, file, cb) => {
cb(null, file.originalname); // 保持前端传递的分片名(fileMD5-索引)
},
});
const uploadChunk = multer({ storage: chunkStorage }).single("file");
// 接口1:断点检测(新增,返回已上传分片/秒传判断)
app.post("/api/check-chunk", (req, res) => {
try {
const { fileMD5 } = req.body;
// 无需手动read:FileSync适配器会自动同步最新数据
// 先判断是否秒传(文件已完成上传)
const finishedFile = db.get("finishedFiles").find({ fileMD5 }).value();
if (finishedFile) {
return res.json({
code: 200,
msg: "文件已存在(秒传)",
data: { isFinished: true, filePath: finishedFile.filePath },
});
}
// 查找上传中文件的已上传分片
const uploadingFile = db.get("uploadingFiles").find({ fileMD5 }).value();
const uploadedChunks = uploadingFile ? uploadingFile.uploadedChunks : [];
res.json({
code: 200,
msg: "断点检测成功",
data: { isFinished: false, uploadedChunks },
});
} catch (err) {
res
.status(500)
.json({ code: 500, msg: "断点检测失败", error: err.message });
}
});
// 接口2:接收分片上传(修改,新增分片状态记录)
app.post("/api/upload-chunk", uploadChunk, (req, res) => {
try {
const { fileMD5, chunkIndex } = req.body;
const chunkIndexNum = parseInt(chunkIndex); // 转为数字类型,避免索引异常
if (isNaN(chunkIndexNum)) throw new Error("分片索引格式错误");
// 查找当前文件的上传记录,无则创建
let uploadingFile = db.get("uploadingFiles").find({ fileMD5 }).value();
if (!uploadingFile) {
uploadingFile = { fileMD5, uploadedChunks: [] };
db.get("uploadingFiles").push(uploadingFile).write();
}
// 避免重复记录分片(防止重复上传)
if (!uploadingFile.uploadedChunks.includes(chunkIndexNum)) {
db.get("uploadingFiles")
.find({ fileMD5 })
.update("uploadedChunks", (arr) => [...arr, chunkIndexNum])
.write(); // 同步写入数据库
}
res.json({ code: 200, msg: "分片上传成功" });
} catch (err) {
res
.status(500)
.json({ code: 500, msg: "分片上传失败", error: err.message });
}
});
// 接口3:合并分片(修改,更新文件状态+清理分片记录)
app.post("/api/merge-chunk", async (req, res) => {
const { fileMD5, originalName, chunkCount } = req.body;
try {
// 定义文件存储路径
const targetFileDir = path.join(__dirname, "uploads/files");
fs.ensureDirSync(targetFileDir);
const targetFilePath = path.join(targetFileDir, originalName);
const chunkDir = path.join(__dirname, "uploads/chunks");
// 按索引顺序合并分片(核心:必须按顺序,否则文件损坏)
for (let i = 0; i < chunkCount; i++) {
const chunkFilePath = path.join(chunkDir, `${fileMD5}-${i}`);
if (!fs.existsSync(chunkFilePath)) {
throw new Error(`第${i}个分片不存在,合并失败`);
}
// 追加写入分片(不存在则创建,存在则追加)
await fs.appendFile(targetFilePath, await fs.readFile(chunkFilePath));
// 删除已合并的临时分片,释放磁盘空间
await fs.unlink(chunkFilePath);
}
// 数据库状态更新:移除上传中记录,添加已完成记录
const filePath = `/uploads/files/${originalName}`;
db.get("uploadingFiles").remove({ fileMD5 }).write(); // 删除上传中记录
db.get("finishedFiles").push({ fileMD5, originalName, filePath }).write(); // 添加完成记录
res.json({
code: 200,
msg: "文件合并成功",
data: { filePath },
});
} catch (err) {
res
.status(500)
.json({ code: 500, msg: "文件合并失败", error: err.message });
}
});
// 托管上传目录为静态资源,支持前端直接访问文件
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
// 启动服务器
app.listen(PORT, () => {
console.log(`大文件上传服务器启动成功,地址:http://localhost:${PORT}`);
});
3.3.2 前端改造:MD5计算+断点检测+按需上传
前端仅需修改uploadByChunk函数,核心新增3个步骤:
- 上传前先计算文件MD5,提示用户「正在计算MD5」;
- 调用后端
/api/check-chunk接口进行断点检测,若文件已完成则直接返回结果; - 根据服务器返回的已上传分片,跳过已完成的分片,只上传未完成的部分。
改造后的前端完整uploadByChunk函数(并行版,带断点续传)如下:
// 分片上传核心函数(并行版+断点续传)
async function uploadByChunk(file) {
try {
// 新增步骤1:计算文件MD5,提示用户
showResult("正在计算文件MD5,请稍候...", "success");
const fileMD5 = await calculateFileMD5(file);
const fileName = file.name;
const fileSize = file.size;
const chunkCount = Math.ceil(fileSize / config.chunkSize);
const MAX_PARALLEL = 3;
let currentIndex = 0; // 控制分片索引
let completedCount = 0; // 控制进度计数
let uploadPromiseList = [];
// 新增步骤2:断点检测,查询已上传分片
const checkRes = await fetch(`${config.baseUrl}/check-chunk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileMD5 }),
});
const checkData = await checkRes.json();
if (checkData.code !== 200) {
throw new Error(checkData.msg || "断点检测失败");
}
// 若文件已完成上传,直接返回结果(秒传铺垫)
if (checkData.data.isFinished) {
showResult(
`上传成功!文件访问地址:http://localhost:3000${checkData.data.filePath}`,
"success",
);
return;
}
// 获取已上传分片索引数组
const uploadedChunks = checkData.data.uploadedChunks || [];
showResult(
`断点检测完成,已上传${uploadedChunks.length}/${chunkCount}片`,
"success",
);
// 并行上传单个分片(修改:分片名改为fileMD5-索引)
const uploadSingleChunk = async (i) => {
// 若该分片已上传,直接更新进度并返回
if (uploadedChunks.includes(i)) {
const progress = Math.ceil((++completedCount / chunkCount) * 100);
progressBar.style.width = `${progress}%`;
progressText.innerText = `上传进度:${progress}%`;
return;
}
// 未上传的分片,执行上传逻辑
const start = i * config.chunkSize;
const end = Math.min(start + config.chunkSize, fileSize);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append("file", chunk, `${fileMD5}-${i}`); // 分片名改为MD5-索引
formData.append("fileMD5", fileMD5); // 传递文件MD5
formData.append("chunkIndex", i);
formData.append("chunkCount", chunkCount);
formData.append("originalName", fileName);
await fetch(`${config.baseUrl}/upload-chunk`, {
method: "POST",
body: formData,
});
// 更新进度(使用 completedCount)
const progress = Math.ceil((++completedCount / chunkCount) * 100);
progressBar.style.width = `${progress}%`;
progressText.innerText = `上传进度:${progress}%`;
};
// 启动并行上传(修复:正确管理 currentIndex)
while (currentIndex < chunkCount) {
const promise = uploadSingleChunk(currentIndex);
uploadPromiseList.push(promise);
currentIndex++; // 递增分片索引
// 当Promise列表达到最大并行数,等待其中一个完成后再继续
if (uploadPromiseList.length >= MAX_PARALLEL) {
await Promise.race(uploadPromiseList);
// 移除已完成的Promise
uploadPromiseList = uploadPromiseList.filter((p) => !p.isFulfilled);
}
}
await Promise.all(uploadPromiseList);
// 发送合并请求(修改:传递fileMD5,移除fileKey)
const mergeRes = await fetch(`${config.baseUrl}/merge-chunk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileMD5,
originalName: fileName,
chunkCount,
}),
});
const mergeData = await mergeRes.json();
if (mergeData.code === 200) {
showResult(
`上传成功!文件MD5:${fileMD5},访问地址:http://localhost:3000${mergeData.data.filePath}`,
"success",
);
} else {
throw new Error(mergeData.msg || "文件合并失败");
}
} catch (err) {
showResult(`上传失败:${err.message}`, "error");
console.error("分片上传异常:", err);
}
}
3.3.3 断点续传测试
- 重启后端:
node server.js(确保数据库初始化成功,项目根目录会生成db.json文件); - 前端选择大文件上传,上传到50%时,手动关闭浏览器/断开网络,中断上传;
- 重新打开
index.html,选择同一个文件再次上传,前端会先计算MD5,然后检测到已上传的分片,只上传剩余部分,实现断点续传。
四、进阶功能2:秒传(文件已存在,瞬间上传)
秒传是大文件上传的「极致优化」,也是断点续传的天然延伸功能,无需额外开发新逻辑。用户选择文件后,前端计算MD5,后端检测到该MD5对应的文件已上传完成,则直接返回文件访问地址,无需任何上传操作,实现「瞬间上传」的效果。
4.1 秒传核心原理
秒传的核心逻辑已在断点检测接口中实现,无需额外新增代码:
- 前端选择文件后,先计算文件MD5;
- 调用后端
/api/check-chunk接口进行断点检测; - 后端首先判断该MD5是否存在于「已完成文件」列表中:
- 若存在 → 返回
isFinished: true和文件访问地址,前端直接显示上传成功,实现秒传; - 若不存在 → 返回已上传分片,执行断点续传。
- 若存在 → 返回
4.2 秒传测试
- 确保某大文件已成功上传到服务器(
db.json的finishedFiles中有该文件的MD5记录); - 重新打开前端
index.html,选择同一个文件(内容完全一致,即使重命名也可); - 前端计算MD5后,后端检测到文件已完成,直接返回结果,上传进度瞬间变为100%,实现秒传。
五、生产环境必备优化方案
上述案例为了易理解,做了简化处理,实际生产环境中,需要从前端、后端、网络、安全四个维度进行优化,让大文件上传方案更健壮、更高效、更安全。
5.1 前端优化
- MD5计算优化:将MD5计算放入Web Worker,彻底避免阻塞主线程(即使计算大文件MD5,页面也能正常交互);
- 上传暂停/取消:通过
AbortController中断fetch请求,实现上传的暂停和取消功能; - 分片重试机制:分片上传失败后,自动重试3-5次(避免网络波动导致的单次失败),重试失败则提示用户;
- 文件校验:上传前校验文件大小、文件类型(通过
file.type或后缀名),拒绝超过服务器限制的文件; - 进度防抖:上传进度更新时增加防抖(如500ms一次),避免频繁更新DOM导致页面卡顿;
- 并行数动态调整:根据网络速度动态调整最大并行数(网络快则增加,网络慢则减少)。
5.2 后端优化
- 分片校验:接收分片时,校验分片MD5、分片大小、分片索引,防止非法分片上传;
- 流式合并:大文件合并时,采用
fs.createReadStream和fs.createWriteStream流式合并,避免一次性读取所有分片到内存导致内存溢出; - 分片过期清理:定时清理未完成上传的分片(如24小时未完成则删除),释放服务器磁盘空间;
- 分布式存储:将分片和最终文件存储到分布式文件系统(如MinIO、阿里云OSS、腾讯云COS),而非服务器本地(避免单服务器磁盘不足);
- 数据库优化:生产环境替换lowdb为MySQL/Redis/MongoDB,支持高并发、高可用;
- 负载均衡:多服务器部署时,通过文件MD5做哈希路由,确保同一个文件的所有分片上传到同一台服务器(避免分片分散在不同服务器无法合并)。
5.3 网络与安全优化
- HTTPS传输:使用HTTPS协议上传,防止文件在传输过程中被窃取、篡改;
- 接口鉴权:为所有上传接口添加Token/签名验证,防止非法人员调用接口上传文件;
- 分片限速:限制单个分片的上传速度,避免单用户占用过多服务器带宽;
- 跨域精细化配置:后端CORS配置仅允许指定域名的上传请求,禁止通配符
*; - 防重传:为每个分片添加唯一标识,防止同一分片被重复上传。
六、主流开源大文件上传组件(生产环境直接用)
如果不想手动开发大文件上传功能,生产环境可直接使用成熟的开源组件,这些组件已实现分片上传、断点续传、秒传、暂停/取消等所有功能,开箱即用,兼容性好。
6.1 WebUploader(百度开源,经典稳定)
- 特点:支持分片上传、断点续传、秒传、多文件上传,兼容性好(支持IE8+),文档完善;
- 适用场景:传统PC端项目、兼容性要求高的项目;
- 地址:fex.baidu.com/webuploader…
6.2 Plupload(轻量灵活)
- 特点:轻量、无框架依赖、支持分片上传、多源上传(本地文件、远程URL),可自定义扩展;
- 适用场景:轻量项目、需要自定义上传逻辑的项目;
- 地址:www.plupload.com/
6.3 Vue-Upload-Component(Vue专属)
- 特点:Vue2/Vue3兼容、支持大文件分片上传、断点续传、进度反馈,配置简单;
- 适用场景:Vue.js项目;
- 地址:github.com/simple-uplo…
6.4 Uppy(现代前端上传组件)
- 特点:基于现代前端技术、支持分片上传、断点续传、多源上传(本地、云存储、摄像头),UI美观,可按需加载插件;
- 适用场景:现代前端项目(React/Vue/Angular)、需要高颜值UI的项目;
- 地址:uppy.io/
七、常见问题与避坑指南
7.1 计算MD5时页面卡顿?
- 原因:在主线程中一次性读取大文件,阻塞了UI渲染和交互;
- 解决方案:① 分片计算MD5(本文案例方式);② 将MD5计算放入Web Worker,实现多线程计算。
7.2 服务器合并文件时内存溢出?
- 原因:使用
fs.readFile一次性读取分片到内存,大文件合并时占用过多内存; - 解决方案:使用Node.js流式文件操作(
fs.createReadStream/fs.createWriteStream),边读边写,不占用大量内存。
7.3 并行上传时部分分片上传失败?
- 原因:并行数过多,导致浏览器跨域请求数超限或服务器连接数被占满;
- 解决方案:① 限制最大并行数(3-5个为宜);② 增加分片上传失败后的自动重试机制。
7.4 秒传功能不生效?
- 原因:① 文件MD5计算错误(文件内容不变但MD5不同);② 服务器未正确记录已完成文件的MD5;③ 文件重命名后内容改变;
- 解决方案:① 检查MD5计算逻辑,确保分片读取完整;② 检查服务器数据库,确保文件合并后正确写入
finishedFiles;③ 确认选择的是内容完全一致的文件。
7.5 断点续传后文件合并损坏?
- 原因:① 前端分片索引计算错误(最后一片超出文件大小);② 服务器未按索引顺序合并分片;③ 分片丢失;
- 解决方案:① 前端用
Math.min确保最后一片的结束位置为文件大小;② 服务器合并时严格按分片索引从小到大遍历;③ 后端接收分片时添加校验,分片丢失则提示重新上传。
八、总结(核心知识点梳理)
本文从基础到高级,循序渐进讲解了前端大文件上传的完整实现,核心知识点可总结为「1个核心思想+3个核心环节+2个高级功能」:
- 1个核心思想:分而治之,将大文件拆分为小分片,分批次上传,服务器最后合并,解决大文件直接上传的所有痛点;
- 3个核心环节:
- 文件拆分:通过
File.slice()实现,是分片上传的基础; - 分片上传:串行/并行上传分片,每个分片对应独立请求,带进度反馈;
- 分片合并:所有分片上传完成后,前端发送合并请求,服务器按索引合并为原始文件;
- 文件拆分:通过
- 2个高级功能:
- 断点续传:基于文件MD5(唯一标识),检测已上传分片,只上传未完成部分,解决中断后重新上传的问题;
- 秒传:断点续传的延伸,检测文件MD5是否已存在,存在则直接返回结果,实现瞬间上传;
- 关键技术:
File.slice()(文件拆分)、SparkMD5(文件MD5计算)、lowdb/数据库(分片状态记录)、并行请求控制(提升上传速度); - 生产优化:前端做MD5计算优化、重试机制、暂停/取消;后端做流式合并、分片清理、分布式存储;网络做HTTPS、接口鉴权;安全做文件校验、跨域精细化配置。
大文件上传的实现并不复杂,核心是掌握「分片上传」的基础逻辑,然后在此基础上逐步叠加断点续传、秒传等高级功能。对于新手来说,建议先实现基础的串行分片上传,再逐步改造为并行版,最后添加断点续传和秒传,通过实际测试理解每一步的核心逻辑。生产环境中,若追求开发效率,可直接使用成熟的开源组件,避免重复造轮子。
希望本文能帮助大家彻底掌握前端大文件上传的核心实现,顺利解决实际开发中的大文件上传需求!