大文件上传全方案:Vue + Node.js 实战
1 为什么需要大文件上传方案?
在视频平台、云存储、数据备份等场景中,GB 级文件上传是常见需求。直接上传大文件会面临三大核心问题:
- 网络不稳定:单次上传失败需重新上传整个文件,耗时耗力;
- 超时风险:大文件上传耗时久,易触发服务器/客户端超时;
- 资源占用过高:前端一次性读取大文件到内存会导致页面卡顿,后端一次性接收大文件会占用大量内存和带宽。
解决方案的核心是分片上传—— 将大文件切割为小分片,逐个传输后在服务器合并,配合断点续传、秒传等机制,实现高效可靠的传输。
2 前端实现(Vue 3)
2.1 技术栈
-
框架:Vue 3(Composition API)
-
UI 组件:Ant Design Vue(提供上传区域、进度条等基础组件)
-
网络请求:Axios(处理分片上传、合并请求)
-
多线程:Web Worker(避免哈希计算阻塞主线程)
-
哈希计算:SparkMD5(高效计算大文件哈希,用于秒传和分片标识)
2.2 实现方案
2.2.1 项目结构
upload-demo/
├── public/
│ ├── index.html
│ ├── filenameWorker.js
│ └── spark-md5.min.js // 本地存储SparkMD5库,避免CDN依赖
├── src/
│ ├── utils/
│ │ └── request.js
│ ├── components/
│ │ └── FileUploader.vue // 核心上传组件
│ ├── App.vue
│ └── main.js
├── package.json
└── vite.config.js
2.2.2 public/spark-md5.min.js
从SparkMD5 官网下载最新版本,保存到public目录,避免依赖 CDN。
2.2.3 package.json
{"name": "upload-demo","version": "0.0.0","scripts": {"dev": "vite","build": "vite build","preview": "vite preview"},"dependencies": {"@ant-design/icons-vue": "^7.0.1","ant-design-vue": "^4.0.6","axios": "^1.6.0","vue": "^3.3.4"},"devDependencies": {"@vitejs/plugin-vue": "^4.4.0","vite": "^4.4.11"}
}
2.2.4 vite.config.js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";export default defineConfig({plugins: [vue()],server: {port: 3000,proxy: {"/upload": {target: "http://localhost:8080",changeOrigin: true,},"/verify": {target: "http://localhost:8080",changeOrigin: true,},"/merge": {target: "http://localhost:8080",changeOrigin: true,},},},
});
2.2.5 public/index.html
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8" /><link rel="icon" type="image/svg+xml" href="/favicon.ico" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>大文件上传演示</title></head><body><div id="app"></div><script type="module" src="/src/main.js"></script></body>
</html>
2.2.6 public/filenameWorker.js
// public/filenameWorker.js
self.importScripts("/spark-md5.min.js"); // 引入 SparkMD5 库self.addEventListener("message", async (e) => {const file = e.data;const filename = await getFileName(file);self.postMessage(filename);
});async function getFileName(file) {const fileHash = await calculateHash(file);const ext = file.name.split(".").pop();return `${fileHash}.${ext}`;
}async function calculateHash(file) {const chunkSize = 100 * 1024 * 1024; // 100MB/片(与上传分片大小一致)const chunks = Math.ceil(file.size / chunkSize);const spark = new SparkMD5.ArrayBuffer();const fileReader = new FileReader();let currentChunk = 0;return new Promise((resolve, reject) => {fileReader.onload = function (e) {spark.append(e.target.result);currentChunk++;// 实时发送进度(供前端显示)const progress = Math.floor((currentChunk / chunks) * 100);self.postMessage({ type: "progress", progress });// 分时间片处理,避免长时间占用CPUif (currentChunk < chunks) {setTimeout(loadNextChunk, 0); // 每个分片都使用setTimeout} else {const hash = spark.end();resolve(hash); // 直接返回哈希,不进行缓存}};fileReader.onerror = reject;function loadNextChunk() {try {const start = currentChunk * chunkSize;const end = Math.min(start + chunkSize, file.size);fileReader.readAsArrayBuffer(file.slice(start, end));} catch (err) {self.postMessage({ type: "error", error: err.message });}}loadNextChunk();});
}
2.2.7 src/App.vue
<template><div id="app"><h1 style="text-align: center; margin: 20px 0">大文件上传演示</h1><FileUploader /></div>
</template><script setup>
import FileUploader from "./components/FileUploader.vue";
</script><style>
#app {max-width: 1200px;margin: 0 auto;padding: 20px;font-family: Avenir, Helvetica, Arial, sans-serif;-webkit-font-smoothing: antialiased;-moz-osx-font-smoothing: grayscale;
}
</style>
2.2.8 src/main.js
import { createApp } from "vue";
import App from "./App.vue";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";const app = createApp(App);
app.use(Antd);
app.mount("#app");
2.2.9 src/components/FileUploader.vue
<template><div class="upload-container"><!-- 上传区域 --><divref="uploadArea"class="upload-area"@dragenter.prevent@dragover.prevent@drop.prevent="handleDrop"@click="handleClick"><!-- 文件预览 --><div v-if="filePreview.url" class="preview-container"><videov-if="filePreview.type.startsWith('video/')":src="filePreview.url"controlsclass="preview-media"></video><imgv-else-if="filePreview.type.startsWith('image/')":src="filePreview.url"class="preview-media"alt="预览"/></div><template v-else><InboxOutlined class="upload-icon" /><p>点击或拖拽文件到此处上传</p><p class="hint">支持图片、视频格式,最大支持2GB</p></template></div><!-- 控制按钮 --><div class="control-buttons"><Button @click="handleUploadAction"><component :is="getButtonIcon" />{{ getButtonText }}</Button><Buttonv-if="uploadStatus !== 'NOT_STARTED'"type="text"danger@click="cancelUpload"style="margin-left: 10px">取消上传</Button></div><!-- 进度显示 --><div v-if="uploadStatus !== 'NOT_STARTED'" class="progress-container"><!-- 哈希计算进度 --><div v-if="hashProgress > 0 && hashProgress < 100" class="hash-progress"><span>计算文件哈希:</span><Progress :percent="hashProgress" size="small" status="active" /></div><!-- 分片进度(只显示前5个和最后1个,避免过多DOM) --><divv-for="(percent, chunkName) in visibleChunkProgress":key="chunkName"class="chunk-progress"><span>分片{{ chunkName.split("-").pop() }}:</span><Progress v-if="percent !== null" :percent="percent" size="small" :key="chunkName"/></div><!-- 总进度和速度信息 --><div class="total-progress" v-if="Object.keys(uploadProgress).length > 0"><span>总进度:</span><Progress :percent="totalProgress" status="active" /><div class="upload-info"><span>速度: {{ uploadSpeed }} MB/s</span><span>剩余时间: {{ remainingTime }}s</span></div></div></div><!-- 计算文件名加载中 --><Spin v-if="calculatingFilename" tip="计算文件哈希中..."></Spin></div
