
做后台系统最容易被低估的组件大文件上传一定排得上号。平时传个几MB的图片、PDF普通上传方案完全够用但真等用户拖进来一个几个GB的压缩包或者视频素材普通方案的毛病就全暴露了页面卡死、请求超时、传一半断了要整个重来。这篇文章想认真聊一聊我对大文件上传组件的优化经验从分片上传打底到用 Web Worker 分担哈希和切片任务再到断点续传、秒传、后端存储配合和参数调优尽量把一条完整链路说透。适合正在做上传功能、或者已经被大文件上传坑过想找解决方案的前端和全栈工程师参考。1. 先搞清楚普通上传为什么扛不住大文件1.1 一次性读入内存文件一大就原形毕露很多人最初写上传功能就是拿一个input typefile选完文件直接塞给 XHR 或者 FormData 发出去。小文件没问题但文件一旦上G问题立刻出现。最直接的问题在内存。有些方案会先用 FileReader 把文件整个读成 ArrayBuffer再交给 xhr.send()。这个操作看着简单实际是让浏览器一次性把文件的所有字节都装进内存。我实测过传一个 2GB 的视频素材Chrome 的内存占用直接飙到 3GB 以上页面滚动都开始掉帧用户体验非常糟糕。// 最常见的反例一次性读入内存再上传 const reader new FileReader() reader.readAsArrayBuffer(file) reader.onload () { xhr.send(reader.result) }即使不用 FileReader直接把 File 对象放进 FormData 里发给后端底层也是一次极长的 TCP 数据流。普通路由器和家用宽带在这种持续大流量传输下很容易出现连接中断一旦断掉这个请求就废了。还有一个经常被忽略的问题服务端普遍对请求体大小有限制。Nginx 默认的client_max_body_size是 1MBTomcat 默认maxPostSize也只有 2MBSpring 的max-file-size默认 1MB。不做调整几百MB的文件传上去直接收到 413 响应前端还一脸懵。1.2 失败重传的代价比想象中高一个量级很多人默认传失败了重新传一次就行这个心态在大文件场景下真的要不得。网络上传的本质是把字节流从客户端推送到服务端传输时间越长失败概率越高。我遇到过一种典型情况用户上传一个 800MB 的文件传到 600MB 的时候家里路由器重启了一下请求断了。重新上传又要从头开始前面 600MB 的带宽全部白费。用户这时候的情绪不是我再传一次而是这什么破系统。这种体验上的负面反馈远比技术问题本身难处理。分片上传解决的核心问题之一就是让失败的代价变得可接受。整个文件切成几十个小片某一小片失败只需要重传这一小片几百KB到几MB的数据几秒钟就补完了。1.3 分片工程上可控性优先的标准答案分片上传的思路很简单用File.slice()把大文件切成若干个小 Blob每个 Blob 独立上传最后一个分片到达后由服务端把所有分片合并成完整文件。// 分片的基本操作File.slice 切出子 Blob const CHUNK_SIZE 5 * 1024 * 1024 // 5MB const chunkCount Math.ceil(file.size / CHUNK_SIZE) for (let i 0; i chunkCount; i) { const start i * CHUNK_SIZE const end Math.min((i 1) * CHUNK_SIZE, file.size) const blob file.slice(start, end) // 每个 blob 独立上传失败单独重试 }分片不是炫技它同时解决了三个问题内存可控每个分片只有几MB不存在一次性读取整个文件的内存压力。失败范围可控单个分片失败只重传该分片而不是整个文件。并发可能互相独立的分片可以同时上传把带宽用满整体耗时反而比单连接串行上传更短。理解了这三个动机后面所有优化方向就都有一个判断标准任何改动只要能在不显著增加复杂度的前提下继续控好内存、控好失败成本、提升并发效率就值得做反之就是过度设计。2. 组件整体设计分片、Worker、断点续传怎么组合成一套2.1 一个上传任务的完整生命周期大文件上传组件不是一个输入框加进度条它本质上是一个小型任务管理系统。一个完整的上传任务要经过选文件、校验格式和大小、计算文件指纹、预上传查重、切片、并发上传、合并、通知完成这么几个阶段。我给上传任务设计了一个状态机就五种状态idle等待、hashing计算指纹、uploading上传中、paused暂停、success/error终态。组件UI只需要根据状态切换按钮可用性和进度条展示其余逻辑都藏在服务层里。// 上传任务的核心数据结构 interface UploadTask { id: string file: File fileHash: string // 文件内容指纹秒传和续传都靠它 status: idle | hashing | uploading | paused | success | error uploadedBytes: number totalBytes: number chunks: ChunkInfo[] // 分片清单 uploadId: string // 服务端生成的批次ID }这里有一个很重要的设计决策fileHash必须在任务创建早期就算出来因为后续的秒传判断、断点续传、服务端校验全部依赖它。但算 hash 又是一个典型的耗时 CPU 任务所以必须引出一个关键角色——Web Worker。2.2 视图、服务、Worker 三层分离我见过最多的大文件上传组件翻车方式是上传逻辑全部写在组件内部upload()方法在组件里进度数组在组件里分片循环也在组件里。后果就是用户一刷新页面任务全没了切换路由后组件销毁上传进程也被一起打断。正确的做法是把组件拆成三个层次视图层组件本体负责文件选择、拖拽区域、进度条、按钮状态只做展示和事件转发。服务层一个UploadManager类持有上传任务实例负责调度、并发控制、状态维护、与后端通信。它脱离了 Vue/React 的生命周期组件销毁不影响上传继续运行。Worker 层负责 CPU 密集任务主要是切片和 hash 计算通过postMessage与服务层通信。这样分层之后组件只是上传任务的投影。多个页面可以引用同一个上传任务路由切走了任务照常执行唯一的代价是任务状态需要一份全局存储来承接视图刷新。2.3 并发调度器的基本形态并发上传需要一个简单的调度器。我推荐用固定并发数 任务队列的方式不要写死 Promise.all也不要逐个等。// 固定并发数的异步调度器 async function runWithConcurrency(tasks, limit) { const pool new Set() for (const task of tasks) { const promise task().then(() { pool.delete(promise) }) pool.add(promise) if (pool.size limit) { await Promise.race(pool) } } await Promise.allSettled(pool) }这个调度器配合分片数组使用tasks就是把文件切片后生成的上传任务limit就是并发数。线程池满时用Promise.race等待最先完成的一个空出位置再塞下一个。它不区分任务优先级每个分片一视同仁简单可靠绝大多数的上传场景够用了。3. Worker 里到底该放哪些活切片与哈希的任务边界3.1 Worker 能干什么、不能干什么Web Worker 的引入动机很单纯浏览器主线程不能卡。算一个 1GB 文件的 MD5耗时可能在 5 到 20 秒之间如果放在主线程里整个页面在这段时间内对用户的任何操作都没有响应拖动、点击、输入全部无效观感就是一死机。把任务丢进 Worker 后主线程只是等一个消息回来期间界面保持流畅。但 Worker 不是万能的它有两个重要的限制不能操作 DOM所有涉及界面更新的逻辑仍然要回到主线程。通过postMessage传递数据时如果传递的是普通对象或 Blob会走结构化克隆数据会被复制一份只有 ArrayBuffer 之类支持 Transferable 的对象才能零拷贝转移。所以任务边界的划分原则是CPU 密集型的活放 WorkerIO 型或需要频繁和 UI 交互的活留在主线程。上传请求本身我从不放进 Worker原因也很现实主线程里的 XMLHttpRequest 有现成的onprogress事件、可以方便地加拦截器处理 token、可以统一捕获跨域错误放 Worker 里这些便利全没了还得自己维护一套通信协议性价比极低。3.2 主线程只发指令Worker 负责切片和哈希我的方案是主线程把 File 对象和配置参数扔给 WorkerWorker 内部完成切片和 hash 计算最后只把一个分片清单包含每个分片的索引、大小、hash传回主线程。分片内容不需要传回主线程拿 File 对象自己可以直接按索引 slice因为 File 底层数据是磁盘/内存引用切片操作基本无开销。Worker 内部逻辑大概是这样的// upload.worker.js self.onmessage async (e) { const { file, chunkSize, fileId } e.data const chunkCount Math.ceil(file.size / chunkSize) const chunks [] for (let i 0; i chunkCount; i) { const start i * chunkSize const end Math.min((i 1) * chunkSize, file.size) const blob file.slice(start, end) const buffer await blob.arrayBuffer() const hash computeHash(buffer) // 这里用你选的哈希算法 chunks.push({ index: i, size: blob.size, hash }) } const fileHash computeFileHash(chunks) self.postMessage({ type: chunks-ready, fileId, fileHash, chunks }) }这里有个关键点Worker 里用blob.arrayBuffer()读分片数据算 hash。这会读一个分片的数据进内存但分片只有几MB问题不大。算完整文件 hash 时可以用增量哈希算法逐个分片 feed最后一次性拿到摘要。注意不要一次性把所有分片的 ArrayBuffer 都读出来再算那样内存又爆了。3.3 Transferable Objects 与数据拷贝的坑这一节是纯实测经验。postMessage传数据时如果不指定 transfer 列表浏览器会做结构化克隆。对于一个小字符串或者简单对象这个成本可以忽略但如果你试图把整个文件的所有分片 Blob 一次性传给 Worker成本是完全不可接受的。我第一次实现时做的是把主线程切好的所有 Blob 传给 Worker让 Worker 算 hash。传一个 600MB 文件切出的 120 个 5MB 分片给 Worker页面直接卡了好几秒——这比主线程自己算 hash 还慢因为复制数据的时间远超 hash 计算的时间。正确做法是只传 File 对象本身给 Worker 一次。File 对象在结构化克隆时底层数据用的是引用移交不会整份拷贝文件内容。Worker 拿到的 File 仍然可以调用.slice()和.arrayBuffer()所以在 Worker 内部分片和算 hash既避开了大数据传输又不占主线程。再补一个细节如果你在 Worker 里算完某个分片的 hash想把那个分片的 ArrayBuffer 传回主线程做上传也请用 transfer 列表控制权直接移交不产生拷贝// 需要把数据传回主线程时用 transfer 避免拷贝 self.postMessage({ buffer }, [buffer])上传本身在主线程再从 File 中 slice 对应分片即可没必要让数据在 Worker 和主线程之间来回搬运。这个设计能省下非常可观的内存复制开销。3.4 哈希算法选型全量算还是采样算秒传和断点续传都需要文件指纹。最常见的做法是 MD5但 MD5 在 GitHub 上的spark-md5包全量算一个 1GB 文件实测大约需要 5 到 15 秒取决于机器性能。这种时长可以接受但用户会明显感到选完文件后不能立刻开始上传。工程上还有一个折中方案采样 hash。只取文件开头 1MB、中间几个 1MB 块、结尾 1MB拼接后计算摘要。速度极快但因为只看局部数据理论上存在不同文件碰撞的概率。如果你只是做上传去重、续传标记这个碰撞风险可以接受如果你做的是云盘级应用建议还是全量 hash。我自己的项目采用了一个混合策略文件小于 50MB 时全量 hash秒传判断必须准确文件大于 50MB 时也做全量 hash但放在 Worker 里同时允许界面先展示正在计算校验信息的友好提示。这个方案的体验损失不大准确性有保障。4. 断点续传和秒传从文件指纹到 uploadId 的完整闭环4.1 预上传先问后端这个文件要不要传分片上传的完整流程不是直接传分片而是先有一次预上传请求。前端把文件大小、文件名、fileHash 发给后端后端根据 fileHash 判断两件事这个文件的完整数据是否已经存在如果存在直接返回一个标记前端展示秒传成功实际没有传输任何分片。这个文件之前是否传过一部分如果传过返回已上传的分片索引列表前端只补传缺失的部分。请求POST /api/upload/preflight { fileHash: abc123..., fileName: demo.zip, fileSize: 1073741824, chunkSize: 5242880 } 响应 { uploadId: u_20250101_abc, deleted: true/false, // 秒传标记 uploadedChunks: [0, 1, 2, 5], // 已存在分片索引仅续传场景 chunkTotal: 205 }秒传是这个流程里体验最爽的点用户选了一个之前传过的几个GB文件点击上传瞬间就完成了进度条从 0 直接跳到 100%。有人认为这是鸡肋实际在协作场景里同一个大文件被反复上传的概率非常高这个功能能省下大量带宽和用户时间。4.2 分片上传协议与合并预上传返回 uploadId 之后前端就开始并发上传分片。每个分片的请求长这样请求POST /api/upload/chunk Headers: Authorization: Bearer xxx Body: 二进制分片数据 Query/Form: { uploadId: u_20250101_abc, chunkIndex: 3, chunkHash: md5/xxhash的值, chunkSize: 5242880 }后端收到分片后按uploadId chunkIndex落盘存成一个独立的临时分片文件。这里不需要保证分片按顺序到达各写各的互不影响。最后所有分片都传完前端调用一次合并接口请求POST /api/upload/complete { uploadId: u_20250101_abc }后端收到合并请求后校验分片数量、每个分片大小、总字节数确认无误后把所有临时分片按索引顺序拼接成最终文件。4.3 前端断点记录localStorage 只记状态不记数据断点续传的前端实现有两个存储选择localStorage 和 IndexedDB。大文件上传的断点状态数据量很小就是每个分片是否完成的一个布尔数组加上 uploadId 和文件信息几KB而已localStorage 完全够用没必要上 IndexedDB。我推荐的结构// localStorage 中存储的上传进度记录 { fileHash: abc123..., fileName: demo.zip, fileSize: 1073741824, uploadId: u_20250101_abc, chunkStatus: [true, true, false, true, false, ...], updatedAt: 1700000000000 }刷新页面后用户再次选择同一个文件时组件可以先用文件的哈希比对 localStorage 里的记录如果匹配且 chunkStatus 不全是 true就自动恢复这个 uploadId只传 status 为 false 的分片。这样上传中断之后重新上传只需要补传剩余部分而不是从零开始。这里有一个很容易翻车的点文件指纹必须基于文件内容绝不能基于文件名。用户可能有 100 个 5GB 的文件都叫新建文件夹.zip内容完全不同。用文件名做 key断点续传会恢复到一个完全不相关的任务上分片错乱合并出来的文件是损坏的。4.4 服务端幂等与合并校验断点续传对服务端有一个硬性要求接口必须幂等。同一个分片因为网络超时被前端重试多次服务端不能因为这个分片已经存过了就报错也不能重复追加数据把文件写坏。做法很简单落盘前检查uploadId chunkIndex是否已存在存在就直接返回成功不重复写入。合并请求的校验尤其重要。完整透传场景有一个经典 bug用户传了 200 个分片但网络丢了一个小分片没传上去前端却不知道直接调了 complete服务端拿 199 个分片合并出一个小文件。校验逻辑必须包含实际分片数等于预期分片数每个分片索引都合法且唯一所有分片字节总和等于文件总大小可选按分片 hash 重新合并计算和客户端提交的 fileHash 对比只要有一条不满足合并请求就返回 422并明确告诉客户端缺少哪个分片客户端重新补传后再调 complete。5. 后端配合MinIO 的 multipart 和那张容易慢的 SQL 表5.1 直接用 S3/MinIO 协议做分片上传如果项目后端用了对象存储MinIO、阿里云 OSS、腾讯 COS、AWS S3分片上传可以直接复用对象存储的 Multipart Upload 能力不需要自己设计落盘协议。S3 协议里就是三个接口CreateMultipartUpload 拿 uploadIdUploadPart 传单个分片CompleteMultipartUpload 合并所有分片。以 Node.js 的aws-sdk/client-s3为例const s3 new S3Client({ endpoint: http://minio:9000, ... }) // 1. 创建分片上传任务 const created await s3.send(new CreateMultipartUploadCommand({ Bucket: my-bucket, Key: upload/${fileHash}.zip })) const uploadId created.UploadId // 2. 上传单个分片拿到 ETag const part await s3.send(new UploadPartCommand({ Bucket: my-bucket, Key: upload/${fileHash}.zip, UploadId: uploadId, PartNumber: chunkIndex 1, // S3 要求从 1 开始 Body: chunkBuffer })) // 3. 所有分片传完后合并 await s3.send(new CompleteMultipartUploadCommand({ Bucket: my-bucket, Key: upload/${fileHash}.zip, UploadId: uploadId, MultipartUpload: { Parts: collectedParts.sort((a, b) a.PartNumber - b.PartNumber) } }))这里有两个实际踩过的坑。一是 S3 的 PartNumber 从 1 开始而前端分片索引通常从 0 开始一定要做 1 转换否则顺序错乱。二是 CompleteMultipartUpload 要求 Parts 按 PartNumber 升序排列而且每个分片的 ETag 必须是从 UploadPart 响应里原样拿回来的不能自己拼接否则合并接口会报错。5.2 自建扣分表时的表结构与慢查询优化如果团队自己写上传服务的落盘逻辑数据库表设计直接决定后续会不会出现慢查询。我见过一个半年后线上被一条 SQL 查挂的案例问题就出在表结构和索引上。推荐的分片上传任务表结构如下CREATE TABLE file_upload_task ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, file_hash CHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, chunk_size INT NOT NULL, chunk_total INT NOT NULL, upload_id VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_upload_id (upload_id), KEY idx_file_hash (file_hash) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE chunk_record ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, task_id BIGINT UNSIGNED NOT NULL, chunk_index INT NOT NULL, chunk_hash CHAR(32) DEFAULT NULL, uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_task_index (task_id, chunk_index), KEY idx_task_id (task_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;慢 SQL 优化我的直接经验是这么三条第一预上传查重时的SELECT ... WHERE file_hash ?必须走索引。没有索引时一个几千行的表不至于慢但上传系统跑上几个月积累几十万行全表扫描的耗时就开始肉眼可见了。加上KEY idx_file_hash之后我从几百毫秒降到了个位数毫秒级。第二chunk_record 的写入要批量不要一条条 INSERT。单条插入 100 个分片记录实测耗时是批量插入的十倍以上。用INSERT INTO ... VALUES (...), (...), ...或者INSERT ... ON DUPLICATE KEY UPDATE既保证幂等又大幅减少数据库往返。第三合并前要做一个COUNT(*)校验分片数量。这个查询在没有联合索引时会比较慢有了UNIQUE KEY uk_task_index (task_id, chunk_index)之后InnoDB 可以直接走唯一索引统计速度快很多。5.3 合并策略与超时处理合并大文件前要想清楚是把所有分片在服务端内网环境合并还是让客户端直接上传到对象存储由对象存储合并。自建服务合并几个 GB 的文件时要注意不能用读入内存再写盘的方式要做流式拼接用一个可写流逐个分片 append。一次读入 5GB 再写内存直接原地爆炸。合并这个动作耗时可能很长尤其是文件很大的时候HTTP 请求很容易超时。推荐把合并设计成异步任务complete 接口只负责把任务标记为合并中并返回任务ID前端轮询合并状态合并完成后再返回最终文件信息。这样既避免了请求超时也让用户能看到已上传 100%正在合并这个明确的阶段反馈。6. 参数怎么定并发数、分片大小与重试策略的实测值6.1 分片大小不是拍脑袋定的分片大小的选择受两个约束影响对象存储的硬限制和失败重传成本。如果后端用的是 S3/MinIO硬限制是除了最后一片之外每个分片至少 5MB。低于这个值UploadPart 接口直接拒绝。如果自建服务没有这个约束但分片太小会导致请求数爆炸。我用一张表来说明不同分片大小对 500MB 文件的影响分片大小分片数量单片失败重传成本请求开销适用场景1MB500低很高容易触发限流弱网环境移动端5MB100较低正常通用推荐值10MB50中低普通宽带 / 企业内网50MB10高极低千兆内网 / 大文件加速我自己的默认值是 5MB项目里会把这个参数暴露成可配置项。弱网场景下前端可以按最近上传速度动态调低分片大小比如当前上行带宽低于 500KB/s 时把分片改成 2MB让失败重传的代价更小。6.2 并发数与带宽的关系并发数调优是最直接的上传加速手段。单连接上传时TCP 的拥塞窗口增长需要时间而且任何一个数据包的丢失都会让窗口缩水导致吞吐上不去。用多个并发连接同时上传本质上是把一条窄管线变成多条并行管线。但并发数不是越大越好。实际经验是并发从 1 提到 4上传吞吐提升非常明显经常能到 3 到 4 倍继续从 4 提到 8收益快速递减有些场景反而变慢。原因在于上行带宽是硬瓶颈你的宽带套餐上行如果是 30Mbps那并发 8 和并发 4 都在抢同一份带宽每个连接分到的速率反而更低而且路由器和负载均衡要维护更多的并发连接CPU 开销也上来了。所以我推荐的公司宽带场景默认并发数为 4 到 6。如果追求极致可以做动态调节统计最近 5 个分片的平均上传耗时如果持续低于预期值加一个并发持续高于预期值减一个并发。这个实现在调度器里不算复杂但收益已经超出大多数场景的需求了。还有一个和并发相关但容易被忽略的点浏览器对同一个域名的 HTTP/1.1 并发连接数限制通常是 6 个。如果你的并发数设了 8可以用 HTTP/2 来绕过这个限制或者做域名分片上传请求分散到 upload1.example.com、upload2.example.com 等子域名。不要在这上面过度纠结正常 4 到 6 并发在 HTTP/1.1 下跑得很稳。6.3 重试策略和错误分级分片上传过程中一定会遇到失败重试策略决定了用户体感的稳定性。我的做法是给错误分级网络层错误断网、超时、DNS 失败指数退避重试间隔 1 秒、2 秒、4 秒最多 3 次。超过 3 次暂停整个任务并提示用户检查网络。5xx 服务端错误重试同样走退避。5xx 通常是服务端临时故障重试能解决。4xx 客户端错误如 401 token 过期、403 无权限、413 分片超限不重试直接终止任务提示用户重新登录或联系管理员。4xx 重试一万次结果也一样还浪费带宽。这里有一个经验之谈token 过期是最隐蔽的翻车现场。大文件上传时长动辄几分钟access token 如果是 5 分钟有效期传到一半 token 就失效了。前端收到 401 的瞬间一定要让整个队列暂停弹出重新登录提示而不是让其他分片继续撞墙失败。我在生产项目里加了这个逻辑之后上传失败率直接降了一半。7. 上传组件与业务组件的通信状态别堆在组件里7.1 组件 API 设计组件的 API 设计决定了它接入业务方时的体验。我把上传组件设计成受控 暴露方法双通道的组合interface UploadComponentProps { accept: string[] // 允许的文件类型 maxSize: number // 单文件大小上限 chunkSize: number // 分片大小默认 5MB concurrency: number // 并发数默认 4 autoStart: boolean // 选完文件后是否立刻开始 retryCount: number // 单片最大重试次数默认 3 disabled: boolean // 是否禁用 } interface UploadComponentEvents { onStart(task: UploadTask): void onProgress(task: UploadTask, percent: number): void onPause(task: UploadTask): void onSuccess(task: UploadTask, result: UploadResult): void onError(task: UploadTask, error: UploadError): void }组件暴露的方法用expose提供start()、pause()、retry()、clear()。父组件如果想要自己控制流程比如选完文件后弹窗先让用户填备注再点确认开始上传可以设置autoStart false之后手动调用start()上传任务完全在上层掌控中。7.2 跨组件状态同步跨组件状态同步最容易踩的坑是把上传任务实例直接放在组件内部的ref里。Vue 或 React 的组件一旦卸载任务实例就失去引用后续上传完成的事件也没有人消费等于白传。我的做法是在服务层维护一个全局的UploadManager单例所有上传任务在创建时被登记在 manager 里组件只订阅 manager 的更新。Vue 项目里再把需要展示的数据同步到 Pinia store多个页面组件可以同时展示同一个上传任务的进度。这样可以保证路由切走、组件卸载、甚至页面刷新后重新初始化只要任务在 manager 里有记录就能恢复。父子组件通信在这个场景里也有一个反直觉的设计父组件不直接调用子组件的内部方法而是把文件选择、人工确认、开始上传这些动作全部设计为事件流。父组件负责业务逻辑子组件负责展示和交互。举个例子父组件拿到了需要上传的文件列表会调用子组件的setFiles(files)来喂文件而不是直接操作子组件内部的分片逻辑。7.3 几个常见翻车现场最后整理几个我在实际项目中遇到过的通信层翻车现场或者说经验复盘第一个是重复上传同一个文件。用户第一次传失败后又选了一次同名同大小的文件结果没有做 hash 比对系统当作新任务重新上传了一遍。解决办法是选文件后先查文件 hash如果在 UploadManager 里已经有相同 hash 的任务直接把新 UI 挂到旧任务上或者提示该文件已存在是否续传。第二个是上传过程中文件被用户改动。用户选了文件开始上传中途又去编辑了这个文件比如编辑了个视频。前端记录的file.size、file.lastModified可能已经变了分片内容对不上原来的 hash。稳妥的做法是在开始上传前记录文件快照complete校验时发现文件被修改就直接终止任务让用户重新选择。这个场景不常见但遇到了就是大麻烦。第三个是权限失效时的全局广播。多个分片并发上传一个 token 过期会有十几个请求几乎同时收到 401。如果每个请求都弹一个登录过期的模态框用户会被弹窗刷屏。做法是在请求拦截器里加一个是否已弹出过的全局标记同一个时间段只提醒一次同时把所有分片请求清空统一进入暂停状态。这个细节直接决定了大文件上传功能在存量用户手里的风评。我自己在几个项目里把这些链路全部走完一遍后最大的感受是大文件上传组件不是一个工具函数而是一个小型的分布式任务系统。它真正的难点不在怎么把一个文件发出去而在怎么在内存、带宽、后端压力、用户体感这几个互相拉扯的约束之间找到平衡点。如果你也在做类似的功能建议一开始就把分片大小、并发数、重试次数这些参数全部做成可配置项组件对外只暴露清晰的事件和状态把内部复杂度全部关在服务层和 Worker 里。后面每一次基于线上监控数据的调优都会省心非常多。