应用页不用 client:only:静态空壳加显式挂载
游戏与工具页是「空壳 + createApp」,不是 client:only。加载、成功、失败三态由我自己控制,重试按钮也是真的能按。
游戏和工具页有一个共同点:它们都是 [slug] 动态路由,组件随 URL 变化。这恰好是 <Component client:only> 最容易出问题的场景。
client:only 在这里缺什么
client:only 只负责一件事:跳过 SSR、在浏览器里渲染。它不负责加载态,不负责失败态,不负责重试。
于是三个问题都得自己补:组件 chunk 还在下载时页面是空白,用户分不清加载和坏掉;挂载抛错时错误只进控制台,页面上什么都没有;想「重试」就得自己再包一层状态机。
而且动态路由下组件是个变量,水合指令在编译期就要确定目标,绕开它反而更省事。
空壳的形状
AppStage.astro 输出一个带三态标记的容器:
<div data-app-stage data-state="loading">
<div data-app-root data-app-kind="games" data-app-slug="snake" data-app-locale="zh"></div>
<p class="app-loading">正在加载…</p>
<div class="app-error">
<p>应用没能启动。</p>
<button data-app-retry>重试</button>
</div>
</div>
挂载器只读 data-* 属性,不认识任何具体应用:
const loader = loaders[loaderKey(kind, slug)];
const mod = await loader();
createApp(mod.default, { locale }).mount(root);
三态由 CSS 接管
data-state 变了,显示哪一块由样式决定。加载文案、错误面板、重试按钮都写在静态 HTML 里,不依赖 JS 生成——脚本挂掉时用户至少还能看到「应用没能启动」和那个按钮。
每个应用仍然是独立 chunk
注册表用字面量相对路径的 import.meta.glob:
const loaders = {
...import.meta.glob('../games/*/App.vue'),
...import.meta.glob('../tools/*/App.vue'),
};
Vite 在构建期把它展开成「文件路径 → 动态 import」的映射,每个 App.vue 自动成为一个独立 chunk。运行时点开哪个游戏才下载哪个。这里不能用别名,Vite 只接受字面量相对路径。
代价
应用页不像内容页那样零 JS:它至少有挂载器本身和一次 import()。内容页完全不引入 Vue 运行时,这条界线画在「这页是不是应用」上。
loading 与 error 不是框架兜底给的,是页面本来就该有的两个状态。把它交出去,等于把它们从设计里删掉。

评论
…