三维场景什么时候才该开始加载

一个页面里塞十四座 Three 场景,关键不是画得快,而是「什么时候才开始下载」。这里记两道闸、一次 SPA 换页的翻车,以及卸载时漏掉的 WebGL 上下文。

场景页最贵的不是渲染,是下载 three 的那一刻。three 加场景代码接近 600 KB,如果跟着首屏一起走,正文还没渲染完,带宽已经被一个用户可能根本不看的画布吃掉了。

我的做法是两道闸,缺一不可。

两条路径:整页加载走「可见 + 空闲」两道闸,站内换页后模块脚本不重跑,必须由 astro:page-load 驱动挂载

图:上面是整页加载的挂载路径,中间是换页后卡住的路径,下面是修好之后的路径。

两道闸:可见与空闲

第一道是可见。舞台进入视口(带 200px 提前量)才开始动作,用户没滚到那儿,一个字节都不下载。

第二道是空闲。可见之后还要等 requestIdleCallback,避免和首屏的 CSS、字体、引导脚本抢带宽。Safari 至今没有这个 API,退化成 200ms 延时——总比抢首屏好。

whenVisible(stage, () => {
  if (started) return;
  whenIdle(() => {
    if (!started) void run();
  });
});

这两道闸本身没问题,问题是它们挂在模块脚本里。

第二次进来为什么是空的

现象:直接打开 /zh/scenes/aurora/ 一切正常;从 /zh/scenes/dunes/ 点导航切过去,画布永远是骨架屏。

原因不在 WebGL,在 ESM 的缓存语义。站内换页用的是 ClientRouter,它替换 <body> 之后会重新插入新页面的 <script type="module">。但模块按 URL 缓存,同一个 URL 第二次插入根本不会重新求值——于是 bootstrapScene() 只在第一次进站时跑过一次。

第一次能进是因为整页加载;第二次是模块已经躺在缓存里,脚本标签插进去等于什么都没发生。舞台上的 data-state 停在 loading,看起来就像「加载失败」,其实一个请求都没发。

修法是把挂载从「模块顶层执行」改成「事件驱动」:

document.addEventListener('astro:page-load', () => bootstrapScene());
document.addEventListener('astro:before-swap', teardownScene);

astro:page-load 在首次加载和每次换页后都会触发,所以只要监听一次,整条生命周期就都覆盖到了。

卸载也得管

顺手补的第二个坑:换页时旧场景的 Vue 实例没人拆。ClientRouter 直接把旧 DOM 扔掉,onBeforeUnmount 不会执行,于是 renderer.dispose() 也没执行——WebGL 上下文就这么留在那儿。

一个上下文不致命,但浏览器的上限大约是十几个。反复切场景页,迟早会碰到「创建上下文失败」,然后新场景一进来就黑屏。所以 astro:before-swap 里必须显式 app.unmount(),让组件自己的 onBeforeUnmount 去释放几何体、材质和 renderer。

现在的约定

  • 挂载入口只有 astro:page-load 一个,模块顶层不做事
  • 换页前拆实例,释放 WebGL 上下文
  • 舞台用 data-state 表达 loading / ready / error,样式只认这个属性
  • 手动重试直通,不再走「可见 + 空闲」两道闸——用户已经点了,就别让他等

这套结构的好处是:新增场景只要放一个目录,生命周期不用再想一遍。踩过的坑写进挂载器,而不是写进每一座场景。

← 返回文章列表

评论

…