上传没你想得那么难?切片?断点续传?秒传?🎯

基于 Vue3 的上传 - 切片 断点续传 秒传

一个功能完整、性能出色的文件上传组件,基于 Vue3 和 Element Plus 构建。支持大文件分片上传、断点续传和秒传功能,为用户提供流畅的文件上传体验。

✨ 特性

  • 🚀 大文件分片上传:自动将大文件切片,分片并发上传,显著提升上传速度
  • 🔄 断点续传:支持暂停/继续上传,意外中断后可从断点处恢复
  • 秒传功能:基于文件 hash 检测,相同文件秒级上传
  • 🎯 上传进度:精确的进度显示和状态反馈
  • 🛡️ 可靠性:完善的错误处理和状态管理
  • 🎨 优雅的 UI:基于 Element Plus 的现代化界面设计

🛠️ 技术栈

  • 前端框架:Vue 3 + Vite
  • UI 组件库:Element Plus
  • 文件处理:Spark-MD5(文件 hash 计算)
  • 后端服务:Express + express-fileupload

💡 核心功能实现

1. 分片上传

javascript 复制代码
// 配置分片大小
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB

// 文件分片处理
const prepareUpload = (file, fileHash) => {
  const chunks = Math.ceil(file.size / CHUNK_SIZE);
  uploadChunks.value = Array.from({ length: chunks }, (_, index) => ({
    index,
    start: index * CHUNK_SIZE,
    end: Math.min((index + 1) * CHUNK_SIZE, file.size),
    status: 'pending'
  }));
};

2. 上传/断点续传

  • 支持暂停/继续上传操作
  • 记录已上传分片信息
  • 断点处继续上传
javascript 复制代码
// 开始上传
const startUpload = async () => {
  if (!currentFile.value) return;
  
  isUploading.value = true;
  isPaused.value = false;
  uploadMessage.value = '';
  isExist.value = 'false'

  const file = currentFile.value.raw;
  const fileHash = await calculateHash(file);

  try {
    // 获取已上传的分片信息
    const response = await fetch('/demo/upload-info', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash: fileHash })
    });
    const { uploadedChunks } = await response.json();

    // 更新分片状态
    uploadedChunks.forEach(index => {
      uploadChunks.value[index].status = 'uploaded';
    });
    updateProgress();

    // 获取待上传的分片
    const pendingChunks = uploadChunks.value
      .filter(chunk => chunk.status === 'pending');

    // 使用队列控制并发上传
    for (let i = 0; i < pendingChunks.length; i += MAX_CONCURRENT_UPLOADS) {
      if (isPaused.value) break;
      
      const chunksGroup = pendingChunks.slice(i, i + MAX_CONCURRENT_UPLOADS);
      await Promise.all(chunksGroup.map(chunk => uploadChunk(file, chunk, fileHash)));
    }
    
    if (!isPaused.value) {
      await mergeRequest(fileHash, file.name);
      showMessage('上传成功!', 'success');
      uploadProgress.value = 100;
      emit('upload-success'); // 触发上传成功事件
    }
  } catch (error) {
    showMessage('上传失败:' + error.message, 'error');
  } finally {
    isUploading.value = false;
  }
};


// 继续上传
const resumeUpload = () => {
  startUpload();
};

3. 秒传功能

  • 使用 Spark-MD5 计算文件 hash
  • 上传前检查文件是否已存在
  • 秒级完成重复文件上传
javascrip 复制代码
// 选择文件
const handleFileChange = async (file) => {
    console.log({file})
  if (!file) return;
  currentFile.value = file;
  uploadChunks.value = [];
  uploadProgress.value = 0;
  isPaused.value = false;
  isUploading.value = false;
  uploadMessage.value = '';

  // 计算文件hash用于秒传
  const fileHash = await calculateHash(file.raw);
  
  // 检查是否可以秒传
  try {
    const response = await fetch('/demo/check-file', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ hash: fileHash, fileName: file.name })
    });
    const result = await response.json();

    if (result.exists) {
      showMessage('文件已存在,秒传成功!', 'success');
      uploadProgress.value = 100;
      isExist.value = true;
      return;
    }
    isExist.value = false;
    // 准备分片上传
    prepareUpload(file.raw, fileHash);
  } catch (error) {
    showMessage('文件检查失败:' + error.message, 'error');
  }
};

