大文件上传
前后端配合,前端进行文件切片,计算文件hash,作为与后端协作的唯一凭证,标明是哪个文件。
上传的切片信息需包含4个部分:切片索引,文件hash, 总分片数,分片的内容 {index,hash, total, chuck}。
后端API提供:
- /check 文件是否上传过
- /uploaded-chunks 已上传的分片列表(后端以chuck的索引存储)
- /upload-chunk 上传分片
- /merge 合并分片

文件切片
js
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
// 将文件切片成多个 Blob
// 返回 Blob 数组,每个 Blob 保留原始文件的 type
function sliceFile(file, chunkSize = CHUNK_SIZE) {
const chunks = [];
const totalChunks = Math.ceil(file.size / chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const slicedChunk = file.slice(start, end);
// file.slice() 在某些情况下可能不会保留原始文件的 type
// 手动创建 Blob 并保留原始文件的 type
const chunk = new Blob([slicedChunk], {
type: file.type || '' // 保留原始文件的 type,如果是空字符串也保留
});
chunks.push(chunk);
}
return chunks;
}
计算文件hash
js
// 计算文件hash(使用SparkMD5库)
// 增量算法:分块计算,因为计算hash要读取整个文件的内容,一次性读取到内存中,内存会溢出OOM。读取一块计算该块的hash,最后合并。
function calculateHash(file) {
return new Promise((resolve)=>{
const spark = new SparkMD5()
// 使用统一的切片函数
const chunks = sliceFile(file, CHUNK_SIZE)
function _read(index){
if(index >= chunks.length){
resolve(spark.end()) //返回文件hash
return //读取完成
}
const blob = chunks[index]
if(blob){
const fileReader = new FileReader()
fileReader.onload = (e)=>{
const bytes = e.target.result //读取到的字节数组ArrayBuffer
spark.append(bytes)
_read(index + 1)
}
fileReader.readAsArrayBuffer(blob)
}
}
_read(0)
})
}
分片上传
js
// 分片上传
async function uploadFile(file) {
if (!file) return;
// 计算文件hash
const fileHash = await calculateHash(file);
console.log(fileHash)
// 检查文件是否已经上传过(秒传)
const checkResp = await apiClient.post('/api/check', { hash: fileHash });
const checkResult = checkResp.data;
if (checkResult.exists) {
// 秒传,直接跳转到结果页面
alert('文件已存在,秒传成功!');
return;
}
// 获取已上传的分片(断点续传)
const uploadedResp = await apiClient.post('/api/uploaded-chunks', { hash: fileHash });
const uploadedChunks = uploadedResp.data; // 假设返回已上传的分片索引数组
// 使用统一的切片函数
const blobChunks = sliceFile(file, CHUNK_SIZE);
const totalChunks = blobChunks.length
// 将 Blob 数组转换为包含元数据的分片对象数组
const chunks = blobChunks.map((chunk, index) => ({
index: index,
hash: fileHash,
chunk: chunk,
total: totalChunks
}));
// 上传分片(过滤已上传的分片)
const chunksToUpload = chunks.filter(chunk => !uploadedChunks.includes(chunk.index));
// 控制并发上传数
// 建议值:
// - 小文件(< 50MB):3-5
// - 中等文件(50-500MB):3-6
// - 大文件(> 500MB):4-8
// 注意:浏览器 HTTP/1.1 每个域名最多 6 个并发连接
const concurrency = Math.min(6, Math.max(3, Math.ceil(totalChunks / 10))); // 动态调整,但不超过6
// const concurrency = 1
console.log(`总分片数: ${totalChunks}, 并发数: ${concurrency}`);
// console.log(uploadChunk(chunks[0]))
try {
const results = await runConcurrent(chunksToUpload, concurrency, uploadChunk);
// 检查所有分片是否上传成功
const failedChunks = [];
results.forEach((result, index) => {
// 如果 result 是错误对象,则认为失败
if (result instanceof Error) {
console.error(`分片 ${chunksToUpload[index].index} 上传失败:`, result);
failedChunks.push({ index: chunksToUpload[index].index, error: result });
return;
}
// 如果 result 为空或 undefined,认为失败
if (!result) {
console.error(`分片 ${chunksToUpload[index].index} 上传失败: 返回结果为空`);
failedChunks.push({ index: chunksToUpload[index].index, error: '返回结果为空' });
return;
}
// 如果后端返回了 success 字段,检查是否为 false
if (result.success === false) {
console.error(`分片 ${chunksToUpload[index].index} 上传失败:`, result);
failedChunks.push({ index: chunksToUpload[index].index, error: result });
return;
}
});
if (failedChunks.length > 0) {
alert(`有 ${failedChunks.length} 个分片上传失败,请重试!`);
return;
}
console.log('所有分片上传成功,开始合并...');
// 所有分片上传完成后,通知合并
const mergeResp = await apiClient.post('/api/merge', { hash: fileHash, total: totalChunks });
const mergeResult = mergeResp.data;
if (mergeResult.success) {
alert('上传成功!');
// 跳转到结果页面等
} else {
alert('合并失败,请重试!');
}
} catch (error) {
console.error('上传过程中发生错误:', error);
alert('上传失败,请重试!');
}
}
// 上传单个分片
async function uploadChunk(chunk) {
const formData = new FormData();
formData.append('hash', chunk.hash);
formData.append('index', chunk.index);
formData.append('total', chunk.total);
formData.append('chunk', chunk.chunk);
const resp = await apiClient.post('/api/upload-chunk', formData);
return resp.data;
}
// 并发控制
// tasks: 任务数组(数据)
// concurrency: 最大并发数
// taskFn: 执行任务的函数
async function runConcurrent(tasks, concurrency, taskFn) {
const results = [];
const executing = [];
for (const task of tasks) {
// 立即执行任务函数,创建 Promise(任务会立即开始执行)
const promise = Promise.resolve().then(() => taskFn(task));
results.push(promise);
// 创建一个清理函数,当任务完成时从 executing 数组中移除
const cleanup = promise.finally(() => {
// promise.finally 会在任务完成(成功或失败)后执行
// 从 executing 数组中移除这个已完成的任务
const index = executing.indexOf(cleanup);
if (index > -1) {
executing.splice(index, 1);
}
});
executing.push(cleanup);
// 如果达到并发上限,等待至少一个任务完成
// Promise.race 会等待 executing 中任意一个 Promise 完成。阻塞循环
if (executing.length >= concurrency) {
await Promise.race(executing);
}
}
// 等待所有任务完成(使用 allSettled 确保即使有失败也能获取所有结果)
const settledResults = await Promise.allSettled(results);
// 将 allSettled 的结果转换为普通结果或错误
return settledResults.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value; // 成功的结果
} else {
return result.reason; // 失败的错误对象
}
});
}
后端
js
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json()); //只负责解析 Content-Type: application/json 的请求
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');
// 确保上传目录存在
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR);
}
// 存储分片的临时目录
const CHUNK_DIR = path.resolve(UPLOAD_DIR, 'chunks');
if (!fs.existsSync(CHUNK_DIR)) {
fs.mkdirSync(CHUNK_DIR);
}
// 检查文件是否存在(秒传)
app.post('/api/check', (req, res) => {
const { hash } = req.body;
const filePath = path.resolve(UPLOAD_DIR, hash);
if (fs.existsSync(filePath)) {
res.json({ exists: true });
} else {
res.json({ exists: false });
}
});
// 获取已上传的分片列表
app.post('/api/uploaded-chunks', (req, res) => {
const { hash } = req.body;
const chunkDir = path.resolve(CHUNK_DIR, hash);
if (!fs.existsSync(chunkDir)) {
return res.json([]);
}
const chunks = fs.readdirSync(chunkDir);
// 假设分片文件名就是索引
const uploadedChunks = chunks.map(chunk => parseInt(chunk));
res.json(uploadedChunks);
});
// 上传分片
//multer:只负责解析 带文件的 multipart/form-data 请求
const upload = multer({ dest: CHUNK_DIR });
app.post('/api/upload-chunk', upload.single('chunk'), (req, res) => {
// 检查文件是否成功上传
if (!req.file) {
return res.status(400).json({
success: false,
message: '文件上传失败:未检测到文件。请确保前端使用 FormData 发送,且文件字段名为 "chunk"'
});
}
const { hash, index } = req.body;
console.log('upload-chunk:', { hash, index, file: req.file.path });
// 检查必要参数
if (!hash || index === undefined) {
return res.status(400).json({
success: false,
message: '缺少必要参数:hash 或 index'
});
}
const chunkDir = path.resolve(CHUNK_DIR, hash);
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir, { recursive: true });
}
// 将上传的临时文件移动到对应的分片目录,并以索引命名
const tempPath = req.file.path;
const targetPath = path.resolve(chunkDir, index);
fs.renameSync(tempPath, targetPath);
res.json({ success: true });
});
// 合并分片
app.post('/api/merge', async (req, res) => {
const { hash, total } = req.body;
const chunkDir = path.resolve(CHUNK_DIR, hash);
const filePath = path.resolve(UPLOAD_DIR, hash);
// 检查分片是否全部上传完成
const chunks = fs.readdirSync(chunkDir);
// console.log(chunks.length)
if (chunks.length !== total) {
return res.status(400).json({ success: false, message: '分片数量不符' });
}
// 按索引排序分片
const sortedChunks = chunks
.map(chunk => parseInt(chunk))
.sort((a, b) => a - b);
// 合并分片
const writeStream = fs.createWriteStream(filePath);
for (const chunkIndex of sortedChunks) {
const chunkPath = path.resolve(chunkDir, chunkIndex.toString());
const chunkBuffer = fs.readFileSync(chunkPath);
writeStream.write(chunkBuffer);
fs.unlinkSync(chunkPath); // 删除分片
}
writeStream.end();
// 删除分片目录
fs.rmdirSync(chunkDir);
// 这里可以调用后续处理日志文件的函数,并返回处理结果
res.json({ success: true });
});
app.listen(3000, () => {
console.log('Server is running on port 3000');
});