ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

jQuery老项目集成Vue3大文件上传:秒传、分片与断点续传实践

jQuery老项目集成Vue3大文件上传:秒传、分片与断点续传实践 前两天刚给一个老项目做完文件模块升级说是升级其实就是在一套jQuery写得满满当当的后台管理系统里塞进一个基于Vue3开发的大文件上传组件。需求本身不复杂要支持几个G的大文件同一个文件第二次上传要达到“秒传”效果碰到服务器上已经存在的文件直接跳过网络传输。真正动起手来才发现混合开发的边界问题比预想多得多。这篇文章把完整的实现过程、踩过的坑和一些取舍经验整理出来给同样要在老系统里做新模块的同学做个参考。1. “秒传”的真面目不是传输快而是不用传做这个需求之前团队里有同事第一反应是“提升网速”“优化带宽”“上CDN”。这些方向不能说错但都绕开了问题的本质。秒传在网盘、对象存储这些产品里早就不是新概念它的核心逻辑是如果相同内容的文件已经在服务器上就不需要再传输文件数据只需要让服务器知道你选中的文件和已有文件是同一个完成一个“关联”动作。这个动作只走一次接口请求所以用户看到的结果就是“秒传”。1.1 秒传的完整时序要实现“两个文件是不是同一个”的判断前端需要给文件算一个唯一标识最经典的做法是对文件内容做哈希运算得到一个固定长度的摘要值。内容完全一致的文件哈希值一定一致内容有一丁点变化哈希值就完全变了。实际项目里的秒传时序是这样的用户选择文件前端开始读取文件内容计算整个文件的MD5或SHA-256哈希值。前端把哈希值、文件名称、文件大小提交给后端的“秒传验证”接口。后端拿哈希值和文件大小去自己的存储索引里查。如果找到完全一致的记录说明相同文件已经存在后端直接返回一个文件ID并把当前用户与这个文件建立关联关系。前端收到“已存在”的结果页面直接提示上传成功不传任何文件数据。如果后端返回“不存在”前端才开始分片读取文件、逐片上传全部传完后通知后端合并。这里有两个词值得强调文件ID和关联。秒传并不是把“同名文件”当成同一个文件文件名没有任何判断价值A传的“毕业设计.zip”和B传的“毕业设计.zip”很可能完全不是同一个东西。判断的唯一依据是文件内容哈希。在后端存储设计里文件本体和用户目录分开管理文件本体有一个唯一标识通常就是hash或hashsize用户记录里只保存“我引用了哪个文件ID”。多个用户引用同一个文件ID物理存储只有一份这就是网盘类产品秒传的底层原理。1.2 为什么文件哈希能用来判重哈希算法把任意长度的数据映射成固定长度的摘要。MD5是128位几个G的文件算出来就是32个十六进制字符这就是前端每次要提交的指纹。看到这里肯定有人问MD5不是已经能被构造碰撞了吗严格来说确实存在碰撞攻击但那是针对恶意攻击者刻意构造数据的场景。在秒传这种内容寻址的业务场景里我们面对的是正常用户上传的正常文件MD5再叠加文件大小双重校验碰撞概率已经低到没必要担心。如果项目对完整性有更严格的要求或者文件涉及安全敏感内容可以换成SHA-256代价是前端计算速度更慢。实际项目按场景权衡我这次选了MD5加文件大小的组合。1.3 秒传、分片上传、断点续传三者关系很多人会把秒传和分片上传、断点续传混在一起其实它们是三件事方案适用场景核心操作秒传服务器已有相同内容的文件哈希比对 建立关联分片上传单个文件体积过大超出传输限制把文件切开并发上传断点续传上传中途网络中断或用户手动暂停记录已上传分片下次跳过三者的关系是分片上传是大文件上传的基础设施秒传是分片上传之前的一次“免传”判断断点续传是分片上传中断后的恢复能力。一个好的上传模块会把这三件事全部整合进一套流程先算哈希验证秒传不命中就走分片上传分片上传时考虑中断恢复全部传完后端合并。2. jQuery和Vue3的混合开发底座两种共存姿势老系统不可能因为一个新模块推倒重写但新功能确实需要Vue3的组件化、响应式和状态管理。这是绝大多数混合开发需求出现的根本原因。在一个历史悠久的jQuery后台系统里做Vue3组件有两种主流姿势我这次两种都用了。2.1 为什么会出现“jQuery老工程Vue3新模块”jQuery老项目通常是服务端渲染的页面加各种DOM操作全局到处是$很多逻辑和数据都挂在全局变量上。团队不是不能继续用jQuery写上传模块而是这个上传模块需要承载的功能太多了并发控制、进度计算、失败重试、多文件列表、各种状态联动用jQuery手写这些逻辑代码很快就会变成一团线球。Vue3的价值在于把DOM操作从业务逻辑里剥离出来一个上传列表就是一套状态驱动的视图。于是混合开发成了最务实的路径页面框架、老交互、菜单权限这些继续用jQuery体系新上传组件内部用Vue3组织两者之间通过初始化参数和事件回调通信。2.2 姿势A在老页面里用createApp挂载Vue3组件如果Vue3组件已经通过Vite或Webpack打包成了一个独立模块老页面只需要一个容器节点和一段挂载脚本。div idfileUploader/div script src/lib/vue.global.prod.js/script script src/dist/uploader.umd.js/script script $(function () { window.FileUploader.create(#fileUploader, { checkUrl: /api/file/exists, uploadUrl: /api/upload/chunk, mergeUrl: /api/upload/merge, onSuccess: function (file) { // 老系统的收尾逻辑 $(#fileList).append(li file.name /li); } }); }); /script如果是直接通过CDN引入Vue全局库不经过构建工具挂载方式也类似$(function () { const { createApp, ref } Vue; const app createApp({ setup() { const progress ref(0); const handleUpload () { // 上传逻辑 }; return { progress, handleUpload }; }, template: div input typefile changehandleUpload / div进度{{ progress }}%/div /div }); app.mount(#fileUploader); });Vue3和Vue2最大的区别就在这里Vue2可以new Vue({ el: #xxx })直接挂载Vue3必须用createApp创建应用实例再mount。如果团队之前只写过Vue2第一次切到Vue3挂载方式时很容易卡壳。2.3 姿势B在Vue3工程里复用jQuery遗留代码另一种混合方向是反向的新工程是Vue3但里面有一段抽象不了的老上传控件、老表单或者某个历史插件只有jQuery版本没有原生JS或Vue版本。这时候可以通过npm安装jquery在组件里直接使用import $ from jquery; import { onMounted, onBeforeUnmount } from vue; export default { setup() { let oldWidget null; onMounted(() { oldWidget $(#legacyWidget).legacyPlugin({ onBeforeUpload: () {} }); }); onBeforeUnmount(() { if (oldWidget) { oldWidget.legacyPlugin(destroy); } }); } };关键点是要在onBeforeUnmount里把jQuery插件创建的事件、DOM节点处理干净。jQuery插件不销毁的话组件卸载后DOM事件仍然绑定在已经移除的节点上内存泄露和重复触发问题很快就会来找你。2.4 混合开发的边界红线两种姿势都试过之后我总结出几条铁律同一个容器节点内Vue和jQuery不要同时持有DOM控制权。上传组件内部的进度条、按钮、文件列表全部交给Vue渲染外层的弹窗开关、表单提交这些继续留给jQuery。通信只走初始化参数和回调事件不要在Vue组件内部用$(#xxx).text()去改数据。Vue的虚拟DOM一重渲染jQuery写进DOM的内容直接就被覆盖了。注意Vue3不支持IE11。如果老系统的用户群还有大量IE访问那Vue3这条路先别走要么坚持老方案要么上Vue2的兼容版本。老jQuery项目里的全局变量污染问题要留心。Vue3组件打包时尽量用UMD格式暴露一个独立的全局命名空间不要直接把大量内部状态挂到window上。3. 大文件秒传全链路实现从指纹到合并这一章是核心实现完整走一遍前端的分支逻辑算哈希、验证、分片、并发、合并。代码我做了适当的简化但主体结构可以直接用到项目里。3.1 文件指纹计算SparkMD5增量读取前端算文件哈希首选SparkMD5。这个库支持增量计算不会一次性把整个文件读进内存非常适合大文件场景。import SparkMD5 from spark-md5; function calcFileHash(file) { return new Promise((resolve, reject) { const chunkSize 2 * 1024 * 1024; // 每片2MB const fileReader new FileReader(); const spark new SparkMD5.ArrayBuffer(); let currentChunk 0; const totalChunks Math.ceil(file.size / chunkSize); fileReader.onerror () reject(new Error(读取文件失败)); fileReader.onload (e) { spark.append(e.target.result); currentChunk 1; if (currentChunk totalChunks) { loadNext(); } else { const hash spark.end(); resolve(hash); } }; function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } if (totalChunks 0) { resolve(spark.end()); } else { loadNext(); } }); }这里的读法很关键file.slice(start, end)一次只切2MB读完之后再把ArrayBuffer交给spark累加然后继续读下一片。千万不要写reader.readAsArrayBuffer(file)直接读整个文件几个G的文件会直接把内存撑爆。3.2 秒传验证接口设计与前端逻辑分支拿到哈希后走秒传验证接口。接口设计成“验证进度查询”二合一POST /api/file/exists Content-Type: application/json { hash: f1e2d3c4b5a6..., fileName: project.zip, fileSize: 5368709120 }后端响应{ code: 0, data: { exists: true, fileId: file_123456, uploadId: upload_abc, uploadedChunks: [] } }如果exists为true前端直接进入成功流程如果为false前端把uploadedChunks当断点续传的已上传分片列表用Set存起来分片上传时跳过。async function checkAndUpload(file) { const hash await calcFileHash(file); const resp await request(/api/file/exists, { method: POST, body: JSON.stringify({ hash, fileName: file.name, fileSize: file.size }) }); const data resp.data; if (data.exists) { return { instant: true, fileId: data.fileId }; } return uploadByChunks(file, hash, data.uploadId || , data.uploadedChunks || []); }秒传验证接口返回uploadedChunks的另一个好处是即使网络中断导致部分分片传完了但合并没触发下次进来也能自动续传不需要用户重新选文件。这正是断点续传能够落地的关键。3.3 分片上传的并发控制分片上传的主要参数是分片大小和并发数。我这次用的是2MB分片、3个并发。这个组合在大多数服务端环境下比较稳既不会因为分片太小导致请求数过多也不会因为分片太大在弱网下容易超时。并发控制不依赖第三方库用一个共享的index变量加固定数量的worker循环取任务async function uploadByChunks(file, hash, uploadId, uploadedChunks) { const chunkSize 2 * 1024 * 1024; const totalChunks Math.ceil(file.size / chunkSize); const uploadedSet new Set(uploadedChunks || []); const concurrency 3; let index 0; let successCount 0; const onProgress (done, total) { // 通知外部更新进度条 }; async function worker() { while (index totalChunks) { const current index; index 1; if (uploadedSet.has(current)) { successCount 1; onProgress(successCount, totalChunks); continue; } const start current * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); const formData new FormData(); formData.append(file, blob, file.name); formData.append(hash, hash); formData.append(index, current); formData.append(total, totalChunks); formData.append(fileName, file.name); formData.append(uploadId, uploadId); try { await request(/api/upload/chunk, { method: POST, body: formData }); successCount 1; onProgress(successCount, totalChunks); } catch (err) { // 传入失败的分片序号由上层做重试 throw new Error(分片 current 上传失败: err.message); } } } const workers Array.from({ length: concurrency }, () worker()); await Promise.all(workers); await request(/api/upload/merge, { method: POST, body: JSON.stringify({ hash, fileName: file.name, total: totalChunks }) }); }这里有个细节很多人会踩坑如果用axiosContent-Type不要手动设置为multipart/form-data。axios会识别FormData对象自动生成带boundary的Content-Type。一旦手动写死boundary丢失后端就会报错。3.4 合并通知与前后端契约最后一步是通知后端合并分片。后端的合并逻辑虽然不属于前端范畴但前后端契约理清楚能避免大量撕扯每个分片以hash_index命名单独落盘到临时目录例如tmp_upload/abc123_0.part。合并时检查分片总数是否齐全、每个分片大小是否等于期望大小不包含最后一个。合并完成后对合并后的文件重新计算一次MD5与前端提交的hash比对。不一致就说明某个分片坏了返回明确错误前端可以针对性补传。合并成功后把文件移到正式存储目录以hash为文件ID建立索引再给用户建立关联记录。接口必须幂等。同一个hash、同一个index重复收到分片后到的覆盖先到的同一个merge请求重复触发最多报一次成功不产生重复文件。这样设计之后秒传验证、分片上传、断点续传、合并校验全部共用同一套存储索引逻辑闭环。4. 实战踩坑记录混合环境上传大文件的事故现场任何方案写得再漂亮上了真机都会有问题。这一章把我实际遇到的最典型的几个事故现场复盘一遍每一个都是先描述现象再说排查和解决。4.1 hash计算把页面搞到卡死现象选择一个1.2GB的视频文件页面直接卡住滚动条拖不动点击按钮没反应几十秒后才恢复有时候浏览器直接提示无响应。原因hash计算默认跑在主线程。FileReader每读一片就触发一次load事件spark计算也占用CPU整个渲染线程被阻塞页面自然失去响应。排查过程先用控制台Performance面板录制操作能看到主线程长时间处于黑色忙碌状态基本没有空闲帧。确认是hash计算拖垮了UI线程。解决方案把hash计算扔进Web Worker。Worker里用importScripts引入SparkMD5然后接收主线程传来的File对象算完通过postMessage把hash返回。// hash.worker.js importScripts(/lib/spark-md5.min.js); self.onmessage function (event) { const file event.data; const chunkSize 2 * 1024 * 1024; const spark new SparkMD5.ArrayBuffer(); let offset 0; function readNext() { const reader new FileReader(); const slice file.slice(offset, offset chunkSize); reader.onload function (e) { spark.append(e.target.result); offset chunkSize; if (offset file.size) { readNext(); } else { self.postMessage({ hash: spark.end() }); } }; reader.onerror function () { self.postMessage({ error: read fail }); }; reader.readAsArrayBuffer(slice); } readNext(); };主线程里这样用const worker new Worker(/js/hash.worker.js); worker.postMessage(file); worker.onmessage function (e) { console.log(hash result:, e.data.hash); worker.terminate(); };这里值得多说一句很多人以为Web Worker里拿不到File对象实际上File对象通过结构化克隆传给Worker后依然可以使用FileReader读取。我在Chrome和Firefox里都验证过。这样计算hash几乎不影响页面交互用户还能继续操作其他控件。4.2 超大文件的内存暴涨现象上传8GB的数据库备份文件时选择文件后Chrome标签页内存从800MB一路飙升到7GB最后崩溃。原因代码里有人图省事直接reader.readAsArrayBuffer(file)一次性读取整个文件。8GB文件就会生成一个8GB的ArrayBuffer内存不爆才怪。解决方案强制分片读取每次只读2MB读到的ArrayBuffer交给SparkMD5累加后立即释放引用。关键是不要在循环外保存所有分片的Blob引用随用随丢。// 错误写法不要这样 const slices []; for (let i 0; i totalChunks; i) { slices.push(file.slice(i * chunkSize, (i 1) * chunkSize)); } // 这个数组里存了所有分片的引用load事件里再逐个读内存同样爆炸正确写法就是上一章calcFileHash里的增量读法读一片、append一片、释放一片。确保任意时刻内存里最多保留一个2MB的ArrayBuffer。4.3 并发分片乱序到达导致文件错乱现象分片上传改成3个并发后偶尔出现合并出来的文件损坏解压报错。原因后端一开始图省事把分片按到达顺序直接追加写到一个临时文件。3个并发请求到达顺序是不确定的第5个分片可能先到第3个分片后到文件的二进制顺序就乱了。排查过程合并失败后在服务端临时目录检查分片文件发现除了顺序问题还有两个分片同时写同一个文件导致的内容互相覆盖。解决方案每个分片独立落盘文件名带上hash和index例如abc123_0.part、abc123_1.part合并时按index从小到大读取。这样并发到达顺序不影响正确性也能支持断点续传。另外写分片时加了lock同一个分片重复上传时直接覆盖不产生两个临时文件。4.4 jQuery和Vue双重操作DOM的状态错位现象上传进度条偶尔走到一半突然回退或者明明已经传完页面上按钮还是“上传中”。排查过程看代码发现同事在Vue3组件里用$(#progress).text(50%)这种jQuery方式更新进度条。Vue组件内部其他状态一变触发了重渲染jQuery写进去的文本就被Vue的旧状态覆盖了。反过来Vue的进度数据已经更新了但jQuery管理的DOM不肯刷新状态就错乱了。解决方案划清边界。上传组件内部所有进度相关DOM全部由Vue管jQuery只负责组件外部的弹窗操作和提交后的列表刷新。如果确实需要从Vue内部通知jQuery侧就通过回调事件派发不要直接操作DOM。4.5 弱网下的超时、重试与老浏览器兼容现象弱网环境上传单个2MB分片经常在30秒左右超时整个上传流程中断用户原地抓狂。原因我们统一封装的请求层给所有接口设了15秒超时。小接口没问题但上传分片在弱网下传输时间很容易超过15秒一旦超时axios抛错整个并发队列直接中断。解决方案上传接口单独设置超时时间我设的是120秒。分片上传失败后不要立即重试做指数退避第一次等待1秒重试第二次3秒第三次8秒最多重试3次。第四次还失败才中断整个上传流程并记录已上传分片方便用户刷新后续传。兼容性方面老系统用户群里如果还有旧版SafariFile.slice方法可能不存在或带前缀。现代浏览器都已经不需要处理但稳妥起见可以加一句判断const sliceFn File.prototype.slice || File.prototype.webkitSlice || File.prototype.mozSlice;5. 把“跑通”打磨成“好用”的关键细节功能跑通只是开始真正交付给用户还要解决体验问题。这一章全是一些不太起眼、但直接影响满意度的细节。5.1 两段式进度条的计算方式大文件上传的总时间分成两段hash计算时间和分片上传时间。尤其是几个G的文件全量hash可能就要几秒甚至十几秒。如果进度条一直停在0%用户第一反应是“卡住了刷新重来”。我的做法是两段式进度条第一段hash计算阶段进度从0%走到25%按已读取分片数/总分片数推进。第二段上传阶段进度从25%走到100%按已上传分片数/总分片数推进。这个25%不是拍脑袋定的我测量过一次中等大小文件的耗时比例hash约占20%到30%上传占大头。把hash阶段的终点和上传阶段的起点平滑衔接用户能明显感知到流程在推进心里就不会慌。UI上还可以显示“正在校验文件指纹”“正在上传 12.4%”之类的文字提示进一步降低焦虑。5.2 秒传验证失败后的自动降级秒传验证接口本身也可能失败服务端索引异常、接口超时、用户内网DNS解析问题。如果验证失败就直接报错让用户无法上传完全违背了秒传设计的初衷。我做的处理是验证接口网络层面报错时自动降级为分片上传不阻断用户。验证接口明确返回“文件不存在”时正常走分片上传。只有分片上传真正失败才提示用户重试。这样设计之后秒传对用户是一个体验增强而不是一个可能的新故障点。即便秒传服务挂了老上传通道还能继续用。5.3 断点续传与重试策略断点续传依赖前端的已上传分片集合也依赖后端分片接口的幂等性。具体策略每个分片失败后最多重试3次采用指数退避的等待时间。重试全部失败后中断上传流程保存一个上传记录hash、uploadId、已上传分片列表。用户下次选择同一个文件或刷新页面再进来先调秒传验证接口接口返回uploadedChunks前端直接跳过已完成分片只续传剩余部分。后端必须保证同一个hash_index分片重复提交时不会追加写、不会产生重复文件否则断点续传反而会造成数据错乱。5.4 抽样哈希大文件秒传的提速思路全量MD5对几GB文件的计算成本不可忽略。如果项目对秒传体验要求特别高可以引入“抽样哈希”作为快速入口取文件开头2MB、中间2MB、结尾2MB连同文件总大小算一个指纹。前端提交这个抽样指纹服务端快速筛出若干个候选文件再对命中候选做一次精确校验。精确校验可以是对删掉位置的二进制内容抽查也可以直接让服务端对已有文件再算一次完整MD5进行比对。好处很明显抽样计算量从全量遍历变成固定几个位置耗时从几秒降到几十毫秒秒传体验更好。不足是抽样指纹不能作为最终判据只能用来快速缩小范围所以需要精确校验兜底。5.5 封装独立上传组件给未来迁移留后路这次改造里面我个人最受益的决策是把整个上传能力封装成独立组件对外只暴露配置项校验接口地址、分片接口地址、合并接口地址、进度回调、成功回调。老jQuery页面通过一个容器节点挂载完全不知道组件内部用了什么框架。这样一来团队未来如果把某个老页面整体改造成Vue3这个上传组件可以直接复用不需要重写。切回Vue3工程后它就是一个普通的Vue组件放在jQuery页面里它是一个对外的黑盒模块。混合开发本身就是一种过渡态组件化隔离能让过渡过程从容很多。这次做完最大的感受是混合开发的难点从来不在某个框架的API而在于边界划分。哪些DOM归Vue管、哪些通信走回调、哪些状态放哪一侧这些想不清楚后面全是坑。把边界划清楚、把核心链路想明白jQuery和Vue3一起干活也能配合得不错。
返回列表