技术栈与插件说明
前端技术栈
- Vue3 + Vite:构建用户界面和前端工程化
- SparkMD5:计算文件哈希值,用于唯一标识文件
后端技术栈
- Node.js + Express:搭建 HTTP 服务器和接口
- multiparty:处理表单数据,解析上传的分片文件
- fs-extra:文件系统操作的增强库,支持递归创建目录、移动文件等
- cors:处理跨域请求,允许前端访问后端接口
- body-parser:解析 HTTP 请求体,支持 JSON 和表单数据
- nodemon(开发依赖):监测文件变化并自动重启服务器,提高开发效率
插件 | 作用 | 前端 / 后端 |
---|---|---|
SparkMD5 | 计算文件哈希值,用于唯一标识文件(断点续传核心) | 前端 |
Express | 快速搭建 HTTP 服务器,提供路由和中间件支持 | 后端 |
multiparty | 解析包含文件的表单数据,提取上传的分片文件 | 后端 |
fs-extra | 扩展 Node.js 原生 fs 模块,支持递归创建目录、安全移动文件等 | 后端 |
cors | 配置跨域规则,允许前端域名访问后端接口 | 后端 |
body-parser | 解析 JSON 和表单格式的请求体,方便获取接口参数 | 后端 |
nodemon | 开发时自动重启服务器,无需手动重启即可生效代码变更 | 后端(开发依赖) |
项目搭建步骤
前端项目搭建
# 创建Vue3项目(使用Vite模板)
npm create vite@latest file-upload-frontend -- --template vue
cd file-upload-frontend
# 安装依赖
npm install
# 安装文件哈希计算库
npm install spark-md5
后端项目搭建
# 创建后端目录并初始化
mkdir file-upload-backend && cd file-upload-backend
npm init -y
# 安装核心依赖
npm install express multiparty fs-extra cors body-parser
# 安装开发依赖(热重载)
npm install -D nodemon
前端实现:核心逻辑拆解
前端的核心任务是:将文件切片、计算哈希、并发上传分片、请求合并分片。我们按逻辑拆分为以下函数。
// 文件切片函数:将文件按指定大小分割为多个Blob
const createChunks = (file) => {
const chunkSize = 1024 * 1024; // 1MB/片(可根据需求调整)
let cur = 0; // 当前切片位置
const chunks = [];
while (cur < file.size) {
// 从cur位置开始,截取chunkSize大小的内容作为一个分片
const blob = file.slice(cur, cur + chunkSize);
chunks.push(blob);
cur += chunkSize;
}
return chunks; // 返回所有分片数组
};
作用:避免一次性加载大文件到内存,降低浏览器内存占用,同时实现分片上传。
2. 哈希计算:生成文件唯一标识
// 计算文件哈希值:用于标识文件唯一性(断点续传核心)
const calculateHash = (chunks) => {
return new Promise((resolve) => {
const spark = new SparkMD5.ArrayBuffer(); // 创建SparkMD5实例
const fileReader = new FileReader(); // 文件读取器
// 优化:大文件无需完整计算,取关键部分即可
const targets = [];
chunks.forEach((chunk, index) => {
if (index === 0 || index === chunks.length - 1) {
// 首尾分片完整计算
targets.push(chunk);
} else {
// 中间分片取部分内容(前2字节+中间2字节+后2字节)
targets.push(chunk.slice(0, 2));
targets.push(chunk.slice(chunk.size / 2, chunk.size / 2 + 2));
targets.push(chunk.slice(-2));
}
});
// 读取选中的内容并计算哈希
fileReader.readAsArrayBuffer(new Blob(targets));
fileReader.onload = (e) => {
spark.append(e.target.result); // 添加内容到哈希计算
const hash = spark.end(); // 完成计算,获取哈希值
resolve(hash);
};
});
};
作用:通过哈希值唯一标识文件,后端可通过哈希判断文件是否已上传、哪些分片已上传,实现断点续传。
3. 断点续传检查:获取已上传分片
// 检查文件上传状态:获取已上传的分片,避免重复上传
const checkFileUploadStatus = async (hash, fileName) => {
try {
const res = await fetch('http://localhost:3000/check-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash: hash, fileName })
});
return res.json();
} catch (err) {
console.error('检查文件状态失败:', err);
return { exists: false, uploadedChunks: [] };
}
};
作用:上传前先询问后端,该文件是否已完整上传?如果没有,哪些分片已经上传?以此实现「断点续传」—— 只传未完成的分片。
4. 并发上传分片:控制上传速度
// 并发上传分片:控制同时上传的分片数量
const uploadChunksConcurrently = async (chunksToUpload, maxConcurrent = 6) => {
const taskPool = []; // 存储当前正在执行的上传任务
let completed = 0; // 已完成的分片数量
const total = chunksToUpload.length; // 需上传的总分片数
for (let i = 0; i < chunksToUpload.length; i++) {
// 创建单个分片的上传任务
const task = fetch('http://localhost:3000/upload-chunk', {
method: 'POST',
body: chunksToUpload[i].formData
})
.then(res => res.json())
.then(() => {
completed++;
// 计算并更新进度(可根据需求渲染到UI)
const progress = Math.floor((completed / total) * 100);
console.log(`上传进度: ${progress}%`);
})
.catch(err => {
console.error(`分片 ${i} 上传失败:`, err);
throw err; // 抛出错误,便于后续捕获
});
taskPool.push(task);
// 当并发数达到上限,等待任一任务完成后再继续
if (taskPool.length >= maxConcurrent) {
await Promise.race(taskPool);
// 移除已完成的任务(保持任务池大小不超过上限)
taskPool.splice(taskPool.findIndex(t => t.isResolved), 1);
}
}
// 等待剩余任务全部完成
await Promise.all(taskPool);
console.log('所有分片上传完成');
};
作用:控制同时上传的分片数量(默认 6 个),避免一次性发起过多请求导致浏览器或服务器压力过大。
5. 合并分片:组装完整文件
// 通知后端合并分片
const mergeChunks = async (hash, fileName, totalChunks) => {
try {
const res = await fetch('http://localhost:3000/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileHash: hash,
fileName,
totalChunks
})
});
return res.json();
} catch (err) {
console.error('请求合并分片失败:', err);
return { success: false, error: '合并请求失败' };
}
};
作用:所有分片上传完成后,通知后端将分片按顺序合并为完整文件。
6. 主流程函数:串联所有步骤
// 主上传流程:串联文件处理、检查状态、上传分片、合并文件
const handleFileUpload = async (file) => {
if (!file) return;
// 1. 切片:将文件分割为分片
const chunks = createChunks(file);
console.log(`文件已分割为 ${chunks.length} 个分片`);
// 2. 计算哈希:生成文件唯一标识
const hash = await calculateHash(chunks);
console.log('文件哈希值:', hash);
// 3. 检查状态:获取已上传的分片
const checkResult = await checkFileUploadStatus(hash, file.name);
if (checkResult.exists) {
console.log('文件已完整上传,无需重复上传');
return;
}
// 4. 准备需上传的分片(过滤已上传的分片)
const chunksToUpload = chunks
.map((chunk, index) => ({
index,
chunkHash: `${hash}-${index}`,
formData: createFormData(hash, `${hash}-${index}`, chunk)
}))
.filter(item => !checkResult.uploadedChunks.includes(item.chunkHash));
if (chunksToUpload.length === 0) {
console.log('所有分片已上传,准备合并');
} else {
// 5. 并发上传未完成的分片
await uploadChunksConcurrently(chunksToUpload);
}
// 6. 合并分片:所有分片上传完成后,请求合并
const mergeResult = await mergeChunks(hash, file.name, chunks.length);
if (mergeResult.success) {
console.log('文件上传成功!保存路径:', mergeResult.filePath);
} else {
console.error('文件合并失败:', mergeResult.error);
}
};
// 辅助函数:创建分片上传的FormData
const createFormData = (fileHash, chunkHash, chunk) => {
const formData = new FormData();
formData.append('chunk', chunk); // 分片文件
formData.append('fileHash', fileHash); // 文件哈希
formData.append('chunkHash', chunkHash); // 分片哈希(唯一标识分片)
return formData;
};
作用:串联「切片→哈希→检查→上传→合并」的完整流程,实现大文件的分片上传。
前端完整组件代码
<template>
<div class="upload-container">
<input type="file" @change="handleFileSelect" class="file-input" />
<button @click="startUpload" class="upload-btn">上传文件</button>
<div class="progress" v-if="progress > 0">
<div class="progress-bar" :style="{ width: progress + '%' }"></div>
<span class="progress-text">{{ progress }}%</span>
</div>
<p class="status">{{ statusText }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
import SparkMD5 from 'spark-md5';
// 状态变量
const file = ref(null);
const progress = ref(0);
const statusText = ref('');
const chunkSize = 1024 * 1024; // 1MB/分片
// 1. 文件切片
const createChunks = (file) => {
let cur = 0;
const chunks = [];
while (cur < file.size) {
chunks.push(file.slice(cur, cur + chunkSize));
cur += chunkSize;
}
return chunks;
};
// 2. 计算文件哈希
const calculateHash = (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(-2));
}
});
fileReader.readAsArrayBuffer(new Blob(targets));
fileReader.onload = (e) => {
spark.append(e.target.result);
resolve(spark.end());
};
});
};
// 3. 检查文件上传状态
const checkFileUploadStatus = async (hash, fileName) => {
statusText.value = '检查文件状态...';
try {
const res = await fetch('http://localhost:3000/check-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash: hash, fileName })
});
return res.json();
} catch (err) {
statusText.value = '检查文件状态失败';
return { exists: false, uploadedChunks: [] };
}
};
// 4. 创建FormData
const createFormData = (fileHash, chunkHash, chunk) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('fileHash', fileHash);
formData.append('chunkHash', chunkHash);
return formData;
};
// 5. 并发上传分片
const uploadChunksConcurrently = async (chunksToUpload) => {
const maxConcurrent = 6;
const taskPool = [];
let completed = 0;
const total = chunksToUpload.length;
for (let i = 0; i < chunksToUpload.length; i++) {
const { formData, index } = chunksToUpload[i];
const task = fetch('http://localhost:3000/upload-chunk', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(() => {
completed++;
progress.value = Math.floor((completed / total) * 100);
statusText.value = `上传中: ${progress.value}%`;
})
.catch(err => {
statusText.value = `分片 ${index} 上传失败`;
throw err;
});
taskPool.push(task);
if (taskPool.length >= maxConcurrent) {
await Promise.race(taskPool);
taskPool.splice(taskPool.findIndex(t => t.isResolved), 1);
}
}
await Promise.all(taskPool);
};
// 6. 合并分片
const mergeChunks = async (hash, fileName, totalChunks) => {
statusText.value = '正在合并文件...';
try {
const res = await fetch('http://localhost:3000/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileHash: hash, fileName, totalChunks })
});
return res.json();
} catch (err) {
statusText.value = '合并文件失败';
return { success: false };
}
};
// 7. 主上传流程
const startUpload = async () => {
if (!file.value) {
statusText.value = '请先选择文件';
return;
}
progress.value = 0;
statusText.value = '准备上传...';
try {
const chunks = createChunks(file.value);
const hash = await calculateHash(chunks);
const checkResult = await checkFileUploadStatus(hash, file.value.name);
if (checkResult.exists) {
statusText.value = '文件已存在,无需上传';
return;
}
const chunksToUpload = chunks
.map((chunk, index) => ({
index,
chunkHash: `${hash}-${index}`,
formData: createFormData(hash, `${hash}-${index}`, chunk)
}))
.filter(item => !checkResult.uploadedChunks.includes(item.chunkHash));
if (chunksToUpload.length > 0) {
await uploadChunksConcurrently(chunksToUpload);
}
const mergeResult = await mergeChunks(hash, file.value.name, chunks.length);
if (mergeResult.success) {
progress.value = 100;
statusText.value = '文件上传成功!';
} else {
statusText.value = '合并失败: ' + mergeResult.error;
}
} catch (err) {
statusText.value = '上传失败: ' + err.message;
}
};
// 处理文件选择
const handleFileSelect = (e) => {
file.value = e.target.files[0];
if (file.value) {
statusText.value = `已选择文件: ${file.value.name}`;
}
};
// 扩展Promise,添加状态跟踪(用于并发控制)
Promise.prototype.isResolved = false;
const originalThen = Promise.prototype.then;
Promise.prototype.then = function(...args) {
const result = originalThen.apply(this, args);
this.isResolved = true;
return result;
};
</script>
<style scoped>
.upload-container {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
border: 1px solid #eee;
border-radius: 8px;
}
.file-input {
display: block;
margin-bottom: 1rem;
padding: 0.5rem;
width: 100%;
}
.upload-btn {
background: #42b983;
color: white;
border: none;
padding: 0.7rem 1.5rem;
border-radius: 4px;
cursor: pointer;
margin-bottom: 1rem;
}
.upload-btn:hover {
background: #359e75;
}
.progress {
height: 20px;
border-radius: 10px;
background: #f0f0f0;
overflow: hidden;
position: relative;
margin: 1rem 0;
}
.progress-bar {
height: 100%;
background: #42b983;
transition: width 0.3s;
}
.progress-text {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
font-size: 0.8rem;
color: #333;
}
.status {
color: #666;
min-height: 1.5rem;
}
</style>
后端实现:核心逻辑拆解
1. 服务器初始化与配置
const express = require('express');
const multiparty = require('multiparty');
const fs = require('fs-extra');
const path = require('path');
const cors = require('cors');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
// 配置存储目录
const UPLOAD_DIR = path.resolve(__dirname, 'uploads'); // 最终文件存储目录
const TEMP_DIR = path.resolve(__dirname, 'temp'); // 分片临时存储目录
// 确保目录存在(不存在则创建)
fs.ensureDirSync(UPLOAD_DIR);
fs.ensureDirSync(TEMP_DIR);
// 配置中间件
app.use(cors({ origin: 'http://localhost:5173' })); // 允许前端跨域访问
app.use(bodyParser.json()); // 解析JSON请求体
app.use(bodyParser.urlencoded({ extended: true })); // 解析表单请求体
作用:初始化服务器,配置跨域、请求解析,创建文件存储目录。
2. /check-file 接口:检查文件上传状态
// 检查文件是否已上传、已上传哪些分片
app.post('/check-file', async (req, res) => {
try {
const { fileHash, fileName } = req.body;
const filePath = path.join(UPLOAD_DIR, fileName); // 完整文件路径
// 检查文件是否已完整上传
if (await fs.pathExists(filePath)) {
return res.json({ exists: true, uploadedChunks: [] });
}
// 检查已上传的分片:分片存储在以fileHash命名的临时目录
const chunkDir = path.join(TEMP_DIR, fileHash);
let uploadedChunks = [];
if (await fs.pathExists(chunkDir)) {
// 读取目录下的所有分片文件,提取分片标识
const chunks = await fs.readdir(chunkDir);
uploadedChunks = chunks.map(chunk => chunk.replace('.chunk', ''));
}
res.json({ exists: false, uploadedChunks });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
作用:响应前端的断点续传检查请求,返回文件是否完整上传、已上传的分片列表。
3. /upload-chunk 接口:接收分片文件
// 接收并存储分片文件
app.post('/upload-chunk', (req, res) => {
// 使用multiparty解析包含文件的表单数据
const form = new multiparty.Form({ uploadDir: TEMP_DIR });
form.parse(req, async (err, fields, files) => {
if (err) {
return res.status(500).json({ error: err.message });
}
try {
// 从表单字段中获取哈希信息
const fileHash = fields.fileHash[0];
const chunkHash = fields.chunkHash[0];
// 获取上传的分片文件临时路径
const chunkFile = files.chunk[0];
// 创建分片存储目录(以fileHash命名,避免不同文件分片冲突)
const chunkDir = path.join(TEMP_DIR, fileHash);
await fs.ensureDir(chunkDir);
// 将临时分片移动到目标目录,并重命名
const destPath = path.join(chunkDir, `${chunkHash}.chunk`);
await fs.move(chunkFile.path, destPath, { overwrite: true });
res.json({ success: true, message: `分片 ${chunkHash} 上传成功` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
});
作用:接收前端上传的分片文件,存储到以文件哈希命名的临时目录,确保分片不冲突
4. /merge-chunks 接口:合并分片为完整文件
// 合并分片为完整文件
app.post('/merge-chunks', async (req, res) => {
try {
const { fileHash, fileName, totalChunks } = req.body;
const chunkDir = path.join(TEMP_DIR, fileHash); // 分片存储目录
const destPath = path.join(UPLOAD_DIR, fileName); // 合并后的文件路径
// 检查分片目录是否存在
if (!await fs.pathExists(chunkDir)) {
return res.status(400).json({ error: '分片不存在' });
}
// 获取所有分片并检查数量是否完整
const chunks = await fs.readdir(chunkDir);
if (chunks.length !== totalChunks) {
return res.status(400).json({
error: '分片不完整',
received: chunks.length,
total: totalChunks
});
}
// 按分片索引排序(确保合并顺序正确)
chunks.sort((a, b) => {
const indexA = parseInt(a.split('-')[1].replace('.chunk', ''));
const indexB = parseInt(b.split('-')[1].replace('.chunk', ''));
return indexA - indexB;
});
// 创建可写流,合并分片
const writeStream = fs.createWriteStream(destPath);
for (const chunk of chunks) {
const chunkPath = path.join(chunkDir, chunk);
const chunkBuffer = await fs.readFile(chunkPath); // 读取分片内容
// 写入到目标文件
await new Promise((resolve, reject) => {
writeStream.write(chunkBuffer, (err) => {
if (err) reject(err);
else resolve();
});
});
}
// 完成合并,关闭流并删除临时分片目录
writeStream.end();
await fs.remove(chunkDir); // 清理临时文件
res.json({
success: true,
message: '文件合并成功',
filePath: destPath
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
作用:接收合并请求,检查分片完整性,按顺序合并分片为完整文件,最后清理临时分片。
后端完整代码
const express = require('express');
const multiparty = require('multiparty');
const fs = require('fs-extra');
const path = require('path');
const cors = require('cors');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
// 配置存储目录
const UPLOAD_DIR = path.resolve(__dirname, 'uploads'); // 最终文件存储目录
const TEMP_DIR = path.resolve(__dirname, 'temp'); // 分片临时存储目录
// 确保目录存在
fs.ensureDirSync(UPLOAD_DIR);
fs.ensureDirSync(TEMP_DIR);
// 配置中间件
app.use(cors({ origin: 'http://localhost:5173' })); // 允许前端跨域
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
/**
* 1. 检查文件上传状态接口
* 作用:判断文件是否已完整上传,返回已上传的分片列表
*/
app.post('/check-file', async (req, res) => {
try {
const { fileHash, fileName } = req.body;
const filePath = path.join(UPLOAD_DIR, fileName);
// 检查文件是否已完整上传
if (await fs.pathExists(filePath)) {
return res.json({
exists: true,
uploadedChunks: [],
message: '文件已完整上传'
});
}
// 检查已上传的分片
const chunkDir = path.join(TEMP_DIR, fileHash);
let uploadedChunks = [];
if (await fs.pathExists(chunkDir)) {
const chunks = await fs.readdir(chunkDir);
uploadedChunks = chunks.map(chunk => chunk.replace('.chunk', ''));
}
res.json({ exists: false, uploadedChunks });
} catch (err) {
console.error('检查文件错误:', err);
res.status(500).json({ error: err.message });
}
});
/**
* 2. 上传分片接口
* 作用:接收前端上传的分片,存储到临时目录
*/
app.post('/upload-chunk', (req, res) => {
const form = new multiparty.Form({ uploadDir: TEMP_DIR });
form.parse(req, async (err, fields, files) => {
if (err) {
console.error('解析表单错误:', err);
return res.status(500).json({ error: err.message });
}
try {
const fileHash = fields.fileHash[0];
const chunkHash = fields.chunkHash[0];
const chunkFile = files.chunk[0];
// 创建分片存储目录
const chunkDir = path.join(TEMP_DIR, fileHash);
await fs.ensureDir(chunkDir);
// 移动分片到目标目录
const destPath = path.join(chunkDir, `${chunkHash}.chunk`);
await fs.move(chunkFile.path, destPath, { overwrite: true });
res.json({
success: true,
message: `分片 ${chunkHash} 上传成功`,
chunkHash
});
} catch (moveErr) {
console.error('移动分片错误:', moveErr);
res.status(500).json({ error: moveErr.message });
}
});
});
/**
* 3. 合并分片接口
* 作用:将所有分片按顺序合并为完整文件
*/
app.post('/merge-chunks', async (req, res) => {
try {
const { fileHash, fileName, totalChunks } = req.body;
const chunkDir = path.join(TEMP_DIR, fileHash);
const destPath = path.join(UPLOAD_DIR, fileName);
// 检查分片目录是否存在
if (!await fs.pathExists(chunkDir)) {
return res.status(400).json({ error: '分片不存在' });
}
// 检查分片数量是否完整
const chunks = await fs.readdir(chunkDir);
if (chunks.length !== totalChunks) {
return res.status(400).json({
error: '分片不完整',
received: chunks.length,
total: totalChunks
});
}
// 按索引排序分片
chunks.sort((a, b) => {
const indexA = parseInt(a.split('-')[1].replace('.chunk', ''));
const indexB = parseInt(b.split('-')[1].replace('.chunk', ''));
return indexA - indexB;
});
// 合并分片
const writeStream = fs.createWriteStream(destPath);
for (const chunk of chunks) {
const chunkPath = path.join(chunkDir, chunk);
const chunkBuffer = await fs.readFile(chunkPath);
await new Promise((resolve, reject) => {
writeStream.write(chunkBuffer, (err) => {
if (err) reject(err);
else resolve();
});
});
}
// 完成合并,清理临时文件
writeStream.end();
await fs.remove(chunkDir);
res.json({
success: true,
message: '文件合并成功',
filePath: destPath
});
} catch (err) {
console.error('合并分片错误:', err);
res.status(500).json({ error: err.message });
}
});
// 启动服务器
app.listen(port, () => {
console.log(`后端服务已启动,端口: ${port}`);
console.log(`上传文件存储目录: ${UPLOAD_DIR}`);
console.log(`分片临时存储目录: ${TEMP_DIR}`);
});