4.并发控制上传梳理

javascrip 复制代码
// 获取待上传的分片
const pendingChunks = uploadChunks.value
  .filter(chunk => chunk.status === 'pending');

// 使用队列控制并发上传
for (let i = 0; i < pendingChunks.length; i += MAX_CONCURRENT_UPLOADS) {
  if (isPaused.value) break;

  const chunksGroup = pendingChunks.slice(i, i + MAX_CONCURRENT_UPLOADS);
  await Promise.all(chunksGroup.map(chunk => uploadChunk(file, chunk, fileHash)));
}

if (!isPaused.value) {
  await mergeRequest(fileHash, file.name);
  showMessage('上传成功!', 'success');
  uploadProgress.value = 100;
  emit('upload-success'); // 触发上传成功事件
}

🚀 快速开始

  1. 安装依赖
bash 复制代码
npm install
  1. 启动开发服务器
bash 复制代码
npm run dev
  1. 启动后端服务
bash 复制代码
npm run server

📝 使用示例

vue 复制代码
<template>
  <UploadFile @upload-success="handleUploadSuccess" />
</template>

<script setup>
import UploadFile from './components/UploadFile.vue';

const handleUploadSuccess = () => {
  console.log('文件上传成功!');
};
</script>

🎯 特色亮点

  1. 高性能

    • 并发分片上传
    • 智能秒传判断
    • 优化的文件处理流程
  2. 用户体验

    • 直观的进度显示
    • 友好的操作反馈
    • 优雅的交互设计
  3. 可靠性

    • 完善的错误处理
    • 断点续传保障
    • 上传状态可控

🛡️ 后续规划

  • 支持文件夹上传
  • 添加上传队列管理
  • 支持自定义分片大小
  • 优化文件 hash 计算性能

NodeJS 接口

javascript 复制代码
import express from 'express';
import fileUpload from 'express-fileupload';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const PORT = 3000;

// 存储文件的目录
const UPLOAD_DIR = path.join(__dirname, '../uploads');
const TEMP_DIR = path.join(__dirname, '../temp');

// 确保上传目录存在
[UPLOAD_DIR, TEMP_DIR].forEach(dir => {
    if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
    }
});

// 设置CORS头
app.use((req, res, next) => {
    res.setHeader('Access-Control-Allow-Origin', '*');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    if (req.method === 'OPTIONS') {
        return res.status(200).end();
    }
    next();
});

// 中间件配置
app.use(express.json());
app.use(fileUpload({
    limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50GB
    useTempFiles: true,
    tempFileDir: TEMP_DIR
}));

// API路由

// 检查文件是否已存在(用于秒传)
app.post('/demo/check-file', (req, res) => {
    res.setHeader('Content-Type', 'application/json');
    const { hash, fileName } = req.body;
    const fileExt = path.extname(fileName);
    const storedFileName = `${hash}${fileExt}`;
    const filePath = path.join(UPLOAD_DIR, storedFileName);
    res.json({ exists: fs.existsSync(filePath) });
});

// 获取已上传的分片信息
app.post('/demo/upload-info', (req, res) => {
    res.setHeader('Content-Type', 'application/json');
    const { hash } = req.body;
    const chunkDir = path.join(TEMP_DIR, hash);
    
    if (!fs.existsSync(chunkDir)) {
        return res.json({ uploadedChunks: [] });
    }

    const uploadedChunks = fs.readdirSync(chunkDir)
        .map(name => parseInt(name))
        .filter(name => !isNaN(name));

    res.json({ uploadedChunks });
});

// 上传分片
app.post('/demo/upload-chunk', (req, res) => {
    if (!req.files || !req.files.file) {
        return res.status(400).json({ error: 'No file uploaded' });
    }

    const { hash, chunkIndex } = req.body;
    const chunk = req.files.file;
    const chunkDir = path.join(TEMP_DIR, hash);

    // 确保分片目录存在
    if (!fs.existsSync(chunkDir)) {
        fs.mkdirSync(chunkDir, { recursive: true });
    }

    // 移动分片到临时目录
    const chunkPath = path.join(chunkDir, chunkIndex);
    chunk.mv(chunkPath, err => {
        if (err) {
            console.error('分片上传失败:', err);
            return res.status(500).json({ error: err.message });
        }
        res.json({ message: '分片上传成功' });
    });
});

