做前端久了都会遇到一个绕不开的需求:上传文件。小文件还好,form表单一把梭,后端收完就完事。一旦文件变成几个GB,比如视频素材、安装包、数据库备份,你要是还拿老办法直接提交,几乎必翻车——服务端超时、内存被打满、浏览器卡死、传了一半网络断了又从头再来,哪个都让人血压飙升。
这篇文章纯粹是多年填坑经验的汇总。我会把JavaScript处理大附件上传的几条主流路线、分片上传的核心原理、断点续传和秒传的实现思路、以及实际开发中的各种坑都梳理一遍。不管你是刚接手文件上传功能的新手,还是想优化现有上传模块的老手,这篇应该都能给你一些可以直接用的参考。
1. 方案演进与整体选型
1.1 为什么传统上传搞不定大文件
先说清楚传统表单上传到底死在哪儿。一个常规的<form>提交,浏览器是把整个文件内容读进请求体里一次性发送的。对小文件这没问题,一旦文件大了,问题全来了。
第一是服务器压力。不管你用Nginx、Node.js还是Java,接收请求时基本都要先把整个请求体缓冲下来。PHP默认post_max_size是8M,Nginx默认client_max_body_size是1M,即使你把这些限制调大了,服务器在接收几个GB的文件时,内存和磁盘I/O都会瞬间拉满,处理其他正常请求都会受影响。
第二是超时问题。大文件上传耗时动辄几分钟到几十分钟,中间只要网络稍微抖动一下,TCP连接断了,整个请求就废了。代理层一般还有空闲超时配置,长时间没有数据传输的请求会被主动掐断,用户等半天最后收到一个502或504,体验非常糟糕。
第三是断点问题。没有分片的上传,失败就从头再来。用户传了80%掉线,再传还是从0%开始,这不是技术问题,这是产品口碑问题。
第四是浏览器内存。某些实现会先把文件读成DataURL或ArrayBuffer再发送,几个GB的文件读进内存,Chrome直接给你OOM(内存溢出)崩溃,这个应该有不少人踩过。
所以核心思路很简单:把一个大的上传任务,拆成多个独立的小任务来执行。这就是分片上传能成为主流方案的根本原因。
1.2 当前主流的几种技术方案对比
目前JavaScript侧真正在用的方案,我梳理下来其实就四大类。
第一类是传统表单上传,只适合小文件,大文件场景直接不建议。唯一的优化空间是用XMLHttpRequest或fetch发送FormData,这样可以拿到上传进度,但本质没变,还是整体传输。
第二类是分片上传,这是目前大文件上传事实上的标准方案。思路是用File.slice()把大文件切成多个几MB的块,逐个上传到服务器,最后让服务端按顺序合并。单纯分片还不行,还要配套做并发控制、失败重试、进度合并。如果你做视频上传、网盘上传这类业务,基本用的都是这一套。
第三类是断点续传+秒传。断点续传要解决的是上一次没传完,下一次不用全部重来;秒传要解决的是服务器上已经有相同文件时,直接跳过上传。这两个功能通常和分片上传是捆绑实现的,现在网上能搜到的大文件上传方案,基本都是这个组合。
第四类是直传对象存储。如果你的后端不强,或者干脆想省流量,可以采用前端直接把文件上传到云存储服务,比如阿里云OSS、腾讯云COS、AWS S3。云端会给你生成带签名的上传URL,前端拿到后直接PUT或POST过去。这种方式的分片、续传能力由云厂商帮你实现了,很多SDK内部就是在做分片+并发+续传。对大多数业务来说,这是我个人很推荐的方式,能省掉自己维护一套上传服务的成本。
这四个方案可以对比一下,方便快速选型。
| 方案 | 适合场景 | 大文件支持 | 断点续传 | 服务端复杂度 | 开发量 |
|---|---|---|---|---|---|
| 传统表单上传 | 头像、文档等小文件 | 差 | 无 | 低 | 极低 |
| 分片上传 | 视频、压缩包等中大文件 | 好 | 可扩展 | 中 | 高 |
| 分片+续传+秒传 | 网盘、素材库等正式产品 | 很好 | 完整 | 较高 | 很高 |
| 云存储直传 | 有稳定云服务预算的业务 | 好 | 由SDK提供 | 低 | 中 |
1.3 选型时的几个关键判断
这里说一些实际工作中沉淀下来的选型经验。你看到网上一堆五花八门的方案,很容易挑花眼,但真正决定该用哪个的其实就是几个问题。
第一,文件最大会有多大?如果业务场景里基本不会超过200M,那传统上传加一个进度条就够了,真没必要上分片,除非你预料到以后文件会越传越大。如果可能到1GB甚至更大,分片是必须的。我经手的项目里,用户传一个10GB的工程文件都有,这种就只能走分片+续传,没有任何商量余地。
第二,需不需要断点续传?做内部工具可以不要,用户传断了让他重传一次问题不大。但如果你做的是面向公众的产品,网络环境不可控,断点续传基本是标配。做续传意味着要有文件唯一标识的机制、服务端要记录分片状态,开发量会上一个台阶。
第三,团队有没有人力维护一个后端上传接口?如果后端人少,或者你只是一个全栈想快速搞定,云存储直传是最优解。说句实在话,大部分中小项目的上传需求,用OSS或者COS的分片上传SDK就够了,自己写一套完整的分片上传服务端,代价并没有想象中那么小——你要处理分片存储、合并任务、过期清理、并发保护,一堆脏活累活。
2. 分片上传的核心原理与前端实现细节
2.1 分片上传的基本流程
用一句话概括分片上传就是:前端把大文件切成多个小块,逐个上传,全部传完后通知服务端合并,服务端按顺序把小片拼成完整文件。
流程看起来简单,但每个环节都有讲究。完整的分片上传,一般会经过这几个步骤:
- 用户选择文件后,前端计算文件唯一标识(通常是文件内容的Hash值,后面说秒传时再展开)。
- 前端把文件按固定大小切成多个分片,并为每个分片生成序号(从0或1开始)。
- 前端先请求后端一个“初始化上传”的接口,把文件名、大小、分片总数、文件唯一标识传过去,后端记录一条上传任务并返回一个uploadId。
- 前端拿着分片列表,在并发数控制下逐个上传分片,每个分片请求里带上uploadId、分片序号、分片内容。
- 每个分片传完后,后端单独保存并校验大小,返回该分片的上传结果。
- 所有分片传完后,前端请求后端“合并分片”接口,后端按分片序号合并成完整文件,并做完整性校验。
- 前端轮询或等待合并结果,成功后展示最终的文件地址。
这个流程是现在所有主流分片上传组件的底线,不管是webuploader、vue-simple-uploader还是各家云SDK,本质都绕不开这一套。
2.2 File.slice切片与分片大小怎么选
切片本身很简单,file.slice(start, end)拿到一个Blob实例,直接放进FormData里上传就行。真正需要认真考虑的是分片大小。
我见过有人把分片设成2MB,也有人设成10MB,还有人干脆切成1MB。这个值没有标准答案,但要结合你的网络、服务端和业务场景来权衡。
分片设得太小,比如1MB,有几个问题:一是分片数量太多,每个分片都是一次HTTP请求,请求数猛增,服务端要处理的请求量太大;二是HTTP请求本身的握手、传输头部这些开销占比变大,效率反而低;三是很多品牌的服务端日志会被海量请求刷爆。
分片设得太大,比如50MB,虽然请求数少了,但万一某一片传失败需要重传,代价就大了;而且在弱网环境下,一个分片传输时间过长,超时和断连的概率也会变大。
从我实际测试的经验看,普通办公网络和企业服务器场景,分片大小设在2MB到10MB之间是比较稳的区间。我个人用得最多的是5MB。假设上传一个1GB的文件,按5MB分片就是205个分片,并发控制在3到6个,整体速度不会太差,容错也好。如果业务上传的文件普遍很大(几十GB起步),可以适当调大到8MB或10MB,因为分片总数太多的话,光是服务端存小文件的数量就会造成压力。
另外,切片时要留意最后一个分片可能比设定值小,这是正常的,不需要特殊处理,服务端按实际接收大小保存即可。
2.3 并发控制:为什么不能一次性全发
很多人第一次实现分片上传时,会写一个循环把所有分片全部发出去,结果发现要么浏览器卡死,要么进度条乱跳,要么到后面一堆失败。
原因很简单。浏览器对同一域名的并发TCP连接数有限制。HTTP/1.1下Chrome是6个,Firefox是6个,如果分片数量是几百,一次性全发出去,后面的请求全都在排队,而且前6个请求还没完成时就传满了连接池,其他页面的正常请求也会被堵住。再一个,几十个分片同时上传,内存占用会跟着涨,网络状况稍差就会整片整片地超时失败。
所以必须做并发控制。我自己常用的实现方式是写一个简单的并发池,核心逻辑就几条:
- 维护一个待上传任务队列。
- 同时最多启动N个上传任务(N我通常设成3到6)。
- 每当一个任务完成,从队列里取出下一个任务开始执行。
- 所有任务完成后,进入合并阶段。
用Promise实现也就三四十行代码,不需要引入额外库。这里给一个可运行的示例。
class ConcurrencyPool { constructor(tasks, limit = 4) { this.tasks = tasks; this.limit = limit; this.index = 0; this.activeCount = 0; this.results = []; } async run() { const workers = new Array(Math.min(this.limit, this.tasks.length)) .fill(0) .map(() => this._worker()); await Promise.all(workers); return this.results; } async _worker() { while (this.index < this.tasks.length) { const current = this.index++; this.activeCount++; try { const result = await this.tasks[current](); this.results[current] = result; } catch (err) { this.results[current] = { error: err.message }; } finally { this.activeCount--; } } } }使用的时候,把每个分片的上传函数包装成一个() => uploadPart()任务塞给ConcurrencyPool,设置并发数为4,然后await pool.run(),就能稳定地按指定数量并发上传分片。
有一点要注意:并发数不是越大越好。调成10以上,网络吞吐并不一定会线性上升,反而容易触发服务端的连接限制或限流,导致大面积的429、502。我自己实践下来,并发数控制在3到6比较理想,具体还要看分片大小和服务器能力。
2.4 上传进度与体验细节
进度条是大文件上传的脸面,做得好不好直接影响用户对产品可靠性的判断。分片上传的进度计算和普通上传不太一样,不能只看某一个分片的进度,要算整体进度。
整体进度的计算公式是:(已成功上传的分片字节数 + 当前正在传输的分片已传输字节数) / 文件总大小 * 100。
实现时,我会给每个分片都挂一个loaded和total,通过XMLHttpRequest的upload.onprogress事件更新。整体进度则用所有分片的loaded之和除以total之和来算。注意是“所有分片”,包括还没开始传的——没开始传的loaded视为0。
如果你是整套自己写,还要处理几个常见的体验细节。
一是失败重试。单次分片上传失败,先别急着报错给用户,自动重试两三次再说。我用的是指数退避策略:第一次失败等1秒,第二次失败等2秒,第三次等4秒,最多重试3次。这样能扛住大部分网络抖动导致的分片丢失。
二是取消上传。用户中途点取消,前端要把已经发出去但还没完成的XHR全部abort(),同时通知服务端把这个uploadId对应的临时分片清理掉,不然服务端磁盘上会堆一堆垃圾文件。
三是暂停和恢复。这个和取消不同,暂停不是清掉任务,而是把未完成的分片列表保存起来,等用户点击继续时接着传。实现上依赖断点续传的能力,下面会展开说。
3. 断点续传与秒传的完整方案
3.1 断点续传的两种实现思路
断点续传的核心是“记住上次传到了哪里”,下次接着传。实现上有两条路。
一条是纯前端思路:把已完成的分片序号记录在浏览器的localStorage或IndexedDB里。下次用户选择同一文件时,先判断本地有没有历史记录,有就直接跳过已传分片。这种方式实现简单,但问题也明显:localStorage容量极小(一般5M左右),历史记录多了容易爆;而且用户换了浏览器或清了缓存,续传就失效了;记录的是本地状态,服务端可能早把临时分片清掉了,对不上。
另一条是服务端查询思路:每次打开页面时,前端先调用一个查询接口,把文件唯一标识传给后端,后端返回这个文件已上传成功且校验通过的分片序号列表,前端拿到后只传缺失的部分。这是正规产品里我推荐的做法,数据都在服务端,所有客户端共享一套状态,不依赖浏览器缓存。
实际实现时,两种可以结合:先看本地有没有缓存,有就少问一次后端;没有就走服务端查询。我个人觉得,为了逻辑统一,直接走服务端查询就够了,本地缓存省下那几个请求意义不大。
3.2 文件指纹与秒传原理
秒传其实不是“传得快”,而是“不用传”。原理是:上传前前端算出文件的唯一哈希值(比如MD5),发给服务端,服务端查一下自己的文件库,如果发现完全相同的文件已经存在,就直接返回“上传成功”,前端连传都不用传了。
这个功能在实际业务里很实用。比如企业内部网盘里,经常有人用即时通讯软件传一个安装包给同事,同事再传到网盘——同一个文件被传好几遍。有了秒传,后面再传就是一瞬间的事,服务器和带宽的压力立刻小很多。
做秒传的关键在于文件唯一标识怎么算。最稳妥的是对全文件算MD5或SHA-1,但问题是一个大文件算MD5也要把整个文件都读一遍,几个GB的文件在浏览器主线程里算Hash,页面会卡得没法用。
所以实践中常用两个优化手段。
第一个是抽样Hash。不读全文件,只选取文件开头、中间、结尾几个固定位置的块来算哈希。虽然不是100%准确,但对绝大多数业务场景足够用了,性能提升明显。注意抽样Hash有碰撞风险,出现碰撞后合并结果可能是个损坏文件——如果业务对数据准确性要求极高,还是全量Hash加上后端二次校验。
第二个是Web Worker。把Hash计算的逻辑放到后台线程去跑,主线程就不会被阻塞。这个是真的关键,几GB的文件在主线程算MD5,用户看着页面卡住,第一反应是站点出bug了。
我习惯用SparkMD5这个库。它支持增量计算,可以一段一段读文件,防止一次把整个文件读进内存。配合Web Worker使用,基本流程是:
// 在 Web Worker 内部 importScripts('spark-md5.min.js'); self.onmessage = function (e) { const file = e.data.file; const chunkSize = 2 * 1024 * 1024; // 每次读2MB const spark = new SparkMD5.ArrayBuffer(); let currentChunk = 0; const totalChunks = Math.ceil(file.size / chunkSize); const fileReader = new FileReader(); fileReader.onload = function (ev) { spark.append(ev.target.result); currentChunk++; // 上报计算进度 self.postMessage({ type: 'progress', percent: Math.round((currentChunk / totalChunks) * 100) }); if (currentChunk < totalChunks) { loadNext(); } else { self.postMessage({ type: 'done', hash: spark.end() }); } }; fileReader.onerror = function () { self.postMessage({ type: 'error', message: '文件读取失败' }); }; function loadNext() { const start = currentChunk * chunkSize; const end = Math.min(start + chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); };这样算出来的Hash就是文件的唯一ID,秒传的判断、分片的状态记录、断点续传的恢复,都依赖这个唯一ID来关联。
3.3 服务端接口设计与合并策略
前端流程清晰了,服务端接口怎么设计就成了关键。按上面的分片流程,服务端最少需要三个接口。
第一个是初始化上传接口。接收文件唯一标识、文件名、文件大小、分片大小、分片总数。服务端先查一下这个唯一标识是否已有完整文件,有就返回已存在,实现秒传;没有就创建上传任务,返回uploadId。
第二个是分片上传接口。接收uploadId、分片序号、分片文件。服务端把分片保存到临时目录,校验大小,然后返回成功。这个接口是并发被调用最多的,要能扛住并发写。
第三个是合并分片接口。接收uploadId。服务端检查该上传任务的所有分片是否都已成功,缺哪个就返回哪个缺失,前端可以补齐再重新请求合并;全齐就按序号把分片流式合并成完整文件,并做一次文件大小校验。
合并策略上,有人用先存分片后合并的方式,也有人用直接流式追加的方式。对小项目前者简单清晰,分片就是以uploadId_index命名的临时文件,合并时按顺序读出来拼一起就行。唯一要注意的是合并时要给每个分片的大小做个校验,防止部分分片内容不完整导致最终文件损坏。
4. 完整实战:从零手写一个可落地的分片上传模块
4.1 前端核心代码实现
理论说了那么多,直接上一段能跑的核心代码。这个模块我抽掉了业务层,保留了最核心的分片上传逻辑,覆盖切片、并发、进度、重试、合并请求这几个关键点。
class ChunkedUploader { constructor({ file, chunkSize = 5 * 1024 * 1024, concurrency = 4, maxRetries = 3, initUrl = '/api/upload/init', chunkUrl = '/api/upload/chunk', mergeUrl = '/api/upload/merge', queryUrl = '/api/upload/query', getUploadFileUrl = '/api/file/detail', headers = {} }) { this.file = file; this.chunkSize = chunkSize; this.concurrency = concurrency; this.maxRetries = maxRetries; this.urls = { initUrl, chunkUrl, mergeUrl, queryUrl, getUploadFileUrl }; this.headers = headers; this.fileId = null; // 文件唯一标识,由 hash 计算得到 this.uploadId = null; // 服务端返回的上传任务ID this.chunkList = []; // 分片列表 this.aborted = false; } // 1. 生成分片列表 _buildChunkList() { const count = Math.ceil(this.file.size / this.chunkSize); this.chunkList = Array.from({ length: count }, (_, index) => ({ index, blob: this.file.slice(index * this.chunkSize, Math.min((index + 1) * this.chunkSize, this.file.size)), size: Math.min(this.file.size - index * this.chunkSize, this.chunkSize), status: 'pending' // pending | uploading | success | failed })); } // 2. 计算文件哈希(实际建议放到 Web Worker 中) async _calcFileHash() { // 这里省略 spark-md5 的 Worker 实现,可参考上面章节 // 返回一个 Promise<string> } // 3. 初始化上传任务 async _initUpload() { const res = await fetch(this.urls.initUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.headers }, body: JSON.stringify({ fileId: this.fileId, fileName: this.file.name, fileSize: this.file.size, chunkSize: this.chunkSize, chunkCount: this.chunkList.length }) }); const data = await res.json(); if (data.code === 0) { // 如果服务端返回 SKIP,说明文件已存在,直接秒传 if (data.data.skipUpload) return { skip: true, fileUrl: data.data.fileUrl }; this.uploadId = data.data.uploadId; return { skip: false }; } throw new Error(data.message || '初始化上传失败'); } // 4. 查询已上传分片(用于断点续传) async _queryUploadedChunks() { const res = await fetch(`${this.urls.queryUrl}?fileId=${this.fileId}`, { method: 'GET', headers: { ...this.headers } }); const data = await res.json(); if (data.code !== 0) return []; const uploadedSet = new Set(data.data.uploadedChunks || []); this.chunkList.forEach(chunk => { if (uploadedSet.has(chunk.index)) { chunk.status = 'success'; } }); } // 5. 上传单个分片(带重试) async _uploadSingleChunk(chunk) { const formData = new FormData(); formData.append('uploadId', this.uploadId); formData.append('index', chunk.index); formData.append('chunk', chunk.blob, `part-${chunk.index}`); formData.append('size', chunk.size); for (let attempt = 1; attempt <= this.maxRetries; attempt++) { if (this.aborted) return; chunk.status = 'uploading'; try { const res = await fetch(this.urls.chunkUrl, { method: 'POST', headers: { ...this.headers }, // 注意:不要手动设置 Content-Type,让浏览器自动带 boundary body: formData }); const data = await res.json(); if (data.code === 0) { chunk.status = 'success'; return; } } catch (err) { // 网络异常,继续重试 } await this._sleep(attempt * 1000); // 指数退避:1s, 2s, 3s } throw new Error(`分片 ${chunk.index} 上传失败`); } _sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // 6. 并发上传所有分片 async _uploadAllChunks(onProgress) { const pending = this.chunkList.filter(c => c.status !== 'success'); let completedBytes = this.chunkList .filter(c => c.status === 'success') .reduce((sum, c) => sum + c.size, 0); const workers = Array.from({ length: Math.min(this.concurrency, pending.length) }, () => this._chunkWorker(pending, completedBytes, onProgress) ); await Promise.all(workers); } async _chunkWorker(pending, completedBytes, onProgress) { while (pending.length > 0 && !this.aborted) { const chunk = pending.shift(); try { await this._uploadSingleChunk(chunk); } catch (err) { chunk.status = 'failed'; throw err; } // 进度回调:直接把整体进度抛出去给UI层 const doneBytes = this.chunkList .filter(c => c.status === 'success') .reduce((sum, c) => sum + c.size, 0); onProgress?.(Math.round((doneBytes / this.file.size) * 100)); } } // 7. 合并分片 async _mergeChunks() { const res = await fetch(this.urls.mergeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.headers }, body: JSON.stringify({ uploadId: this.uploadId, fileId: this.fileId }) }); const data = await res.json(); if (data.code !== 0) { throw new Error(data.message || '合并失败'); } return data.data.fileUrl; } // 对外入口:执行上传 async upload({ onProgress } = {}) { await this._calcFileHash(); this._buildChunkList(); await this._queryUploadedChunks(); // 断点续传,可选 const initResult = await this._initUpload(); if (initResult.skip) { onProgress?.(100); return initResult.fileUrl; } await this._uploadAllChunks(onProgress); const fileUrl = await this._mergeChunks(); onProgress?.(100); return fileUrl; } abort() { this.aborted = true; } }上面这段代码有几个细节值得说明。
FormData上传时,不要手动设置Content-Type为multipart/form-data,否则要手动拼boundary,很容易出错。让浏览器自己生成Content-Type和boundary是最省心的做法。
重试逻辑里,指数退避的await this._sleep(attempt * 1000)是必要的,连续快速重试只会让本来就抖动的网络雪上加霜。这个策略在线上确实能大大降低最终失败率。
断点续传查询接口要在初始化之前调用,因为服务端需要先用fileId查到已存在的分片记录。如果顺序反了,初始化接口会新建一个空的uploadId,之前的进度就丢了。
4.2 关键参数到底怎么调
很多参数不是凭感觉设的,下面是我在真实项目中调参时的思考逻辑。
分片大小,默认5MB。理由前面说了:2MB太小请求数翻倍,10MB以上弱网重试成本高。如果目标用户大多在移动网络环境(比如4G),建议降到2MB到3MB,因为弱网下大分片更容易超时;如果用户基本是办公宽带,可以升到8MB到10MB,减少请求总数,服务端压力更小。
并发数,默认4。同时考虑浏览器连接数限制和服务器承受能力。HTTP/1.1下最多6个,所以设4是合理的;如果服务器是云函数这类按调用计费的服务,可以降到2,省点钱;如果上传文件普遍在1GB以上且服务器扛得住,开到6也行。不要盲目开大,我见过有人把并发设成10,结果后端日志一堆502。
重试次数,3次比较合适。低于3次扛不住偶发网络抖动,高于5次的话,某些分片如果是因为文件内容本身的问题失败(比如服务端磁盘满了),再重试也是浪费时间和流量。
另外建议加一个上传超时时间。分片上传的XHR要设置超时,比如60秒。不设超时的话,某个分片万一卡在连接阶段,整个上传任务会一直挂在那里不动,用户永远看不到进展。
4.3 服务端要点(以Node.js为例)
前端写好了,服务端也得能对接。这里给一个精简的Node.js示例说明接口设计思路,实际生产肯定要换成框架和数据库。
// 伪代码,示意接口逻辑 const uploadTasks = new Map(); // 实际应用请用Redis或数据库 // 初始化上传 app.post('/api/upload/init', (req, res) => { const { fileId, fileName, fileSize, chunkSize, chunkCount } = req.body; // 1. 先查文件是否已存在(秒传) if (fileStore.exists(fileId)) { return res.json({ code: 0, data: { skipUpload: true, fileUrl: fileStore.get(fileId) } }); } // 2. 创建上传任务 const uploadId = randomUUID(); uploadTasks.set(uploadId, { fileId, fileName, fileSize, chunkSize, chunkCount, uploadedChunks: new Set(), status: 'uploading' }); res.json({ code: 0, data: { uploadId, skipUpload: false } }); }); // 上传分片 app.post('/api/upload/chunk', upload.single('chunk'), (req, res) => { const { uploadId, index } = req.body; const task = uploadTasks.get(uploadId); if (!task) return res.json({ code: 1, message: '上传任务不存在' }); // 保存分片到临时目录,如 /tmp/uploads/{uploadId}/{index} saveChunk(uploadId, index, req.file.buffer); task.uploadedChunks.add(Number(index)); res.json({ code: 0 }); }); // 合并分片 app.post('/api/upload/merge', (req, res) => { const { uploadId, fileId } = req.body; const task = uploadTasks.get(uploadId); // 检查分片是否全齐 for (let i = 0; i < task.chunkCount; i++) { if (!task.uploadedChunks.has(i)) { return res.json({ code: 1, message: `缺少分片 ${i}` }); } } // 按序合并 const output = createWriteStream(getFinalPath(fileId)); for (let i = 0; i < task.chunkCount; i++) { const chunkBuffer = readChunk(uploadId, i); output.write(chunkBuffer); deleteChunk(uploadId, i); // 合并后清理 } output.end(); res.json({ code: 0, data: { fileUrl: getFileUrl(fileId) } }); }); // 查询已上传分片 app.get('/api/upload/query', (req, res) => { const { fileId } = req.query; // 找到该 fileId 对应的任务,返回已上传分片序号列表 res.json({ code: 0, data: { uploadedChunks: [...task.uploadedChunks] } }); });这里必须强调两个生产环境要点。
第一,临时分片目录一定要有定期清理策略。用户传了一半就关掉页面,服务端临时目录里会留下一堆孤儿分片,如果什么定期清理都不做,用不了多久磁盘就满了。我在项目里是每小时清理一次超过2小时未完成任务的临时分片。
第二,合并时要加文件完整性校验。合并完成后,检查最终文件大小是否等于初始化时记录的文件大小。不一致要立刻报错,否则用户拿到一个损坏文件,排查成本会高得多。
5. 常见问题速查与避坑记录
5.1 高频问题排查表
整理了一份我在实际调试中经常遇到的问题和对应解法。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 上传到一半报502/504 | 代理层或服务端超时时间过短 | 调大Nginx的proxy_read_timeout;同时确认分片大小是否太大,导致单片传输耗时过长 |
| 进度条不动或卡住 | 分片上传卡在请求阶段 | 给XHR设置超时;检查服务端是否对请求体有大小限制 |
| 合并后的文件损坏或打不开 | 分片顺序错乱或分片内容不完整 | 合并前校验每个分片大小;合并时严格按序号写入;合并后做最终大小校验 |
| 某个分片反复上传失败 | 服务端磁盘满了或网络丢包严重 | 检查服务端磁盘;降低并发数;增加重试次数和退避时间 |
| 秒传没生效,每次都全量传 | 前端Hash计算逻辑有误或服务端未正确存储文件ID | 先用同一个文件调试,确认前端Hash一致,再排查服务端查询逻辑 |
| 用户关闭页面后重新上传,旧进度丢失 | 未实现服务端查询已传分片 | 实现查询接口,前端上传前先拉取已传分片列表 |
| HBuilder或IDE里报JS语法错误 | 本地方案引用了不兼容的语法或旧API | 按ES6确认node和浏览器版本,FileReader/File.slice检查兼容性写法 |
有一类和“安全检测提示脆弱的JavaScript库”相关的情况也值得说一句。如果你项目里引用了比较老的上传组件,用的还是jQuery插件或老版本SDK,安全扫描工具经常会报“目标站点存在JavaScript框架库漏洞”之类的风险提示。这不是功能bug,但最好及时升级组件版本,把那些废弃API换掉,不然安全评审这关很难过。
5.2 实战中那些文档里不会写的细节
这个部分算是老实习生在坑里爬出来的经验,不一定写在什么官方文档里,但对实际开发非常有用。
第一,上传进度条的数值不要直接透传给用户。真实的进度不是线性的,前5%可能在算Hash,后10%可能在合并,用户看到卡在99%会以为系统死了。我一般会在UI层做一次平滑处理,比如进度到95%后合并阶段故意显示“服务端处理中”,避免用户焦虑。
第二,分片上传不是简单的“切了就完”。并发上传时,服务端接收分片的顺序是乱的。如果你的合并逻辑把“先收到的分片写入输出文件末尾”,那文件铁定损坏。正确的做法永远是:按序号合并,而不是按接收顺序合并。
第三,file.slice(start, end)的end是开区间。切片时不注意边界,最后一个分片很容易重复或缺失。我习惯统一用Math.min(start + chunkSize, file.size)来截断end,避免手滑写错。
第四,大文件Hash计算一定要用增量读取。直接用readAsArrayBuffer(file)传整个文件给SparkMD5,内存立刻爆炸。我在前面代码里用了分块循环读,这是必须的。
第五,关于开发环境,如果你用HBuilder这类工具配置JavaScript调试环境,要注意设置合适的模拟网络环境,否则本地测试时无法模拟真实弱网下分片失败的情况。我在HBuilder里会同时开一个移动端真机预览,测试移动网络下的上传表现。
5.3 遇到上传组件“漏洞报告”怎么办
前面提过安全扫描报“脆弱的JavaScript库”,这里单独展开一下。现在很多公司上线前都要过安全扫描,如果引用了老版本的上传组件,扫描结果里会出现类似“检测到目标站点存在javascript框架库漏洞”的提醒。
处理思路不是急着换框架,而是先顺着扫描报告定位到具体是哪个库哪个版本。拿我踩过的坑举例,之前项目里用了老版webuploader,它内置的Flash上传模块已经停止维护,安全扫描直接标记。我的做法是先看了看业务侧依赖的API,然后升级到了社区维护的版本,或者换成基于XHR的上传方案,把Flash相关逻辑彻底删掉。升级完成后重新扫描,漏洞项就消失了。
这里提醒一句,如果升级组件发现有接口变化,不要硬着头皮改配置,先把文档里废弃的API找出来替换一遍。安全扫描过的组件不意味着完美,但至少不会再被扫出明显的漏洞提示。
6. 写在最后的个人经验
文件上传这个功能,做起来不难,做得好难。它横跨前端、后端、网络、存储四个领域,任何一个环节掉链子,最终暴露的都是用户面前的失败弹窗。
我个人的习惯是:先明确文件大小的上限和用户网络环境,再决定方案复杂度。小项目能直传云存储就直传,不要自己折腾分片服务端;大项目宁可前期多花点时间把分片、续传、秒传这套做扎实,也别等上线后天天被客服投诉“传不了大文件”。
最后分享一个小技巧:开发时多准备几个不同大小的测试文件,一个几百KB的、一个100M左右的、一个1GB以上的。很小的文件用来测秒传,中等文件测分片进度和并发,大文件测稳定性和内存。用三个文件跑通全流程,基本能覆盖绝大多数线上场景。