打开 scale-transition demo 时,页面会停留在"模型加载中…"的 loading 状态较长时间,严重影响演示体验。这个问题在向同事分享链接时尤为明显——对方打开后等很久才看到内容,第一印象差。
问题:加载慢
└─ 直接原因:模型文件体积大(46 MB)
└─ 每次访问都要从服务器重新下载
├─ 网络传输时间长
└─ 没有任何缓存策略46 MB 是什么概念?在国内普通 4G 网络(约 20 Mbps)下,下载需要约 18 秒;在较好的 Wi-Fi(100 Mbps)下也需要约 3-4 秒。这还不包括浏览器解析和渲染时间。
GLB(GL Binary)是 glTF(GL Transmission Format)的二进制打包格式,是 3D 模型的"标准通用格式",类似图片里的 JPEG/PNG。一个 GLB 文件里通常包含几何数据(体积最大)、材质和贴图、以及层级结构。
Draco 是 Google 开源的 3D 几何数据压缩算法,专门针对 GLB/glTF 的几何部分做有损压缩,原理类似 MP3 对音频的压缩——人眼感知不到的精度差异被舍弃,换取极大的体积缩减。本次压缩效果:46 MB → 11 MB(压缩率 75%)。代价是浏览器端需要额外加载 Draco Decoder(WASM 格式,约 1 MB)才能解压模型,且解压过程在 CPU 上运行,低端设备可能稍慢。
Service Worker(SW)是浏览器提供的一种"后台代理"机制。它运行在独立线程里,可以拦截页面发出的所有网络请求,并决定直接返回本地缓存、放行请求去服务器拿最新数据,或做其他自定义处理。
| HTTP 缓存 | Service Worker Cache | |
|---|---|---|
| 控制权在 | 服务器(通过 HTTP 响应头) | 前端开发者(JS 代码) |
| 缓存时机 | 请求发生时 | 可以主动预缓存(install 阶段) |
| 离线支持 | 否 | 是 |
| 适用场景 | 静态资源常规缓存 | PWA、精细化缓存控制 |
WebAssembly(WASM) 是一种可以在浏览器里运行的"接近原生速度"的二进制格式。Draco Decoder 就是以 WASM 格式发布的——解压计算可以跑得很快,但首次加载需要额外下载这个 WASM 文件(约 1 MB)。
| 方案 | 首次加载 | 二次加载 | 实现成本 | 模型真实感 |
|---|---|---|---|---|
| ① 换小体积占位模型 | 快 | 快 | 低 | 差 |
| ② Draco 压缩 GLB | 较快 | 较快 | 中 | 好 |
| ③ Service Worker 缓存 | 不变 | 极快 | 中 | 好 |
| ④ ② + ③ 组合 | 较快 | 极快 | 中 | 好 |
选择方案 ④(Draco + SW 组合),理由:方案 ① 改变了 demo 的核心内容,不可取;方案 ② 单独,二次访问仍然要下载 11 MB,体验还是有等待感;方案 ③ 单独,首次访问完全没有改善;方案 ④ 两者互补,覆盖首次和二次两种场景。
工程思维: 这种"组合拳"策略在实际项目中很常见——单一手段往往只解决问题的一半,找到互补的两个方案组合才能完整覆盖。
整体架构。
用户浏览器
│
├─ 第一次访问
│ ├─ 下载 sw.js,注册 Service Worker
│ ├─ SW install 阶段:主动 fetch + 缓存 after_moved_draco.glb
│ └─ 页面加载 11 MB GLB(Draco 压缩版)
│ └─ DRACOLoader 调用 WASM 解码 → Three.js 渲染
│
└─ 第二次访问
├─ SW fetch 拦截 GLB 请求
├─ 命中 Cache Storage → 直接返回,无网络请求
└─ 近乎秒开Draco 压缩,工具是 @gltf-transform/cli,一个处理 GLB/glTF 文件的命令行工具集,类似图片处理领域的 ImageMagick。
npx @gltf-transform/cli draco \
assets/models/after_moved.glb \
assets/models/after_moved_draco.glbThree.js 侧的变化:GLTFLoader 默认不能解读 Draco 压缩的文件,需要额外挂载 DRACOLoader:
// 原来
new GLTFLoader().load('after_moved.glb', callback)
// 修改后
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('CDN地址/draco/')
const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)
gltfLoader.load('after_moved_draco.glb', callback)Service Worker 的生命周期分三个阶段:
install(安装)
└─ 主动 fetch 并缓存 GLB 文件
└─ skipWaiting():跳过等待,立即激活
activate(激活)
└─ 清理旧版本 cache(通过版本号 'scanat-v1' 对比)
└─ clients.claim():立即接管当前页面
fetch(拦截请求)
└─ 判断是 .glb 文件 → 查缓存 → 命中则返回缓存,否则去网络关键细节:c.put(request, res.clone()).catch(() => {}) 这里的 .catch(() => {}) 很重要——如果写入缓存失败(磁盘满、隐私模式等情况),不能让这个错误阻断对用户的响应,必须吞掉这个错误。
版本管理策略。 SW 里定义了 const CACHE = 'scanat-v1'。当模型文件更新时,只需要把这个版本号改成 'scanat-v2',SW 激活时会自动删掉旧缓存、重新缓存新文件,这是常见的 SW 缓存失效(Cache Busting)策略。
通过浏览器 Network 面板确认:页面加载的是 after_moved_draco.glb(11 MB),而不是原始文件(46 MB);DRACOLoader 相关文件正常加载;SW 状态为 activated;二次刷新时,GLB 请求由 SW 返回,无实际网络请求。
遇到的问题: 验证阶段发现 net::ERR_CACHE_WRITE_FAILURE。排查后确认这个错误来自浏览器的 HTTP 磁盘缓存层(不是 SW Cache API),原因是本地开发服务器没有设置合适的缓存响应头。结论:不影响 SW Cache API 的正常工作,生产环境(Vercel)会自动设置正确的缓存头,可以忽略。
教训: 本地开发服务器和生产环境的行为不总是一致,遇到"看起来有问题但功能正常"的错误,要分清是哪一层出了问题。
| 指标 | 优化前 | 优化后(首次) | 优化后(二次) |
|---|---|---|---|
| 文件体积 | 46 MB | 11 MB | 0 MB(缓存) |
| 普通 Wi-Fi 下载时间(估算) | ~4s | ~1s | ~0s |
| 4G 下载时间(估算) | ~18s | ~4s | ~0s |
这个问题本质上是一个感知性能(Perceived Performance)问题。用户感受到的"慢"不一定等于实际的慢——有时候一个好的 loading 动画就能让人觉得快,有时候实际很快但因为没有反馈让人觉得卡。
做方案选型时用到的思维框架——列出所有可能的方案、评估各方案的多个维度、找到互补方案组合——和设计中的方案评审流程几乎一样。PM 和 Design Engineer 做技术决策时,也是同样的思路,只是评估维度更偏向工程可行性和长期维护。
这个需求如果用 PRD 语言写会是:需求背景 ——demo 首次加载时间过长,影响向同事展示时的体验;成功指标 ——首次加载时间 < 2s(普通 Wi-Fi),二次加载 < 0.5s;约束条件 ——不能更换模型,不引入后端服务;方案 ——前端压缩 + 前端缓存,无需后端配合,可独立上线。这就是 PM 思维:把技术问题翻译成用户体验目标,明确约束,评估方案,量化成功标准。
对 Design Engineer 转型的启示:不需要什么都会写,但要能读懂代码在做什么、哪里是关键决策点;工具链很重要,知道 gltf-transform 这类 CLI 工具的存在,比知道它的原理更重要;理解浏览器机制(缓存、SW、WASM)让你在和工程师沟通时能说上话,也能在设计时考虑技术可行性边界。