做上传功能这几年,我踩过的坑比很多人写过的代码都多。特别是网页里传大文件这件事,表面上看是“选个文件、发个请求”的事,一旦文件体积上了几个GB,问题就全冒出来了——进度条卡死、连接中断、服务端超时、内存飙升。最后你会发现,老老实实把文件切成一块一块传才是唯一靠谱的路。所谓“网页插件实现大文件分片上传”,本质就是做一套可复用的上传模块:前端负责切块、调度、重试、续传,后端负责接收、合并、校验。这篇博文我会直接把前后端的核心源码和设计思路都拆给你看,全程用原生实现讲清楚原理。这套内容适合正在做后台系统、文件管理平台,或者纯粹被大文件上传逼疯的前后端开发者。看完你能落地一套自己的分片上传方案,而不是只会调第三方库。1. 为什么大文件上传必须重新造轮子先别急着写代码。我见过太多人拿着几十MB的文件测完就说“没问题”,结果一上生产环境就被用户教做人。普通表单上传的局限不是服务器配置不够,而是架构上就扛不住大文件。1.1 普通上传接口的三大硬伤第一个问题是请求体和内存限制。浏览器把文件通过表单提交时,本质上是一整个二进制流发给服务端。如果前端用FileReader.readAsDataURL去读,整个文件会被直接塞进内存,文件一上GB页面基本就卡死了。服务端这边,Nginx默认的client_max_body_size通常是1MB到2MB,就算你把它调成500MB,网关和代理层的超时时间也会成为新的瓶颈——一个1GB文件在不理想的网络下传半小时很正常,中间任何一次波动就断给你看。第二个问题是用户体验极差。普通上传对用户来说就是一个无法取消、无法续传的漫长等待。一旦失败,进度条清零,用户骂娘,你还得在工单系统里解释“网络波动导致上传失败”。第三个问题是没有断点续传的基因。你当然可以前端做一次性提交、后端配合做断点接收,但没有分片,断点续传根本无从谈起。服务端拿着一个巨大的请求体,要么整体成功,要么整体失败,根本没法只补传缺失的那一段。我用一个生活类比来总结:一次性上传大文件,就像雇了一辆大卡车搬家,货全部装进一个集装箱,路上爆胎一次就得卸下来重装。分片上传则像搬家公司的标准箱,一箱一箱搬,哪一箱磕坏了就单独补一箱,整体不受影响。这个思路,就是分片上传的本质。1.2 分片上传的核心能力拆解从需求倒推,一套能用的分片上传方案至少要具备四个能力:前端切片:把大File对象按固定大小切成多个Blob,每个分片独立上传。并发控制:不是把几百个分片一次性全发出去,而是限制同时进行的请求数,避免带宽和内存被瞬间打满。进度可计算:能精确算出“已上传字节 / 总字节”,而不是靠猜。失败重试与续传:单个分片失败不拖垮整个任务,页面刷新后还能从已完成的分片继续传。再往上一层,还要考虑秒传和断点续传。秒传依赖文件内容的哈希指纹,文件已存在就直接返回成功。断点续传则依赖服务端保存已上传分片的信息,前端重新请求时跳过这些分片。这四个能力加两个进阶项,就是整套源码的地基。下面我按模块拆开讲。2. 前端整体架构设计2.1 模块划分与整体流程分片上传不是一个单一函数能搞定的,我建议把代码分成六个模块,各司其职:FileSelect:负责文件选择、类型校验、大小提示。Uploader核心类:管理文件元数据、分片列表、状态流转(待上传、上传中、已完成、失败)。HashWorker:通过Web Worker计算文件内容哈希,避免阻塞主线程。Scheduler:并发调度器,控制同时执行的上传任务数。Transport:真正发请求的模块,我用原生XHR实现,方便拿到上传进度事件。ProgressEmitter:汇总进度、发布事件,驱动页面上的进度条。整体执行流程是一条逻辑链:用户选择文件,读取文件名、大小、类型。启动Web Worker,对文件做增量哈希计算,得到fileHash。前端调用后端/check接口,传入fileHash。后端返回三种结果:fileExists为真(秒传)、返回uploadedChunks数组(断点续传)、返回空数组(全新上传)。前端根据结果生成或过滤分片任务列表。调度器按固定并发数上传分片,每个分片独立处理进度和重试。全部分片完成后,调用/merge接口,服务端合并文件。这套流程把“分片”、“并发”、“进度”、“续传”四个关键点串成了一条线,下面我逐个讲实现。2.2 核心数据结构与文件指纹方案分片上传的代码里,最核心的数据结构就是“分片任务”。我一般这样定义:// 每个分片任务的数据结构 { file, // 原始File对象 blob, // 当前分片的Blob,通过file.slice()切出 index, // 分片序号,从0开始 total, // 总分片数 fileHash, // 整个文件的哈希,用于续传/秒传 chunkHash, // 当前分片的哈希,可选,用于服务端校验 size, // 当前分片实际字节数 status, // pending / uploading / success / error retryCount // 当前分片已重试次数 }之所以要用fileHash作为续传和秒传的依据,而不是用“文件名+大小”,是因为文件名和大小完全相同的两个文件,内容可能完全不同。你不想用户传了一个1GB的文件,结果服务端因为文件名对得上就返回“秒传成功”,实际上传的是另一个文件。哈希算法上,用SparkMD5做增量计算就够了。注意我这里说的是增量,不是一次性把文件读完再算。大文件一次性读进内存做哈希,内存直接炸。正确姿势是用FileReader配合Blob.slice分块读取,每读一块就往哈希算法里喂一块,最后拿到整个文件的指纹。2.3 工具选型:为什么不用现成上传库前端这边我故意不用任何上传库,网络请求也是用原生XHR而不是Axios。原因很简单:上传库封装了大量逻辑,出了问题你很难定位到底是库的bug还是自己的业务bug。自己在核心模块上做一层薄封装,代码全部掌控在自己手里,出了问题可以一行行排查。后端我用Node.js + Express + multer。你可能会问,为什么用multer?其实它在这里只干一件事:解析multipart/form-data,把上传的分片二进制数据拿出来。你完全可以用busboy或者formidable替代,我选multer纯粹是因为它生态稳定、文档丰富,读者拿到代码就能跑通。Web Worker不是可选项,而是必须项。计算大文件哈希非常吃CPU,放在主线程会导致页面操作卡顿,用户动一下滚动条都费劲。后面我会给Worker的代码示例。3. 前端源码实现:从分片到并发上传3.1 分片逻辑与关键参数选择分片的核心就一个API:Blob.prototype.slice。它可以从一个Blob中截取指定字节范围,生成一个新的Blob。这个新Blob可以直接放进FormData发送。先看切分代码:function createChunks(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, size: end - start, blob: file.slice(start, end) }); start = end; index++; } return chunks; }这段代码没有太多玄机,但有两个细节容易掉坑。第一,file.slice()返回的Blob是懒加载的,并不会立刻把数据读进内存,真正读数据是在XHR发送时。第二,最后一个分片的大小往往小于chunkSize,如果你在后端合并时假设所有分片大小一致,就会出问题。合并逻辑必须按实际大小处理。分片大小怎么选?我梳理一下取舍逻辑:如果设512KB,一个1GB文件会产生2048个分片,HTTP请求数量大,服务端合并时文件句柄开销高,进度事件也会有压力。如果设50MB,分片数少了,但单分片上传时间长,失败重试的代价大,而且服务端如果要把整个分片放在内存里,并发几路内存就爆了。经验值在2MB~10MB之间。文件平均体积小的项目用5MB,几个GB起步的项目可以用10MB,并发数控制在3~5路。我自己的默认配置是:const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const MAX_CONCURRENCY = 3; // 同时3个分片在上传 const MAX_RETRY = 3; // 单个分片最多重试3次3.2 并发池实现:控制同时上传的分片数很多人第一次做分片上传时,会写一个循环把所有分片一次性发出去。这是新手最容易踩的坑:浏览器对同一域名有并发连接限制(目前Chrome是6个),超出之后HTTP请求会排队,你发100个请求,实际同时只有6个在跑,剩下的全在隐形排队,这还不算内存中堆了100个FormData对象。真的没必要,并发池才是正解。我写了一个很经典的并发池实现:/** * 并发执行任务 * @param {Array} tasks 任务数组 * @param {number} limit 最大并发数 * @param {Function} workerFn 处理单个任务的异步函数 */ async function parallelRun(tasks, limit, workerFn) { const results = new Array(tasks.length); let idx = 0; async function worker() { while (idx < tasks.length) { const currentIndex = idx++; try { results[currentIndex] = await workerFn(tasks[currentIndex], currentIndex); } catch (err) { results[currentIndex] = { error: err }; console.error(`任务 ${currentIndex} 执行失败`, err); } } } const workers = Array.from({ length: limit }, () => worker()); await Promise.all(workers); return results; }这个实现的核心是一个共享的idx计数器。多个worker并发执行时,每次从idx取一个任务,idx++是原子性的,JavaScript的事件循环保证了不会有两个worker拿到同一个任务。Promise.all等所有worker跑完,任务就全部执行完毕。配合分片上传,你只需要把每个分片的XHR上传函数包装成workerFn传进去就行。这几十行代码的价值,是让你完全掌控“同一时间到底有几个请求在飞”。单分片上传我用XHR实现,关键在于监听xhr.upload.onprogress拿真实的上传进度:function uploadChunk(chunkTask, onProgress) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', chunkTask.blob, chunkTask.fileName); formData.append('fileHash', chunkTask.fileHash); formData.append('index', chunkTask.index); formData.append('total', chunkTask.total); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress && onProgress(event.loaded, event.total); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('网络异常')); xhr.ontimeout = () => reject(new Error('请求超时')); xhr.send(formData); }); }这里用XHR而不是fetch,是因为fetch至今没有提供原生的上传进度事件。如果你非要用fetch,得自己去包装ReadableStream,收益不大,没必要折腾。3.3 全局进度计算与失败重试机制全局进度的计算,代码可能只有一行,但思路要拎清:必须按字节数累计,而不是按分片数累计。因为除最后一个分片外,其他分片大小一致,最后一个分片可能特别小。若按“已完成分片数 / 总分片数”计算,进度条会走到“99%”后停很久,最后一下跳到100%,用户看了会疑惑。正确做法:在调度器里维护一个completedBytes变量,每个分片成功上传后,累加该分片的实际大小:let completedBytes = 0; async function handleChunk(task) { try { await uploadChunk(task, (loaded, total) => { // 单个分片实时进度,可以细分 }); completedBytes += task.size; const percent = Math.round((completedBytes / file.size) * 100); emitter.emit('progress', { percent, completedBytes, total: file.size }); } catch (err) { throw err; } }失败重试的逻辑也不复杂:上传分片失败后,不立即向用户报错,而是给这个分片安排重试。重试不能间隔固定时间傻等,我用指数退避的方式:async function uploadChunkWithRetry(task, maxRetry = 3) { let delay = 1000; for (let attempt = 0; attempt < maxRetry; attempt++) { try { return await uploadChunk(task); } catch (err) { if (attempt === maxRetry - 1) { throw err; } await sleep(delay); delay *= 2; // 1s -> 2s -> 4s } } }第一次失败等1秒重试,第二次等2秒,第三次等4秒。如果连续三次都在同一分片上失败,说明这个分片的数据或网络有硬问题,此时才中止整个上传流程,并明确告诉用户“第N个分片上传失败”,而不是给一个模糊的“上传失败”。3.4 秒传与断点续传的源码级实现前端做完分片和并发控制,还差一个关键的预处理环节:询问服务端这个文件之前是否传过。这个动作在正式上传之前执行,我封装成一个prepareUpload方法:async function prepareUpload(file, fileHash) { const response = await fetch(`/api/upload/check?fileHash=${fileHash}`); const result = await response.json(); if (result.fileExists) { return { mode: 'instantSuccess' }; // 秒传 } const uploadedSet = new Set(result.uploadedChunks || []); const allChunks = createChunks(file, CHUNK_SIZE); // 只保留未上传的分片 const pendingTasks = allChunks .filter(chunk => !uploadedSet.has(chunk.index)) .map(chunk => createTask(file, fileHash, chunk)); return { mode: 'upload', pendingTasks }; }这里关键的优化点是:服务端返回已完成分片索引集合,前端直接用filter过滤掉已完成的分片。如果上次已经传了300个分片中的250个,这次只需要传剩下的50个。这个方案的准确度,完全取决于服务端临时目录里分片数据的完整性和正确性,所以服务端写入分片时必须确保幂等,不能因为重复请求而追加数据。秒传其实就发生在前端拿到fileExists: true的那一刻。用户点击上传,进度条直接跳到100%,体验极好。但它不是做什么魔法,只是服务端通过文件名或哈希定位到了已存在的完整文件。4. 后端接口与分片合并源码前端是这套方案的脸面,后端是真正干活的。有些前端开发者以为后端只要无脑把分片拼起来就行,真做起来会发现,分片接收、状态记录、合并校验各有各的坑。4.1 分片接收接口与临时目录管理我定义一个临时目录tmp/{fileHash}/,每个分片独立存储为一个.part文件。目录名用fileHash而不是文件名,因为文件名可能重复,但哈希是唯一的。分片接收接口的源码:const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const router = express.Router(); const TMP_DIR = path.join(__dirname, '../tmp'); const FILE_DIR = path.join(__dirname, '../files'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024 } // 比前端5MB分片稍大 }); router.post('/chunk', upload.single('file'), async (req, res) => { const { fileHash, index, total } = req.body; const buffer = req.file ? req.file.buffer : null; if (!buffer) { return res.status(400).json({ ok: false, message: '缺少分片数据' }); } const partDir = path.join(TMP_DIR, fileHash); await fs.promises.mkdir(partDir, { recursive: true }); const partPath = path.join(partDir, `${index}.part`); // 关键:覆盖写入。同一个分片重复上传时,直接覆盖,不做追加 await fs.promises.writeFile(partPath, buffer); res.json({ ok: true }); });两个细节值得说。第一,multer.memoryStorage()会把分片数据放在内存,5MB一个分片、并发3路,内存峰值在可接受范围。如果你的分片大小定到50MB,就必须换diskStorage,否则内存会爆。第二,写入分片用writeFile覆盖而不是appendFile追加,是为了保证接口幂等——如果前端因网络超时重传了同一个分片,服务端不会把两段数据拼成一个损坏的.part文件。这台接口逻辑看起来简单,实际上有你容易忽视的点:客户端上传分片时带上total,但服务端不依赖total做完整校验。total交给/merge接口去校验。4.2 合并分片的流式写法所有分片上传完成后,前端调用/merge接口。后端要做的事情是:先检查分片完整性,再按索引顺序把所有.part文件拼接成最终文件,最后清理临时目录。合并接口的源码:router.post('/merge', async (req, res) => { const { fileHash, fileName, total } = req.body; const partDir = path.join(TMP_DIR, fileHash); // 1. 校验所有分片是否齐全 for (let i = 0; i < Number(total); i++) { const partPath = path.join(partDir, `${i}.part`); if (!fs.existsSync(partPath)) { return res.status(400).json({ ok: false, message: `缺少分片 ${i}` }); } } // 2. 创建最终文件写入流 const ext = path.extname(fileName); const targetPath = path.join(FILE_DIR, `${fileHash}${ext}`); const ws = fs.createWriteStream(targetPath); // 3. 按索引顺序逐个pipe for (let i = 0; i < Number(total); i++) { await new Promise((resolve, reject) => { const rs = fs.createReadStream(path.join(partDir, `${i}.part`)); rs.on('error', reject); ws.on('error', reject); rs.pipe(ws, { end: false }); rs.on('end', resolve); }); } ws.end(); // 4. 清理临时目录 await fs.promises.rm(partDir, { recursive: true, force: true }); res.json({ ok: true, url: `/files/${fileHash}${ext}` }); });合并逻辑里最需要注意的,就是每个分片pipe到同一个WriteStream时必须传{ end: false }。这个参数的意思是:当前分片的数据流结束后,不要自动关闭目标写入流,让它继续接收下一个分片的数据。如果你漏了这个参数,第一个分片传完,写入流就关闭了,后面所有分片会写入一个已关闭的流,报错。为什么用流而不是把所有分片读进内存再一次性写?一个1GB文件如果用readFileSync读分片再拼接,内存立刻爆炸。用createReadStream边读边写,内存占用始终只有几MB。4.3 check接口:续传与秒传的判断逻辑/check接口是断点续传的“大脑”。前端上传前会请求这个接口,通过fileHash查询文件的存储状态。router.get('/check', async (req, res) => { const { fileHash } = req.query; // 1. 完整文件已存在,直接秒传 const files = await fs.promises.readdir(FILE_DIR); const exists = files.some(f => f.startsWith(fileHash)); if (exists) { return res.json({ fileExists: true, uploadedChunks: [] }); } // 2. 临时目录存在,说明有已上传的分片 const partDir = path.join(TMP_DIR, fileHash); if (fs.existsSync(partDir)) { const partFiles = await fs.promises.readdir(partDir); const uploadedChunks = partFiles .filter(f => f.endsWith('.part')) .map(f => parseInt(f.split('.')[0])) .sort((a, b) => a - b); return res.json({ fileExists: false, uploadedChunks }); } // 3. 全新上传 return res.json({ fileExists: false, uploadedChunks: [] }); });这里有一个体验层面的细节:如果文件已经完整上传过,服务端直接返回fileExists: true,前端就能立即给出“秒传成功”的反馈。如果临时目录还有残留分片,返回的uploadedChunks数组就能让前端精准跳过这些分片。哪怕用户中途关了浏览器,只要服务端没有清理临时目录,下次重新选择同一个文件,依旧能继续,不用从头再来。5. 实际运营中挖到的坑与性能优化5.1 我踩过的几个真实大坑代码写完只是第一步。我在实际项目中跑这套方案时,又遇到了很多文档里不会写的东西,这里详述几个影响最大的。坑1:文件哈希计算卡死主线程一个2GB文件,在Web Worker里算MD5,大概需要几秒到十几秒。如果你把这段代码放在主页面线程跑,用户能明显感觉到页面卡住,点击、滚动都没反应。解决方案就是Web Worker。切分文件本身也要放在Worker里和哈希计算一起做,主线程只负责展示状态。在Worker里做增量哈希的代码大致是这个结构:// hash-worker.js self.importScripts('/spark-md5.min.js'); self.onmessage = async (event) => { const file = event.data.file; const chunkSize = 5 * 1024 * 1024; const spark = new SparkMD5.ArrayBuffer(); let offset = 0; while (offset < file.size) { const end = Math.min(offset + chunkSize, file.size); const chunk = file.slice(offset, end); const buffer = await chunk.arrayBuffer(); spark.append(buffer); offset = end; self.postMessage({ type: 'progress', percent: Math.round(offset / file.size * 100) }); } const fileHash = spark.end(); self.postMessage({ type: 'done', fileHash }); };主线程只负责postMessage把文件传进去、监听返回消息即可。这样即使在算哈希期间,用户也完全无感。坑2:并发数开太高导致内存爆炸我曾经把并发数调到6,觉得“反正浏览器限制6个连接,那就发满”。结果上传一个8GB文件时,页面内存直接冲到700MB,低配机器直接白屏。原因很简单:每个XHR请求在发送时,都会把对应的分片Blob读入内存,6个同时读,内存峰值就是6 × 5MB的缓冲区加各种开销。实际运营中,并发数设在3比较稳健,2更保守。不要顶着上限跑,优先保证稳定性。坑3:服务端分片写入用了append模式最早的版本我在服务端用fs.appendFile写入分片,想着“反正只写一次,追加和覆盖没区别”。直到有一次网络重传,同一个分片被前端重发了一次,服务端毫无防备地把两段数据都写进了.part文件,最终合并出的文件直接损坏,而且损坏方式非常隐蔽。修复方案上面也写了:分片写入必须用writeFile覆盖写,保证接口幂等。前端的重试机制越是完善,这类问题就越要提前堵住。坑4:Nginx默认限制把你卡死在门外本地联调一切正常,部署到测试环境之后,上传超过1MB就直接报413,查了半天才发现是Nginx的client_max_body_size默认只有1MB。后端代码再正确,请求也到不了应用层。这个问题简单但致命,部署前记得检查网关层配置,包括client_max_body_size和proxy_read_timeout,后者要改大,因为大文件上传吃力不讨好,一个慢请求可能在网关层就被强杀。坑5:临时目录垃圾文件堆积断点续传依赖临时目录里的分片文件,但用户一旦中途放弃上传,这些分片就永远留在服务器上,日积月累会吃掉大量磁盘。我在服务端加了一个定时清理任务:每隔1小时扫描临时目录,删除创建时间超过24小时且未完成合并的分片文件夹。这个逻辑不需要多复杂,定期跑一个脚本就行,但一定要有,不然迟早出磁盘告警。5.2 常见问题速查表我把几个高频问题和对应方案整理成一个表,方便你遇到问题时直接查:问题现象根本原因解决方案断点续传后文件损坏文件哈希不准确,续传时基于错误的分片状态哈希必须基于完整文件内容,不能只对文件头部采样合并后文件比原文件小合并前未校验所有分片是否存在合并时逐索引检查.part文件,缺失则拒绝合并进度条卡在99%不动最后一个分片太小,按分片数计算进度导致跳档全局进度按字节数累计,不按分片数累计并发上传时部分分片频繁失败网络丢包严重或服务端连接被网关关闭指数退避重试,重试3次仍失败再整体中止上传1GB文件页面卡死哈希计算在主线程执行采用Web Worker做增量计算生产环境上传超过1MB报413Nginx默认client_max_body_size限制调大Nginx请求体限制,并同步调大超时时间服务端临时目录越来越大用户弃传导致分片残留加定时任务清理超期未合并的临时目录这些坑不是一次性踩完的,很多东西是你上线跑了一两个月才暴露出来。所以上线前的回归测试很重要,我建议至少准备一个2GB左右的真实大文件,把“全新上传”、“中途断网重传”、“刷新页面续传”、“秒传二次提交”四条路径全部走一遍,别让用户替你试错。最后分享一点个人体会这套代码写完之后,我最深的感触是:分片上传真正考验人的不是“切片”和“合并”这两件事,而是细节的闭环。并发控制、进度计算、重试策略、幂等写入、垃圾清理,每一项单拿出来都不难,难的是把它们组合成一个拼命不出错的整体。我自己在维护一个旧的上传模块时就发现,代码跑得是否稳定,往往取决于那些你不会写进文档里的判断——比如分片大小和并发数怎么配合,比如服务端写分片时必须幂等,这些才是真正的经验壁垒。最后提醒一句:分片上传不是银弹。如果你的业务文件普遍小于50MB,老老实实用单请求上传加超时重试就够了,分片带来的复杂度不值得。但如果你的场景是几GB甚至几十GB的文件,那这套方案值得你花一个下午把它吃透。
做上传功能这几年,我踩过的坑比很多人写过的代码都多。特别是网页里传大文件这件事,表面上看是“选个文件、发个请求”的事,一旦文件体积上了几个GB,问题就全冒出来了——进度条卡死、连接中断、服务端超时、内存飙升。最后你会发现,老老实实把文件切成一块一块传才是唯一靠谱的路。所谓“网页插件实现大文件分片上传”,本质就是做一套可复用的上传模块:前端负责切块、调度、重试、续传,后端负责接收、合并、校验。这篇博文我会直接把前后端的核心源码和设计思路都拆给你看,全程用原生实现讲清楚原理。这套内容适合正在做后台系统、文件管理平台,或者纯粹被大文件上传逼疯的前后端开发者。看完你能落地一套自己的分片上传方案,而不是只会调第三方库。1. 为什么大文件上传必须重新造轮子先别急着写代码。我见过太多人拿着几十MB的文件测完就说“没问题”,结果一上生产环境就被用户教做人。普通表单上传的局限不是服务器配置不够,而是架构上就扛不住大文件。1.1 普通上传接口的三大硬伤第一个问题是请求体和内存限制。浏览器把文件通过表单提交时,本质上是一整个二进制流发给服务端。如果前端用FileReader.readAsDataURL去读,整个文件会被直接塞进内存,文件一上GB页面基本就卡死了。服务端这边,Nginx默认的client_max_body_size通常是1MB到2MB,就算你把它调成500MB,网关和代理层的超时时间也会成为新的瓶颈——一个1GB文件在不理想的网络下传半小时很正常,中间任何一次波动就断给你看。第二个问题是用户体验极差。普通上传对用户来说就是一个无法取消、无法续传的漫长等待。一旦失败,进度条清零,用户骂娘,你还得在工单系统里解释“网络波动导致上传失败”。第三个问题是没有断点续传的基因。你当然可以前端做一次性提交、后端配合做断点接收,但没有分片,断点续传根本无从谈起。服务端拿着一个巨大的请求体,要么整体成功,要么整体失败,根本没法只补传缺失的那一段。我用一个生活类比来总结:一次性上传大文件,就像雇了一辆大卡车搬家,货全部装进一个集装箱,路上爆胎一次就得卸下来重装。分片上传则像搬家公司的标准箱,一箱一箱搬,哪一箱磕坏了就单独补一箱,整体不受影响。这个思路,就是分片上传的本质。1.2 分片上传的核心能力拆解从需求倒推,一套能用的分片上传方案至少要具备四个能力:前端切片:把大File对象按固定大小切成多个Blob,每个分片独立上传。并发控制:不是把几百个分片一次性全发出去,而是限制同时进行的请求数,避免带宽和内存被瞬间打满。进度可计算:能精确算出“已上传字节 / 总字节”,而不是靠猜。失败重试与续传:单个分片失败不拖垮整个任务,页面刷新后还能从已完成的分片继续传。再往上一层,还要考虑秒传和断点续传。秒传依赖文件内容的哈希指纹,文件已存在就直接返回成功。断点续传则依赖服务端保存已上传分片的信息,前端重新请求时跳过这些分片。这四个能力加两个进阶项,就是整套源码的地基。下面我按模块拆开讲。2. 前端整体架构设计2.1 模块划分与整体流程分片上传不是一个单一函数能搞定的,我建议把代码分成六个模块,各司其职:FileSelect:负责文件选择、类型校验、大小提示。Uploader核心类:管理文件元数据、分片列表、状态流转(待上传、上传中、已完成、失败)。HashWorker:通过Web Worker计算文件内容哈希,避免阻塞主线程。Scheduler:并发调度器,控制同时执行的上传任务数。Transport:真正发请求的模块,我用原生XHR实现,方便拿到上传进度事件。ProgressEmitter:汇总进度、发布事件,驱动页面上的进度条。整体执行流程是一条逻辑链:用户选择文件,读取文件名、大小、类型。启动Web Worker,对文件做增量哈希计算,得到fileHash。前端调用后端/check接口,传入fileHash。后端返回三种结果:fileExists为真(秒传)、返回uploadedChunks数组(断点续传)、返回空数组(全新上传)。前端根据结果生成或过滤分片任务列表。调度器按固定并发数上传分片,每个分片独立处理进度和重试。全部分片完成后,调用/merge接口,服务端合并文件。这套流程把“分片”、“并发”、“进度”、“续传”四个关键点串成了一条线,下面我逐个讲实现。2.2 核心数据结构与文件指纹方案分片上传的代码里,最核心的数据结构就是“分片任务”。我一般这样定义:// 每个分片任务的数据结构 { file, // 原始File对象 blob, // 当前分片的Blob,通过file.slice()切出 index, // 分片序号,从0开始 total, // 总分片数 fileHash, // 整个文件的哈希,用于续传/秒传 chunkHash, // 当前分片的哈希,可选,用于服务端校验 size, // 当前分片实际字节数 status, // pending / uploading / success / error retryCount // 当前分片已重试次数 }之所以要用fileHash作为续传和秒传的依据,而不是用“文件名+大小”,是因为文件名和大小完全相同的两个文件,内容可能完全不同。你不想用户传了一个1GB的文件,结果服务端因为文件名对得上就返回“秒传成功”,实际上传的是另一个文件。哈希算法上,用SparkMD5做增量计算就够了。注意我这里说的是增量,不是一次性把文件读完再算。大文件一次性读进内存做哈希,内存直接炸。正确姿势是用FileReader配合Blob.slice分块读取,每读一块就往哈希算法里喂一块,最后拿到整个文件的指纹。2.3 工具选型:为什么不用现成上传库前端这边我故意不用任何上传库,网络请求也是用原生XHR而不是Axios。原因很简单:上传库封装了大量逻辑,出了问题你很难定位到底是库的bug还是自己的业务bug。自己在核心模块上做一层薄封装,代码全部掌控在自己手里,出了问题可以一行行排查。后端我用Node.js + Express + multer。你可能会问,为什么用multer?其实它在这里只干一件事:解析multipart/form-data,把上传的分片二进制数据拿出来。你完全可以用busboy或者formidable替代,我选multer纯粹是因为它生态稳定、文档丰富,读者拿到代码就能跑通。Web Worker不是可选项,而是必须项。计算大文件哈希非常吃CPU,放在主线程会导致页面操作卡顿,用户动一下滚动条都费劲。后面我会给Worker的代码示例。3. 前端源码实现:从分片到并发上传3.1 分片逻辑与关键参数选择分片的核心就一个API:Blob.prototype.slice。它可以从一个Blob中截取指定字节范围,生成一个新的Blob。这个新Blob可以直接放进FormData发送。先看切分代码:function createChunks(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, size: end - start, blob: file.slice(start, end) }); start = end; index++; } return chunks; }这段代码没有太多玄机,但有两个细节容易掉坑。第一,file.slice()返回的Blob是懒加载的,并不会立刻把数据读进内存,真正读数据是在XHR发送时。第二,最后一个分片的大小往往小于chunkSize,如果你在后端合并时假设所有分片大小一致,就会出问题。合并逻辑必须按实际大小处理。分片大小怎么选?我梳理一下取舍逻辑:如果设512KB,一个1GB文件会产生2048个分片,HTTP请求数量大,服务端合并时文件句柄开销高,进度事件也会有压力。如果设50MB,分片数少了,但单分片上传时间长,失败重试的代价大,而且服务端如果要把整个分片放在内存里,并发几路内存就爆了。经验值在2MB~10MB之间。文件平均体积小的项目用5MB,几个GB起步的项目可以用10MB,并发数控制在3~5路。我自己的默认配置是:const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const MAX_CONCURRENCY = 3; // 同时3个分片在上传 const MAX_RETRY = 3; // 单个分片最多重试3次3.2 并发池实现:控制同时上传的分片数很多人第一次做分片上传时,会写一个循环把所有分片一次性发出去。这是新手最容易踩的坑:浏览器对同一域名有并发连接限制(目前Chrome是6个),超出之后HTTP请求会排队,你发100个请求,实际同时只有6个在跑,剩下的全在隐形排队,这还不算内存中堆了100个FormData对象。真的没必要,并发池才是正解。我写了一个很经典的并发池实现:/** * 并发执行任务 * @param {Array} tasks 任务数组 * @param {number} limit 最大并发数 * @param {Function} workerFn 处理单个任务的异步函数 */ async function parallelRun(tasks, limit, workerFn) { const results = new Array(tasks.length); let idx = 0; async function worker() { while (idx < tasks.length) { const currentIndex = idx++; try { results[currentIndex] = await workerFn(tasks[currentIndex], currentIndex); } catch (err) { results[currentIndex] = { error: err }; console.error(`任务 ${currentIndex} 执行失败`, err); } } } const workers = Array.from({ length: limit }, () => worker()); await Promise.all(workers); return results; }这个实现的核心是一个共享的idx计数器。多个worker并发执行时,每次从idx取一个任务,idx++是原子性的,JavaScript的事件循环保证了不会有两个worker拿到同一个任务。Promise.all等所有worker跑完,任务就全部执行完毕。配合分片上传,你只需要把每个分片的XHR上传函数包装成workerFn传进去就行。这几十行代码的价值,是让你完全掌控“同一时间到底有几个请求在飞”。单分片上传我用XHR实现,关键在于监听xhr.upload.onprogress拿真实的上传进度:function uploadChunk(chunkTask, onProgress) { return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', chunkTask.blob, chunkTask.fileName); formData.append('fileHash', chunkTask.fileHash); formData.append('index', chunkTask.index); formData.append('total', chunkTask.total); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { onProgress && onProgress(event.loaded, event.total); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('网络异常')); xhr.ontimeout = () => reject(new Error('请求超时')); xhr.send(formData); }); }这里用XHR而不是fetch,是因为fetch至今没有提供原生的上传进度事件。如果你非要用fetch,得自己去包装ReadableStream,收益不大,没必要折腾。3.3 全局进度计算与失败重试机制全局进度的计算,代码可能只有一行,但思路要拎清:必须按字节数累计,而不是按分片数累计。因为除最后一个分片外,其他分片大小一致,最后一个分片可能特别小。若按“已完成分片数 / 总分片数”计算,进度条会走到“99%”后停很久,最后一下跳到100%,用户看了会疑惑。正确做法:在调度器里维护一个completedBytes变量,每个分片成功上传后,累加该分片的实际大小:let completedBytes = 0; async function handleChunk(task) { try { await uploadChunk(task, (loaded, total) => { // 单个分片实时进度,可以细分 }); completedBytes += task.size; const percent = Math.round((completedBytes / file.size) * 100); emitter.emit('progress', { percent, completedBytes, total: file.size }); } catch (err) { throw err; } }失败重试的逻辑也不复杂:上传分片失败后,不立即向用户报错,而是给这个分片安排重试。重试不能间隔固定时间傻等,我用指数退避的方式:async function uploadChunkWithRetry(task, maxRetry = 3) { let delay = 1000; for (let attempt = 0; attempt < maxRetry; attempt++) { try { return await uploadChunk(task); } catch (err) { if (attempt === maxRetry - 1) { throw err; } await sleep(delay); delay *= 2; // 1s -> 2s -> 4s } } }第一次失败等1秒重试,第二次等2秒,第三次等4秒。如果连续三次都在同一分片上失败,说明这个分片的数据或网络有硬问题,此时才中止整个上传流程,并明确告诉用户“第N个分片上传失败”,而不是给一个模糊的“上传失败”。3.4 秒传与断点续传的源码级实现前端做完分片和并发控制,还差一个关键的预处理环节:询问服务端这个文件之前是否传过。这个动作在正式上传之前执行,我封装成一个prepareUpload方法:async function prepareUpload(file, fileHash) { const response = await fetch(`/api/upload/check?fileHash=${fileHash}`); const result = await response.json(); if (result.fileExists) { return { mode: 'instantSuccess' }; // 秒传 } const uploadedSet = new Set(result.uploadedChunks || []); const allChunks = createChunks(file, CHUNK_SIZE); // 只保留未上传的分片 const pendingTasks = allChunks .filter(chunk => !uploadedSet.has(chunk.index)) .map(chunk => createTask(file, fileHash, chunk)); return { mode: 'upload', pendingTasks }; }这里关键的优化点是:服务端返回已完成分片索引集合,前端直接用filter过滤掉已完成的分片。如果上次已经传了300个分片中的250个,这次只需要传剩下的50个。这个方案的准确度,完全取决于服务端临时目录里分片数据的完整性和正确性,所以服务端写入分片时必须确保幂等,不能因为重复请求而追加数据。秒传其实就发生在前端拿到fileExists: true的那一刻。用户点击上传,进度条直接跳到100%,体验极好。但它不是做什么魔法,只是服务端通过文件名或哈希定位到了已存在的完整文件。4. 后端接口与分片合并源码前端是这套方案的脸面,后端是真正干活的。有些前端开发者以为后端只要无脑把分片拼起来就行,真做起来会发现,分片接收、状态记录、合并校验各有各的坑。4.1 分片接收接口与临时目录管理我定义一个临时目录tmp/{fileHash}/,每个分片独立存储为一个.part文件。目录名用fileHash而不是文件名,因为文件名可能重复,但哈希是唯一的。分片接收接口的源码:const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const router = express.Router(); const TMP_DIR = path.join(__dirname, '../tmp'); const FILE_DIR = path.join(__dirname, '../files'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024 } // 比前端5MB分片稍大 }); router.post('/chunk', upload.single('file'), async (req, res) => { const { fileHash, index, total } = req.body; const buffer = req.file ? req.file.buffer : null; if (!buffer) { return res.status(400).json({ ok: false, message: '缺少分片数据' }); } const partDir = path.join(TMP_DIR, fileHash); await fs.promises.mkdir(partDir, { recursive: true }); const partPath = path.join(partDir, `${index}.part`); // 关键:覆盖写入。同一个分片重复上传时,直接覆盖,不做追加 await fs.promises.writeFile(partPath, buffer); res.json({ ok: true }); });两个细节值得说。第一,multer.memoryStorage()会把分片数据放在内存,5MB一个分片、并发3路,内存峰值在可接受范围。如果你的分片大小定到50MB,就必须换diskStorage,否则内存会爆。第二,写入分片用writeFile覆盖而不是appendFile追加,是为了保证接口幂等——如果前端因网络超时重传了同一个分片,服务端不会把两段数据拼成一个损坏的.part文件。这台接口逻辑看起来简单,实际上有你容易忽视的点:客户端上传分片时带上total,但服务端不依赖total做完整校验。total交给/merge接口去校验。4.2 合并分片的流式写法所有分片上传完成后,前端调用/merge接口。后端要做的事情是:先检查分片完整性,再按索引顺序把所有.part文件拼接成最终文件,最后清理临时目录。合并接口的源码:router.post('/merge', async (req, res) => { const { fileHash, fileName, total } = req.body; const partDir = path.join(TMP_DIR, fileHash); // 1. 校验所有分片是否齐全 for (let i = 0; i < Number(total); i++) { const partPath = path.join(partDir, `${i}.part`); if (!fs.existsSync(partPath)) { return res.status(400).json({ ok: false, message: `缺少分片 ${i}` }); } } // 2. 创建最终文件写入流 const ext = path.extname(fileName); const targetPath = path.join(FILE_DIR, `${fileHash}${ext}`); const ws = fs.createWriteStream(targetPath); // 3. 按索引顺序逐个pipe for (let i = 0; i < Number(total); i++) { await new Promise((resolve, reject) => { const rs = fs.createReadStream(path.join(partDir, `${i}.part`)); rs.on('error', reject); ws.on('error', reject); rs.pipe(ws, { end: false }); rs.on('end', resolve); }); } ws.end(); // 4. 清理临时目录 await fs.promises.rm(partDir, { recursive: true, force: true }); res.json({ ok: true, url: `/files/${fileHash}${ext}` }); });合并逻辑里最需要注意的,就是每个分片pipe到同一个WriteStream时必须传{ end: false }。这个参数的意思是:当前分片的数据流结束后,不要自动关闭目标写入流,让它继续接收下一个分片的数据。如果你漏了这个参数,第一个分片传完,写入流就关闭了,后面所有分片会写入一个已关闭的流,报错。为什么用流而不是把所有分片读进内存再一次性写?一个1GB文件如果用readFileSync读分片再拼接,内存立刻爆炸。用createReadStream边读边写,内存占用始终只有几MB。4.3 check接口:续传与秒传的判断逻辑/check接口是断点续传的“大脑”。前端上传前会请求这个接口,通过fileHash查询文件的存储状态。router.get('/check', async (req, res) => { const { fileHash } = req.query; // 1. 完整文件已存在,直接秒传 const files = await fs.promises.readdir(FILE_DIR); const exists = files.some(f => f.startsWith(fileHash)); if (exists) { return res.json({ fileExists: true, uploadedChunks: [] }); } // 2. 临时目录存在,说明有已上传的分片 const partDir = path.join(TMP_DIR, fileHash); if (fs.existsSync(partDir)) { const partFiles = await fs.promises.readdir(partDir); const uploadedChunks = partFiles .filter(f => f.endsWith('.part')) .map(f => parseInt(f.split('.')[0])) .sort((a, b) => a - b); return res.json({ fileExists: false, uploadedChunks }); } // 3. 全新上传 return res.json({ fileExists: false, uploadedChunks: [] }); });这里有一个体验层面的细节:如果文件已经完整上传过,服务端直接返回fileExists: true,前端就能立即给出“秒传成功”的反馈。如果临时目录还有残留分片,返回的uploadedChunks数组就能让前端精准跳过这些分片。哪怕用户中途关了浏览器,只要服务端没有清理临时目录,下次重新选择同一个文件,依旧能继续,不用从头再来。5. 实际运营中挖到的坑与性能优化5.1 我踩过的几个真实大坑代码写完只是第一步。我在实际项目中跑这套方案时,又遇到了很多文档里不会写的东西,这里详述几个影响最大的。坑1:文件哈希计算卡死主线程一个2GB文件,在Web Worker里算MD5,大概需要几秒到十几秒。如果你把这段代码放在主页面线程跑,用户能明显感觉到页面卡住,点击、滚动都没反应。解决方案就是Web Worker。切分文件本身也要放在Worker里和哈希计算一起做,主线程只负责展示状态。在Worker里做增量哈希的代码大致是这个结构:// hash-worker.js self.importScripts('/spark-md5.min.js'); self.onmessage = async (event) => { const file = event.data.file; const chunkSize = 5 * 1024 * 1024; const spark = new SparkMD5.ArrayBuffer(); let offset = 0; while (offset < file.size) { const end = Math.min(offset + chunkSize, file.size); const chunk = file.slice(offset, end); const buffer = await chunk.arrayBuffer(); spark.append(buffer); offset = end; self.postMessage({ type: 'progress', percent: Math.round(offset / file.size * 100) }); } const fileHash = spark.end(); self.postMessage({ type: 'done', fileHash }); };主线程只负责postMessage把文件传进去、监听返回消息即可。这样即使在算哈希期间,用户也完全无感。坑2:并发数开太高导致内存爆炸我曾经把并发数调到6,觉得“反正浏览器限制6个连接,那就发满”。结果上传一个8GB文件时,页面内存直接冲到700MB,低配机器直接白屏。原因很简单:每个XHR请求在发送时,都会把对应的分片Blob读入内存,6个同时读,内存峰值就是6 × 5MB的缓冲区加各种开销。实际运营中,并发数设在3比较稳健,2更保守。不要顶着上限跑,优先保证稳定性。坑3:服务端分片写入用了append模式最早的版本我在服务端用fs.appendFile写入分片,想着“反正只写一次,追加和覆盖没区别”。直到有一次网络重传,同一个分片被前端重发了一次,服务端毫无防备地把两段数据都写进了.part文件,最终合并出的文件直接损坏,而且损坏方式非常隐蔽。修复方案上面也写了:分片写入必须用writeFile覆盖写,保证接口幂等。前端的重试机制越是完善,这类问题就越要提前堵住。坑4:Nginx默认限制把你卡死在门外本地联调一切正常,部署到测试环境之后,上传超过1MB就直接报413,查了半天才发现是Nginx的client_max_body_size默认只有1MB。后端代码再正确,请求也到不了应用层。这个问题简单但致命,部署前记得检查网关层配置,包括client_max_body_size和proxy_read_timeout,后者要改大,因为大文件上传吃力不讨好,一个慢请求可能在网关层就被强杀。坑5:临时目录垃圾文件堆积断点续传依赖临时目录里的分片文件,但用户一旦中途放弃上传,这些分片就永远留在服务器上,日积月累会吃掉大量磁盘。我在服务端加了一个定时清理任务:每隔1小时扫描临时目录,删除创建时间超过24小时且未完成合并的分片文件夹。这个逻辑不需要多复杂,定期跑一个脚本就行,但一定要有,不然迟早出磁盘告警。5.2 常见问题速查表我把几个高频问题和对应方案整理成一个表,方便你遇到问题时直接查:问题现象根本原因解决方案断点续传后文件损坏文件哈希不准确,续传时基于错误的分片状态哈希必须基于完整文件内容,不能只对文件头部采样合并后文件比原文件小合并前未校验所有分片是否存在合并时逐索引检查.part文件,缺失则拒绝合并进度条卡在99%不动最后一个分片太小,按分片数计算进度导致跳档全局进度按字节数累计,不按分片数累计并发上传时部分分片频繁失败网络丢包严重或服务端连接被网关关闭指数退避重试,重试3次仍失败再整体中止上传1GB文件页面卡死哈希计算在主线程执行采用Web Worker做增量计算生产环境上传超过1MB报413Nginx默认client_max_body_size限制调大Nginx请求体限制,并同步调大超时时间服务端临时目录越来越大用户弃传导致分片残留加定时任务清理超期未合并的临时目录这些坑不是一次性踩完的,很多东西是你上线跑了一两个月才暴露出来。所以上线前的回归测试很重要,我建议至少准备一个2GB左右的真实大文件,把“全新上传”、“中途断网重传”、“刷新页面续传”、“秒传二次提交”四条路径全部走一遍,别让用户替你试错。最后分享一点个人体会这套代码写完之后,我最深的感触是:分片上传真正考验人的不是“切片”和“合并”这两件事,而是细节的闭环。并发控制、进度计算、重试策略、幂等写入、垃圾清理,每一项单拿出来都不难,难的是把它们组合成一个拼命不出错的整体。我自己在维护一个旧的上传模块时就发现,代码跑得是否稳定,往往取决于那些你不会写进文档里的判断——比如分片大小和并发数怎么配合,比如服务端写分片时必须幂等,这些才是真正的经验壁垒。最后提醒一句:分片上传不是银弹。如果你的业务文件普遍小于50MB,老老实实用单请求上传加超时重试就够了,分片带来的复杂度不值得。但如果你的场景是几GB甚至几十GB的文件,那这套方案值得你花一个下午把它吃透。