// 合并分片
app.post('/demo/merge', async (req, res) => {
    res.setHeader('Content-Type', 'application/json');
    let writeStream = null;
    try {
        const { hash, fileName } = req.body;
        const chunkDir = path.join(TEMP_DIR, hash);
        const fileExt = path.extname(fileName);
        const storedFileName = `${hash}${fileExt}`;
        const filePath = path.join(UPLOAD_DIR, storedFileName);

        // 确保分片目录存在
        if (!fs.existsSync(chunkDir)) {
            return res.status(400).json({ error: '没有找到分片文件' });
        }

        // 获取所有分片
        const chunks = fs.readdirSync(chunkDir)
            .map(name => parseInt(name))
            .filter(name => !isNaN(name))
            .sort((a, b) => a - b);

        // 创建写入流
        writeStream = fs.createWriteStream(filePath);

        // 按顺序合并分片
        for (const index of chunks) {
            const chunkPath = path.join(chunkDir, index.toString());
            const chunkData = await fs.promises.readFile(chunkPath);
            await new Promise((resolve, reject) => {
                writeStream.write(chunkData, err => {
                    if (err) reject(err);
                    else resolve();
                });
            });
        }

        // 关闭写入流
        await new Promise(resolve => writeStream.end(resolve));

        // 递归删除目录内的所有文件和子目录
        const deleteFolderRecursive = async (folderPath) => {
            if (fs.existsSync(folderPath)) {
                const files = fs.readdirSync(folderPath);
                for (const file of files) {
                    const curPath = path.join(folderPath, file);
                    if (fs.lstatSync(curPath).isDirectory()) {
                        await deleteFolderRecursive(curPath);
                    } else {
                        fs.unlinkSync(curPath);
                    }
                }
                try {
                    fs.rmdirSync(folderPath);
                } catch (err) {
                    if (err.code === 'ENOTEMPTY') {
                        // 如果目录非空,等待一段时间后重试
                        await new Promise(resolve => setTimeout(resolve, 100));
                        await deleteFolderRecursive(folderPath);
                    } else {
                        throw err;
                    }
                }
            }
        };

        // 清理分片目录
        await deleteFolderRecursive(chunkDir);

        // 创建文件名到hash的映射文件,包含扩展名信息
        const mappingPath = path.join(UPLOAD_DIR, 'fileMapping.json');
        let mapping = {};
        if (fs.existsSync(mappingPath)) {
            mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf-8'));
        }
        mapping[fileName] = {
            hash: hash,
            extension: fileExt,
            storedFileName: storedFileName
        };
        fs.writeFileSync(mappingPath, JSON.stringify(mapping, null, 2));

        res.json({ message: '文件合并成功' });
    } catch (error) {
        // 确保在发生错误时关闭写入流
        if (writeStream) {
            writeStream.end();
        }
        console.error('文件合并失败:', error);
        res.status(500).json({ error: '文件合并失败: ' + error.message });
    }
});

// 获取文件列表
app.post('/demo/files-list', (req, res) => {
    try {
        const mappingPath = path.join(UPLOAD_DIR, 'fileMapping.json');
        if (!fs.existsSync(mappingPath)) {
            return res.json([]);
        }

        const mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf-8'));
        const files = [];
        for (const [fileName, fileInfo] of Object.entries(mapping)) {
            const filePath = path.join(UPLOAD_DIR, fileInfo.storedFileName);
            try {
                if (fs.existsSync(filePath)) {
                    const stats = fs.statSync(filePath);
                    files.push({
                        fileName,
                        size: stats.size,
                        uploadTime: stats.mtime,
                        hash: fileInfo.hash
                    });
                }
            } catch (err) {
                console.error(`获取文件信息失败: ${fileName}`, err);
                // 继续处理下一个文件
                continue;
            }
        }

        res.json(files);
    } catch (error) {
        console.error('获取文件列表失败:', error);
        res.status(500).json({ error: '获取文件列表失败' });
    }
});

