# 导语
在日常开发中,我们经常会遇到大文件上传的需求,比如视频上传、文档备份等场景。普通的单次上传方式在面对数百MB甚至数GB的文件时会遇到诸多问题:请求超时、内存占用过高、网络中断后需要重新上传等。
本文将深入讲解大文件上传的核心解决方案,包括分片上传、断点续传和秒传三大核心功能,并提供完整的代码实现。
# 一、大文件上传核心概念
# 1.1 什么是分片上传
分片上传的原理是将大文件切割成多个小块(Chunk),每个小块单独发送请求到服务端,服务端接收并保存所有分片后再合并成完整的文件。

分片上传的优势:
- 支持断点续传:网络中断后,只需上传未完成的分片
- 并行上传:多个分片可以并行传输,提高上传速度
- 减少内存占用:分片处理,不需要一次性加载整个文件到内存
# 1.2 断点续传原理
断点续传的核心是记录已上传的分片信息。客户端首先询问服务端文件的上传状态,服务端返回已上传的分片列表,客户端只需上传剩余分片。
# 1.3 秒传原理
秒传是断点续传的特例。当服务端检测到相同文件已存在时,直接返回成功,无需真正上传文件。实现秒传的关键是文件唯一标识——通过 Hash 算法(如 MD5)计算文件的指纹。
# 二、前置知识
# 2.1 File 对象
File 对象是特殊类型的 Blob,包含文件的基本信息:
| 属性 | 描述 |
|---|---|
File.name |
文件名 |
File.size |
文件大小(字节) |
File.type |
文件 MIME 类型 |
File.lastModified |
最后修改时间 |
# 2.2 Blob 与分片
使用 Blob.slice() 方法可以对文件进行分片:
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
const chunk = blobSlice.call(file, start, end); // 返回指定范围的 Blob
@前端进阶之旅: 代码已经复制到剪贴板
# 2.3 FileReader 读取文件
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(blob); // 读取为 ArrayBuffer
fileReader.onload = (e) => {
console.log(e.target.result); // 读取结果
};
@前端进阶之旅: 代码已经复制到剪贴板
# 2.4 FormData 上传
大文件上传必须使用 multipart/form-data 格式:
const formData = new FormData();
formData.append('chunk', new Blob([chunkData])); // 文件分片
formData.append('index', 1); // 分片索引
formData.append('fileHash', 'xxx'); // 文件哈希
@前端进阶之旅: 代码已经复制到剪贴板
# 三、完整实现方案
# 3.1 前端实现
# 文件分片与 Hash 计算
import SparkMD5 from 'spark-md5';
/**
* 将目标文件分片并计算文件 Hash
* @param {File} targetFile 目标上传文件
* @param {number} baseChunkSize 分片大小(单位 MB)
* @returns {chunkList: ArrayBuffer[], fileHash: string}
*/
async function sliceFile(targetFile, baseChunkSize = 1) {
return new Promise((resolve, reject) => {
// 兼容不同浏览器的分片方法
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
const chunkSize = baseChunkSize * 1024 * 1024;
const targetChunkCount = Math.ceil(targetFile.size / chunkSize);
let currentChunkCount = 0;
const chunkList = [];
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = (e) => {
const curChunk = e.target.result;
spark.append(curChunk); // 追加到 Hash 计算
currentChunkCount++;
chunkList.push(curChunk);
if (currentChunkCount >= targetChunkCount) {
// 全部读取完成,获取文件 Hash
const fileHash = spark.end();
resolve({ chunkList, fileHash });
} else {
loadNext();
}
};
fileReader.onerror = () => reject(null);
const loadNext = () => {
const start = chunkSize * currentChunkCount;
const end = Math.min(start + chunkSize, targetFile.size);
fileReader.readAsArrayBuffer(blobSlice.call(targetFile, start, end));
};
loadNext();
});
}
@前端进阶之旅: 代码已经复制到剪贴板
# 分片上传入口
async function uploadFile(file, baseChunkSize, uploadUrl, verifyUrl, mergeUrl, progressCallback) {
// 1. 分片并计算 Hash
const { chunkList, fileHash } = await sliceFile(file, baseChunkSize);
let neededChunkList = [];
let progress = 0;
// 2. 验证文件上传状态
if (verifyUrl) {
const { data } = await requestInstance.post(verifyUrl, {
fileHash,
totalCount: chunkList.length,
extname: '.' + file.name.split('.').pop(),
});
const { neededFileList, message } = data;
// 秒传成功
if (!neededFileList.length) {
progressCallback(100);
return;
}
neededChunkList = neededFileList;
}
// 3. 计算断点续传的初始进度
progress = ((chunkList.length - neededChunkList.length) / chunkList.length) * 100;
// 4. 上传分片
if (chunkList.length) {
const requestList = chunkList.map(async (chunk, index) => {
if (neededChunkList.includes(index + 1)) {
await uploadChunk(chunk, index + 1, fileHash, uploadUrl);
// 更新进度
progress += Math.ceil(100 / chunkList.length);
if (progress >= 100) progress = 100;
progressCallback(progress);
}
});
Promise.all(requestList).then(() => {
// 5. 通知服务端合并分片
requestInstance.post(mergeUrl, {
fileHash,
extname: '.mp4'
});
});
}
}
/**
* 上传单个分片
*/
async function uploadChunk(chunk, index, fileHash, uploadUrl) {
const formData = new FormData();
// ArrayBuffer 需要转为 Blob
formData.append('chunk', new Blob([chunk]));
formData.append('index', index);
formData.append('fileHash', fileHash);
return requestInstance.post(uploadUrl, formData);
}
@前端进阶之旅: 代码已经复制到剪贴板
# 3.2 后端 Node.js 实现
# 接收分片
const fs = require('fs');
const path = require('path');
uploadChunk(chunk, chunkInfo) {
const { fileHash, index } = chunkInfo;
const dirPath = path.join(__dirname, 'uploadedFiles/chunkFile', fileHash);
const chunkPath = path.join(dirPath, `chunk-${index}`);
// 检查文件夹是否存在
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
// 检查分片是否已存在
if (fs.existsSync(chunkPath)) {
return;
}
// 写入分片文件
fs.writeFileSync(chunkPath, chunk.buffer);
}
@前端进阶之旅: 代码已经复制到剪贴板
# 验证文件状态(断点续传/秒传)
# 合并分片
# 四、上传流程图
整个上传流程如下:
1. 选择文件 → 客户端分片 + 计算 Hash
2. 发送验证请求 → 服务端返回上传状态
3. 根据状态处理:
- 文件已存在 → 秒传成功
- 部分分片存在 → 断点续传(上传剩余分片)
- 文件不存在 → 上传所有分片
4. 全部上传完成后 → 通知服务端合并
@前端进阶之旅: 代码已经复制到剪贴板

# 五、性能优化
# 5.1 Web Worker 优化
当文件非常大时,Hash 计算会占用大量主线程资源,导致页面卡顿。使用 Web Worker 可以在后台线程中执行计算,不影响用户交互。
# 5.2 Hash 策略优化
对于超大文件,即使在 Worker 中计算 Hash 也会耗时较长。可以采用抽样 Hash 策略:
- 仅 Hash 文件的第一个分片 + 中间分片的首尾 + 最后一个分片

这种策略的缺点是碰撞概率略高,但在大多数业务场景下是可接受的。
# 5.3 并发控制
使用 Promise.all 并行上传所有分片,但需要注意控制并发数量,避免占用过多资源: