三维场景什么时候才该开始加载
一个页面里塞十四座 Three 场景,关键不是画得快,而是「什么时候才开始下载」。这里记两道闸、一次 SPA 换页的翻车,以及卸载时漏掉的 WebGL 上下文。
场景页最贵的不是渲染,是下载 three 的那一刻。three 加场景代码接近 600 KB,如果跟着首屏一起走,正文还没渲染完,带宽已经被一个用户可能根本不看的画布吃掉了。
我的做法是两道闸,缺一不可。

图:上面是整页加载的挂载路径,中间是换页后卡住的路径,下面是修好之后的路径。
两道闸:可见与空闲
第一道是可见。舞台进入视口(带 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,样式只认这个属性 - 手动重试直通,不再走「可见 + 空闲」两道闸——用户已经点了,就别让他等
这套结构的好处是:新增场景只要放一个目录,生命周期不用再想一遍。踩过的坑写进挂载器,而不是写进每一座场景。

评论
…