做网页开发这些年,大文件上传几乎是每个业务系统都绕不过去的坎。视频、安装包、离线数据包、设计稿压缩包,动不动几百MB甚至几个GB,用普通的<input type="file">直接 POST 给后端,十有八九会超时、卡死、内存爆掉。用 js 实现大文件分片上传,就是把一个超大文件切成若干小块,一片一片传上去,最后在服务端拼回原始文件。这个方案基本成了跨平台网页应用里的标准解法,不管你的用户是用PC浏览器、手机浏览器,还是套壳App里的WebView,同一套代码都能跑。这篇文章我会把分片上传的完整思路、前后端源码、断点续传和秒传的玩法,以及我踩过的坑都写出来,适合正在做文件上传功能的前端或全栈开发参考,也适合刚接触这个方案的人照着抄。先说清楚,我不会给一个只能点按钮上传的玩具Demo,而是会拆解每一个关键步骤的“为什么”,再给能跑的源码。前端用原生 JavaScript + Fetch API,后端用 Node.js 的 Express 做演示,跨平台指的就是浏览器环境不区分系统。你可以在本地把整套东西跑起来,然后对着代码理解每一步在做什么。1. 为什么上传大文件必须分片1.1 直传大文件的三个硬伤很多刚入行的同学会有疑惑:HTTP 协议本身不限制请求体大小,直接把整个文件塞进请求体里发过去不行吗?理论上是“能发”,但在真实生产环境里,直传大文件会撞上三面墙。第一面墙是连接超时。浏览器和后端之间通常经过 Nginx 或网关,这些中间层一般有默认的请求超时时间,比如 Nginx 默认 60 秒。一个 1GB 文件如果网速一般,要传十几分钟,连接早被掐断了。就算你调大了客户端超时,中间任何一层代理都可能主动断开。第二面墙是服务端内存。后端如果用传统的 multipart 解析,通常会把整个请求体读入内存再落盘。一个 500MB 的文件传上去,Node.js 默认 1.5GB 的内存可能直接被打满;并发几个用户,服务直接宕机。即便用磁盘缓冲,请求体一旦因为网络抖动中断,临时文件也可能残留,回收机制还得另做。第三面墙是失败成本。直传一旦失败,整个文件就要重新传。文件越大,失败概率越高,重传成本呈指数上升。用户体验是“传了两个小时,最后提示失败”,这种产品基本留不住用户。分片上传就是绕开这三面墙的通用思路:把大文件物理切割成若干小块,每块几MB,分别上传。单个分片的请求体很小,不会触发超时,也不会占用大量内存,传失败只需要重传那一片,而不是整个文件。1.2 分片上传到底在解决什么问题分片上传的核心可以概括为八个字:化整为零,合零为整。前端把文件按字节区间切割成多个 chunk,每个 chunk 独立发送;后端收到所有 chunk 后,按照分片序号拼接回原始文件。这个方案天然带来三个能力:断点续传:已经传成功的分片会被记录下来,下次继续传时直接跳过这些分片,不用从头再来。并发上传:多个分片可以并行发送,充分利用带宽,比单连接串行上传速度更快。失败隔离:某个分片失败只需要重试这一个分片,成本极小。用一个生活化的类比:搬家时几个大柜子没法整体搬进电梯,师傅会先把柜子拆成木板,分批搬上楼,到屋里再按编号重新组装。如果路上有一块木板磕坏了,只需要重新加工这一块,不用把整柜子重搬一遍。分片上传就是给文件做的“拆装搬家”。1.3 跨平台网页应用的技术选型所谓的“跨平台网页应用”,在文件上传这个场景里,绝大多数情况指的是:同一套前端代码能运行在 Windows / macOS / Linux 桌面浏览器、Android / iOS 手机浏览器,以及微信或企业App内置的 WebView 中。这些环境的底层都是浏览器提供的 Web API,因此只要我们只用标准能力,就能做到跨平台。实现分片上传我们需要三样东西:File API:让用户选择的文件对象可以被程序读取。Blob.slice 方法:把文件按字节区间切出子片段。XMLHttpRequest 或 Fetch:把每个分片异步发送给后端。其中Blob.slice是老早就被浏览器广泛支持的能力,现在所有主流浏览器包括移动端都支持。后端用什么语言不挑,只要实现接收分片和合并分片的 HTTP 接口即可。这里我用 Node.js 演示,是因为前端开发者最容易读懂 JavaScript 后端代码,而且同一套语言可以方便地调试。生产环境换成 Java、Go、Python 思路完全一致。2. 前端分片上传的核心设计与源码2.1 整体流程:从选文件到上传完成一个完整的分片上传前端流程,需要拆成下面这几个步骤:用户选择文件。计算文件的唯一标识(hash),用于断点续传和秒传。确定分片大小,将文件切割成若干个分片。初始化上传任务,把文件标识和分片信息发给后端,后端返回uploadId。启动并发队列,逐个上传分片。所有分片上传完成后,调用后端的合并接口。后端合并完成,返回文件的访问地址。这里的第 4 步不是必须的。如果你只是做最简单的分片上传,可以不上传初始化接口,直接把文件标识放在每个分片的表单字段里。但有了初始化接口,我们能更好地做任务注册、断点续传和分片状态管理。下面我会给出一套带有初始化接口的实现,因为这是生产环境里最实用的形态。2.2 分片大小怎么定分片大小没有绝对标准,需要结合业务场景权衡。常用的分片大小是1MB ~ 10MB。分片太小,比如 128KB,一个 1GB 文件会被切成 8192 片,产生大量 HTTP 请求。前端每发一个请求都有开销,后端每个分片都要落盘和合并,整体效率反而很低。分片太大,比如 50MB,虽然请求数量少了,但单个分片失败重传的成本变大,而且容易触发网关的超时限制。如果用户网络环境差,50MB 的单个请求依然可能断掉。我的经验是:如果只是内部业务系统,网络相对可靠,可以用10MB;如果面向公网用户且网络波动大,用2MB ~ 5MB会更稳。后面的源码示例用 5MB,这是一个比较均衡的数值。做一个简单计算:一个 500MB 的视频文件,按 5MB 分片,一共 100 片。并发数控制为 3,那么至少需要 34 轮请求才能完成。如果单片上传平均 300ms,理想情况下总耗时大约 10 秒,带宽利用率也远高于串行上传。2.3 文件切片与上传源码下面给出前端核心源码。这段代码不依赖任何框架,可以直接放在一个 HTML 文件中运行。为了便于理解,我把功能拆成几个函数。// 配置项 const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const MAX_CONCURRENT = 3; // 最大并发数 const SERVER_BASE = 'http://localhost:3000'; // 工具函数:生成文件唯一标识(简化版,抽样 hash) async function computeFileIdentifier(file) { // 取第一个分片、中间位置的一个分片、最后一个分片,拼接后做 SHA-256 const size = file.size; const sampleSize = Math.min(CHUNK_SIZE, size); const first = file.slice(0, sampleSize); const middle = file.slice(Math.max(0, Math.floor(size / 2) - sampleSize / 2), Math.floor(size / 2) + sampleSize / 2); const last = file.slice(size - sampleSize, size); const parts = [first, middle, last]; const buffer = await new Response(new Blob(parts)).arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', buffer); return [...new Uint8Array(digest)] .map((b) => b.toString(16).padStart(2, '0')) .join(''); } // 将文件切片 function createChunks(file, chunkSize) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end), start, end }); start = end; } return chunks; } // 上传单个分片 async function uploadChunk(uploadId, chunk, fileIdentifier) { const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('index', chunk.index); formData.append('fileIdentifier', fileIdentifier); formData.append('chunk', chunk.blob, `${fileIdentifier}-${chunk.index}.part`); const response = await fetch(`${SERVER_BASE}/api/upload/chunk`, { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`分片 ${chunk.index} 上传失败: ${response.status}`); } return response.json(); } // 初始化上传任务 async function initUpload(fileIdentifier, fileName, totalSize, totalChunks) { const response = await fetch(`${SERVER_BASE}/api/upload/init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileIdentifier, fileName, totalSize, totalChunks }) }); if (!response.ok) throw new Error('初始化上传失败'); return response.json(); } // 通知后端合并分片 async function completeUpload(uploadId, fileIdentifier, fileName) { const response = await fetch(`${SERVER_BASE}/api/upload/merge`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, fileIdentifier, fileName }) }); if (!response.ok) throw new Error('合并文件失败'); return response.json(); }上面这段代码,computeFileIdentifier是后续断点续传和秒传的基础,我故意用了抽样 hash 而不是对整个文件做 hash,是为了避免把大文件全部读入内存。这里用了crypto.subtle.digest,但需要注意,它只在 HTTPS 或 localhost 环境下可用,本地开发没问题,部署到生产环境必须有 HTTPS。2.4 并发控制与进度计算并发控制是分片上传里非常容易翻车的地方。如果一股脑把所有分片Promise.all发出去,几百个请求同时打向后端,服务端连接数、文件描述符很容易被打满;如果逐个串行上传,速度又太慢。所以需要一个“可同时执行 N 个任务的队列”。下面是一个简易的并发队列实现:// 并发执行任务队列 async function runWithConcurrency(tasks, limit) { const results = []; let index = 0; async function worker() { while (index < tasks.length) { const current = index++; try { results[current] = await tasks[current](); } catch (err) { results[current] = { error: err }; } } } const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker()); await Promise.all(workers); return results; } // 上传所有分片 async function uploadAll(file) { const fileIdentifier = await computeFileIdentifier(file); const chunks = createChunks(file, CHUNK_SIZE); // 初始化任务 const task = await initUpload( fileIdentifier, file.name, file.size, chunks.length ); const uploadId = task.uploadId; // 构造分片上传函数列表 const tasks = chunks.map((chunk) => () => uploadChunk(uploadId, chunk, fileIdentifier)); // 并发执行 await runWithConcurrency(tasks, MAX_CONCURRENT); // 全部完成后触发合并 const result = await completeUpload(uploadId, fileIdentifier, file.name); console.log('上传完成:', result.url); return result; }这个实现里,每个 worker 都是一个独立的循环,不断从任务队列里取下一个任务执行。limit控制同时有几个 worker 在跑。代码里有一个很关键的细节:const current = index++是原子递增的,不会有多个 worker 拿到同一个任务,这也是队列并发里最常见的写法。进度计算我用了一个比较朴素的口径:每个分片上传成功后,把已上传分片数加一,计算已上传分片数 / 总分片数。如果想更精确地显示字节进度,可以维护一个uploadedBytes变量,每片成功后就加上这一片的大小。注意最后一片通常小于分片大小,所以按“片数”算百分比在最后可能会跳一下。更稳妥的做法是记录每个分片的真实字节数,并用字节数计算进度。3. 后端接口设计与合并源码3.1 接口定义:初始化、分片上传、合并前端只有接口调用,后端才是真正承接分片数据的角色。下面用 Express + Multer 实现。需要先安装依赖:npm install express multer接口设计如下:接口路径方法作用主要参数/api/upload/initPOST创建上传任务,返回 uploadIdfileIdentifier, fileName, totalSize, totalChunks/api/upload/chunkPOST接收单个分片并保存uploadId, index, chunk 文件/api/upload/mergePOST合并所有分片为最终文件uploadId, fileIdentifier, fileName/api/upload/statusGET查询某个任务已上传分片序号uploadId这里的核心是uploadId,它把一次上传任务的所有分片绑定在一起。实际生产中可以用数据库记录任务信息,Demo 阶段我用文件目录结构来模拟:每个uploadId对应一个临时目录,目录里放着序号命名的.part文件。3.2 后端接收分片与临时存储先初始化一个临时目录,然后让 Multer 把上传的分片写入磁盘,而不是保留在内存里:const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const app = express(); app.use(express.json()); const UPLOAD_TMP_DIR = path.join(__dirname, 'uploads/tmp'); const UPLOAD_FINAL_DIR = path.join(__dirname, 'uploads/final'); fs.mkdirSync(UPLOAD_TMP_DIR, { recursive: true }); fs.mkdirSync(UPLOAD_FINAL_DIR, { recursive: true }); // multer 直接落盘到临时目录 const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadId = req.body.uploadId; const dir = path.join(UPLOAD_TMP_DIR, uploadId); fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (req, file, cb) => { cb(null, `${req.body.index}.part`); } }); const upload = multer({ storage }); // 1. 初始化上传任务 app.post('/api/upload/init', (req, res) => { const { fileIdentifier, fileName, totalSize, totalChunks } = req.body; const uploadId = crypto.randomUUID(); const taskInfo = { uploadId, fileIdentifier, fileName, totalSize, totalChunks, uploadedChunks: [], createdAt: Date.now() }; // 生产环境这里应写入数据库,这里直接写入 JSON 文件 fs.writeFileSync( path.join(UPLOAD_TMP_DIR, `${uploadId}.json`), JSON.stringify(taskInfo) ); res.json({ uploadId }); }); // 2. 上传分片 app.post('/api/upload/chunk', upload.single('chunk'), (req, res) => { const { uploadId, index } = req.body; if (!req.file) { return res.status(400).json({ error: '没有收到分片文件' }); } const taskFile = path.join(UPLOAD_TMP_DIR, `${uploadId}.json`); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: '上传任务不存在' }); } // 更新任务信息中的已上传分片 const taskInfo = JSON.parse(fs.readFileSync(taskFile, 'utf-8')); if (!taskInfo.uploadedChunks.includes(Number(index))) { taskInfo.uploadedChunks.push(Number(index)); fs.writeFileSync(taskFile, JSON.stringify(taskInfo)); } res.json({ received: Number(index) }); });注意一个细节:分片文件在filename里用的是req.body.index,所以 Multer 会按照前端传的序号命名。这样后端不需要关心分片到达的顺序,先把它们全部落盘,最终合并时按文件名排序即可。3.3 合并分片时的关键点合并接口要做的事情是把临时目录下的所有.part文件按序号拼接成最终文件。这里有一个非常典型的坑:如果直接用fs.readdirSync拿到文件名再排序,通常会得到1.part、10.part、2.part这样的字典序,合并出来的文件必然损坏。必须用自然数排序。// 3. 合并分片 app.post('/api/upload/merge', (req, res) => { const { uploadId, fileIdentifier, fileName } = req.body; const taskFile = path.join(UPLOAD_TMP_DIR, `${uploadId}.json`); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: '上传任务不存在' }); } const taskInfo = JSON.parse(fs.readFileSync(taskFile, 'utf-8')); const chunkDir = path.join(UPLOAD_TMP_DIR, uploadId); const partFiles = fs.readdirSync(chunkDir).filter((f) => f.endsWith('.part')); // 检查分片数量是否完整 if (partFiles.length !== taskInfo.totalChunks) { return res.status(400).json({ error: '分片不完整,无法合并' }); } // 按自然数排序,而不是字典序 partFiles.sort((a, b) => Number(a.split('.')[0]) - Number(b.split('.')[0])); const finalPath = path.join( UPLOAD_FINAL_DIR, `${fileIdentifier}_${Date.now()}_${fileName}` ); // 用流方式按顺序写入,避免一次性把大文件读入内存 const writeStream = fs.createWriteStream(finalPath); const writeNext = (index) => { if (index >= partFiles.length) { writeStream.end(); return; } const readStream = fs.createReadStream(path.join(chunkDir, partFiles[index])); readStream.pipe(writeStream, { end: false }); readStream.on('end', () => writeNext(index + 1)); readStream.on('error', (err) => { writeStream.destroy(); res.status(500).json({ error: err.message }); }); }; writeNext(0); writeStream.on('finish', () => { // 清除临时目录和任务文件 fs.rmSync(chunkDir, { recursive: true, force: true }); fs.rmSync(taskFile, { force: true }); res.json({ url: `/files/${path.basename(finalPath)}`, size: fs.statSync(finalPath).size }); }); });这段代码里用readStream.pipe(writeStream, { end: false })的方式逐片拼接,是为了避免对大文件使用Buffer.concat。如果全部读进内存再写,又回到了最初“内存爆掉”的老问题。用流式写入,无论文件多大,内存占用始终稳定。当然这个合并写法是串行的,速度会受磁盘影响。如果分片数量很大,可以考虑用stream.pipeline配合异步循环,或者直接用fs.open按字节写入,核心思路都一样:保持常量内存占用。3.4 静态文件访问与临时文件清理为了让合并后的文件能被访问,加一行静态目录映射:app.use('/files', express.static(UPLOAD_FINAL_DIR));临时文件的清理是一个容易被忽略的问题。用户上传到一半关闭页面,uploadId对应的临时目录就会残留。生产环境要通过定时任务扫描uploads/tmp目录,删除创建时间超过 24 小时且未完成的任务。这里给一个简单的定时清理思路:setInterval(() => { const files = fs.readdirSync(UPLOAD_TMP_DIR); for (const f of files) { const fullPath = path.join(UPLOAD_TMP_DIR, f); const stat = fs.statSync(fullPath); if (Date.now() - stat.mtimeMs > 24 * 60 * 60 * 1000) { fs.rmSync(fullPath, { recursive: true, force: true }); } } }, 60 * 60 * 1000);这个定时器在生产环境通常会被替换成消息队列或定时任务框架,但思路不变:过期未完成的任务必须清理,否则磁盘会被垃圾分片占满。4. 断点续传与秒传的实现方案4.1 文件唯一标识怎么算断点续传和秒传都依赖同一个东西:文件唯一标识。浏览器端不可能完全信任用户传入的文件名,因为同名文件可能是不同内容;也不能只用文件大小,因为不同文件大小可能相同。最可靠的是对文件内容计算 hash,但大文件全量 hash 在浏览器里会比较耗时。我在前面采用了抽样 hash:取文件头部、中间、尾部各一个分片长度的数据,拼接后计算 SHA-256。这个方案的优势是快,缺点是理论上存在碰撞可能,两个内容大部分相同的文件可能产生相同标识。对于一般业务场景基本够用,但如果要上传的是重要合规文件,需要全量增量 hash。前端可以用 SparkMD5 的增量模式,在切片时一边切一边更新 hash,文件读完 hash 也就算完了,内存占用也不大。战斗在前端的人应该都明白一个道理:不能为了“绝对准确”把计算 hash 变成新的性能瓶颈。实际项目中,我更倾向于用“抽样 hash + 文件大小 + 最后修改时间”组合成标识,以降低碰撞概率。4.2 查询已上传分片并跳过断点续传的典型场景是:用户传了 60% 断了,刷新页面或者重新选择同一文件后,希望能从 60% 继续,而不是从零开始。实现断点续传的关键是“查询分片状态”。后端在初始化接口创建任务时,已经记录了uploadedChunks数组。前端拿到文件标识后,可以调用状态查询接口,获取已经上传成功的分片序号,然后只提交缺失的分片。后端增加一个查询接口:app.get('/api/upload/status', (req, res) => { const { uploadId } = req.query; const taskFile = path.join(UPLOAD_TMP_DIR, `${uploadId}.json`); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: '任务不存在' }); } const taskInfo = JSON.parse(fs.readFileSync(taskFile, 'utf-8')); res.json({ uploadedChunks: taskInfo.uploadedChunks, totalChunks: taskInfo.totalChunks }); });前端在上传前先查询已经上传的分片:async function getUploadedChunks(uploadId) { const response = await fetch(`${SERVER_BASE}/api/upload/status?uploadId=${uploadId}`); if (!response.ok) return []; const data = await response.json(); return data.uploadedChunks; } // 过滤未上传分片 async function uploadRemainingChunks(file, uploadId, fileIdentifier, chunks) { const uploadedChunks = new Set(await getUploadedChunks(uploadId)); const pendingTasks = chunks .filter((chunk) => !uploadedChunks.has(chunk.index)) .map((chunk) => () => uploadChunk(uploadId, chunk, fileIdentifier)); await runWithConcurrency(pendingTasks, MAX_CONCURRENT); }这里有个前提:用户必须能拿到同一个uploadId才能真正实现续传。最简单的方式是把uploadId存在localStorage里,key 为文件标识。下次选到同一文件时,先用文件标识去localStorage找之前未完成的uploadId,找到就直接继续上传,找不到才重新初始化。4.3 秒传:文件相同直接注册秒传的本质是“这个文件别人已经传过了,服务器上已经有相同文件,不需要再传”。它依赖文件唯一标识在服务端去重。如果服务端已经有一个文件的 hash 是abc123,现在某个用户上传初始化时也带abc123,服务端可以检查存储目录里是否已经存在相同 hash 的文件,如果存在,直接将新任务标记为上传完成,返回最终的访问地址。前端甚至不需要上传任何分片。在 Demo 里可以这样实现:初始化接口里加一个检查逻辑。app.post('/api/upload/init', (req, res) => { const { fileIdentifier, fileName, totalSize, totalChunks } = req.body; // 秒传检查:已经存在同名 hash 的最终文件 const existingFiles = fs.readdirSync(UPLOAD_FINAL_DIR); const existed = existingFiles.find((f) => f.startsWith(`${fileIdentifier}_`)); if (existed) { return res.json({ uploadId: null, instant: true, url: `/files/${existed}` }); } const uploadId = crypto.randomUUID(); // ... 其余逻辑不变 });前端在初始化后判断instant === true,就直接显示上传完成,不需要再走切分和上传流程。秒传是用户体感提升最明显的功能。一个 2GB 文件,如果别人传过,你点击上传的一瞬间就完成了。这里要注意的是:秒传的可靠性完全取决于 hash 的质量。抽样 hash 如果碰撞,可能把两个不同的文件当成同一个,造成用户数据丢失。因此涉及秒传的业务,最好使用全量 hash,或者在后端对相同 hash 的文件再比较文件大小,甚至抽样字节对比,做到多重校验。5. 常见问题与排查技巧实录5.1 合并出来的文件损坏这是分片上传最常遇到的问题,没有之一。文件合并完打开提示损坏,或者解压失败,通常原因有四个:分片顺序错误:没按自然序号排序,用了字典序。分片缺失就合并:没有检查partFiles.length === totalChunks。前端切片时字节偏移算错:file.slice的起始位置和结束位置没有正确衔接。后端合并时流式写入没有正确处理end事件,导致部分分片写入不完整。排查时,先看后端合并日志,打印每个参与合并的分片文件大小,把它们的字节数加起来和原始文件大小对比。如果总字节数比原文件少,说明有分片没接受完整;如果大小一致但还是打不开,重点看排序逻辑。我自己在写合并逻辑时曾经犯过一个很低级的错误:直接对文件名数组调用.sort(),结果2.part排在10.part后面。后来我把所有分片名改成在初始化时用String(index).padStart(6, '0')补零命名,比如000002.part、000010.part,这样字符串排序也等于自然排序,一劳永逸。5.2 并发过高导致服务端大量报错并发数设得过高,后端的文件句柄、内存、网络连接都会吃紧。我见过有人直接把并发设成 10,传一个 1GB 文件,后端 Nginx 瞬间冒出大量Connection reset by peer。控制好前端并发数是最直接的解决办法,通常3 ~ 5就够了,分片上传主要靠减少单请求字节数和失败重试成本来提升体验,而不是靠无限并发。另一个容易被忽略的点是后端要支持“分片乱序到达”。前端并发上传时,分片到达后端的顺序是不确定的,后端不能假设分片会按序号依次到达。所以存储分片时一定用独立文件名落盘,不要用“追加写入”的方式。之前有同学用fs.createWriteStream(path, { flags: 'a' })来写分片,并发一开,两个分片就交错写进同一个文件,文件数据全乱了。5.3 浏览器的兼容性细节现在的浏览器对Blob.slice支持已经非常好,但如果你还需要兼容老版本 Safari,注意它的 API 曾经是file.webkitSlice()。稳妥的写法是做一个兼容封装:function sliceFile(file, start, end) { if (file.slice) return file.slice(start, end); if (file.webkitSlice) return file.webkitSlice(start, end); throw new Error('当前浏览器不支持文件切片'); }跨平台网页应用最容易翻车的不是桌面浏览器,而是各种 App 里的 WebView。部分安卓 WebView 对FormData里同时传输普通字段和文件的支持有历史 bug,如果遇到上传分片时后端收不到index字段,可以尝试把index放在 URL query 上,而不是表单字段里:// 兼容写法:把业务参数放到 URL 参数上 fetch(`${SERVER_BASE}/api/upload/chunk?uploadId=${uploadId}&index=${chunk.index}`, { method: 'POST', body: formData });5.4 进度条跳动和百分比不准进度条常见的问题是:每传一个分片,进度条跳一格,到了最后几片跳得特别快。原因是前面提到过的,采用“分片数量”计算进度,而每个分片大小不完全相同。特别是最后一个分片可能只有几KB,显示时就会出现“从 98% 跳到 100%”的突兀感。更好的做法是用字节数统计。初始化时拿到总大小,每次分片上传成功后记录该分片的end - start字节数,累计uploadedBytes,然后用uploadedBytes / file.size计算百分比。这样即使每个分片大小不齐,进度条也是平滑的。如果要做断点续传的进度恢复,还要把本地缓存的uploadedBytes和后端返回的已上传分片字节数对齐,否则刷新后进度会倒退。5.5 取消上传与网络切换用户点了取消按钮,还在进行中的所有分片请求应该被中止。Fetch 可以用AbortController实现:const controller = new AbortController(); async function uploadChunk(uploadId, chunk, fileIdentifier, signal) { // ... fetch(`${SERVER_BASE}/api/upload/chunk`, { method: 'POST', body: formData, signal }); } // 取消时调用 controller.abort();中断之后,服务端的临时分片并不会自动删除,所以最好在取消时调用一个清理接口,或者依赖前文提到的过期清理任务。网络切换是移动端的高频问题。Wi-Fi 切到 4G 时,TCP 连接大概率会断。这种情况下,前端应该监听navigator.onLine或online/offline事件,在恢复在线后自动检查未完成任务并续传。window.addEventListener('online', () => { // 重新查询未完成任务并继续上传 resumePendingUpload(); });6. 从 Demo 走向生产:还要做的几件事到这里,一套可以跑通的分片上传已经完成了:前端切片、并发上传、后端接收、断点合并、断点续传、秒传。但要把这套代码放到真实生产环境,我建议你至少再补齐以下内容。第一件事是鉴权。文件上传接口必须校验用户登录状态和上传权限,否则任何人都可以把文件扔到你的存储目录里。常见做法是在初始化接口时校验 Token,然后把用户信息写入任务记录;后续每个分片请求都带 Token,后端在uploadId对应的任务里校验是否属于当前用户。第二件事是文件元数据入库。Demo 里任务信息和最终文件都只有目录里的 JSON,生产环境要把文件标识、上传用户、文件路径、大小、上传时间、状态存进数据库,否则没法做文件管理、统计和权限控制。第三件事是存储层替换。现在的代码是把文件存到本地磁盘,生产环境通常要接对象存储服务。很多云厂商的 SDK 本身就支持分片上传,思路和你现在看的这套完全一致,只是把后端的“接收分片并合并”换成了“调用对象存储的分片上传接口”。第四件事是上传进度心跳。如果用户在超长上传过程中长时间没有任何分片完成,后端需要知道任务还活着。可以让前端定期上报心跳,或者用“最近一次分片上传时间”来判断任务是否超时。第五件事是失败重试策略。我在 Demo 里没有写重试逻辑,生产环境需要给每个分片设置最大重试次数,比如 3 次。重试要有退避策略,不能失败后立刻疯狂重发,尤其并发场景下会放大服务端压力。我习惯的做法是:指数退避,第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,超过上限就中断任务并提示用户。最后分享一个我个人的习惯:分片上传调试时,不要一上来就用几个 GB 的文件。先用一个 10MB 左右的小文件,把分片大小临时调成 1MB,确认整个流程能跑通、合并后的文件 MD5 和原文件一致,再逐步加大文件体积。我调试这套代码时,光是合并顺序的坑就踩过两次。把基础流程跑稳,后面加断点续传和秒传都是在稳定地基上添砖加瓦,排查问题时也能更快定位到具体环节。希望这份源码和踩坑记录能帮你少走几段弯路。
做网页开发这些年,大文件上传几乎是每个业务系统都绕不过去的坎。视频、安装包、离线数据包、设计稿压缩包,动不动几百MB甚至几个GB,用普通的<input type="file">直接 POST 给后端,十有八九会超时、卡死、内存爆掉。用 js 实现大文件分片上传,就是把一个超大文件切成若干小块,一片一片传上去,最后在服务端拼回原始文件。这个方案基本成了跨平台网页应用里的标准解法,不管你的用户是用PC浏览器、手机浏览器,还是套壳App里的WebView,同一套代码都能跑。这篇文章我会把分片上传的完整思路、前后端源码、断点续传和秒传的玩法,以及我踩过的坑都写出来,适合正在做文件上传功能的前端或全栈开发参考,也适合刚接触这个方案的人照着抄。先说清楚,我不会给一个只能点按钮上传的玩具Demo,而是会拆解每一个关键步骤的“为什么”,再给能跑的源码。前端用原生 JavaScript + Fetch API,后端用 Node.js 的 Express 做演示,跨平台指的就是浏览器环境不区分系统。你可以在本地把整套东西跑起来,然后对着代码理解每一步在做什么。1. 为什么上传大文件必须分片1.1 直传大文件的三个硬伤很多刚入行的同学会有疑惑:HTTP 协议本身不限制请求体大小,直接把整个文件塞进请求体里发过去不行吗?理论上是“能发”,但在真实生产环境里,直传大文件会撞上三面墙。第一面墙是连接超时。浏览器和后端之间通常经过 Nginx 或网关,这些中间层一般有默认的请求超时时间,比如 Nginx 默认 60 秒。一个 1GB 文件如果网速一般,要传十几分钟,连接早被掐断了。就算你调大了客户端超时,中间任何一层代理都可能主动断开。第二面墙是服务端内存。后端如果用传统的 multipart 解析,通常会把整个请求体读入内存再落盘。一个 500MB 的文件传上去,Node.js 默认 1.5GB 的内存可能直接被打满;并发几个用户,服务直接宕机。即便用磁盘缓冲,请求体一旦因为网络抖动中断,临时文件也可能残留,回收机制还得另做。第三面墙是失败成本。直传一旦失败,整个文件就要重新传。文件越大,失败概率越高,重传成本呈指数上升。用户体验是“传了两个小时,最后提示失败”,这种产品基本留不住用户。分片上传就是绕开这三面墙的通用思路:把大文件物理切割成若干小块,每块几MB,分别上传。单个分片的请求体很小,不会触发超时,也不会占用大量内存,传失败只需要重传那一片,而不是整个文件。1.2 分片上传到底在解决什么问题分片上传的核心可以概括为八个字:化整为零,合零为整。前端把文件按字节区间切割成多个 chunk,每个 chunk 独立发送;后端收到所有 chunk 后,按照分片序号拼接回原始文件。这个方案天然带来三个能力:断点续传:已经传成功的分片会被记录下来,下次继续传时直接跳过这些分片,不用从头再来。并发上传:多个分片可以并行发送,充分利用带宽,比单连接串行上传速度更快。失败隔离:某个分片失败只需要重试这一个分片,成本极小。用一个生活化的类比:搬家时几个大柜子没法整体搬进电梯,师傅会先把柜子拆成木板,分批搬上楼,到屋里再按编号重新组装。如果路上有一块木板磕坏了,只需要重新加工这一块,不用把整柜子重搬一遍。分片上传就是给文件做的“拆装搬家”。1.3 跨平台网页应用的技术选型所谓的“跨平台网页应用”,在文件上传这个场景里,绝大多数情况指的是:同一套前端代码能运行在 Windows / macOS / Linux 桌面浏览器、Android / iOS 手机浏览器,以及微信或企业App内置的 WebView 中。这些环境的底层都是浏览器提供的 Web API,因此只要我们只用标准能力,就能做到跨平台。实现分片上传我们需要三样东西:File API:让用户选择的文件对象可以被程序读取。Blob.slice 方法:把文件按字节区间切出子片段。XMLHttpRequest 或 Fetch:把每个分片异步发送给后端。其中Blob.slice是老早就被浏览器广泛支持的能力,现在所有主流浏览器包括移动端都支持。后端用什么语言不挑,只要实现接收分片和合并分片的 HTTP 接口即可。这里我用 Node.js 演示,是因为前端开发者最容易读懂 JavaScript 后端代码,而且同一套语言可以方便地调试。生产环境换成 Java、Go、Python 思路完全一致。2. 前端分片上传的核心设计与源码2.1 整体流程:从选文件到上传完成一个完整的分片上传前端流程,需要拆成下面这几个步骤:用户选择文件。计算文件的唯一标识(hash),用于断点续传和秒传。确定分片大小,将文件切割成若干个分片。初始化上传任务,把文件标识和分片信息发给后端,后端返回uploadId。启动并发队列,逐个上传分片。所有分片上传完成后,调用后端的合并接口。后端合并完成,返回文件的访问地址。这里的第 4 步不是必须的。如果你只是做最简单的分片上传,可以不上传初始化接口,直接把文件标识放在每个分片的表单字段里。但有了初始化接口,我们能更好地做任务注册、断点续传和分片状态管理。下面我会给出一套带有初始化接口的实现,因为这是生产环境里最实用的形态。2.2 分片大小怎么定分片大小没有绝对标准,需要结合业务场景权衡。常用的分片大小是1MB ~ 10MB。分片太小,比如 128KB,一个 1GB 文件会被切成 8192 片,产生大量 HTTP 请求。前端每发一个请求都有开销,后端每个分片都要落盘和合并,整体效率反而很低。分片太大,比如 50MB,虽然请求数量少了,但单个分片失败重传的成本变大,而且容易触发网关的超时限制。如果用户网络环境差,50MB 的单个请求依然可能断掉。我的经验是:如果只是内部业务系统,网络相对可靠,可以用10MB;如果面向公网用户且网络波动大,用2MB ~ 5MB会更稳。后面的源码示例用 5MB,这是一个比较均衡的数值。做一个简单计算:一个 500MB 的视频文件,按 5MB 分片,一共 100 片。并发数控制为 3,那么至少需要 34 轮请求才能完成。如果单片上传平均 300ms,理想情况下总耗时大约 10 秒,带宽利用率也远高于串行上传。2.3 文件切片与上传源码下面给出前端核心源码。这段代码不依赖任何框架,可以直接放在一个 HTML 文件中运行。为了便于理解,我把功能拆成几个函数。// 配置项 const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB const MAX_CONCURRENT = 3; // 最大并发数 const SERVER_BASE = 'http://localhost:3000'; // 工具函数:生成文件唯一标识(简化版,抽样 hash) async function computeFileIdentifier(file) { // 取第一个分片、中间位置的一个分片、最后一个分片,拼接后做 SHA-256 const size = file.size; const sampleSize = Math.min(CHUNK_SIZE, size); const first = file.slice(0, sampleSize); const middle = file.slice(Math.max(0, Math.floor(size / 2) - sampleSize / 2), Math.floor(size / 2) + sampleSize / 2); const last = file.slice(size - sampleSize, size); const parts = [first, middle, last]; const buffer = await new Response(new Blob(parts)).arrayBuffer(); const digest = await crypto.subtle.digest('SHA-256', buffer); return [...new Uint8Array(digest)] .map((b) => b.toString(16).padStart(2, '0')) .join(''); } // 将文件切片 function createChunks(file, chunkSize) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ index: chunks.length, blob: file.slice(start, end), start, end }); start = end; } return chunks; } // 上传单个分片 async function uploadChunk(uploadId, chunk, fileIdentifier) { const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('index', chunk.index); formData.append('fileIdentifier', fileIdentifier); formData.append('chunk', chunk.blob, `${fileIdentifier}-${chunk.index}.part`); const response = await fetch(`${SERVER_BASE}/api/upload/chunk`, { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`分片 ${chunk.index} 上传失败: ${response.status}`); } return response.json(); } // 初始化上传任务 async function initUpload(fileIdentifier, fileName, totalSize, totalChunks) { const response = await fetch(`${SERVER_BASE}/api/upload/init`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileIdentifier, fileName, totalSize, totalChunks }) }); if (!response.ok) throw new Error('初始化上传失败'); return response.json(); } // 通知后端合并分片 async function completeUpload(uploadId, fileIdentifier, fileName) { const response = await fetch(`${SERVER_BASE}/api/upload/merge`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId, fileIdentifier, fileName }) }); if (!response.ok) throw new Error('合并文件失败'); return response.json(); }上面这段代码,computeFileIdentifier是后续断点续传和秒传的基础,我故意用了抽样 hash 而不是对整个文件做 hash,是为了避免把大文件全部读入内存。这里用了crypto.subtle.digest,但需要注意,它只在 HTTPS 或 localhost 环境下可用,本地开发没问题,部署到生产环境必须有 HTTPS。2.4 并发控制与进度计算并发控制是分片上传里非常容易翻车的地方。如果一股脑把所有分片Promise.all发出去,几百个请求同时打向后端,服务端连接数、文件描述符很容易被打满;如果逐个串行上传,速度又太慢。所以需要一个“可同时执行 N 个任务的队列”。下面是一个简易的并发队列实现:// 并发执行任务队列 async function runWithConcurrency(tasks, limit) { const results = []; let index = 0; async function worker() { while (index < tasks.length) { const current = index++; try { results[current] = await tasks[current](); } catch (err) { results[current] = { error: err }; } } } const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker()); await Promise.all(workers); return results; } // 上传所有分片 async function uploadAll(file) { const fileIdentifier = await computeFileIdentifier(file); const chunks = createChunks(file, CHUNK_SIZE); // 初始化任务 const task = await initUpload( fileIdentifier, file.name, file.size, chunks.length ); const uploadId = task.uploadId; // 构造分片上传函数列表 const tasks = chunks.map((chunk) => () => uploadChunk(uploadId, chunk, fileIdentifier)); // 并发执行 await runWithConcurrency(tasks, MAX_CONCURRENT); // 全部完成后触发合并 const result = await completeUpload(uploadId, fileIdentifier, file.name); console.log('上传完成:', result.url); return result; }这个实现里,每个 worker 都是一个独立的循环,不断从任务队列里取下一个任务执行。limit控制同时有几个 worker 在跑。代码里有一个很关键的细节:const current = index++是原子递增的,不会有多个 worker 拿到同一个任务,这也是队列并发里最常见的写法。进度计算我用了一个比较朴素的口径:每个分片上传成功后,把已上传分片数加一,计算已上传分片数 / 总分片数。如果想更精确地显示字节进度,可以维护一个uploadedBytes变量,每片成功后就加上这一片的大小。注意最后一片通常小于分片大小,所以按“片数”算百分比在最后可能会跳一下。更稳妥的做法是记录每个分片的真实字节数,并用字节数计算进度。3. 后端接口设计与合并源码3.1 接口定义:初始化、分片上传、合并前端只有接口调用,后端才是真正承接分片数据的角色。下面用 Express + Multer 实现。需要先安装依赖:npm install express multer接口设计如下:接口路径方法作用主要参数/api/upload/initPOST创建上传任务,返回 uploadIdfileIdentifier, fileName, totalSize, totalChunks/api/upload/chunkPOST接收单个分片并保存uploadId, index, chunk 文件/api/upload/mergePOST合并所有分片为最终文件uploadId, fileIdentifier, fileName/api/upload/statusGET查询某个任务已上传分片序号uploadId这里的核心是uploadId,它把一次上传任务的所有分片绑定在一起。实际生产中可以用数据库记录任务信息,Demo 阶段我用文件目录结构来模拟:每个uploadId对应一个临时目录,目录里放着序号命名的.part文件。3.2 后端接收分片与临时存储先初始化一个临时目录,然后让 Multer 把上传的分片写入磁盘,而不是保留在内存里:const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const app = express(); app.use(express.json()); const UPLOAD_TMP_DIR = path.join(__dirname, 'uploads/tmp'); const UPLOAD_FINAL_DIR = path.join(__dirname, 'uploads/final'); fs.mkdirSync(UPLOAD_TMP_DIR, { recursive: true }); fs.mkdirSync(UPLOAD_FINAL_DIR, { recursive: true }); // multer 直接落盘到临时目录 const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadId = req.body.uploadId; const dir = path.join(UPLOAD_TMP_DIR, uploadId); fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (req, file, cb) => { cb(null, `${req.body.index}.part`); } }); const upload = multer({ storage }); // 1. 初始化上传任务 app.post('/api/upload/init', (req, res) => { const { fileIdentifier, fileName, totalSize, totalChunks } = req.body; const uploadId = crypto.randomUUID(); const taskInfo = { uploadId, fileIdentifier, fileName, totalSize, totalChunks, uploadedChunks: [], createdAt: Date.now() }; // 生产环境这里应写入数据库,这里直接写入 JSON 文件 fs.writeFileSync( path.join(UPLOAD_TMP_DIR, `${uploadId}.json`), JSON.stringify(taskInfo) ); res.json({ uploadId }); }); // 2. 上传分片 app.post('/api/upload/chunk', upload.single('chunk'), (req, res) => { const { uploadId, index } = req.body; if (!req.file) { return res.status(400).json({ error: '没有收到分片文件' }); } const taskFile = path.join(UPLOAD_TMP_DIR, `${uploadId}.json`); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: '上传任务不存在' }); } // 更新任务信息中的已上传分片 const taskInfo = JSON.parse(fs.readFileSync(taskFile, 'utf-8')); if (!taskInfo.uploadedChunks.includes(Number(index))) { taskInfo.uploadedChunks.push(Number(index)); fs.writeFileSync(taskFile, JSON.stringify(taskInfo)); } res.json({ received: Number(index) }); });注意一个细节:分片文件在filename里用的是req.body.index,所以 Multer 会按照前端传的序号命名。这样后端不需要关心分片到达的顺序,先把它们全部落盘,最终合并时按文件名排序即可。3.3 合并分片时的关键点合并接口要做的事情是把临时目录下的所有.part文件按序号拼接成最终文件。这里有一个非常典型的坑:如果直接用fs.readdirSync拿到文件名再排序,通常会得到1.part、10.part、2.part这样的字典序,合并出来的文件必然损坏。必须用自然数排序。// 3. 合并分片 app.post('/api/upload/merge', (req, res) => { const { uploadId, fileIdentifier, fileName } = req.body; const taskFile = path.join(UPLOAD_TMP_DIR, `${uploadId}.json`); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: '上传任务不存在' }); } const taskInfo = JSON.parse(fs.readFileSync(taskFile, 'utf-8')); const chunkDir = path.join(UPLOAD_TMP_DIR, uploadId); const partFiles = fs.readdirSync(chunkDir).filter((f) => f.endsWith('.part')); // 检查分片数量是否完整 if (partFiles.length !== taskInfo.totalChunks) { return res.status(400).json({ error: '分片不完整,无法合并' }); } // 按自然数排序,而不是字典序 partFiles.sort((a, b) => Number(a.split('.')[0]) - Number(b.split('.')[0])); const finalPath = path.join( UPLOAD_FINAL_DIR, `${fileIdentifier}_${Date.now()}_${fileName}` ); // 用流方式按顺序写入,避免一次性把大文件读入内存 const writeStream = fs.createWriteStream(finalPath); const writeNext = (index) => { if (index >= partFiles.length) { writeStream.end(); return; } const readStream = fs.createReadStream(path.join(chunkDir, partFiles[index])); readStream.pipe(writeStream, { end: false }); readStream.on('end', () => writeNext(index + 1)); readStream.on('error', (err) => { writeStream.destroy(); res.status(500).json({ error: err.message }); }); }; writeNext(0); writeStream.on('finish', () => { // 清除临时目录和任务文件 fs.rmSync(chunkDir, { recursive: true, force: true }); fs.rmSync(taskFile, { force: true }); res.json({ url: `/files/${path.basename(finalPath)}`, size: fs.statSync(finalPath).size }); }); });这段代码里用readStream.pipe(writeStream, { end: false })的方式逐片拼接,是为了避免对大文件使用Buffer.concat。如果全部读进内存再写,又回到了最初“内存爆掉”的老问题。用流式写入,无论文件多大,内存占用始终稳定。当然这个合并写法是串行的,速度会受磁盘影响。如果分片数量很大,可以考虑用stream.pipeline配合异步循环,或者直接用fs.open按字节写入,核心思路都一样:保持常量内存占用。3.4 静态文件访问与临时文件清理为了让合并后的文件能被访问,加一行静态目录映射:app.use('/files', express.static(UPLOAD_FINAL_DIR));临时文件的清理是一个容易被忽略的问题。用户上传到一半关闭页面,uploadId对应的临时目录就会残留。生产环境要通过定时任务扫描uploads/tmp目录,删除创建时间超过 24 小时且未完成的任务。这里给一个简单的定时清理思路:setInterval(() => { const files = fs.readdirSync(UPLOAD_TMP_DIR); for (const f of files) { const fullPath = path.join(UPLOAD_TMP_DIR, f); const stat = fs.statSync(fullPath); if (Date.now() - stat.mtimeMs > 24 * 60 * 60 * 1000) { fs.rmSync(fullPath, { recursive: true, force: true }); } } }, 60 * 60 * 1000);这个定时器在生产环境通常会被替换成消息队列或定时任务框架,但思路不变:过期未完成的任务必须清理,否则磁盘会被垃圾分片占满。4. 断点续传与秒传的实现方案4.1 文件唯一标识怎么算断点续传和秒传都依赖同一个东西:文件唯一标识。浏览器端不可能完全信任用户传入的文件名,因为同名文件可能是不同内容;也不能只用文件大小,因为不同文件大小可能相同。最可靠的是对文件内容计算 hash,但大文件全量 hash 在浏览器里会比较耗时。我在前面采用了抽样 hash:取文件头部、中间、尾部各一个分片长度的数据,拼接后计算 SHA-256。这个方案的优势是快,缺点是理论上存在碰撞可能,两个内容大部分相同的文件可能产生相同标识。对于一般业务场景基本够用,但如果要上传的是重要合规文件,需要全量增量 hash。前端可以用 SparkMD5 的增量模式,在切片时一边切一边更新 hash,文件读完 hash 也就算完了,内存占用也不大。战斗在前端的人应该都明白一个道理:不能为了“绝对准确”把计算 hash 变成新的性能瓶颈。实际项目中,我更倾向于用“抽样 hash + 文件大小 + 最后修改时间”组合成标识,以降低碰撞概率。4.2 查询已上传分片并跳过断点续传的典型场景是:用户传了 60% 断了,刷新页面或者重新选择同一文件后,希望能从 60% 继续,而不是从零开始。实现断点续传的关键是“查询分片状态”。后端在初始化接口创建任务时,已经记录了uploadedChunks数组。前端拿到文件标识后,可以调用状态查询接口,获取已经上传成功的分片序号,然后只提交缺失的分片。后端增加一个查询接口:app.get('/api/upload/status', (req, res) => { const { uploadId } = req.query; const taskFile = path.join(UPLOAD_TMP_DIR, `${uploadId}.json`); if (!fs.existsSync(taskFile)) { return res.status(404).json({ error: '任务不存在' }); } const taskInfo = JSON.parse(fs.readFileSync(taskFile, 'utf-8')); res.json({ uploadedChunks: taskInfo.uploadedChunks, totalChunks: taskInfo.totalChunks }); });前端在上传前先查询已经上传的分片:async function getUploadedChunks(uploadId) { const response = await fetch(`${SERVER_BASE}/api/upload/status?uploadId=${uploadId}`); if (!response.ok) return []; const data = await response.json(); return data.uploadedChunks; } // 过滤未上传分片 async function uploadRemainingChunks(file, uploadId, fileIdentifier, chunks) { const uploadedChunks = new Set(await getUploadedChunks(uploadId)); const pendingTasks = chunks .filter((chunk) => !uploadedChunks.has(chunk.index)) .map((chunk) => () => uploadChunk(uploadId, chunk, fileIdentifier)); await runWithConcurrency(pendingTasks, MAX_CONCURRENT); }这里有个前提:用户必须能拿到同一个uploadId才能真正实现续传。最简单的方式是把uploadId存在localStorage里,key 为文件标识。下次选到同一文件时,先用文件标识去localStorage找之前未完成的uploadId,找到就直接继续上传,找不到才重新初始化。4.3 秒传:文件相同直接注册秒传的本质是“这个文件别人已经传过了,服务器上已经有相同文件,不需要再传”。它依赖文件唯一标识在服务端去重。如果服务端已经有一个文件的 hash 是abc123,现在某个用户上传初始化时也带abc123,服务端可以检查存储目录里是否已经存在相同 hash 的文件,如果存在,直接将新任务标记为上传完成,返回最终的访问地址。前端甚至不需要上传任何分片。在 Demo 里可以这样实现:初始化接口里加一个检查逻辑。app.post('/api/upload/init', (req, res) => { const { fileIdentifier, fileName, totalSize, totalChunks } = req.body; // 秒传检查:已经存在同名 hash 的最终文件 const existingFiles = fs.readdirSync(UPLOAD_FINAL_DIR); const existed = existingFiles.find((f) => f.startsWith(`${fileIdentifier}_`)); if (existed) { return res.json({ uploadId: null, instant: true, url: `/files/${existed}` }); } const uploadId = crypto.randomUUID(); // ... 其余逻辑不变 });前端在初始化后判断instant === true,就直接显示上传完成,不需要再走切分和上传流程。秒传是用户体感提升最明显的功能。一个 2GB 文件,如果别人传过,你点击上传的一瞬间就完成了。这里要注意的是:秒传的可靠性完全取决于 hash 的质量。抽样 hash 如果碰撞,可能把两个不同的文件当成同一个,造成用户数据丢失。因此涉及秒传的业务,最好使用全量 hash,或者在后端对相同 hash 的文件再比较文件大小,甚至抽样字节对比,做到多重校验。5. 常见问题与排查技巧实录5.1 合并出来的文件损坏这是分片上传最常遇到的问题,没有之一。文件合并完打开提示损坏,或者解压失败,通常原因有四个:分片顺序错误:没按自然序号排序,用了字典序。分片缺失就合并:没有检查partFiles.length === totalChunks。前端切片时字节偏移算错:file.slice的起始位置和结束位置没有正确衔接。后端合并时流式写入没有正确处理end事件,导致部分分片写入不完整。排查时,先看后端合并日志,打印每个参与合并的分片文件大小,把它们的字节数加起来和原始文件大小对比。如果总字节数比原文件少,说明有分片没接受完整;如果大小一致但还是打不开,重点看排序逻辑。我自己在写合并逻辑时曾经犯过一个很低级的错误:直接对文件名数组调用.sort(),结果2.part排在10.part后面。后来我把所有分片名改成在初始化时用String(index).padStart(6, '0')补零命名,比如000002.part、000010.part,这样字符串排序也等于自然排序,一劳永逸。5.2 并发过高导致服务端大量报错并发数设得过高,后端的文件句柄、内存、网络连接都会吃紧。我见过有人直接把并发设成 10,传一个 1GB 文件,后端 Nginx 瞬间冒出大量Connection reset by peer。控制好前端并发数是最直接的解决办法,通常3 ~ 5就够了,分片上传主要靠减少单请求字节数和失败重试成本来提升体验,而不是靠无限并发。另一个容易被忽略的点是后端要支持“分片乱序到达”。前端并发上传时,分片到达后端的顺序是不确定的,后端不能假设分片会按序号依次到达。所以存储分片时一定用独立文件名落盘,不要用“追加写入”的方式。之前有同学用fs.createWriteStream(path, { flags: 'a' })来写分片,并发一开,两个分片就交错写进同一个文件,文件数据全乱了。5.3 浏览器的兼容性细节现在的浏览器对Blob.slice支持已经非常好,但如果你还需要兼容老版本 Safari,注意它的 API 曾经是file.webkitSlice()。稳妥的写法是做一个兼容封装:function sliceFile(file, start, end) { if (file.slice) return file.slice(start, end); if (file.webkitSlice) return file.webkitSlice(start, end); throw new Error('当前浏览器不支持文件切片'); }跨平台网页应用最容易翻车的不是桌面浏览器,而是各种 App 里的 WebView。部分安卓 WebView 对FormData里同时传输普通字段和文件的支持有历史 bug,如果遇到上传分片时后端收不到index字段,可以尝试把index放在 URL query 上,而不是表单字段里:// 兼容写法:把业务参数放到 URL 参数上 fetch(`${SERVER_BASE}/api/upload/chunk?uploadId=${uploadId}&index=${chunk.index}`, { method: 'POST', body: formData });5.4 进度条跳动和百分比不准进度条常见的问题是:每传一个分片,进度条跳一格,到了最后几片跳得特别快。原因是前面提到过的,采用“分片数量”计算进度,而每个分片大小不完全相同。特别是最后一个分片可能只有几KB,显示时就会出现“从 98% 跳到 100%”的突兀感。更好的做法是用字节数统计。初始化时拿到总大小,每次分片上传成功后记录该分片的end - start字节数,累计uploadedBytes,然后用uploadedBytes / file.size计算百分比。这样即使每个分片大小不齐,进度条也是平滑的。如果要做断点续传的进度恢复,还要把本地缓存的uploadedBytes和后端返回的已上传分片字节数对齐,否则刷新后进度会倒退。5.5 取消上传与网络切换用户点了取消按钮,还在进行中的所有分片请求应该被中止。Fetch 可以用AbortController实现:const controller = new AbortController(); async function uploadChunk(uploadId, chunk, fileIdentifier, signal) { // ... fetch(`${SERVER_BASE}/api/upload/chunk`, { method: 'POST', body: formData, signal }); } // 取消时调用 controller.abort();中断之后,服务端的临时分片并不会自动删除,所以最好在取消时调用一个清理接口,或者依赖前文提到的过期清理任务。网络切换是移动端的高频问题。Wi-Fi 切到 4G 时,TCP 连接大概率会断。这种情况下,前端应该监听navigator.onLine或online/offline事件,在恢复在线后自动检查未完成任务并续传。window.addEventListener('online', () => { // 重新查询未完成任务并继续上传 resumePendingUpload(); });6. 从 Demo 走向生产:还要做的几件事到这里,一套可以跑通的分片上传已经完成了:前端切片、并发上传、后端接收、断点合并、断点续传、秒传。但要把这套代码放到真实生产环境,我建议你至少再补齐以下内容。第一件事是鉴权。文件上传接口必须校验用户登录状态和上传权限,否则任何人都可以把文件扔到你的存储目录里。常见做法是在初始化接口时校验 Token,然后把用户信息写入任务记录;后续每个分片请求都带 Token,后端在uploadId对应的任务里校验是否属于当前用户。第二件事是文件元数据入库。Demo 里任务信息和最终文件都只有目录里的 JSON,生产环境要把文件标识、上传用户、文件路径、大小、上传时间、状态存进数据库,否则没法做文件管理、统计和权限控制。第三件事是存储层替换。现在的代码是把文件存到本地磁盘,生产环境通常要接对象存储服务。很多云厂商的 SDK 本身就支持分片上传,思路和你现在看的这套完全一致,只是把后端的“接收分片并合并”换成了“调用对象存储的分片上传接口”。第四件事是上传进度心跳。如果用户在超长上传过程中长时间没有任何分片完成,后端需要知道任务还活着。可以让前端定期上报心跳,或者用“最近一次分片上传时间”来判断任务是否超时。第五件事是失败重试策略。我在 Demo 里没有写重试逻辑,生产环境需要给每个分片设置最大重试次数,比如 3 次。重试要有退避策略,不能失败后立刻疯狂重发,尤其并发场景下会放大服务端压力。我习惯的做法是:指数退避,第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,超过上限就中断任务并提示用户。最后分享一个我个人的习惯:分片上传调试时,不要一上来就用几个 GB 的文件。先用一个 10MB 左右的小文件,把分片大小临时调成 1MB,确认整个流程能跑通、合并后的文件 MD5 和原文件一致,再逐步加大文件体积。我调试这套代码时,光是合并顺序的坑就踩过两次。把基础流程跑稳,后面加断点续传和秒传都是在稳定地基上添砖加瓦,排查问题时也能更快定位到具体环节。希望这份源码和踩坑记录能帮你少走几段弯路。