ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue 3大文件秒传实战:文件指纹与分片上传详解

Vue 3大文件秒传实战:文件指纹与分片上传详解 1. 项目概述1.1 核心需求解析大文件秒传这个词做过文件上传的人都绕不过去。最早听到这类需求通常是产品经理或者老板甩过来一句你看某某网盘传大文件一下子就传完了咱们也得做到。但实际上所谓秒传并不是网络快而是压根没传文件本身——只在服务器上做了一次文件指纹比对发现文件已经存在就直接把新记录挂到那个已有文件上前端跳过了上传等待看起来就像秒传。这篇文章要做的就是用 Vue 3 HTML5 的 File API 组合从零写一个带文件指纹计算、秒传校验、分片上传、断点续传的 DEMO后端用 Node.js 简化模拟不依赖任何付费服务。整个项目跑起来之后你把这个文件传给 App 端或者别的 Web 项目核心逻辑都是通用的。这个 DEMO 解决的核心问题有三个用户上传 1GB 以上文件时HTTP 请求直接传整个文件容易超时、容易断而且浏览器内存扛不住。上传中断后从头再来浪费带宽和时间体验极差。公司内网里多人传同一个安装包、镜像文件服务器上明明已经有了还每次都重新传一遍浪费磁盘也浪费网络。这套东西适合谁来参考后端同学想给前端提供上传接口时先了解前端行为前端同学想搞明白秒传原理、分片策略以及对前端为什么能算出一个很大的文件哈希值感到好奇的初学者。我按实际落地的思路走代码会逐步拆清楚。1.2 技术选型思路选 HTML5 而不是 Flash 或客户端插件核心原因是 HTML5 把文件操作能力直接下沉到了浏览器标准层。你不需要安装任何东西一个 input[typefile] 就能拿到 File 对象File 对象有 size、type 这些属性有 slice() 方法做文件切片配合 FileReader 可以读文件内容配合 Web Worker 可以做耗时计算不卡页面。Vue 这边主要是做状态管理、UI 渲染和请求编排。用 Vue 3 的 Composition API 处理上传任务的生命周期会比 Vue 2 舒服很多——watch、computed、reactive 这套东西处理多任务进度是天然契合的。模板编译也快调试方便。我曾经见过一个项目用原生 JavaScript 手写上传队列状态分散在各个全局变量里后期加需求改得崩溃。用 Vue 来管理上传状态属于用对了工具它不是为上传而生但响应式状态管理确实让多文件上传、进度更新、任务取消这类逻辑变得直观。2. 秒传设计背后的原理拆解2.1 什么才算真正的秒传现在很多项目号称支持秒传实现方式五花八门但真正经得起推敲的做法是基于文件摘要比对。流程很简单前端选中文件。前端计算整个文件的 MD5 或 SHA-1 摘要哈希值。将摘要 文件大小发送给后端。后端在数据库 / 文件系统索引里查询是否存在相同摘要 相同大小的文件。如果存在则返回文件已存在前端直接显示上传完成跳过后面的上传过程。如果不存在前端再走分片上传流程。这里有两个必须一起比的维度摘要和大小。只比摘要理论上也够但哈希碰撞的可能性虽然低叠加上大小比对可以把误判概率压到几乎为零。同时这种做法也方便后端做快速筛掉明显不同的文件减轻摘要检索压力。有人会问MD5 不是已经不安全了吗在秒传场景里MD5 不是做加密而是做内容指纹攻击者不会刻意构造一个 MD5 碰撞来上传恶意文件因为文件内容服务器会原样保存不涉及防篡改场景。当然如果项目安全意识极强可以升级到 SHA-256代价是性能略低但对大文件计算来说差别不大。2.2 秒传为什么离不开分片只有秒传还不够。如果文件确实不存在你是做不到秒传的得要老老实实上传。这时就要考虑怎么把大文件安全高效地传完。直接一次性 POST 整个文件在几百 MB 以内勉强能玩但上了 GB 级别就全是问题浏览器内存被大 File 对象撑爆其实 File 对象有点特殊它是引用磁盘/内存中的数据不一定会一次性把整个文件读进 JS 堆但你如果构造 FormData 塞一个超大文件网络层就要处理整段数据。网络闪断导致请求失败一切从头再来。后端接口接收超大 body超时设置、内存限制都要调Nginx 默认client_max_body_size才 1MB生产环境忘了改就直接 413。进度条只有没开始和完成两个状态体验极度糟糕。分片上传把大文件切成比如 5MB、10MB 的小块逐块上传这样每块请求都在一个合理的网络范围内失败后只需要重传那一块。而且多个分片可以并发上传整个上传速度往往比单线程传大文件更快。这是秒传和分片在我这个 DEMO 中的关系秒传负责不传分片负责怎么传先秒传后分片。2.3 前端计算大文件指纹的难点计算整个文件 MD5 需要把文件内容从头到尾读取一遍。FileReader 一次读一个切片然后通过 SparkMD5 库的 append 方法逐步累积哈希计算状态最后 end() 得到最终摘要。这里有个性能问题4GB 的文件如果在前端主线程里切分成几千个分片循环读取计算浏览器会卡顿到像死机一样用户会以为网页崩了。我之前第一次实现时就是这么干的用户反馈一选文件就白屏。后来改成 Web Worker 之后再大的文件计算期间 UI 依然能操作进度条照常动画体验好了几个量级。流程变成主线程把 File 对象交给 Worker。Worker 内部通过 File 对象的 slice 读取分片逐片交给 SparkMD5。Worker 每计算完一个分片通过 postMessage 通知主线程更新进度。全部完成后 postMessage 返回最终 MD5。注意一点File 对象是可以直接传给 Web Worker 的不需要通过结构化的拷贝把整个文件数据拿过来浏览器底层用引用传递处理这个所以不用担心内存翻倍。3. 前端工程落地Vue 3 HTML5 实现一个可运行 DEMO3.1 环境准备与项目初始化这一步没什么高深的就按 Vue 官方推荐的 Vite 来。我假设你已经装了 Node.js 18。npm create vuelatest模板选的时候只需要勾选JSX和Vue Router就好其他 Test、Lint 看自己习惯。项目初始化的目的是快速跑起来一个最小 Vue 3 工程接下来在 App.vue 和新建的 Worker 文件里写核心代码。如果不想用脚手架也可以用 CDN 方式在单 HTML 里引 Vue 3但那样 Worker 文件和模块组织会比较别扭DEMO 还是用工程化方式更贴近真实项目。3.2 核心代码结构我的 DEMO 文件结构如下├── index.html ├── package.json ├── src │ ├── App.vue │ ├── components │ │ └── UploadPanel.vue │ ├── utils │ │ ├── fileHash.js # 负责与 Worker 通信 │ │ ├── fileHash.worker.js # 真正计算 MD5 的地方 │ │ └── uploader.js # 分片上传核心逻辑 │ └── api │ └── uploadApi.js # 后端接口封装每个文件的职责要理清楚别把 Worker 逻辑和 Vue 组件混在一起后期改起来会很头疼。我见过不少初学者把 Worker 的代码写成字符串用 Blob 动态创建虽然能跑但调试很痛苦还是物理文件清晰。3.3 前端代码全解UploadPanel.vue组件要做的事文件选择显示文件信息调起秒传校验触发分片上传展示进度先看组件的核心逻辑部分template div classupload-panel input typefile changeonFileChange / div v-iffile p文件名{{ file.name }}/p p文件大小{{ formatSize(file.size) }}/p div v-ifhashProgress 0 正在计算文件指纹{{ hashProgress }}% /div div v-else-ifuploadProgress 0 上传进度{{ uploadProgress }}% /div button clickstartUpload :disableduploading开始上传/button /div /div /template script setup import { ref } from vue; import { getFileHash } from ../utils/fileHash; import { checkExist, uploadChunk, mergeChunks } from ../api/uploadApi; const file ref(null); const hashProgress ref(-1); // -1 表示未开始 const uploadProgress ref(-1); const uploading ref(false); const fileMd5 ref(); const chunkSize 5 * 1024 * 1024; // 每片 5MB function onFileChange(e) { const selected e.target.files[0]; if (!selected) return; file.value selected; hashProgress.value -1; uploadProgress.value -1; } async function startUpload() { if (!file.value) return; uploading.value true; // 第一步计算文件指纹 fileMd5.value await getFileHash(file.value, (p) { hashProgress.value p; }); hashProgress.value 100; // 第二步秒传校验 const exist await checkExist(fileMd5.value, file.value.size); if (exist) { uploadProgress.value 100; uploading.value false; alert(文件已在服务器上秒传成功); return; } // 第三步分片上传 const chunks Math.ceil(file.value.size / chunkSize); let uploaded 0; for (let i 0; i chunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.value.size); const blob file.value.slice(start, end); await uploadChunk({ fileMd5: fileMd5.value, chunkIndex: i, chunk: blob, }); uploaded; uploadProgress.value Math.round((uploaded / chunks) * 100); } // 第四步通知后端合并分片 await mergeChunks({ fileMd5: fileMd5.value, fileName: file.value.name }); uploading.value false; alert(上传完成); } function formatSize(size) { if (size 1024 * 1024) return (size / 1024).toFixed(2) KB; return (size / (1024 * 1024)).toFixed(2) MB; } /script这段代码把流程串起来了但说实话第一次写别按这个直接上由于await uploadChunk是串行的效率偏低。我在后面 3.5 小节会给出一个并发控制版本的思路DEMO 先保证逻辑正确再谈性能。fileHash.worker.js的核心代码import SparkMD5 from spark-md5; self.onmessage function (e) { const { file, chunkSize } e.data; const spark new SparkMD5.ArrayBuffer(); const reader new FileReaderSync(); const chunks Math.ceil(file.size / chunkSize); let currentChunk 0; try { while (currentChunk chunks) { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); const buffer reader.readAsArrayBuffer(file.slice(start, end)); spark.append(buffer); currentChunk; self.postMessage({ type: progress, percent: Math.round((currentChunk / chunks) * 100), }); } self.postMessage({ type: done, md5: spark.end(), }); } catch (err) { self.postMessage({ type: error, message: err.message }); } };注意这里用了FileReaderSync在 Worker 里它是可以同步读取文件的天然适合这种循环切片的逻辑代码比异步回调清晰得多。浏览器兼容性方面现代浏览器对 Worker 里的 FileReaderSync 支持没问题。fileHash.js负责创建 Worker 并做 Promise 封装export function getFileHash(file, onProgress) { return new Promise((resolve, reject) { const worker new Worker(new URL(./fileHash.worker.js, import.meta.url), { type: module, }); worker.postMessage({ file, chunkSize: 5 * 1024 * 1024 }); worker.onmessage (e) { const { type, percent, md5, message } e.data; if (type progress) { onProgress onProgress(percent); } else if (type done) { worker.terminate(); resolve(md5); } else if (type error) { worker.terminate(); reject(new Error(message)); } }; }); }这里用import.meta.url的方式引入 Worker 是 Vite 的标准做法打包时浏览器会自己处理 Worker 的资源路径问题。uploadApi.js是后端接口封装。注意分片上传的请求头要加Content-Type: application/octet-stream如果传 FormData 也可以但二进制流更干净import axios from axios; const BASE_URL http://localhost:3000; export function checkExist(fileMd5, fileSize) { return axios .post(${BASE_URL}/exists, { fileMd5, fileSize }) .then((res) res.data.data.exists); } export function uploadChunk({ fileMd5, chunkIndex, chunk }) { const formData new FormData(); formData.append(chunk, chunk); formData.append(fileMd5, fileMd5); formData.append(chunkIndex, chunkIndex); return axios.post(${BASE_URL}/upload, formData, { headers: { Content-Type: multipart/form-data }, }); } export function mergeChunks({ fileMd5, fileName }) { return axios .post(${BASE_URL}/merge, { fileMd5, fileName }) .then((res) res.data); }3.4 为什么要把 chunkSize 定为 5MBchunkSize 的选择是有讲究的不是随便拍的。太小的分片比如 256KB分片数量暴增请求次数太多HTTP 握手开销压过传输收益后端也要处理海量元数据。太大的分片比如 100MB单请求耗时太长失败重试代价大而且浏览器内存占用高。5MB 是业界比较通用的值7Zip 官方在分卷压缩时也常用 5MB 级别的块大小。对于普通的企业内网带宽5MB 大约 1-3 秒传完一片用户感知进度会平滑更新。我实际测过 10MB 分片在千兆内网没问题但公网上就反而容易出现单片超时。200MB 视频有 40 片每片重试一次也就是多 40 个请求可接受。3.5 并发上传与断点续传升级DEMO 里的串行上传在文件特别大时耗时较长但可以简单改造为并发控制。思路是维护一个任务池限定同时进行 3-5 个上传请求async function uploadChunksWithConcurrency(file, fileMd5, chunks, limit 3) { const tasks Array.from({ length: chunks }, (_, i) i); const pool new Set(); let uploadedCount 0; for (const index of tasks) { if (pool.size limit) { await Promise.race(pool); } const p uploadSingleChunk(file, fileMd5, index).then(() { pool.delete(p); uploadedCount; uploadProgress.value Math.round((uploadedCount / chunks) * 100); }); pool.add(p); } await Promise.all([...pool]); }断点续传需要后端支持查询哪些分片已经上传过前端在开始上传前请求一下已上传分片列表只传没传过的。这个逻辑在 DEMO 里我留作扩展点后面 4.2 会展开讲实现方式。4. 后端配合用 Node.js 实现最小可行服务前端代码再花哨后端不支持也是白搭。这个 DEMO 的后端我用 Express 写了最简版本存储用本地磁盘 一个 JSON 文件做元数据记录。4.1 Express 后端接口实现const express require(express); const multer require(multer); const fs require(fs); const path require(path); const app express(); app.use(express.json()); const UPLOAD_DIR path.join(__dirname, uploads); const META_FILE path.join(__dirname, meta.json); const storage multer.diskStorage({ destination: (req, file, cb) { const chunkDir path.join(UPLOAD_DIR, req.body.fileMd5); if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }); } cb(null, chunkDir); }, filename: (req, file, cb) { cb(null, ${req.body.chunkIndex}); }, }); const upload multer({ storage }); function readMeta() { if (!fs.existsSync(META_FILE)) return {}; return JSON.parse(fs.readFileSync(META_FILE, utf-8)); } function writeMeta(meta) { fs.writeFileSync(META_FILE, JSON.stringify(meta, null, 2)); } app.post(/exists, (req, res) { const { fileMd5, fileSize } req.body; const meta readMeta(); const record meta[fileMd5]; res.json({ data: { exists: !!(record record.size fileSize), }, }); }); app.post(/upload, upload.single(chunk), (req, res) { res.json({ code: 0, message: chunk received }); }); app.post(/merge, (req, res) { const { fileMd5, fileName } req.body; const chunkDir path.join(UPLOAD_DIR, fileMd5); const chunks fs.readdirSync(chunkDir); chunks.sort((a, b) a - b); const dest path.join(UPLOAD_DIR, fileName); const fd fs.openSync(dest, w); for (const chunk of chunks) { const chunkPath path.join(chunkDir, chunk); const data fs.readFileSync(chunkPath); fs.writeSync(fd, data); } fs.closeSync(fd); const meta readMeta(); meta[fileMd5] { size: fs.statSync(dest).size }; writeMeta(meta); res.json({ code: 0, message: merge done }); }); app.listen(3000, () { console.log(server running at http://localhost:3000); });这个后端的核心思路/exists做秒传判断看 meta.json 里是否有相同文件指纹记录。/upload把分片保存到以 fileMd5 为名的目录下文件名就是分片序号。/merge把所有分片按序号拼回原文件更新元数据。只跑 DEMO 的话完全够用。真实项目里需要把 meta 换成 Redis MySQL磁盘存储换成对象存储OSS / MinIO分片合并也可以放到异步队列里做不能在请求里同步合成几百 MB 的文件。4.2 已上传分片查询与补传机制断点续传的核心是前端要知道哪些分片上传过了。后端加一个接口app.get(/chunks/:fileMd5, (req, res) { const chunkDir path.join(UPLOAD_DIR, req.params.fileMd5); if (!fs.existsSync(chunkDir)) { res.json({ data: { uploadedChunks: [] } }); return; } const chunks fs.readdirSync(chunkDir).map(Number); res.json({ data: { uploadedChunks: chunks } }); });前端拿到uploadedChunks后在分片上传循环里直接跳过已存在的序号。进度计算也要基于当前累计已上传数来做不能从 0 开始。5. 常见问题与排查技巧实录5.1 计算哈希时浏览器卡死现象选了一个 2GB 的文件页面直接白屏鼠标都动不了。原因FileReader 逐片读取文件是在主线程执行的循环几万次切片操作会持续占用渲染进程。解决把哈希计算扔进 Web Worker。如果已经用了 Worker 还卡检查是不是你把fileHash.js里的 Worker 实例重复创建了或者你的 Worker 里又 require 了大模块。我之前见过一个同事写了 Worker但每次分片都 new 一个 SparkMD5 实例内存炸了也是卡。5.2 秒传校验通过但文件迟迟不出现在目标路径现象前端拿到了秒传成功提示但服务器上找不到文件。原因元数据记录在了 meta.json但文件本身可能在第一次上传之后被清理了或者是 merge 接口只更新了 meta没有真正把分片拼装成文件。解决/exists 判断时不仅要查 meta 记录还要校验目标文件是否真的存在于磁盘const destPath path.join(UPLOAD_DIR, fileName); const fileExists fs.existsSync(destPath); res.json({ data: { exists: !!record record.size fileSize fileExists } });注意 fileName 不要直接用用户传来的原始名有路径穿越风险应当用哈希重命名存储。5.3 分片合并后文件内容损坏现象上传完成后打开文件提示格式损坏或者 MD5 校验不通过。原因大概率是分片合并顺序有问题。Node 的fs.readdirSync返回顺序不一定是文件名的数字顺序比如字符串排序会把 10 排在 2 前面导致合并错乱。解决合并前必须先按数字排序const chunks fs.readdirSync(chunkDir).sort((a, b) parseInt(a) - parseInt(b));这是一个非常经典的坑。任何时候合并分片都要显式排序别依赖文件系统返回顺序。5.4 超大文件上传时后端报错 413 Payload Too Large现象本地开发跑得好好的放到 Nginx 后面就失败。原因Nginx 默认client_max_body_size是 1MB分片虽然只有 5MB但也会被拦下来。解决在 Nginx 配置里显式声明client_max_body_size 10m;如果你后续调大分片这个值跟着调大就行。如果服务器上有网关层也需要检查网关的请求体大小限制。5.5 前后端联调时 CORS 报错本地用 Vite5173去请求后端3000端口浏览器会拦截跨域响应。在后端加上const cors require(cors); app.use(cors());DEMO 阶段直接放开所有来源没毛病生产环境再按域名白名单收紧。6. 实操心得与扩展建议6.1 从 DEMO 到生产级上传组件你还需要补什么这个 DEMO 跑通之后距离生产环境还有一些距离我列一下优先级比较高的增强点上传任务持久化刷新页面之后已传分片还能查到续传不丢。接入文件流加密敏感场景下分片内容要做 AES 加密再上传服务端解密落盘。后端摘要二次校验分片合并完成后后端重新计算 MD5和前端传过来的 fileMd5 比对能发现传输过程中被篡改或损坏的问题。进度展示优化结合请求耗时、网络吞吐预估剩余时间而不是只显示百分比。拖拽上传与文件夹上传HTML5 的 DataTransfer 接口可以轻松实现视觉体验提升很大。并发阈值自适应根据网络环境动态调整并发数弱网时降低并发避免大量重试。6.2 一个容易踩的坑文件重复上传时的重复合并你可能会遇到这种情况用户第一次上传时中断了第二次点开始上传秒传校验没通过服务端没记录完整文件走分片上传但是服务端已经残留了一部分分片。如果后端合并接口不检查已有分片是否存在可能重复执行 merge把两个不完整的文件拼在一起。我的做法是 merge 之前先检查目标文件是否已经存在如果存在且 size 等于记录值直接返回成功。如果 size 不一致则先删除残留分片目录再从零开始传保证分片干净。6.3 最后分享一个小技巧很多大文件上传场景会碰到用户等不到计算哈希完成就关页面的问题。我的经验是在哈希计算阶段就开始做用户引导比如显示正在分析文件请勿关闭页面同时把哈希计算的粒度调得细一些比如 2MB 一片进度条能更快动起来用户就不会以为卡死了。还有一个细节对体验影响很大秒传成功后建议给一个动画反馈比如打勾或者弹一层遮罩玩家心理上会有这么快就完成了的惊喜感。续传、并发控制的完整代码其实在这个 DEMO 基础上加不了多少但生产工程里每一步都要考虑到异常处理和资源释放这块值得你花时间好好打磨。
返回列表