做工程建筑信息化的人应该都遇到过这种场景:BIM模型、GIS航测影像、无人机倾斜摄影数据、施工监控视频,动辄几个GB,碰上连续几十G的航拍正射影像更是家常便饭。这种量级的文件用传统表单上传,不是浏览器崩了就是服务端超时,用户盯着进度条十分钟走一格,分分钟想砸电脑。今天我就把工程建筑网页里用JavaScript实现大文件分片上传和秒传的完整源码思路拆开讲,从前端分片、MD5指纹到服务端合并、断断续续传,一条链路全部走通。
这篇文章适合两类人:一类是正在给工程管理系统、项目协同平台做文件功能的前端工程师,另一类是后端同学想了解分片接口怎么设计更合理。我会结合工程建筑场景的真实约束——网络环境不稳定、文件普遍偏大、用户电脑配置参差不齐——把技术选型的原因讲清楚,再给出可以直接抄的源码。看完你不仅能知道怎么实现,还能明白为什么这么实现。
1. 工程建筑大文件上传的痛点与整体方案设计
1.1 工程文件上传的现实困境
工程建筑行业大概是互联网领域之外,对超大文件需求最集中的行业之一。我做过的一个项目协同平台里,用户上传的类型包括:设计方案阶段的Revit模型(单文件2GB+)、施工阶段的旁站视频(一天能产生几十GB)、竣工阶段的BIM竣工模型(轻量化之前动辄10GB以上)。这类文件有几个共同特性:单文件体积大、网络传输耗时久、传输中断代价极高。
传统方式的致命问题在于:一个2GB的文件直接放进<input type="file">,浏览器先把整个文件读进内存,内存占用瞬间飙升。网络一旦抖动,TCP连接断开,整个文件从头传,前面几个小时的进度全部归零。服务端如果用Multipart/form-data接收完整文件,还需要在磁盘上写出临时文件,磁盘IO压力大,遇到并发上传直接拖垮。
工程建筑项目往往是多方协作、异地办公的场景,网络质量远不如IDC机房内部。设计院在A城市,施工单位在B城市,中间经过跨网ISP路由,丢包、延迟、抖动都不可控。这些现实约束决定了我们必须在应用层做容错设计——分片上传不是炫技,是刚需。
1.2 分片加秒传的核心原理
分片上传的思路一句话讲完:把一个大文件切开,一块一块地传。浏览器里用File.prototype.slice()就能做到不加载整个文件、只按偏移量截取数据块,每一块通过独立的HTTP请求发送到服务端,全部传完后服务端再按顺序拼接。这套方案完美绕开了单请求超大体积的坑。
秒传的原理就要牵扯一个底层事实:文件在计算机里本质是一串字节,两个文件如果字节序列完全一致,它们的哈希值(比如MD5)就一定相同。所以上传前先计算整个文件的MD5,把这个值发给服务端查询:如果服务端已经有一份MD5完全一致的文件,前端直接跳过上传过程,瞬间显示“上传完成”。
这里有个工程建筑场景非常有用的点:参建各方经常会传递完全相同的文件,比如同一版施工图发给5家单位,或者同一个BIM模型在不同模块间流转。秒传在这种“一对多分发”场景下收益巨大,能做到真正零耗时。
断点续传则是分片上传的自然延伸:每个分片上传成功后,服务端记录下分片的编号。中途网络断了,前端再次发起上传时先查询服务端已收到的分片列表,只传缺失的那几片,不用从头再来。
分片、秒传、断点续传这三个能力组合起来,就是我在这类项目里最终落地的完整方案。三者的依赖关系也很清晰:秒传依赖MD5的完整计算,分片是断点续传的基础,断点续传是分片上传的状态管理升级。
1.3 技术栈与模块划分
这个方案的实现,前端靠三个关键能力:
第一是SparkMD5,一个极快的纯JS MD5计算库,配合Web Worker在后台线程里做增量哈希计算,不让页面卡死。秒传的前提就是它。
第二是File.slice()切片加上XMLHttpRequest或fetch一个个发分片请求,每个分片独立上传、独立重试,互不干扰。
第三是用localStorage或IndexedDB记录上传状态,刷新页面后能恢复进度。
服务端我用Node.js(Koa框架)来演示,因为前端工程师最容易看懂,接口设计也简洁。生产环境换成Java、Go、Python都是同样的思路,核心就是几个接口:检查秒传状态、接收分片、查询已传分片、合并分片。
开发环境我建议准备一个纯静态的HTML文件加一个简单的Node服务就够了,不需要搭完整前端工程。调试工具用Chrome DevTools的Network面板看每个分片请求的状态码,一目了然。
2. 核心细节拆解:MD5指纹与分片策略
2.1 MD5计算:看似简单的性能陷阱
秒传的第一步是拿到完整文件的MD5值。这个环节有个最常见的坑:如果你直接FileReader.readAsArrayBuffer(file)把整个2GB的文件一次性读进内存再算哈希,浏览器当场就会内存溢出,或者页面卡到无法交互。实测下来,一个2GB的文件全量读入内存大约要占4~5GB的临时内存,这还没算哈希计算本身的CPU消耗。
正确做法是用Web Worker配合SparkMD5的增量接口,把文件分片读入、算一块丢弃一块,内存占用恒定在几十MB以内。原理是通过FileReader每次只读一个分片(比如2MB),把ArrayBuffer传给SparkMD5.ArrayBuffer实例的append()方法,累加哈希状态,最后end()得出完整MD5。
还有个工程细节:MD5计算时长不容忽视。一个2GB文件在普通笔记本上增量算完MD5大约需要10到20秒,这个时间用户盯着页面会以为卡死了。所以UI上必须有独立的“校验文件指纹”进度条,和上传进度条分开显示。实测数据是:i5处理器、2GB文件、2MB分片粒度下,MD5计算大约需要14秒,进度条平滑推进。
计算MD5期间用户能不能做别的操作?可以,因为计算在Worker线程里,主线程不受影响。但要注意一点:如果用户在这个阶段又重新选择了文件,旧的Worker必须终止掉,否则多个Worker同时跑,CPU会被拖垮。
2.2 分片大小的确定与计算
分片大小是整个方案里最有讲究的参数,选不好会带来两类问题:分片太小(比如256KB),一个2GB文件要切8000多片,每片一个HTTP请求,光请求往返的开销就把带宽吃掉了,服务端要处理的请求数也暴涨;分片太大(比如20MB),单片失败重传的成本太高,而且容易出现单请求超时。
我在这类工程建筑系统里的推荐值是1MB到2MB。一个2GB文件切成约1024片,请求数量可控;单片1MB在网络正常时几百毫秒就能传完,重传成本也可接受。跨运营商的网络环境中,1MB分片能有效降低TCP窗口阻塞导致的传输失败概率。
分片大小还需要根据服务端的限制动态调整。比如Nginx默认client_max_body_size一般限制在1MB或2MB,如果服务端用的是默认配置,分片超过这个值就会被直接拒绝。我在实际项目中遇到过这个问题,排查好久才发现是Nginx配置挡路。所以分片大小定下来之后,需要同步检查前端、Web服务器、应用服务器三层对请求体大小的限制。
计算分片总数有一个简单公式:Math.ceil(file.size / chunkSize)。2GB文件、1MB分片就是Math.ceil(2 * 1024 * 1024 * 1024 / (1024 * 1024)),正好2048片。单片1MB时,Chrome DevTools里能看到网络面板刷出一排整齐的请求,节奏感很好。
2.3 秒传判定的业务逻辑
秒传的业务判断逻辑不能只看MD5是否存在,还要结合工程建筑业务场景的实际需求。
核心流程是这样:前端算完MD5后,请求服务端检查接口,传参是{ md5, size }。服务端拿着MD5去文件索引表里查,如果找到匹配记录,直接返回{ exists: true, fileId: 'xxx' },前端立刻显示上传成功。找不到就返回{ exists: false },继续走分片上传流程。
这里有个工程上的细节:用MD5加文件大小双重校验比只用MD5更稳妥。虽然MD5碰撞在常规业务场景下概率极低,但加上文件大小这个维度后,误判的可能性进一步降低。服务端要查的字段也就两个,索引加个联合索引,查询效率没压力。
秒传还有一个容易忽略的业务前提:文件是否存在于“当前用户的可见范围内”。工程建筑平台上不同项目间的文件是隔离的,同一个MD5在项目A存在,不代表项目B可以直接秒传。所以判断秒传时,服务端要带上projectId作为查询条件,或者明确设计成全局共享、项目间互通的模式,这个要根据系统权限模型来决策。
还要提醒一点:秒传命中后,返回的fileId要写进业务表单里,对接数据库里的附件字段。不能只在前端显示一下成功就完事,后续文件下载、预览、版本关联都依赖这个fileId。
3. 全流程源码实现:从MD5计算到断点续传
3.1 前端核心源码实现
整个前端实现我拆成几个模块:文件指纹计算、分片生成与上传、并发控制、状态管理。下面代码是生产级的写法,可以直接放进你的项目改造。
先看Web Worker里算MD5的代码。这里我直接在主线程创建Worker并传入File对象,现代浏览器都能支持File的结构化克隆。如果遇到兼容性问题,可以改成主线程读好分片、把ArrayBuffer传给Worker的模式,原理一样:
// md5-worker.js importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js'); self.onmessage = function (e) { const { file, chunkSize } = e.data; const spark = new SparkMD5.ArrayBuffer(); let offset = 0; const total = file.size; function readNext() { const slice = file.slice(offset, Math.min(offset + chunkSize, total)); const reader = new FileReader(); reader.onload = function (ev) { spark.append(ev.target.result); offset += chunkSize; self.postMessage({ type: 'progress', percent: Math.min(Math.round(offset / total * 100), 100) }); if (offset < total) { readNext(); } else { self.postMessage({ type: 'done', md5: spark.end() }); } }; reader.onerror = function () { self.postMessage({ type: 'error', message: '文件读取失败' }); }; reader.readAsArrayBuffer(slice); } readNext(); };主线程里封装一个计算MD5的Promise对象:
function calcFileMD5(file, chunkSize = 2 * 1024 * 1024) { return new Promise((resolve, reject) => { const worker = new Worker('/md5-worker.js'); worker.postMessage({ file, chunkSize }); worker.onmessage = function (e) { const data = e.data; if (data.type === 'progress') { // 这里更新UI上的MD5计算进度条 onMd5Progress && onMd5Progress(data.percent); } else if (data.type === 'done') { worker.terminate(); resolve(data.md5); } else if (data.type === 'error') { worker.terminate(); reject(new Error(data.message)); } }; worker.onerror = function (err) { worker.terminate(); reject(err); }; }); }分片上传的主流程我用async/await串起来,每一步的状态异常都可以直接抛出,方便统一处理:
async function uploadLargeFile(file, options = {}) { const { projectId, bizType, onProgress, onStatus } = options; const chunkSize = 2 * 1024 * 1024; // 2MB const totalChunks = Math.ceil(file.size / chunkSize); onStatus && onStatus('calculating'); const md5 = await calcFileMD5(file, chunkSize); // 第一步:检查秒传 const existsRes = await fetch('/api/upload/exists', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ md5, size: file.size, projectId }) }).then(res => res.json()); if (existsRes.exists) { onProgress && onProgress(100); onStatus && onStatus('done'); return { fileId: existsRes.fileId, instant: true }; } // 第二步:新文件,开始分片上传 // 先查询服务端已有的分片,用于断点续传 const chunkRes = await fetch(`/api/upload/chunks?md5=${md5}`).then(res => res.json()); const uploadedSet = new Set(chunkRes.uploadedChunks || []); onStatus && onStatus('uploading'); // 生成需要上传的分片任务 const tasks = []; for (let i = 0; i < totalChunks; i++) { if (uploadedSet.has(i)) continue; const start = i * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); tasks.push(() => uploadChunk({ md5, index: i, totalChunks, blob, projectId })); } await runWithConcurrency(tasks, 3, onProgress); // 第三步:通知服务端合并 const completeRes = await fetch('/api/upload/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ md5, totalChunks, size: file.size, fileName: file.name, projectId, bizType }) }).then(res => res.json()); onProgress && onProgress(100); onStatus && onStatus('done'); return { fileId: completeRes.fileId, instant: false }; }单个分片的上传函数,注意这里用的是XHR而不是fetch,因为XHR有upload.onprogress事件,可以精确获取每片的传输进度。另外加了失败重试逻辑,单片失败自动重试3次:
function uploadChunk(params) { const { md5, index, totalChunks, blob, projectId } = params; const formData = new FormData(); formData.append('md5', md5); formData.append('index', index); formData.append('totalChunks', totalChunks); formData.append('projectId', projectId); formData.append('file', blob, `chunk-${index}`); return new Promise((resolve, reject) => { let retries = 0; const doUpload = () => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload/chunk'); xhr.timeout = 30000; xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.responseText); } else if (retries < 3) { retries++; setTimeout(doUpload, 1000 * retries); } else { reject(new Error(`分片${index}上传失败`)); } }; xhr.onerror = function () { if (retries < 3) { retries++; setTimeout(doUpload, 1000 * retries); } else { reject(new Error(`分片${index}网络错误`)); } }; xhr.ontimeout = function () { if (retries < 3) { retries++; setTimeout(doUpload, 1000 * retries); } else { reject(new Error(`分片${index}请求超时`)); } }; xhr.send(formData); }; doUpload(); }); }并发控制不依赖第三方库,手写一个简单的池子就行。核心思路:同时最多跑N个Promise,完成后自动从池里取出下一个。这里要注意,错误处理一定要用Promise.allSettled而不是Promise.all,否则一个分片失败会中断整批任务:
async function runWithConcurrency(tasks, limit, onProgress) { const pool = new Set(); let completed = 0; const total = tasks.length; for (const task of tasks) { const p = task().then(() => { completed++; onProgress && onProgress(Math.round(completed / total * 100)); }).catch(err => { completed++; throw err; }).finally(() => { pool.delete(p); }); pool.add(p); if (pool.size >= limit) { await Promise.race(pool); } } await Promise.allSettled(pool); }这套代码跑起来后,Chrome的Network面板里你能看到稳定的并发3个分片请求,每个1~2MB,传输速度均匀。断网时,前端的catch会捕获错误并提示用户,网络恢复后重跑uploadLargeFile,已经传完的分片会被服务端记录跳过,只传缺失的部分。
3.2 服务端接口设计
服务端我给出Node.js Koa实现的关键逻辑。接口就4个,生产环境可以按需拆分:
| 接口 | 方法及路径 | 作用 |
|---|---|---|
| 秒传检查 | POST /api/upload/exists | 接收MD5和大小,查文件索引表,判断是否已存在 |
| 上传分片 | POST /api/upload/chunk | 接收单个分片文件,暂存到临时目录 |
| 查询已传分片 | GET /api/upload/chunks | 根据MD5返回已上传的分片序号数组 |
| 合并分片 | POST /api/upload/complete | 所有分片传完后,按序拼接成完整文件 |
秒传检查的代码逻辑简洁,查表返回结果:
router.post('/api/upload/exists', async ctx => { const { md5, size, projectId } = ctx.request.body; const record = await fileRepo.findByMd5AndProject(md5, size, projectId); ctx.body = record ? { exists: true, fileId: record.fileId } : { exists: false }; });分片上传要把临时文件按md5 + '-' + index命名保存,这里注意一定要用原子性的写入,先传到临时位置再改名,避免并发写同一个文件时出问题:
const multer = require('@koa/multer'); const upload = multer({ dest: '/tmp/chunks/' }); router.post('/api/upload/chunk', upload.single('file'), async ctx => { const { md5, index } = ctx.request.body; const { path, size } = ctx.file; // 限制分片大小,超过5MB直接拒绝 if (size > 5 * 1024 * 1024) { ctx.status = 400; ctx.body = { error: '分片大小超限' }; return; } const target = `/tmp/chunks/${md5}-${index}`; await fs.rename(path, target); ctx.body = { uploaded: true, index: Number(index) }; });查询已传分片的接口返回一个数组,前端据此过滤掉已完成的分片。这个接口对性能要求不高,直接扫一下目录里的文件名:
router.get('/api/upload/chunks', async ctx => { const { md5 } = ctx.query; const dir = '/tmp/chunks/'; const files = await fs.readdir(dir); const uploaded = files .filter(f => f.startsWith(md5 + '-')) .map(f => Number(f.replace(md5 + '-', ''))); ctx.body = { uploadedChunks: uploaded }; });合并分片是整个后端逻辑的收尾环节,也是最容易踩坑的地方。思路是读取所有分片文件,按序号依次写入目标文件流。Node里可以用stream的管道,也可以分片读入再写入,大文件场景下必须用流式处理,不能一次性读入内存:
const { createWriteStream, createReadStream } = require('fs'); router.post('/api/upload/complete', async ctx => { const { md5, totalChunks, size, fileName, projectId, bizType } = ctx.request.body; const chunkDir = '/tmp/chunks/'; const outputPath = `/data/files/${md5}-${fileName}`; const writeStream = createWriteStream(outputPath); for (let i = 0; i < totalChunks; i++) { const chunkPath = `${chunkDir}${md5}-${i}`; if (!fs.existsSync(chunkPath)) { ctx.status = 400; ctx.body = { error: `缺少分片 ${i}` }; return; } await pipeStream(createReadStream(chunkPath), writeStream); } writeStream.end(); // 合并完成后删除临时分片文件 for (let i = 0; i < totalChunks; i++) { fs.unlink(`${chunkDir}${md5}-${i}`, () => {}); } // 验证文件大小,防止数据丢失 const stat = fs.statSync(outputPath); if (stat.size !== size) { ctx.status = 500; ctx.body = { error: '合并文件大小不一致' }; return; } const fileId = generateId(); await fileRepo.save({ fileId, md5, fileName, size, projectId, bizType, path: outputPath }); ctx.body = { fileId }; }); function pipeStream(readStream, writeStream) { return new Promise((resolve, reject) => { readStream.pipe(writeStream, { end: false }); readStream.on('end', resolve); readStream.on('error', reject); }); }有几个细节必须强调:一是合并前要校验分片是否齐全,缺了就报错,前端会拿到错误提示;二是合并后的文件要校验大小,因为合并过程如果某个分片读失败,生成的文件大小对不上,不能入库;三是临时分片文件的清理要在合并成功后执行,失败时保留现场,方便排查。
3.3 断点续传与并发控制
断点续传的实现其实不需要额外引入复杂的中间件,核心就是前面源码里的那一步:上传前先调用GET /api/upload/chunks?md5=xxx,获取服务端已有的分片集合,然后在生成任务列表时用uploadedSet.has(i)跳过。
这里有一个用户在刷新页面场景下需要注意的细节:MD5计算很耗时,能不能不重新算?可以。做法是计算完MD5后,把{ fileName, fileSize, md5, chunkSize, lastModified }存进localStorage。下次选择同一个文件时,先对比文件大小和lastModified,如果一致就直接用缓存的MD5,跳过Worker计算流程。实测这个优化能把重传场景的启动时间从十几秒降到毫秒级。
但是要小心一个陷阱:lastModified在某些场景下可能不变但文件内容变了,比如从版本管理系统里检出同一个文件、时间戳被保留。稳妥做法是只把MD5缓存当作加速手段,服务端发现MD5不存在时自动回退到完整计算流程,前端重新上一次传就走正常路径。
并发数也不是越大越好。我测试的时候发现,3到5个并发是上传速度和稳定性的平衡点。太高(比如10个并发),在跨运营商网络下会加剧丢包和TCP重传,反而拖慢总速度;太低(比如1个),大文件上传时间线性拉长,用户等不起。
并发控制还能做动态调整:根据最近一分钟的失败率调整并发数。上传统计发现失败率超过20%就自动降为2,稳定后再慢慢升回5。这个功能不是必须的,但确实能在弱网环境下显著提升完成率,工程建筑项目现场经常是4G、5G网络,这种自适应机制值得加上。
4. 常见问题排查与避坑实录
4.1 高频问题速查表
实际项目落地过程中,我遇到过不少问题,整理成表格方便对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 页面卡死或崩溃 | 直接读取整个文件计算MD5、没有用Worker | 改用Web Worker分片增量计算 |
| 上传到某个分片就停滞 | 服务端或反向代理有请求体大小限制 | 检查并修改Nginx的client_max_body_size |
| 秒传误判 | 只用MD5判断,没有加文件大小校验 | MD5和size双重校验 |
| 合并后文件损坏 | 合并时漏了分片、分片顺序错乱 | 合并前检查分片齐全;按编号严格排序 |
| 刷新页面后从头传 | 没有查询已传分片列表 | 实现GET /api/upload/chunks并使用 |
| 并发高时失败率飙升 | 并发数设置过大 | 降到3,或加自适应降并发逻辑 |
| 分片上传超时 | 单片体积过大、网络抖动 | 调到1~2MB;加超时重试机制 |
| 临时分片堆积 | 合并成功后没有及时清理 | 合并后逐片删除;定时任务兜底清理 |
| CORS跨域报错 | 前端域名和上传接口域名不一致 | 后端配置CORS白名单;或用Nginx反代同域 |
| 大文件超过2GB | FileReader或某些浏览器对象不支持大文件 | 使用File.slice方式,不整体读入内容 |
这张表基本覆盖了从开发到上线的绝大多数坑,照着排查能省不少时间。
4.2 实战排查案例
挑三个印象最深的案例详细说。
第一个是内存在上传过程中持续攀升。现象是2GB文件传到一半,浏览器Tab直接崩溃。排查后发现是计算MD5时没有走Worker,主线程用FileReader读了整个文件,加上分片上传时FormData里反复引用同一个Blob引用导致的内存累积。修复方式是MD5计算迁到Worker,上传时每个分片独立切片、传完即时释放,内存占用稳定在300MB左右。
第二个案例比较隐蔽:秒传命中后用户下载文件显示乱码。排查发现是服务端保存时记录的文件名不含扩展名,下载响应头里缺失Content-Disposition导致浏览器不认识文件类型。修复方式是在保存文件记录时带上原始文件名和后缀,下载接口根据文件类型动态设置响应头。
第三个案例是分片缺片的隐蔽局势。用户体验是进度显示99%但卡住不动,打开Network面板看见最后一个请求不停地重发。排查定位到合并接口在缺少分片时返回400,但前端的重试逻辑没有区分业务错误和网络错误,一直把400当成可重试错误反复提交。修复方案是上传分片的重试逻辑只对网络错误和5xx响应做重试,4xx响应直接抛业务异常并提示用户。
4.3 生产环境优化建议
如果上面的基础版本跑通了,以下几个优化方向可以进一步提升体验。
第一个是使用navigator.onLine加online/offline事件监测网络状态。弱网环境下自动暂停上传队列,网络恢复后自动续传,用户完全不用手动干预。我在某项目的现场测试中,这一项就把弱网环境下的上传成功率从73%拉到96%。
第二个是服务端加一个定期清理任务。分片文件最终没有合并成功的残留需要兜底清理,比如超过24小时未合并的分片自动删除。否则临时目录会塞满几百GB的孤儿文件。
第三个是计算MD5阶段加一个取消按钮。用户可能发现自己选错了文件,但MD5计算还在跑,不给取消就直接卡十几秒。实现方式是在Worker实例上调用worker.terminate(),同时把缓存里的状态清掉。
第四个是上传进度的统计口径要统一。工程建筑平台里经常要展示“整体进度”,我的做法是把MD5计算阶段和上传阶段按权重分配:MD5占5%,上传占95%,这样用户看到的是一个平滑推进的整体进度条,而不是两个割裂的进度。
上传完成后的回调也很重要:工程建筑系统里,文件上传完往往关联着后续流程,比如BIM模型上传后要触发轻量化转换、视频文件上传后要触发转码、图纸上传后要分发到各参建方。这些下游动作建议用消息队列异步触发,不要让HTTP请求一直等到所有处理完成才返回。
还有一个容易被忽略的点:文件重名的问题。工程建筑协同平台里不同项目可能有相同文件名的文件,服务端保存时一定要用UUID或者MD5重命名,原始文件名只作为业务元数据存数据库。直接用原始文件名落盘,迟早会踩同目录覆盖的坑。
5. 后续扩展与个人经验
最后分享几个这套方案在生产中继续演化的方向。
工程建筑行业做BIM模型协同的,上传的文件往往不是单个文件而是一个文件包,比如Navisworks模型要附带材质贴图、漫游动画等外围文件。这种场景可以在分片上传基础上加一层“文件包”概念:前端遍历目录生成文件清单,每个文件独立走分片逻辑,全部完成后调一个绑包接口把文件ID列表关联起来。用户解压下载时自动打包成一个完整目录结构。
GIS影像数据上传还有一种特殊需求:同一个地理范围会多次上传不同时相的数据。这时候可以加一个“同名覆盖”策略,秒传接口查出已存在的文件后,不是直接返回成功,而是询问用户是否替换,替换则走分片上传并更新版本号。这套逻辑在业主要求数据版本留痕的场景下尤其重要。
对于超大文件(严格说是几十GB量级),前端MD5计算会遇到耗时过长的困扰。有一个变通方案:放弃完整文件MD5,改算采样哈希——取文件开头2MB、中间2MB、结尾2MB分别计算哈希,服务端做分段匹配。误判率极低,计算时间从几分钟降到几秒,适合视频监控这类不追求绝对安全、只追求重复文件快速识别的场景。
我个人踩过最大的坑是在一次上线前的压测中,10个人同时上传时临时目录被磁盘IO打满,结果所有上传集体超时。后来在服务端加了一层受限队列:每个文件的分片请求进入全局队列,控制同时落盘的文件数不超过3个。这样做之后,即便20个人同时传大文件,服务端也能稳定前行。这个教训让我意识到:前端做得再好,服务端没有限流和排队控制,生产环境还是会出事。
最后再分享一个小技巧:调试时用Chrome DevTools的Network Throttling模拟弱网环境,把吞吐量限制在500KB/s,设置50%丢包率。你能用最接近真实工地网络的条件下验证断点续传和重试逻辑,比在本地局域网全速环境下测出来的数据靠谱得多。这套方案在我参与的多个工程建筑信息化项目中稳定跑了一年半,传输总数据量几个TB,没出过生产级事故。你照着实现一遍,再针对自己的场景调整参数和业务逻辑,应该能少走不少弯路。