当前位置: 首页 > news >正文

大文件的切片上传和断点续传前后端(Vue+node.js)具体实现

技术栈与插件说明

前端技术栈

  • 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}`);
});

http://www.lryc.cn/news/603647.html

相关文章:

  • 巡台效率:精准胜勤快
  • 基于YOLOP与GAN的图像修复与防御系统设计与实现
  • 把查出来的值加上双引号,并逗号分隔
  • 宇树 G1 部署(九)——遥操作控制脚本 teleop_hand_and_arm.py 分析与测试部署
  • 汇总数据(使用聚集函数)
  • 智能制造的空间度量:机器视觉标定技术解析
  • 微店商品详情接口micro.item_get请求参数响应参数解析
  • 以太坊十年:智能合约与去中心化的崛起
  • Linux文件归档和备份
  • 自动调优 vLLM 服务器参数(实战指南)
  • IDEA中全局搜索快捷键Ctrl+Shift+F为何失灵?探寻原因与修复指南
  • ARM7微处理器的核心优势
  • 如何在Windows操作系统上通过conda 安装 MDAnalysis
  • 继续打卡day6
  • 机器学习线性回归:从基础到实践的入门指南
  • Wndows Docker Desktop-Unexpected WSL error错误
  • unity 使用PropertyDrawer 在Inspector 面板上自定义字段的显示方式
  • 天铭科技×蓝卓 | “1+2+N”打造AI驱动的汽车零部件行业智能工厂
  • RPG增容2.尝试使用MMC根据游戏难度自定义更改怪物的属性(二)
  • 本土化DevOps实践:Gitee为核心的协作工具链与高效落地指南
  • git中多仓库工作的常用命令
  • Mac安装Navicat步骤Navicat Premium for Mac v17.1.9【亲测】
  • 【腾讯云】EdgeOne网站安全防护的配置方法 防范盗刷流量 附恶意IP和UA黑名单
  • YOLOv11.pt 模型转换为 TFLite 和 NCNN 模型
  • npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
  • Kafka运维实战 17 - kafka 分区副本从 1 增加到 3【实战】
  • 图形界面应用程序技术栈大全
  • Java把word转HTML格式
  • python中的 @dataclass
  • It学习资源下载