// 下载文件
app.get('/demo/download/:hash', (req, res) => {
    try {
        const { hash } = req.params;
        const mappingPath = path.join(UPLOAD_DIR, 'fileMapping.json');
        const mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf-8'));
        
        // 查找对应的文件信息
        const fileEntry = Object.entries(mapping).find(([_, info]) => info.hash === hash);
        if (!fileEntry) {
            return res.status(404).json({ error: '文件不存在' });
        }

        const [originalFileName, fileInfo] = fileEntry;
        const filePath = path.join(UPLOAD_DIR, fileInfo.storedFileName);

        if (!fs.existsSync(filePath)) {
            return res.status(404).json({ error: '文件不存在' });
        }

        // 设置响应头
        res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(originalFileName)}`);
        res.setHeader('Content-Type', 'application/octet-stream');

        // 创建文件读取流并传输
        const fileStream = fs.createReadStream(filePath);
        fileStream.pipe(res);
    } catch (error) {
        console.error('文件下载失败:', error);
        res.status(500).json({ error: '文件下载失败' });
    }
});

// 删除文件
app.post('/demo/delete-file', (req, res) => {
    try {
        const { hash } = req.body;
        
        const mappingPath = path.join(UPLOAD_DIR, 'fileMapping.json');
        const mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf-8'));
        
        // 查找对应的文件信息
        const fileEntry = Object.entries(mapping).find(([_, info]) => info.hash === hash);
        if (!fileEntry) {
            return res.status(404).json({ error: '文件不存在' });
        }

        const [originalFileName, fileInfo] = fileEntry;
        const filePath = path.join(UPLOAD_DIR, fileInfo.storedFileName);
        const tempDir = path.join(TEMP_DIR, hash);

        // 删除临时目录(如果存在)
        if (fs.existsSync(tempDir)) {
            fs.rmSync(tempDir, { recursive: true, force: true });
        }

        // 删除上传的文件(如果存在)
        if (fs.existsSync(filePath)) {
            fs.unlinkSync(filePath);
        }

        // 更新mapping文件
        delete mapping[originalFileName];
        fs.writeFileSync(mappingPath, JSON.stringify(mapping, null, 2));

        res.json({ message: '文件删除成功' });
    } catch (error) {
        console.error('文件删除失败:', error);
        res.status(500).json({ error: '文件删除失败' });
    }
});

// 静态文件服务
app.use(express.static(path.join(__dirname, '../public'), {
    setHeaders: (res, path) => {
        console.log('----进入静态资源中间件')
        if (path.endsWith('.js')) {
            res.set('Content-Type', 'application/javascript');
        }
    }
}));

// 启动服务器
app.listen(PORT, () => {
    console.log(`服务器运行在 http://localhost:${PORT}`);
});

📝源码:github.com/Tao0929/dem... 【vite5 nodejs 用v20以上得】

🔮 效果展示

相关推荐
麻芝汤圆31 分钟前
MapReduce 入门实战:WordCount 程序
大数据·前端·javascript·ajax·spark·mapreduce
juruiyuan1112 小时前
FFmpeg3.4 libavcodec协议框架增加新的decode协议
前端
Peter 谭3 小时前
React Hooks 实现原理深度解析:从基础到源码级理解
前端·javascript·react.js·前端框架·ecmascript
LuckyLay4 小时前
React百日学习计划——Deepseek版
前端·学习·react.js
gxn_mmf4 小时前
典籍知识问答重新生成和消息修改Bug修改
前端·bug
hj10434 小时前
【fastadmin开发实战】在前端页面中使用bootstraptable以及表格中实现文件上传
前端
乌夷4 小时前
axios结合AbortController取消文件上传
开发语言·前端·javascript
晓晓莺歌5 小时前
图片的require问题
前端
码农黛兮_465 小时前
CSS3 基础知识、原理及与CSS的区别
前端·css·css3
水银嘻嘻6 小时前
web 自动化之 Unittest 四大组件
运维·前端·自动化