大文件上传最佳实践:前端分片上传、断点续传与秒传方案详解

做网页开发这些年,大文件上传几乎是每个业务系统都绕不过去的坎。视频、安装包、离线数据包、设计稿压缩包,动不动几百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,因此只要我们只用标准能力,就能做到跨平台。

实现分片上传我们需要三样东西:

  1. File API:让用户选择的文件对象可以被程序读取。
  2. Blob.slice 方法:把文件按字节区间切出子片段。
  3. XMLHttpRequest 或 Fetch:把每个分片异步发送给后端。

其中Blob.slice是老早就被浏览器广泛支持的能力,现在所有主流浏览器包括移动端都支持。后端用什么语言不挑,只要实现接收分片和合并分片的 HTTP 接口即可。这里我用 Node.js 演示,是因为前端开发者最容易读懂 JavaScript 后端代码,而且同一套语言可以方便地调试。生产环境换成 Java、Go、Python 思路完全一致。

2. 前端分片上传的核心设计与源码

2.1 整体流程:从选文件到上传完成

一个完整的分片上传前端流程,需要拆成下面这几个步骤:

  1. 用户选择文件。
  2. 计算文件的唯一标识(hash),用于断点续传和秒传。
  3. 确定分片大小,将文件切割成若干个分片。
  4. 初始化上传任务,把文件标识和分片信息发给后端,后端返回uploadId。
  5. 启动并发队列,逐个上传分片。
  6. 所有分片上传完成后,调用后端的合并接口。
  7. 后端合并完成,返回文件的访问地址。

这里的第 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 合并出来的文件损坏

这是分片上传最常遇到的问题,没有之一。文件合并完打开提示损坏,或者解压失败,通常原因有四个:

  1. 分片顺序错误:没按自然序号排序,用了字典序。
  2. 分片缺失就合并:没有检查partFiles.length === totalChunks。
  3. 前端切片时字节偏移算错:file.slice的起始位置和结束位置没有正确衔接。
  4. 后端合并时流式写入没有正确处理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 和原文件一致,再逐步加大文件体积。我调试这套代码时,光是合并顺序的坑就踩过两次。把基础流程跑稳,后面加断点续传和秒传都是在稳定地基上添砖加瓦,排查问题时也能更快定位到具体环节。希望这份源码和踩坑记录能帮你少走几段弯路。

相关新闻

MATLAB实现DQN深度强化学习路径规划:从原理到工程实战

MATLAB实现DQN深度强化学习路径规划:从原理到工程实战

做路径规划这行的老哥们&#xff0c;这两年多少都听到过“强化学习”“深度强化学习”这些词。今天要聊的这个项目&#xff0c;就是用 DQN&#xff08;Deep Q Network&#xff09;这种深度强化学习算法&#xff0c;去解一个二维栅格地图上的路径规划问题&#xff0c;而且整套东…

2026/10/22 14:23:35 阅读更多 →
QD准确定性信道模型MATLAB实现:射线追踪与随机散射结合

QD准确定性信道模型MATLAB实现:射线追踪与随机散射结合

简介&#xff1a;MATLAB 准确定性&#xff08;QD&#xff09;信道仿真实现面向毫米波&#xff08;mmWave&#xff09;射频应用&#xff0c;提供基于图像法的开源射线追踪通道代码&#xff0c;适合无线通信领域学生、研究人员与工程开发者用于传播建模和信道分析。资源共 328 个…

2026/10/22 11:02:17 阅读更多 →
司法AI文书生成的质量防线:从架构设计到分层校验系统实践

司法AI文书生成的质量防线:从架构设计到分层校验系统实践

1. 先泼一盆冷水&#xff1a;文书翻车&#xff0c;多半不是“模型不行”做过司法AI这个方向的同行应该都有类似感受&#xff1a;外界一提“AI写判决书”&#xff0c;第一反应就是“模型会不会乱写”。但真正在一线盯过项目的人会告诉你&#xff0c;绝大多数线上翻车&#xff0c…

2026/10/22 12:53:50 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年&#xff0c;我大部分时间都在跟浏览器标签页打交道&#xff0c;直到第一次把桌面应用跑起来&#xff0c;才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词&#xff0c;最开始是在某段时间频繁出现在同事的讨论里&#xff0c;一个用HTML…

2026/10/22 10:18:38 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档&#xff0c;主要面向智能锁安装人员、终端用户及售后维护者&#xff0c;帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件&#xff0c;大小4.37MB&#xff0c;内容结构清晰&#xff…

2026/10/22 11:01:53 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统&#xff0c;整车要求空载跑到8m/s&#xff0c;电机转速已经拉到额定点&#xff0c;驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令&#xff0c;转速纹丝不动&#xff0c;三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/10/22 9:44:47 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年&#xff0c;用它写过嵌入式驱动、做过游戏服务端&#xff0c;也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词&#xff0c;我其实挺感慨的——这门语言的难点从来不是语法多&#xff0c;而是知识点之间的耦合太深…

2026/10/22 10:42:00 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介&#xff1a;亚太天能T109滑盖智能锁产品说明书以PDF格式发布&#xff0c;适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开&#xff0c;先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能&#xff0c;再给出完整安装步骤&…

2026/10/22 10:40:08 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历&#xff1a;翻开源码注释、论文摘要或者一份专业报告&#xff0c;每个字都认识&#xff0c;连在一起却像天书。想找人聊聊&#xff0c;身边要么没人懂&#xff0c;要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型&#xff0c;几秒钟之后&a…

2026/10/22 10:56:37 阅读更多 →

日新闻

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/22 1:47:45 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/21 21:32:28 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/22 5:12:39 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:50:56 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 21:51:55 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 16:24:48 阅读更多 →