做系统时被大文件上传折腾惨了——单个视频好几个GB用最普通的表单上传传一半断网就从头再来。后来我改用Vue3的Composition API做了一整套大文件上传方案分片、断点续传、秒传、并发控制全部搞定整个上传流程才算真正能用。这篇文章把方案选型、核心代码和踩过的坑完整梳理一遍适合已经会用Vue3、但还没系统搞过上传组件的开发者也适合准备做网盘、课程平台这类功能的团队参考。1. 大文件上传的核心痛点与方案选型1.1 直接上传为什么会挂先说最直观的问题直接拿一个巨大的文件走普通请求失败是必然的只是时间早晚。我最初对接某内部工具时客户端把几个GB的视频课程包直接POST给后端上传一分钟内基本都会出问题。一个是网络中断用户用公司Wi-Fi或者手机热点信号稍微波动连接就断了而普通上传没有任何续传能力断一次就从头再来一个是网关超时很多服务前端的代理层默认超时时间是60秒左右2GB的文件在常见上行带宽下要传十几分钟甚至更久超时后连接被服务端掐断前端拿到的是一个莫名其妙的网络错误还有一个是内存和服务端压力把整个文件读进内存再交给请求体超大文件会直接把浏览器拖到崩溃服务端接收这么大的单体请求也容易出现IO阻塞整站响应变慢。表象是“传不上去”本质是我们把一个不可控的长耗时长任务交给了一次性请求去扛。而分片上传的核心思路就是把这个任务切割成大量独立的小任务每个小任务体量可控、失败成本低、还能并发执行。1.2 分片、断点续传、秒传是怎么组合起来的这三个能力经常被放在一起说但它们的职责完全不同。分片解决的是“单次传输体量过大”的问题把一个2GB文件切成几百个5MB的块每个块的传输时间控制在几秒到十几秒网络抖动时最多损失一个块而不是整个半天的心血。断点续传解决的是“失败后重复成本过高”的问题前端和后端都记录哪些分片已经传成功重新上传时只传缺失的部分不会从头再来。秒传解决的是“同一个文件被反复上传”的资源浪费文件内容一样时用文件指纹也就是内容hash判断服务端直接复用已有文件前端瞬间到100%。打个比方搬家时找一辆大卡车把所有家具装满路上遇到堵车整辆车卡几个小时动弹不得你只能干等。如果把家当分别装进几百个编号的箱子里每箱独立发货丢了一箱只需要补发那一箱其他箱子正常到家。秒传更像是如果目的地已经有一套完全一样的箱子检查内容编号后直接搬进去连车都不用出。1.3 为什么用Vue3的组合式API来封装上传这个场景的状态特别多等待、计算hash、校验中、上传中、暂停、重试、合并中、完成、失败各有各的进度和提示。在Vue3里用组合式API这些状态可以全部收敛到一个useUploader函数中组件拿到的就是响应式的状态和方法页面上的进度条、按钮态、提示语全部自动跟着响应式数据走。放在Vue2时代这类逻辑多半散在组件的data、methods和mixin里状态一多就很难维护组合式API写起来更像“把整个上传引擎封装成一个黑盒”组件侧只关心调用start、pause、cancel顺手解构出progress显示进度。另外两个实用原因一是组件销毁时执行onBeforeUnmount可以自动取消所有未完成的上传请求避免页面都关了后端还在收分片二是上传工具函数可以独立单测不依赖任何页面组件我甚至在一个纯函数项目中也能复用同一套逻辑。生态上axios等网络库在Vue3里无缝使用整个方案落地很平滑。2. 前端分片与文件指纹两个关键基础能力2.1 分片大小怎么定分片大小是个典型的需要平衡的参数。我见过有人为了显得“粒度细”用了256KB一个分片结果1GB文件切成了4096片每一片都要走一次完整的网络握手光是请求往返的开销就让速度直线下降服务端频繁创建和写入小文件性能和稳定性都遭殃。反过来分片切成50MB甚至100MB虽然请求数量少了但弱网下单块传输依然可能超时分片的意义就打折扣了。我一般固定用5MB。给个直观的数据2GB文件切5MB一个总共有约410个分片并发开4个的话大约100轮请求这个数量级在日志排查时可读性也不错。实际上分片大小落到2MB到5MB之间都可以具体看业务弱网场景偏多建议用2MB能进一步压缩单块失败成本带宽充足、内网环境下用5MB甚至10MB都没问题。最后一个分片往往是残缺的计算时用Math.min(start CHUNK_SIZE, file.size)保证末尾不越界。function buildChunks(file, chunkSize) { const chunks []; let start 0; let index 0; while (start file.size) { const end Math.min(start chunkSize, file.size); chunks.push({ index, start, end }); start end; index; } return chunks; }这个buildChunks是整个上传流程的地基后面的hash计算、并发上传、进度计算全部基于这份分片元数据。2.2 文件hash秒传和续传的地基文件hash就是文件内容的指纹。两个文件名不同但内容完全一致的文件算出来的hash一致只要有一个字节不同hash就完全不同。后端拿到这个hash才知道“这个文件是不是已经传过”、“哪些分片之前已经成功接收”。计算整文件hash最常用的库是spark-md5。关键是不能把整个文件一次性读进内存再算2GB文件直接读会卡到浏览器崩溃。正确做法是配合FileReader按分片增量读取每读一片就把ArrayBuffer追加到spark实例里读完后调用spark.end()得到结果。async function computeFileHash(file, chunkSize) { const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let offset 0; while (offset file.size) { const blob file.slice(offset, offset chunkSize); const buffer await new Promise((resolve, reject) { reader.onload () resolve(reader.result); reader.onerror () reject(reader.error); reader.readAsArrayBuffer(blob); }); spark.append(buffer); offset blob.size; hashProgress.value offset / file.size; } return spark.end(); }这个实现有个性能隐患在主线程上逐片读取并计算一个2GB视频文件可能要花几十秒甚至更久用户看到页面卡住会直接以为是死机。我踩过这个坑之后改成了Web Worker方案把文件读取和spark计算全部放到worker线程里主线程只接收进度消息。// hashWorker.js importScripts(https://cdn.jsdelivr.net/npm/spark-md53.0.2/spark-md5.min.js); self.onmessage (event) { const { file, chunkSize } event.data; const spark new self.SparkMD5.ArrayBuffer(); let offset 0; const readNext () { if (offset file.size) { self.postMessage({ type: done, hash: spark.end() }); return; } const blob file.slice(offset, offset chunkSize); const reader new FileReader(); reader.onload () { spark.append(reader.result); offset blob.size; self.postMessage({ type: progress, ratio: offset / file.size }); readNext(); }; reader.readAsArrayBuffer(blob); }; readNext(); };如果不想引入Worker另一个折中方案是抽样hash只取第一个分片、文件中间位置的几个分片、最后一个分片来算hash计算时间在毫秒级但碰撞概率提高不适合用做完整性校验。我的建议是需要做可靠校验的场景坚持全量hash加Worker只做秒传判断、追求响应速度的场景才考虑抽样hash而且抽样结果只用于“疑似重复”最终以服务端完整文件hash比对为准。2.3 计算hash时的用户交互这里容易出现体验断层用户明明选完文件了上传按钮却没反应或者状态还在“准备中”但没有任何提示。我在做优化时就把流程状态定义为ready、hashing、checking、uploading、paused、merging、done、error每一步都对应界面上的明确提示。选完文件立刻进入ready“开始上传”按钮可用点击开始后马上切到hashing按钮禁用显示“正在计算文件指纹”加百分比进度。这样用户知道系统在做事情而不是干等着。3. 核心流程实现上传、续传、并发与进度计算3.1 上传前三步先校验再上传完整流程其实分三段算完hash之后先请求一个校验接口再决定是秒传、续传还是全新上传。校验接口的返回通常包含两个关键信息这个文件整体是否已存在、哪些分片已经上传成功。async function checkUploadStatus() { const res await http.get(/upload/check, { params: { fileHash: fileHash.value }, }); const { exists, uploaded } res.data; if (exists) { uploadedSet.value new Set(chunkMeta.map((_, index) index)); return { exists: true }; } uploadedSet.value new Set(uploaded || []); return { exists: false }; }如果校验接口返回exists为true说明服务端已经有这个hash对应的完整文件这时候直接跳到100%属于秒传成功。如果返回一个已上传分片索引列表比如[0, 2, 5]就说明用户之前传过一部分网断了或者刷新页面了服务端保存了这些成功接收的分片前端只需要补齐缺失的分片。这个逻辑是断点续传成立的前提千万不要自己在前端本地记录一下就完事万一用户清了缓存或者换台设备完全没有意义。3.2 并发控制别把几百个请求一次打出去切完分片之后最直接的做法是for循环一个接一个传但2GB文件400多个分片串行很慢。反过来全部并发也不可取同域连接数限制、服务端压力、失败重试的成本都会飙升。需要的是并发池同时保持固定数量的请求在传输中比如默认4个。async function mapLimit(list, limit, asyncMapper) { const results []; let index 0; const worker async () { while (index list.length) { const current index; results[current] await asyncMapper(list[current], current); } }; const workers Array.from({ length: Math.min(limit, list.length) }, () worker()); await Promise.all(workers); return results; }这个并发池实现很轻量不依赖额外库。选择4到6个并发的理由是家用宽带场景下4路并行基本能把带宽打满再开更多请求反而因为互相竞争而速度下降浏览器对同一域名也有连接数限制HTTP1.1下一般是6条左右超过的部分排队等待并没有真正的“并行加速”。弱网环境下我习惯把并发降到2到3降低单块失败概率也方便重试逻辑快速收敛。3.3 断点续传的完整细节断点续传不仅需要前端维护“已成功分片”还需要后端持久化已接收分片。前端的核心是一个Set每个分片上传成功后立刻把索引加进去。async function uploadOne(chunk, index) { const formData new FormData(); formData.append(file, new Blob([chunk], { type: file.value.type })); formData.append(fileHash, fileHash.value); formData.append(chunkIndex, index); formData.append(chunkTotal, totalChunks.value); await http.post(/upload/chunk, formData, { signal: abortController?.signal, onUploadProgress: (event) { inFlightLoaded[index] event.loaded; }, }); delete inFlightLoaded[index]; uploadedSet.value.add(index); }这里有个细节真实项目中我是从chunkMeta里取对应的分片Blob避免重复切片造成的数据不一致。每次真正发请求前还会检查一下uploadedSet过滤掉那些已经成功的索引只把待传分片丢进并发池。刷新后恢复上传的准确姿势是先调check接口拿到服务端记录的已上传分片列表再把服务端返回的Set与本地残留记录做合并过滤出真正需要补传的分片列表。过程中如果出现某个分片上次传成功了但响应丢失的情况前端会重传服务端要能幂等处理忽略重复的分片或者直接覆盖存储。3.4 进度计算的正确姿势进度条算是个容易翻车的地方。最直观的错误写法是在axios的onUploadProgress里直接loadedTotal event.loaded然后除以总大小。问题是并发上传时好几个请求同时回调各自维护的loaded是不同分片的累计起来不是总进度进度条会乱跳甚至超过100%。我的做法是区分“硬性进度”和“软性进度”。硬性进度指已经成功上传完的分片用completedBytes表示这个值只会单调增加软性进度指正在传输中的分片当前已经传了多少用inFlightLoaded这个map维护请求完成时就把对应索引从map里删掉避免和completedBytes重复计算。const completedBytes computed(() { let sum 0; chunkMeta.forEach((chunk, index) { if (uploadedSet.value.has(index)) { sum chunk.end - chunk.start; } }); return sum; }); const progress computed(() { const total file.value?.size || 1; const inflight Object.values(inFlightLoaded).reduce((a, b) a b, 0); const done completedBytes.value inflight; return Math.min(99, Math.round((done / total) * 100)); });注意合并分片时进度条还会在99%的地方停一会儿这一步我通常把提示文案从“上传中”切到“服务端合并中”避免用户以为卡住了。4. 接口约定、秒传与后端合并策略4.1 三个核心接口怎么定义前端的整条链路离不开后端配合。接口设计得越清晰前后端联调越省心。我这边用下来最顺的是三个接口功能方法请求参数返回数据校验文件状态GET /upload/checkfileHash{ exists, uploaded: number[] }上传单个分片POST /upload/chunkmultipart: file, fileHash, chunkIndex, chunkTotal{ ok: true }触发合并POST /upload/mergefileHash, fileName, totalChunks{ status, url? }每个参数都有存在的必要。fileHash是整条上传任务的定位符也是秒传的依据chunkIndex决定了合并时的顺序乱传没问题但合并时必须按它排序chunkTotal用于后端判断分片是否收齐fileName用于合并后生成正确的文件扩展名和下载名。缺一个字段后面排查问题时都会很痛苦。合并接口的建议是返回一个status字段值为merging或done。大文件合并本身可能有耗时比如几个GB的二进制流写入磁盘如果前端死等同步响应中间任何超时都会造成困惑。更稳妥的做法是先POST一个合并任务后端返回merging前端轮询合并结果直到状态变为done并拿到最终文件URL。4.2 合并正确性与完整性校验合并的正确性比想象中更容易出问题。我在内部测试时遇到过文件大小看起来是对的但视频播放到一半花屏。排查后发现后端合并时没有按index排序某个分片写到了错误的位置上。正确的合并流程是拿到所有分片后按index升序排序逐片以二进制追加方式写入目标文件最后校验分片数量是否等于totalChunks、每个分片大小是否匹配预期。合并完成后强烈建议再对完整文件算一次hash与前端上传时记录的fileHash比对。一致才标记为上传完成不一致就删除合并产物并报错让用户重新触发合并或重新补传。这个校验步骤虽然让后端多做了一次文件读取但能极大概率拦截损坏文件尤其当分片来自不同用户、不同网络环境时非常值得。每收到一个分片服务端还应该校验分片大小除最后一个分片外其他分片大小应当等于CHUNK_SIZE否则视为非法分片并拒绝。这个前置校验能防止网络传输损坏的数据被写入磁盘从源头减少合并后文件损坏的比例。4.3 秒传与临时文件的清理秒传在后端的实现很巧妙同一个hash如果已经有完整文件记录就不需要再物理存储一份新文件而是把新任务的记录指向已有文件直接返回上传完成。这对大量重复上传相同资源的场景收益巨大比如多个用户上传同一个软件安装包、同一门课程的视频带宽和磁盘都省下来了。临时分片处理的坑也要提前考虑。用户传了一半取消、传完分片但没触发合并、合并失败后一直没人管都会让临时分片堆积在磁盘上。我采用定时扫描策略删除超过24小时仍未被合并且没有对应完整记录的分片目录。这个任务可以和日常数据库清理放在一起调度避免临时文件把服务器磁盘打爆。5. 完整封装useUploader组合式函数代码拆解5.1 设计思路与对外暴露我封装这个组合式函数时要求组件侧的使用尽量简洁外部传入一个http实例和可调节参数内部管理所有状态返回情况下最多是start、pause、resume、cancel这几个方法。组件只需要监控status和progress回应用户的操作完全不需要关心分片列表、并发池、hash计算这些细节。const { file, progress, status, hashProgress, totalChunks, uploadedChunks, selectFile, start, pause, resume, cancel, } useUploader({ http: httpClient, chunkSize: 5 * 1024 * 1024, concurrency: 4, });选完文件后用户点击“上传”函数内部顺序执行计算hash、校验状态、并发上传、触发合并。任何一步出错都把状态标记为error组件统一渲染错误提示和重试按钮。5.2 完整的useUploader实现把核心能力整合起来大概就是下面这段代码。注释里放了几个容易踩坑的点照着参数调整就能用。import { ref, reactive, computed, onBeforeUnmount } from vue; import SparkMD5 from spark-md5; const DEFAULT_CHUNK_SIZE 5 * 1024 * 1024; const DEFAULT_CONCURRENCY 4; export function useUploader(options {}) { const { http, chunkSize DEFAULT_CHUNK_SIZE, concurrency DEFAULT_CONCURRENCY, onStatusChange, } options; const file ref(null); const fileHash ref(); const totalChunks ref(0); const status ref(idle); const progress ref(0); const hashProgress ref(0); const uploadedChunks ref(new Set()); const errorMessage ref(); const uploadedSet uploadedChunks.value; const inFlightLoaded reactive({}); let chunkMeta []; let abortController null; function setStatus(next) { status.value next; onStatusChange?.(next); } function buildChunks(rawFile) { const list []; let start 0; let index 0; while (start rawFile.size) { const end Math.min(start chunkSize, rawFile.size); list.push({ index, start, end }); start end; index; } return list; } async function computeFileHash() { return new Promise((resolve, reject) { const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let offset 0; const readNext () { if (offset file.value.size) { resolve(spark.end()); return; } const blob file.value.slice(offset, offset chunkSize); reader.onload (event) { spark.append(event.target.result); offset blob.size; hashProgress.value offset / file.value.size; readNext(); }; reader.onerror () reject(reader.error); reader.readAsArrayBuffer(blob); }; readNext(); }); } async function checkUploadStatus() { const res await http.get(/upload/check, { params: { fileHash: fileHash.value }, }); const { exists, uploaded } res.data; if (exists) { chunkMeta.forEach((chunk) uploadedSet.add(chunk.index)); return { exists: true }; } (uploaded || []).forEach((index) uploadedSet.add(index)); return { exists: false }; } async function uploadOne(chunk, index) { if (uploadedSet.has(index)) return; const formData new FormData(); const blob file.value.slice(chunk.start, chunk.end); formData.append(file, blob, file.value.name); formData.append(fileHash, fileHash.value); formData.append(chunkIndex, index); formData.append(chunkTotal, totalChunks.value); inFlightLoaded[index] 0; try { await http.post(/upload/chunk, formData, { signal: abortController?.signal, onUploadProgress: (event) { inFlightLoaded[index] event.loaded; }, }); uploadedSet.add(index); } finally { delete inFlightLoaded[index]; } } async function mapLimit(list, limit, mapper) { const results []; let index 0; const worker async () { while (index list.length) { const current index; results[current] await mapper(list[current], current); } }; const workers Array.from({ length: Math.min(limit, list.length) }, () worker()); await Promise.all(workers); return results; } async function triggerMerge() { const res await http.post(/upload/merge, { fileHash: fileHash.value, fileName: file.value.name, totalChunks: totalChunks.value, }); if (res.data.status done) return; setStatus(merging); await pollMergeResult(); } async function pollMergeResult() { while (status.value merging) { const res await http.get(/upload/merge/status, { params: { fileHash: fileHash.value }, }); if (res.data.status done) return; await new Promise((resolve) setTimeout(resolve, 2000)); } } async function start() { if (!file.value || status.value uploading) return; setStatus(hashing); fileHash.value await computeFileHash(); setStatus(checking); const { exists } await checkUploadStatus(); if (exists) { progress.value 100; setStatus(done); return; } setStatus(uploading); abortController new AbortController(); const pending chunkMeta.filter((chunk) !uploadedSet.has(chunk.index)); await mapLimit(pending, concurrency, (chunk) uploadOne(chunk, chunk.index)); await triggerMerge(); progress.value 100; setStatus(done); } function pause() { if (status.value uploading) { abortController?.abort(); setStatus(paused); } } function resume() { if (status.value paused) { setStatus(ready); start(); } } function cancel() { abortController?.abort(); uploadedSet.clear(); progress.value 0; setStatus(idle); } function selectFile(rawFile) { cancel(); file.value rawFile; chunkMeta buildChunks(rawFile); totalChunks.value chunkMeta.length; setStatus(ready); } const totalBytes computed(() { let sum 0; chunkMeta.forEach((chunk) { if (uploadedSet.has(chunk.index)) sum chunk.end - chunk.start; }); return sum; }); progress computed(() { const total file.value?.size || 1; const inflight Object.values(inFlightLoaded).reduce((sum, value) sum value, 0); const done totalBytes.value inflight; return Math.min(99, Math.round((done / total) * 100)); }); onBeforeUnmount(() { abortController?.abort(); }); return { file, fileHash, totalChunks, status, progress, hashProgress, uploadedChunks, errorMessage, selectFile, start, pause, resume, cancel, }; }这里把progress从普通ref改成computed会覆盖掉初始赋值需要注意。实际项目里如果Node版本和构建工具支持也可以用const progress computed(...)替代原来的定义。整体逻辑跑通后再根据自己前后端习惯调整接口路径和参数名就可以。5.3 组件里的使用方式组件侧就清爽多了。我用最简单的原生input加几个按钮就能跑通全流程。template div input typefile changehandleChange / div v-iffile{{ file.name }} - {{ status }}/div progress :valueprogress max100 / button v-ifstatus ready || status paused || status error clickstart 开始上传 /button button v-ifstatus uploading clickpause暂停/button button v-ifstatus paused clickresume继续/button button v-ifstatus uploading || status paused clickcancel取消/button /div /template script setup import { useUploader } from ./useUploader; import { httpClient } from ./http; const { file, status, progress, selectFile, start, pause, resume, cancel, } useUploader({ http: httpClient, chunkSize: 5 * 1024 * 1024, concurrency: 4, }); function handleChange(event) { const rawFile event.target.files[0]; if (rawFile) selectFile(rawFile); } /script几个按钮的状态完全由status驱动界面上不需要写任何上传逻辑。如果项目里接了UI组件库直接把progress变量绑到进度条组件上即可连样式适配都很省事。6. 实战踩坑清单与排查建议6.1 分片顺序乱导致合并后文件损坏这应该是分片上传最经典的坑。前面说过合并时必须按index排序而且不能用异步并发的方式同时写同一个目标文件否则分片之间互相覆盖。另一个容易被忽略的点是分片在网络上传输时不可控服务端接收到的顺序和发送顺序完全无关。无论前端在做什么并发控制后端合并前都要统一做“按index排序”和“数量校验”。6.2 hash计算卡死页面算速度快但文件太大依然会卡UI。我第一次实现时是在主线程用FileReader边读边算拖入2GB视频后整个页面失去响应用户截图给我说“浏览器卡没了”。改成Web Worker或者抽样hash之后问题就消失了。如果项目条件允许强烈建议优先上Worker方案同时给用户展示hash计算进度避免“假装卡死”的观感。6.3 并发数开太高反而变慢把concurrency从4调到10之后表面上看并行度上去了实际上是排队与竞争。浏览器版本、网络环境、服务端处理能力都会影响最佳并发数。排查思路很简单先用小文件在固定网络下分别测2、4、6、8的耗时和失败率再针对弱网环境把并发下调。上生产后我一般保留4作为默认值代码里做成配置项运营同学在弱网地区可以手动调低。6.4 进度条回跳或者超过100%进度条不准的常见原因在第三章里已经提过这里整理成速查表问题现象常见原因解决办法进度条乱跳onUploadProgress里全局累加loaded改用已上传Set统计加上在飞分片map进度超过100%已上传分片大小重复计算删除inFlightLoaded中已完成索引合并阶段进度卡住没有展示合并状态将status切换为merging展示“合并中”续传后进度倒退未把服务端已有分片计入completedBytes从check接口返回的uploadedList初始化Set排查进度问题不要盯着进度数字本身先看status流转是否正常再看completedBytes和inFlightLoaded两个数据是否单调或受控。6.5 取消上传时请求还在发前端组件销毁后如果不用AbortController把在飞请求全部取消浏览器里的异步任务仍然会继续执行后端也会继续接收分片。我在useUploader里统一维护了一个AbortController每次开始上传时new一个暂停、取消、组件卸载时调用abort()。注意axios支持signal参数用这个机制能干净地中断请求。另一个取消相关的点是用户点取消后服务端已接收的分片要不要删。我的建议是对已经接收的合法分片保留这样用户重新上传时还能通过check接口复用只有明确放弃上传任务时才标记为可清理。6.6 多端兼容性注意事项老生常谈的File.slice兼容问题现代浏览器基本都支持File.prototype.slice但部分老旧WebView和早期Safari可能有前缀或行为差异。真实项目里兜底写法仍然值得保留const fileSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;移动端尤其要小心并发数。iOS上同域连接数限制比桌面浏览器更严格我一般在移动端把并发降到2到3效果反而是稳定的。Web Worker在主流WebView里也基本能跑但最好做个能力检测不支持时自动回退主线程计算避免白屏。6.7 用弱网模拟把问题提前逼出来开发时网速快、网络稳很多上传问题根本暴露不出来。我的习惯是在Chrome DevTools的网络面板里设置慢速3G或者自定义一个劣化带宽专门用来测试大文件的弱网表现。分片大小也临时调小到512KB让分片数量暴涨几十倍压力测试并发池和进度计算。很多隐藏问题比如进度重复计算、分片响应乱序、请求排队加剧都是在这种极端情况下浮出来的。这套方案跑了大半年从最初几GB视频传不上去到后来稳定传几十个GB的离线课程包最深的体会是分片上传不是纯前端工程接口约定必须前后端一起提前对齐开发时也不要一上来就堆并发、秒传这些高级特性先把“失败可控”做到位后面的优化都是锦上添花。最后再分享一个小技巧每轮发布前用弱网模拟把整个流程走一遍看着进度条平滑到100%再点合并比看十遍代码都有用。