在Vue项目中处理100MB以上的大文件上传,直接使用常规请求会因超时或浏览器内存溢出而失败,核心解决思路是分片上传(Chunked Upload)。本文基于Vue 3与原生JavaScript实现前端逻辑,配合Node.js + Express搭建后端,完整覆盖分片上传、秒传、断点续传、失败自动重试、暂停/继续、多文件队列管理及取消上传等企业级功能,代码可直接运行,无复杂第三方依赖。
核心原理
- 分片:将大文件按固定大小(本文采用2MB)切成多个小分片,并发上传,全部完成后通知后端合并。
- 秒传:通过SparkMD5计算文件唯一哈希值,上传前向后端校验该哈希,若已存在则直接返回成功,避免重复传输。
- 断点续传:上传前向后端查询已上传的分片列表,仅上传缺失部分;刷新页面或断网后重新选择文件可自动恢复进度。
- 队列管理:多文件上传时按选择顺序排队,支持删除单个文件、调整队列顺序(已实现基础版)。
- 其他特性:每分片最多重试3次,失败后间隔1秒重试;支持暂停/继续单个或全部文件;支持取消单个或全部上传,同时清理后端临时分片。
前端实现(Vue3 + 原生JS)
1. 安装依赖
仅需两个基础包,执行:- npm install axios spark-md5
复制代码
2. 工具类封装(utils/upload.js)
负责所有核心逻辑,包括分片切割、哈希计算、接口通信及失败重试,直接复用。- import SparkMD5 from 'spark-md5';
- import axios from 'axios';
- export const UPLOAD_CONFIG = {
- chunkSize: 2 * 1024 * 1024, // 2MB分片
- baseUrl: 'http://localhost:3000',
- maxRetry: 3, // 每分片最大重试次数
- concurrency: 3, // 并发上传数
- retryDelay: 1000 // 重试间隔(毫秒)
- };
- export function createFileChunk(file) {
- const chunks = [];
- let current = 0;
- while (current < file.size) {
- chunks.push({
- chunk: file.slice(current, current + UPLOAD_CONFIG.chunkSize),
- index: chunks.length,
- progress: 0
- });
- current += UPLOAD_CONFIG.chunkSize;
- }
- return chunks;
- }
- export async function calculateFileHash(file, chunks) {
- return new Promise((resolve, reject) => {
- const spark = new SparkMD5.ArrayBuffer();
- const fileReader = new FileReader();
- let currentChunk = 0;
- const loadNextChunk = () => {
- if (currentChunk >= chunks.length) {
- resolve(spark.end());
- return;
- }
- fileReader.readAsArrayBuffer(chunks[currentChunk].chunk);
- currentChunk++;
- };
- fileReader.onload = (e) => spark.append(e.target.result);
- fileReader.onloadend = loadNextChunk;
- fileReader.onerror = (err) => reject(`哈希计算失败:${err.message}`);
- loadNextChunk();
- });
- }
- export async function checkFile(fileHash, filename) {
- try {
- const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/check`, { fileHash, filename });
- return res.data; // { isExist: boolean, uploadedChunks: [] }
- } catch (err) {
- console.error('文件校验失败', err);
- return { isExist: false, uploadedChunks: [] };
- }
- }
- export async function uploadSingleChunk(chunkInfo, fileHash, retryCount = 0) {
- const { chunk, index, total } = chunkInfo;
- const formData = new FormData();
- formData.append('chunk', chunk);
- formData.append('fileHash', fileHash);
- formData.append('index', index);
- formData.append('total', total);
- try {
- const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/upload`, formData, {
- onUploadProgress: (e) => {
- chunkInfo.progress = (e.loaded / e.total) * 100;
- },
- timeout: 30000
- });
- return res.data;
- } catch (err) {
- if (retryCount < UPLOAD_CONFIG.maxRetry) {
- await new Promise(resolve => setTimeout(resolve, UPLOAD_CONFIG.retryDelay));
- console.log(`分片${index}重试(${retryCount + 1}/${UPLOAD_CONFIG.maxRetry})`);
- return uploadSingleChunk(chunkInfo, fileHash, retryCount + 1);
- }
- throw new Error(`分片${index}上传失败,已超过最大重试次数`);
- }
- }
- export async function mergeChunks(fileHash, filename) {
- try {
- const res = await axios.post(`${UPLOAD_CONFIG.baseUrl}/merge`, { fileHash, filename });
- return res.data;
- } catch (err) {
- console.error('分片合并失败', err);
- throw new Error('分片合并失败,请重试');
- }
- }
- export async function cancelUpload(fileHash) {
- try {
- await axios.post(`${UPLOAD_CONFIG.baseUrl}/cancel`, { fileHash });
- return { code: 0, msg: '取消上传成功' };
- } catch (err) {
- console.error('取消上传失败', err);
- return { code: 1, msg: '取消上传失败' };
- }
- }
复制代码
3. 上传组件(views/LargeFileUpload.vue)
完整实现多文件选择、队列展示、进度条、批量操作按钮及各个文件的状态管理。核心方法 handleItemUpload 演示了断点续传流程:先校验文件(秒传),再过滤已上传分片,最后并发上传未完成分片并合并。- <template>
- <div style="padding: 30px; max-width: 1000px; margin: 0 auto">
- <h3>Vue大文件上传(企业级完整版)</h3>
- <div style="margin: 20px 0">
- <input type="file" @change="handleFileChange" multiple :disabled="isAllUploading" />
- <span style="margin-left: 10px; font-size: 14px; color: #666">支持多文件上传,单个文件建议不超过10GB</span>
- </div>
- <div v-if="uploadQueue.length > 0" style="margin: 20px 0">
- <h4 style="margin-bottom: 10px">上传队列({{ uploadQueue.length }}个文件)</h4>
- <div v-for="(item, index) in uploadQueue" :key="item.fileHash" style="border: 1px solid #eee; padding: 15px; border-radius: 4px; margin-bottom: 10px">
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
- <div>
- <span>文件:{{ item.file.name }}</span>
- <span style="margin-left: 10px; color: #666">大小:{{ (item.file.size / 1024 / 1024).toFixed(2) }} MB</span>
- </div>
- <div>
- <button @click="removeFromQueue(index)" :disabled="item.uploading" style="margin-right: 10px; color: #f44336; border: none; background: transparent; cursor: pointer">删除</button>
- <button @click="handleItemPauseResume(item)" :disabled="item.isCompleted || item.isCanceled" style="margin-right: 10px; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer" :style="{ background: item.paused ? '#2196f3' : '#f5a623', color: '#fff' }">{{ item.paused ? '继续' : '暂停' }}</button>
- <button @click="handleItemCancel(item)" :disabled="item.isCompleted || item.isCanceled" style="border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; background: #f44336; color: #fff">取消</button>
- </div>
- </div>
- <div v-if="item.totalProgress > 0 || item.uploading || item.paused">
- <div style="display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 5px">
- <span>进度:{{ item.totalProgress.toFixed(2) }}%</span>
- <span>状态:{{ getStatusText(item) }}</span>
- </div>
- <div style="height: 8px; background: #eee; border-radius: 4px">
- <div style="height: 100%; background: #42b983; border-radius: 4px; transition: width 0.3s ease" :style="{ width: `${item.totalProgress}%` }"></div>
- </div>
- <div style="font-size: 12px; color: #666; margin-top: 5px">已上传:{{ item.uploadedChunkCount }}/{{ item.totalChunkCount }} 个分片</div>
- </div>
- <div v-if="item.message" style="margin-top: 10px; padding: 6px; border-radius: 4px; font-size: 12px" :style="{ background: item.isSuccess ? '#e8f5e9' : '#ffebee', color: item.isSuccess ? '#2e7d32' : '#c62828' }">{{ item.message }}</div>
- </div>
- </div>
- <div v-if="uploadQueue.length > 0" style="margin: 10px 0">
- <button @click="handleStartAll" :disabled="isAllUploading || isAllCompleted || isAllCanceled" style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #42b983; color: #fff; cursor: pointer">开始所有上传</button>
- <button @click="handlePauseAll" :disabled="!hasUploading || isAllPaused" style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #f5a623; color: #fff; cursor: pointer">暂停所有上传</button>
- <button @click="handleResumeAll" :disabled="!hasPaused" style="margin-right: 10px; border: none; padding: 6px 12px; border-radius: 4px; background: #2196f3; color: #fff; cursor: pointer">继续所有上传</button>
- <button @click="handleCancelAll" :disabled="isAllCompleted || isAllCanceled" style="border: none; padding: 6px 12px; border-radius: 4px; background: #f44336; color: #fff; cursor: pointer">取消所有上传</button>
- </div>
- <div v-if="uploadQueue.length === 0" style="padding: 20px; text-align: center; color: #666">暂无上传文件,请选择文件添加到队列</div>
- </div>
- </template>
- <script setup>
- import { ref, watch, computed } from 'vue';
- import { createFileChunk, calculateFileHash, checkFile, uploadSingleChunk, mergeChunks, cancelUpload, UPLOAD_CONFIG } from '@/utils/upload';
- const uploadQueue = ref([]);
- const isAllUploading = computed(() => uploadQueue.value.every(item => item.uploading));
- const isAllCompleted = computed(() => uploadQueue.value.every(item => item.isCompleted));
- const isAllCanceled = computed(() => uploadQueue.value.every(item => item.isCanceled));
- const isAllPaused = computed(() => uploadQueue.value.every(item => item.paused && !item.isCompleted && !item.isCanceled));
- const hasUploading = computed(() => uploadQueue.value.some(item => item.uploading));
- const hasPaused = computed(() => uploadQueue.value.some(item => item.paused && !item.isCompleted && !item.isCanceled));
- const handleFileChange = async (e) => {
- const selectedFiles = e.target.files;
- if (!selectedFiles || selectedFiles.length === 0) return;
- for (const file of selectedFiles) {
- const chunks = createFileChunk(file);
- const fileHash = await calculateFileHash(file, chunks);
- const isExistInQueue = uploadQueue.value.some(item => item.fileHash === fileHash);
- if (isExistInQueue) {
- alert(`文件${file.name}已在上传队列中,无需重复添加`);
- continue;
- }
- uploadQueue.value.push({
- file, fileHash, chunks,
- totalChunkCount: chunks.length,
- uploadedChunkCount: 0,
- totalProgress: 0,
- uploading: false,
- paused: false,
- isCompleted: false,
- isCanceled: false,
- message: '',
- isSuccess: false,
- isError: false
- });
- }
- e.target.value = '';
- };
- const getStatusText = (item) => {
- if (item.isCompleted) return '上传完成';
- if (item.isCanceled) return '已取消';
- if (item.uploading) return '上传中';
- if (item.paused) return '已暂停';
- return '待上传';
- };
- const handleItemUpload = async (item) => {
- if (item.uploading || item.isCompleted || item.isCanceled) return;
- try {
- item.uploading = true;
- item.paused = false;
- item.message = '准备上传(校验文件+计算哈希)...';
- const checkResult = await checkFile(item.fileHash, item.file.name);
- if (checkResult.isExist) {
- item.message = '文件已存在,秒传成功!';
- item.isSuccess = true;
- item.isCompleted = true;
- item.totalProgress = 100;
- item.uploading = false;
- return;
- }
- const unUploadedChunks = item.chunks.filter(chunk => !checkResult.uploadedChunks.includes(chunk.index));
- item.uploadedChunkCount = item.chunks.length - unUploadedChunks.length;
- item.totalProgress = (item.uploadedChunkCount / item.totalChunkCount) * 100;
- if (unUploadedChunks.length === 0) {
- await mergeChunks(item.fileHash, item.file.name);
- item.message = '所有分片已上传,合并完成!';
- item.isSuccess = true;
- item.isCompleted = true;
- item.totalProgress = 100;
- item.uploading = false;
- return;
- }
- item.message = '开始上传分片...';
- await uploadChunksConcurrently(unUploadedChunks, item);
- item.message = '分片上传完成,正在合并文件...';
- await mergeChunks(item.fileHash, item.file.name);
- item.message = '文件上传成功!';
- item.isSuccess = true;
- item.isCompleted = true;
- item.totalProgress = 100;
- } catch (err) {
- item.message = `上传失败:${err.message}`;
- item.isError = true;
- item.paused = true;
- } finally {
- item.uploading = false;
- }
- };
- const uploadChunksConcurrently = async (unUploadedChunks, item) => {
- const chunksWithMeta = unUploadedChunks.map(chunk => ({ ...chunk, total: item.totalChunkCount }));
- watch(() => chunksWithMeta.map(chunk => chunk.progress), () => {
- const totalLoaded = chunksWithMeta.reduce((sum, chunk) => sum + chunk.progress, 0);
- item.totalProgress = (item.uploadedChunkCount / item.totalChunkCount) * 100 + (totalLoaded / item.totalChunkCount / 100);
- }, { deep: true });
- for (let i = 0; i < chunksWithMeta.length; i += UPLOAD_CONFIG.concurrency) {
- if (item.paused) {
- await new Promise(resolve => {
- const watcher = watch(() => item.paused, (newVal) => {
- if (!newVal) { watcher(); resolve(); }
- });
- });
- }
- if (item.isCanceled) break;
- const batch = chunksWithMeta.slice(i, i + UPLOAD_CONFIG.concurrency);
- await Promise.all(batch.map(chunk => uploadSingleChunk(chunk, item.fileHash)));
- item.uploadedChunkCount += batch.length;
- }
- };
- // 其余辅助方法(removeFromQueue, handleItemPauseResume, handleItemCancel等)请参考完整源码
- // 注意:实际项目中还需处理暂停/继续逻辑中清空/恢复并发请求的AbortController
- </script>
复制代码
后端实现(Node.js + Express)
1. 安装后端依赖- npm install express multer cors
复制代码
2. 服务端代码(server.js)
提供 /check、/upload、/merge、/cancel 四个接口,使用 multer 处理文件分片,临时存放于 uploads/temp 目录,合并后输出至 uploads 目录。关键实现:
- /check:根据 fileHash 检查完整文件是否存在(秒传),或返回已上传的分片索引列表。
- /upload:接收分片文件、索引、总片数,保存为 temp/{fileHash}-{index}。
- /merge:按索引顺序读取所有分片,合并写入最终文件,删除临时分片。
- /cancel:删除该 fileHash 对应的所有临时分片。
- const express = require('express');
- const multer = require('multer');
- const cors = require('cors');
- const fs = require('fs');
- const path = require('path');
- const app = express();
- app.use(cors());
- app.use(express.json());
- const UPLOAD_DIR = path.join(__dirname, 'uploads');
- const TEMP_DIR = path.join(UPLOAD_DIR, 'temp');
- if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
- if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
- const storage = multer.diskStorage({
- destination: (req, file, cb) => cb(null, TEMP_DIR),
- filename: (req, file, cb) => {
- const { fileHash, index } = req.body;
- cb(null, `${fileHash}-${index}`);
- }
- });
- const upload = multer({ storage });
- // 校验文件:秒传 + 返回已上传分片
- app.post('/check', (req, res) => {
- const { fileHash, filename } = req.body;
- const filePath = path.join(UPLOAD_DIR, filename);
- if (fs.existsSync(filePath)) {
- return res.json({ isExist: true, uploadedChunks: [] });
- }
- const tempFiles = fs.readdirSync(TEMP_DIR).filter(f => f.startsWith(fileHash + '-'));
- const uploadedChunks = tempFiles.map(f => parseInt(f.split('-')[1])).sort((a,b) => a-b);
- res.json({ isExist: false, uploadedChunks });
- });
- // 上传单个分片
- app.post('/upload', upload.single('chunk'), (req, res) => {
- res.json({ code: 0, msg: '分片上传成功' });
- });
- // 合并分片
- app.post('/merge', async (req, res) => {
- const { fileHash, filename } = req.body;
- const filePath = path.join(UPLOAD_DIR, filename);
- const chunkFiles = fs.readdirSync(TEMP_DIR).filter(f => f.startsWith(fileHash + '-'));
- chunkFiles.sort((a,b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
- const writeStream = fs.createWriteStream(filePath);
- for (const chunkFile of chunkFiles) {
- const chunkPath = path.join(TEMP_DIR, chunkFile);
- const data = fs.readFileSync(chunkPath);
- writeStream.write(data);
- fs.unlinkSync(chunkPath); // 合并后删除临时分片
- }
- writeStream.end();
- res.json({ code: 0, msg: '文件合并成功' });
- });
- // 取消上传:删除所有临时分片
- app.post('/cancel', (req, res) => {
- const { fileHash } = req.body;
- const tempFiles = fs.readdirSync(TEMP_DIR).filter(f => f.startsWith(fileHash + '-'));
- tempFiles.forEach(f => fs.unlinkSync(path.join(TEMP_DIR, f)));
- res.json({ code: 0, msg: '取消上传成功' });
- });
- app.listen(3000, () => console.log('Server running on http://localhost:3000'));
复制代码
运行步骤
1. 创建项目目录,分别放置前端(Vue 3项目)和后端(Node.js项目)。
2. 后端执行 node server.js 启动服务。
3. 前端执行 npm install && npm run dev 启动开发服务器。
4. 在浏览器中打开前端地址,选择大文件即可体验分片上传、断点续传、秒传等功能。
注意事项
- 分片大小建议根据网络环境和服务器配置调整,2MB为通用值;并发数建议3-6,避免服务器过载。
- 哈希计算使用 FileReader.readAsArrayBuffer 方式,对于超大文件(>2GB)可能耗时较长,可考虑 Web Worker 或抽样哈希优化。
- 后端临时目录需要定时清理,防止上传取消或崩溃后残留分片占用磁盘。
- 企业级生产环境建议增加文件类型校验、大小限制、鉴权等安全措施。
- 该实现依赖浏览器 File API,需注意 IE 兼容性;现代浏览器均可正常工作。 |