在构建企业级 Web 应用时,文件管理器是一个看似简单实则充满挑战的模块。面对大文件上传卡顿、大文件下载导致浏览器崩溃、以及误删不可恢复等痛点,我们需要一套更科学的架构方案。
本文将通过 Vue 2/3 + Spring Boot 的组合,详细拆解如何实现一套具备:排重检测、多线程后台下载、流式下载进度监控、以及回收站机制的文件管理系统。
一、 核心技术原理分析
1. 智能冲突检测上传
在上传文件前,系统会先发起一个"预检请求"(Check Exists)。
-
原理:前端获取文件名,调用后端接口查询路径下是否已有同名文件。
-
交互:若存在冲突,弹出对话框让用户选择"覆盖"或"跳过",避免误操作。
-
进度监控:利用 Axios 的 onUploadProgress 事件,实时获取已上传字节数,驱动前端 UI 进度条。
2. 动态下载策略(三重机制)
这是本系统的核心亮点,根据文件大小自动切换下载模式:
-
策略 A:Web Worker (中小文件 < 100MB)
-
原理:在主线程之外开启独立线程,通过 XMLHttpRequest 进行下载。
-
优点:完全不阻塞主线程 UI 渲染,进度回传极其精准。
-
限制:需将整个文件读取为 Blob 存入内存,超大文件(如 4GB)会导致浏览器 Tab 页崩溃。
-
-
策略 B:Service Worker + Streams API (大文件 100MB ~ 2GB)
-
原理:Service Worker 充当浏览器与网络之间的代理。它拦截请求并使用 ReadableStream 边读取后端流边刷入磁盘,通过 postMessage 向主页面同步进度。
-
优点:无需将全量文件塞进内存,支持 GB 级文件的进度条展示。
-
-
策略 C:传统 <a> 标签 (兜底方案 > 2GB)
-
原理:通过创建隐藏链接触发浏览器自带的下载管理器。
-
优点:最为稳定,由浏览器底层硬扛,不占用前端 JavaScript 内存。
-
3. 安全删除与回收站机制
-
非物理删除:文件删除时,系统将其移动到隐藏的 .recycle_bin 目录。
-
元数据编码 :为了支持"还原",删除时会将原文件路径进行 Base64 编码并拼接时间戳作为新文件名,确保即使不同路径的同名文件在回收站也能共存。
二、 前端实现步骤与代码
1. 基础设施:Worker 脚本
将这两个脚本放在项目的 public 目录下。
public/downloadWorker.js (Web Worker)
self.onmessage = function (e) {
const { url, filename } = e.data;
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
xhr.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.floor((event.loaded / event.total) * 100);
self.postMessage({ type: 'progress', percent });
}
};
xhr.onload = function () {
if (this.status === 200) {
self.postMessage({ type: 'success', blob: this.response, filename });
} else {
self.postMessage({ type: 'error', error: '下载失败: ' + this.status });
}
};
xhr.send();
};
public/service-worker.js (Service Worker)
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.searchParams.has('sw_download')) {
event.respondWith(handleDownloadStream(event));
}
});
async function handleDownloadStream(event) {
const targetUrl = event.request.url.replace(/[?&]sw_download=true/, '');
const response = await fetch(targetUrl);
const total = parseInt(response.headers.get('content-length'), 10);
let loaded = 0;
const stream = new ReadableStream({
async start(controller) {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
loaded += value.length;
self.clients.matchAll().then(clients => {
clients.forEach(c => c.postMessage({ type: 'sw_progress', percent: Math.floor((loaded/total)*100) }));
});
controller.enqueue(value);
}
controller.close();
}
});
return new Response(stream, { headers: response.headers });
}
2. Vue 组件逻辑核心 (FileManager.vue)
这里展示核心的下载判定逻辑和上传排重逻辑。
// 下载策略选择器
downloadFile(row) {
const filePath = this.currentPath === '/' ? `/${row.name}` : `${this.currentPath}/${row.name}`;
const url = `${BASE_URL}/download?path=${encodeURIComponent(filePath)}`;
const fileSize = row.byteSize; // 假设后端返回了字节大小
if (fileSize < 50 * 1024 * 1024) {
// <50MB: Web Worker 模式
this.downloadByWebWorker(url, row.name);
} else if (fileSize < 1024 * 1024 * 1024 && this.swRegistered) {
// 50MB~1GB: Service Worker 模式
this.downloadByServiceWorker(url, row.name);
} else {
// >1GB: 浏览器原生模式
this.ordinaryDownload(url, row.name);
}
},
// 上传排重逻辑
async customUploadRequest(options) {
const file = options.file;
// 1. 预检
const check = await axios.get(`${BASE_URL}/checkExists`, { params: { path: this.currentPath, filename: file.name } });
let overwrite = false;
if (check.data.data) {
await this.$confirm('文件已存在,是否覆盖?', '提示').then(() => overwrite = true).catch(() => { throw 'cancel' });
}
// 2. 执行上传
const fd = new FormData();
fd.append('file', file);
fd.append('overwrite', overwrite);
await axios.post(`${BASE_URL}/upload`, fd, {
onUploadProgress: (p) => { this.progressPercent = Math.round((p.loaded * 100) / p.total); }
});
}
三、 后端实现步骤与代码
1. 文件存储架构
后端基于 Spring Boot,关键点在于正确设置 HTTP 响应头,以配合前端的流式读取。
2. 核心 Service 实现 (FileService.java)
@Service
public class FileService {
@Value("${file.upload-path}")
private String rootPath;
private final String RECYCLE_BIN = ".recycle_bin";
// 逻辑删除:移动到回收站
public void deleteFile(String relativePath, boolean permanent) throws IOException {
Path source = Paths.get(rootPath, relativePath);
if (permanent) {
FileSystemUtils.deleteRecursively(source);
} else {
// 生成回收站文件名:时间戳_原路径Base64_文件名
String encodedPath = Base64.getEncoder().encodeToString(relativePath.getBytes());
String recycleName = System.currentTimeMillis() + "_" + encodedPath + "_" + source.getFileName();
Path dest = Paths.get(rootPath, RECYCLE_BIN, recycleName);
Files.move(source, dest, StandardCopyOption.REPLACE_EXISTING);
}
}
// 下载 Resource 加载
public Resource loadFileAsResource(String relativePath) throws Exception {
Path filePath = Paths.get(rootPath, relativePath).normalize();
Resource resource = new UrlResource(filePath.toUri());
if (resource.exists()) return resource;
throw new FileNotFoundException("文件不存在");
}
}
3. 控制器流式响应 (FileController.java)
@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(@RequestParam String path, HttpServletRequest request) {
try {
Resource resource = fileService.loadFileAsResource(path);
long length = resource.getFile().length();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(length)) // 必须返回长度,否则前端没进度条
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
四、 总结与建议
1. 方案优势
-
不卡顿:多线程 Worker 下载确保用户在下载 GB 级大文件时,页面依然能顺滑点击。
-
内存友好:Service Worker 方案解决了大文件直接读入内存导致浏览器崩溃的陈年旧疾。
-
容错性:回收站机制为误删提供了最后一层保险。
2. 部署注意事项
-
HTTPS 是必须的:由于 Service Worker 安全限制,除了 localhost,必须在 HTTPS 环境下才能生效。
-
跨域头 (CORS):如果前后端分离,后端必须暴露 Content-Length 和 Content-Disposition 响应头,否则前端无法获取文件大小和正确的文件名。
-
Nginx 配置:若通过 Nginx 转发,需确保 proxy_max_temp_file_size 设置足够大,或关闭代理缓冲以支持流式传输。
这套方案不仅是一个简单的文件管理器,它展示了现代 Web API(Workers, Streams, Service Workers)在处理密集型 I/O 任务时的巨大潜力。