
1. 从一个几十GB的BIM模型说起这个需求到底在解决什么1.1 场景还原国企工程建筑里的建模、审查与交付流程在建筑行业摸爬滚打过的朋友应该都有体会央企国企的工程项目和普通商业项目有本质区别体量大、周期长、参与方多而且对交付物有非常严格的归档要求。一个典型的大型公建项目结构、建筑、机电、幕墙、景观各专业都要出模型这些模型经过深化设计、碰撞检查、施工模拟之后往往还要整合成一套“综合模型”用于审查和交底。我之前参与过一个国企项目的平台化改造当时碰到一个特别实际的问题各分包单位提交的BIM模型打包之后普遍在10GB到50GB之间单文件解包后更是动辄上百GB的零散文件。模型本身是按一定目录结构组织的比如按楼栋号、按专业、按楼层、按构件库分包目录层级复杂文件名也带着版本信息。这些模型要上传到统一的Web管理平台用于后续的进度对比、施工模拟和档案留存。但问题来了普通的上传方式根本扛不住。传统的FormData整体提交浏览器端要读整个文件到内存再把二进制数据拼进请求体几GB的文件大概率直接把浏览器干崩溃或者卡在进度条上几个小时不动。再加上国企内网环境的网络波动、文件大小限制、服务器超时设置一次失败就意味着从头再来非常痛苦。这个项目标题里提到的“目录结构分片断点续传”其实就是在解决这个场景下的三大痛点大文件如何稳定上传、上传中断后如何恢复、以及上传后如何保持模型原有的目录结构不乱。1.2 普通上传为什么扛不住内存、超时和重传的三重困境先说到底为什么传统方案行不通。首先是浏览器内存问题。当我们用formData.append(file, file)这种方式上传时如果直接传入一个Blob对象浏览器本质上要把这个文件当做一个整体来处理。虽然现代浏览器在大部分情况下不会真的把整个文件一次性载入内存但部分老版本内核或资源受限的浏览器确实会这样干内存直接被打满。即便浏览器做了流式处理网络层面的问题依然致命。内网环境下虽然带宽理论上很高但国企内网往往有安全设备、审计网关、流量控制策略一条长连接很容易被中间设备超时掐断。一个10GB的文件传了8GB连接断了服务器端接收到的临时文件被清理掉前端只能重新开始。这个过程重复几次现场工程师心态直接就崩了。整个文件上传还有一个隐藏问题无法精确感知每一段的进度。HTML5的xhr.upload.onprogress确实能提供一个整体进度事件但那是基于当前请求的传输量计算的一旦请求被中断这个进度就归零。工程师看到的进度条从90%跳回0%这种体验极其糟糕。所以分片上传的思路本质上很简单把一个大文件在浏览器端切成若干小块逐块上传。每一块都是独立的HTTP请求服务端收到所有分片后再拼接成完整文件。这样做的好处是某一片失败只需要重传这一片而不是整个文件而且每一片的状态都可以被记录刷新页面、断网、甚至浏览器崩溃之后只要知道哪些片传成功了就可以从失败的位置继续传。1.3 目标拆解分片、断点续传、目录结构、校验缺一不可在动手写代码之前我习惯先把这个标题拆成四个独立的技术指标每一项都要在方案设计阶段就想清楚第一个指标是“分片”。不管文件多大都要切割成可控大小的切片比如每片1MB到8MB前端逐片发送后端按顺序拼接。第二个指标是“断点续传”。这是整个方案的灵魂。它要求前端能持久化记录每一片的上传状态后端也能识别哪些分片已经存在允许前端跳过已上传的片直接从缺失位置继续。第三个指标是“目录结构”。这个在普通文件分片上传方案里不太被提起但BIM模型场景特别重要。模型文件不是一个孤零零的二进制文件而是一个完整的目录树里面混着模型文件、贴图资源、材质库、链接文件、明细表等等。如果上传后目录树被打平、层级丢失下游的所有工具都识别不出来。第四个指标是“完整性校验”。分片上传不等于丢数据每片上传后要有哈希校验合并完成后也要有整体校验确保拼接出来的文件和本地文件完全一致。BIM模型一旦某个字节损坏轻则模型打不开重则算量结果错误这在工程领域是绝对不允许的。我的建议是先把这个需求搞清楚再想技术方案。很多人一上来就搜“分片上传插件”往项目里装了一个结果发现插件只能传单个文件不保留目录结构也没有断点记录最后还是得自己改。与其绕弯子不如直接基于原生JavaScript和File API来实现所有逻辑都握在自己手里后续调试和扩展都方便。2. 技术方案设计先想清楚再写代码2.1 分片上传的核心原理切片大小为什么通常选1MB到8MB分片上传的第一件事就是确定切片大小。这个参数不是拍脑袋定的它直接影响上传效率和重传代价。切片太小比如256KB分片数量会非常夸张。一个10GB的文件要切4万片每片一个HTTP请求光握手开销就把带宽吃掉了。切片太大比如64MB虽然请求数量少了但单片的传输时间变长一旦网络抖动导致某一片失败重传64MB数据的代价又太大同时服务端接收时对内存缓冲区的压力也大。我一般建议在1MB到8MB之间取一个平衡值。打个比方这就像搬家时选纸箱的大小纸箱太大装满后搬不动一旦路上散了损失惨重纸箱太小一趟一趟跑腿累死而且纸箱本身占地方。1MB到8MB这个区间既能保证单片传输在几秒内完成又能控制总片数在一个可管理范围内。用节省流量的逻辑来算更直观假设网络平均传输速度是5MB/s选1MB切片每片大约0.2秒传完选8MB切片每片大约1.6秒传完两者在WAN场景下差别并不大但在LAN场景下更小的切片反而因为请求数量过多而拉低吞吐。所以我实际用下来5MB左右是一个比较舒服的阈值。如果走的是互联网而非内网再降到2MB会更稳。每片切完之后要给每个分片生成一个唯一的标识。比较通用的做法是相对路径 _ 文件大小 _ 最后修改时间 _ 切片序号。这个标识组合能大概率避免同名文件冲突而且方便定位。## 3. 前端实操目录读取、文件切片与断点记录的完整实现3.1 从目录选择到文件树构建让浏览器“看到”BIM模型的目录结构BIM模型一般以文件夹形式交付文件夹内部有清晰的层级。在Web端要保留这个层级首先得让浏览器能遍历目录。HTML5里input typefile webkitdirectory可以做到这一点。const input document.createElement(input); input.type file; input.webkitdirectory true; input.addEventListener(change, (e) { const files Array.from(e.target.files); const tree {}; for (const file of files) { const relativePath file.webkitRelativePath; const segments relativePath.split(/); let current tree; for (let i 0; i segments.length - 1; i) { if (!current[segments[i]]) current[segments[i]] {}; current current[segments[i]]; } current[segments[segments.length - 1]] { size: file.size, lastModified: file.lastModified, type: file.type || }; } window.__fileTree tree; window.__files files; });需要注意的是webkitRelativePath在大多数Chromium内核浏览器中都能正常返回类似结构模型/B2号楼/结构.rvt这样的相对路径。拿到这个路径之后我建议立刻生成一份JSON格式的目录树这份JSON有两个用途一是上传完成后给服务端做目录重建依据二是前端自己展示文件列表时用。还有个容易被忽略的问题目录里可能包含隐藏文件、临时文件、回收站残留之类的东西。在构建文件树时最好做一层过滤比如排除以.开头的文件、排除__MACOSX这类常见打包残留目录。国企项目移交过来的模型经常带着各种莫名其妙的文件传上去之后不仅浪费流量后期清理还很麻烦。3.2 文件切片与标识生成File.slice和稳定ID的配合文件切割在浏览器端非常简单核心就是一个APIfunction createChunks(file, chunkSize 5 * 1024 * 1024) { const chunks []; let start 0; while (start file.size) { chunks.push(file.slice(start, start chunkSize)); start chunkSize; } return chunks; }file.slice()返回的是一个Blob对象它并不会真的把文件内容复制出来而是指向原始文件数据的某一段因此内存消耗相对可控。不过这里有一个必须注意的细节如果对文件调用slice之后原文件对象被释放某些浏览器下这些Blob会变成无效引用。所以在上传期间最好用一个数组把所有切片引用保存住直到全部上传完成。对于断点续传每个分片需要有一个稳定且唯一的标识。我建议把标识拆成两个层级文件级别ID和分片级别索引。function buildFileId(file) { const path file.webkitRelativePath || file.name; return ${path}_${file.size}_${file.lastModified}; } function buildChunkId(fileId, index) { return ${fileId}_chunk_${index}; }这里我刻意没有使用基于文件内容的哈希作为切片标识因为对大文件计算全量MD5或SHA-1在浏览器端是很慢的甚至可能造成主线程卡顿。用路径 大小 最后修改时间来标识文件虽然理论上存在极小概率冲突但对内网交付的模型文件来说已经足够可靠。如果你需要更高强度的校验可以只对第一个切片和最后一个切片做部分哈希这样既能兼顾性能又能防止明显的文件损坏。3.3 上传队列与并发控制别把服务器打懵分片数量上来之后一次性把所有分片同时发出去是不现实的。一个10GB的文件按5MB切大约2048片如果全部并发发起浏览器光在建TCP连接就把资源耗尽了而且服务端也没办法同时处理这么多请求。正确的做法是控制并发数。async function uploadChunksInParallel(chunks, fileId, concurrency 6) { let index 0; const tasks []; for (let i 0; i concurrency; i) { tasks.push(run()); } async function run() { while (index chunks.length) { const current index; const chunk chunks[current]; await uploadOneChunk(fileId, current, chunk); } } await Promise.all(tasks); }并发数取多少合适我试过比较多的方案内网环境下6到8路并发效果最佳外网环境下3到4路更快。这个数字取决于服务器配置、网关并发限制和网络带宽。如果并发设置太高网关可能会直接拒绝连接设置太低又无法利用多路并行加速。每一片的上传请求需要包含三类信息文件标识、分片序号、分片二进制数据。服务端应该以文件标识 分片序号作为幂等键收到重复上传时直接返回成功这样前端在断点续传时反复尝试同一片也不会出问题。3.4 IndexedDB记录进度刷新之后继续传不靠运气断点续传的核心在于“状态记录”。前端每次上传完一片都应该把这一片的成功状态记录下来。刷新页面或者浏览器崩溃之后通过查询已经上传的分片列表就能跳过这些分片从失败位置继续传。localStorage虽然简单但容量有限通常5MB左右而且只能存字符串。一个10GB的文件有2048片每片记录一个ID虽然字符串不会太大但如果同时管理多个文件可能就把localStorage撑爆了。更麻烦的是localStorage的读写是同步的频繁写入会阻塞UI线程。我建议用IndexedDB来存上传进度。它容量大、异步、性能好非常适合做断点恢复这种场景。先简单封装一个存储工具const dbName bim-upload-db; const storeName chunk-status; function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(dbName, 1); request.onupgradeneeded () { const db request.result; if (!db.objectStoreNames.contains(storeName)) { const store db.createObjectStore(storeName, { keyPath: chunkId }); store.createIndex(fileId, fileId, { unique: false }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); } async function markChunkUploaded(fileId, chunkId) { const db await openDB(); return new Promise((resolve, reject) { const tx db.transaction(storeName, readwrite); tx.objectStore(storeName).put({ fileId, chunkId, uploaded: true }); tx.oncomplete resolve; tx.onerror () reject(tx.error); }); } async function getUploadedChunks(fileId) { const db await openDB(); return new Promise((resolve, reject) { const tx db.transaction(storeName, readonly); const store tx.objectStore(storeName); const index store.index(fileId); const request index.getAll(fileId); request.onsuccess () resolve(request.result.map(item item.chunkId)); request.onerror () reject(request.error); }); }有了这个进度存储上传流程就变成了启动上传前先查IndexedDB把已经成功上传的分片序号过滤掉只排尚未上传的切片进入队列。这样一来哪怕之前传了一半浏览器崩了下次打开页面选择同一目录系统自动识别同一批文件ID直接跳过已传部分。这里有个必须要提醒的点IndexedDB里的记录是“前端视角”的它代表的只是浏览器曾经发出过成功响应。如果服务端在接收某一片之后、返回成功之前崩溃了前端记录成功但实际上后端的这一片是残缺的。所以服务端必须在每次分片上传时做文件大小的校验拼接时再做一次整体校验双保险。3.5 合并请求与整体校验分片传完了不代表文件没问题所有分片都传完之后前端需要调用一个合并接口通知服务端把临时目录里的分片按顺序拼接成完整文件。合并请求里要带上文件ID、总大小、总分片数、原始相对路径。服务端在做合并之前会先检查分片数量和大小是否吻合再执行合并。合并完成后我强烈建议做一次文件级校验。最简单的方式是前端在构建文件ID时记录文件总大小合并后由服务端返回合并后的文件大小和哈希值前端进行比对。如果要做更严格的校验可以对整个文件做一次MD5计算但大文件的整体哈希计算在服务端也需要一定时间所以实际工程中我更倾向于让前端对文件的第一个和最后一个切片做MD5作为强校验的近似替代其余切片用大小校验兜底。校验通过之后目录树JSON和文件实体是分离的。前端在上传分片的同时应该把目录树数据一并提交给服务端元数据表。服务端拿到目录树和数据文件后会按相对路径在目标存储位置重建目录结构把文件落到对应目录节点下。这个过程可以在合并接口里一并处理省去一次请求。4. 后端配合接口设计、合并策略与存储规划4.1 三个核心接口初始化、上传分片、合并前端做得再好后端接口设计跟不上也是白搭。我们只需要三个核心接口不复杂但每一步都要考虑边界情况。第一个是初始化接口。前端在开始上传前先调用它把自己要传的文件ID、文件总大小、文件名、相对路径发给服务端。服务端返回一个uploadId并在临时目录下创建以uploadId为名的子目录后续该文件的所有分片都存在这个目录里。第二个是上传分片接口。请求参数包括uploadId、chunkIndex、chunkSize二进制分片数据放在请求体中。服务端把分片写入临时目录/uploadId/chunkIndex.part然后返回成功。如果这个分片已经存在且大小一致直接返回200不上传也没问题。第三个是合并接口。参数包括uploadId、文件总大小、总分片数、目录树JSON。服务端先校验分片完整性然后按索引顺序合并写入最终存储路径最后重建目录树。合并成功之后删除临时目录返回文件元数据。我遇到的比较常见的问题是很多团队在初始化接口里就做了大量业务判断比如数据库查重、权限校验、队列检查结果初始化慢得像龟速。工业级场景应该把初始化接口做得尽量轻量只负责创建上传会话所有业务判断放在后续流程里异步执行。4.2 服务端分片存储与合并策略流式拼接比内存拼接靠谱服务端接收分片时最容易犯的错误是把所有分片读入内存再拼接。一个5MB的分片乘以2048片就是10GB内存正常的服务器根本扛不住。正确的做法是分部落地。每收到一片直接使用文件流写入磁盘路径按uploadId 序号组织。合并时逐片打开文件使用流式方式将内容追加到目标文件尾部。Node.js里用createReadStream和createWriteStream配合管道操作每合并完一片就关闭资源内存占用始终保持恒定。合并校验的策略也要细化。我通常会维护一个分片元信息表记录每片的实际大小。合并前检查两件事已上传分片的数量是否等于总分片数、每片的大小是否与预期一致。如果发现缺失或缺损返回前端一个“缺失分片列表”让前端针对性重传缺失的片而不是整个文件重传。这种“按需补片”机制在弱网环境下尤其好用。关于磁盘规划临时目录要有独立的分区不能和系统目录混在一起。之前我遇到过一次项目临时目录写在系统盘模型上传量一大系统盘被塞满整个服务器直接无响应。临时目录的清理策略也要有。合并成功之后立即删除对应分片这是最基础的要求。还要加一个定时任务清理超过48小时仍处于未完成状态的上传目录防止黑客或异常任务占满磁盘。4.3 并发冲突与重复分片的幂等处理上传过程中前端可能会因为网络超时对同一个分片重复请求。服务端必须对这种情况做幂等处理。用uploadId chunkIndex作为主键如果发现同一个分片已经存在且大小一致就直接返回成功不重复写磁盘。合并操作的并发控制同样重要。我建议用一个锁标记来防止同一个uploadId被多个合并请求同时操作。比如在数据库会话表里加一个mergeStatus字段合并中、合并完成、合并失败。前端偶尔会因为超时对合并接口重复请求一对服务端看到“合并中”状态可以适当阻塞等待看到“合并完成”直接返回结果避免合并逻辑执行两次产生脏数据。5. 国企工程场景下绕不开的坑与排查实录5.1 网络波动与断网恢复指数退避重试是基础操作国企内网虽然有专线但不算稳定。特别是部分老办公楼里的网络设备高峰期经常出现大量丢包。分片上传遇到单次网络失败是常态所以前端必须有一套重试机制。我比较推荐指数退避策略第一次失败等1秒重试第二次失败等2秒第三次等4秒最多重试5次。如果连续5次失败就把这个分片标记为失败但不中断整个上传流程而是继续处理其他分片失败的分片放到队列末尾最后统一补传。实际体验下来这种“边传边补”的策略比“遇到失败就停止整个上传”强太多因为网络抖动通常是短暂的给一小段时间它自己就恢复了。断网的情况更特殊。浏览器检测到网络断开时所有请求会失败。这时候前端应该暂停整个上传队列监听online事件网络恢复后重新查询IndexedDB里的上传状态对未完成的分片继续上传。在这个过程中不需要刷新页面体验是连续的。5.2 浏览器崩溃与内存占用优化用Web Worker把哈希计算挪到后台浏览器崩溃是分片上传最容易遇到的问题大多数原因是内存占用过高。虽然File.slice()不会一次把整个文件载入内存但部分场景下比如多个文件同时读取、切片对象持有大量引用内存还是会快速攀升。我的经验是每次从磁盘读取一个切片后立即上传并释放引用不要一次性把所有切片都放进一个数组里。上面给出的createChunks示例虽然是全量创建但实际项目中我会改为懒加载切片function getChunk(file, chunkSize, index) { const start index * chunkSize; const end Math.min(start chunkSize, file.size); return file.slice(start, end); }这样每次只保留当前正在上传的几片内存占用大幅降低。如果是做全量文件哈希校验一定不要在主线程做。一个10GB文件的全量MD5计算在浏览器里可能需要几十秒甚至几分钟在这期间页面会卡死。正确做法是把文件交给Web Worker在Worker线程里分片读取并计算哈希计算完毕通过postMessage把结果传回主线程。这样用户可以继续操作界面不会出现“页面已失去响应”的尴尬。5.3 老旧浏览器与国产化内核兼容降级方案要提前想好国企办公环境里浏览器的实际情况可能超出技术人员想象。有大量存量电脑还在使用老版本的Chromium内核浏览器有些国产浏览器默认跑在兼容模式下。这些环境下webkitdirectory、IndexedDB这些特性可能是缺失的。我建议在页面加载时做一次能力检测分别检测File.slice、webkitdirectory、indexedDB是否可用。如果某一项缺失就启用降级方案。比如没有IndexedDB就用localStorage存进度没有webkitdirectory就不支持目录选择改为让用户选择单个文件或压缩包实在不行就退回最原始的整包上传方案。虽然这些降级方案体验差很多但总比“打开页面什么都做不了”强。我遇到过某国企内网浏览器连File.slice都不支持的情况那套系统所有文件上传都只能走传统方式这是平台底层的硬伤前端只能尽量兼容没法完全替代。### 5.4 安全与审计模型文件是核心资产不能裸奔BIM模型在工程项目里属于核心数据资产里面包含了完整的建筑结构信息、设备参数、工程量清单一旦泄露或者被篡改后果非常严重。所以上传过程不能只想着“传上去就行”还得考虑安全与审计。权限控制是第一层。上传接口必须验证用户身份不能匿名上传。我当时所在的系统对接了统一的身份认证平台上传时每次请求都要携带token。分片上传接口也同样校验不能因为它是“部分数据”就放宽权限。操作日志是第二层。上传开始、分片接收、合并完成、文件删除这些操作都要记录到日志系统。日志里至少要包含操作人、操作时间、文件ID、分片序号、结果状态。国企项目的审计是要落实到具体人和具体操作的这个不能省。文件校验是第三层。合并完成后对文件做一次病毒扫描和类型检测。你可能会觉得奇怪模型文件怎么会有病毒但实际上从外部协作单位移交的模型包经常混入各种可执行文件、宏病毒、恶意链接。服务端在把文件暴露给其他系统访问之前必须经过安全检测。这不仅是技术问题更是合规问题。6. 实测效果、踩坑记录与可复用的经验6.1 实测环境与最终效果在项目收尾阶段我们做了几轮压测。测试环境是一套典型的国企内网配置一台4核8GB的应用服务器一台文件存储服务器客户端是普通办公电脑。测试对象是一个约35GB的综合BIM模型包包含5600多个文件目录层级在5层左右。使用5MB分片、6路并发、开启IndexedDB断点恢复的方案整体上传耗时大约2小时左右。期间人为中断了两次网络每次恢复后系统都能自动从断点继续没有出现重复上传已成功的分片。服务端合并完成后校验文件大小与源文件完全一致目录结构也按照原始树状关系落到了存储区。相比之前用普通上传方式“传一天断三次”的体验这套方案可以说是从“不可用”变成了“可用”。6.2 踩过的坑与对应解决方法第一个坑是并发数设置过高导致服务端连接数被打满。第一次测试时我图快直接上了20路并发结果应用服务器的连接池瞬间被耗尽所有请求全部排队等待整个Web应用的其他功能也跟着卡住。后来把并发数降到6问题消失。这个参数最好做成配置项根据实际服务器负载动态调整。第二个坑是目录名里包含中文、空格和特殊符号。模型交付方经常用“B2号楼-装修-2024-最终版”这种命名方式。如果文件ID直接用整个相对路径拼接偶尔会出现空格导致的解析错误。解决办法是在生成文件ID时用编码后的相对路径或者把路径单独作为一个字段存放不在ID里直接拼接。第三个坑是合并时对大文件使用同步拼接导致服务端无响应。最初的实现是把所有分片读入数组再用Buffer.concat合并一个4GB的文件直接导致内存溢出。改成流式合并之后就稳定了。这就是我前面反复强调流式处理的原因——在这个场景里它不是一个可选项而是必须项。第四个坑是清理任务误删未完成的上传目录。定时清理我最初设置的阈值是24小时但有些超大模型文件因为网络原因连续传了超过24小时结果被清理任务在合并前删掉了。后来我把阈值改成48小时并且加了个“有进行中上传任务”的保护判断才彻底解决。6.3 一个很实用的小技巧服务端返回缺失分片列表最后分享一个我自己觉得特别实用的技巧。在合并接口的校验阶段服务端除了返回“失败”之外还要返回具体的缺失或异常分片序号列表。前端拿到这个列表后只需要重传这几片然后再次调用合并接口。这个过程可以循环直到合并成功。这种方法在处理“某些分片损坏但不清楚是哪些”的情况下非常高效比无脑全量重传强一个量级。我之所以一直强调这个方案里的“分片断点目录结构”三件套要组合起来看是因为在真实的国企工程建筑场景里这三个需求从来都是同时出现的。一个只支持分片上传、不支持断点恢复的插件在弱网条件下仍然会让工程师崩溃一个支持断点但忽略目录结构的上传工具传完之后下游工具用不了等于白传。只有把三者打通才能真正解决生产环境里的问题。如果你也正在处理类似的需求建议先拿一个真实的BIM模型包做测试不要用几个小文件模拟。真实场景下的大文件数量、目录深度、命名习惯都会逼着你把边界情况想清楚。这套方案跑通之后后续无论是扩展上传进度展示、增加上传任务队列还是对接消息通知都是在现有骨架上做增量开发很顺手。