Vue3大文件上传实战:分片、断点续传与秒传方案
做系统时被大文件上传折腾惨了——单个视频好几个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%再点合并比看十遍代码都有用。

相关新闻

B站视频Python爬虫下载:从接口解析到FFmpeg音视频合并全攻略

B站视频Python爬虫下载:从接口解析到FFmpeg音视频合并全攻略

简介:面向B站视频下载需求的Python学习者与爬虫入门者,这份资源提供一款轻量级B站视频爬虫脚本。脚本基于视频链接中的bvid标识工作,使用前只需将代码中的video_bvid替换为目标视频的对应值即可直接运行,省去复杂接口签名与鉴权流…

2026/10/13 22:33:05 阅读更多 →
Python序列化与反序列化:JSON和pickle选型与实战

Python序列化与反序列化:JSON和pickle选型与实战

搞了个数据缓存,把Python对象直接塞进文件,再读出来发现是一堆乱码,后来才明白序列化这个环节被我省了。类似的坑我见过不少人踩过,所以想把这部分内容彻底讲透——序列化与反序列化在Python里是绕不开的基础技能,写接…

2026/10/13 22:33:05 阅读更多 →
Go调度器GMP深度解析:从数据结构到排障调优实战

Go调度器GMP深度解析:从数据结构到排障调优实战

1. 从一个"调度现象"讲起:GMP设计到底在解决什么很多写Go的同学对goroutine的理解停留在"轻量级线程"这个层面,知道它启动快、栈可以动态增长,但真要回答"它凭什么轻量""为什么几百万goroutine不会把系统…

2026/10/13 22:33:05 阅读更多 →

最新新闻

免费帝国插件内容流水线搭建:采集、伪原创、发布与推送全流程避坑指南

免费帝国插件内容流水线搭建:采集、伪原创、发布与推送全流程避坑指南

简介:这是一款面向站长与SEO从业者的帝国CMS配套插件,聚焦关键词采集、文章采集、伪原创改写、自动发布与搜索引擎推送等环节,帮助希望提升站点收录与排名、又缺乏持续内容产出精力的用户搭建半自动化的优化流程。资源以zip压缩包形式提供&am…

2026/10/13 23:17:20 阅读更多 →
DETR论文笔记与源码解析:端到端目标检测的Transformer实践

DETR论文笔记与源码解析:端到端目标检测的Transformer实践

1. 从标题拆解这个项目到底在讲什么1.1 标题里的三个关键词:论文笔记、源码、DETR看到“论文笔记源码 DETR:End-to-End Object Detection with Transformers”这个标题,我第一反应是:这是一份典型的“论文精读代码复现”组合型笔记。它面向的…

2026/10/13 23:17:20 阅读更多 →
语音FFT变换详解:分帧、加窗、频谱泄漏与Python实现

语音FFT变换详解:分帧、加窗、频谱泄漏与Python实现

拿到一段语音,最先做的往往不是“听”,而是把它丢进傅里叶变换里,看看这个声音到底由哪些频率成分构成。语音信号分析里的FFT变换过程,本质上就是回答一个很基础但又绕不开的问题:这段声音里,低频、中频、高…

2026/10/13 23:17:20 阅读更多 →
LTMU元更新器:让长期目标跟踪不再因更新策略而丢目标

LTMU元更新器:让长期目标跟踪不再因更新策略而丢目标

最近在整理长期目标跟踪相关的资料时,又把这篇文章翻出来重读了一遍。说实话,很多跟踪领域的论文看的是“怎么把特征提得更好”,但LTMU这篇不一样,它瞄准的是一个常被忽略却又致命的问题——跟踪器到底该什么时候更新。这个决定看…

2026/10/13 23:17:20 阅读更多 →
Docker入门与实战——容器实战思考

Docker入门与实战——容器实战思考

容器实战思考1、Docker为什么会成功2、研发人员该如何看待容器2.1、快速上手新技术2.2、容器化的代码仓库2.3、面向业务编程2.4、使用Docker Hub发布开源项目3、容器化开发模式3.1、操作流程3.2、场景示例3.3、注意事项3.4、容器与生产环境1、Docker为什么会成功 Docker实现所…

2026/10/13 23:17:20 阅读更多 →
前馈算法(Feedforward)完全指南:多层感知机与矩阵运算——《Nature of Code》第10章神经网络系列 10.12

前馈算法(Feedforward)完全指南:多层感知机与矩阵运算——《Nature of Code》第10章神经网络系列 10.12

教程文档示例工程 【免费下载链接】website-archive Archive of the Coding Train website (first version) 项目地址: https://gitcode.com/gh_mirrors/we/website-archive 点击查看 免费下载 本文以 Coding Train 网站存档中《Nature of Code》第 10 章 Neural N…

2026/10/13 23:16:20 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →