做后台系统最容易被低估的组件,大文件上传一定排得上号。平时传个几MB的图片、PDF,普通上传方案完全够用,但真等用户拖进来一个几个GB的压缩包或者视频素材,普通方案的毛病就全暴露了:页面卡死、请求超时、传一半断了要整个重来。这篇文章想认真聊一聊我对大文件上传组件的优化经验,从分片上传打底,到用 Web Worker 分担哈希和切片任务,再到断点续传、秒传、后端存储配合和参数调优,尽量把一条完整链路说透。适合正在做上传功能、或者已经被大文件上传坑过想找解决方案的前端和全栈工程师参考。
1. 先搞清楚普通上传为什么扛不住大文件
1.1 一次性读入内存,文件一大就原形毕露
很多人最初写上传功能,就是拿一个<input type="file">,选完文件直接塞给 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是 1MB,Tomcat 默认maxPostSize也只有 2MB,Spring 的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 密集型的活放 Worker,IO 型或需要频繁和 UI 交互的活留在主线程。上传请求本身我从不放进 Worker,原因也很现实:主线程里的 XMLHttpRequest 有现成的onprogress事件、可以方便地加拦截器处理 token、可以统一捕获跨域错误,放 Worker 里这些便利全没了,还得自己维护一套通信协议,性价比极低。
3.2 主线程只发指令,Worker 负责切片和哈希
我的方案是:主线程把 File 对象和配置参数扔给 Worker,Worker 内部完成切片和 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 拿 uploadId,UploadPart 传单个分片,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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;慢 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 文件的影响:
| 分片大小 | 分片数量 | 单片失败重传成本 | 请求开销 | 适用场景 |
|---|---|---|---|---|
| 1MB | 500 | 低 | 很高,容易触发限流 | 弱网环境,移动端 |
| 5MB | 100 | 较低 | 正常 | 通用推荐值 |
| 10MB | 50 | 中 | 低 | 普通宽带 / 企业内网 |
| 50MB | 10 | 高 | 极低 | 千兆内网 / 大文件加速 |
我自己的默认值是 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 里。后面每一次基于线上监控数据的调优,都会省心非常多。