Nuxt4:SSR + 全量 prerender + 内联关键 CSS 踩坑
💡 背景
本博客基于 Nuxt 4 + Nuxt Content + Nuxt UI,生产环境的部署模式是:
- ssr: true ,SSR 保持开启,开发体验和动态路由不受影响
- 公开内容页在构建时全量 prerender,首屏等同静态 HTML
想在这个基础上内联关键 CSS,有三条看起来合理的路:
- render:response + beasties:在 SSR 响应阶段改 HTML
- vite-plugin-beasties:在 Vite 构建期处理
- 在 nitro:build:public-assets 里对 prerender 产物跑 Beasties
这篇文章记录为什么前两条路走不通、为什么第三条路可行,以及具体实现细节(踩坑 data-beasties-container hydration)。
❌ 为什么前两条路走不通
1. render:response + beasties
render:response 是 Nitro 的 SSR 响应钩子,在服务端渲染完成、响应发出前触发。若在 build 时的 render:response 上挂 Beasties,会产生:
- unhead 风险 — Beasties 在响应阶段直接改 HTML 字符串(插入 inline
<style>、改写<link>),发生在unhead管理<head>的管线之外,输出顺序无法保证一致,客户端 hydration 时容易出现<head>结构错乱或样式重复。 - Nuxt UI 冲突 — Nuxt UI 通过
#nuxt-ui-colors等 inline style 写入主题色;Beasties 的<head>改写可能与之冲突,进一步放大 hydration 不一致。
2. vite-plugin-beasties
这个插件挂在 Vite 构建阶段,处理 Vite 输出的 HTML 文件。但在 Nuxt + Nitro 的构建链里,Vite 先完成客户端打包,Nitro 再执行 prerender——逐路由生成独立的 .html 文件。
插件拿到的是 Vite 的入口 HTML,不是 Nitro prerender 后的多页产物;时序上 Vite 在前、Nitro prerender 在后,处理过的入口 HTML 也不等于最终的各页 prerender 产物。
✅ 为什么nitro:build:public-assets + beasties 没问题
核心差异:不在 SSR 响应链里改 HTML,而是在 prerender 全部落盘之后,对静态产物做后处理。
- 时序对 — Nitro 先逐路由 prerender,把每页的
.html写到output.publicDir,然后才进入nitro:build:public-assets。Beasties 读到的就是最终要上 CDN 的那份 HTML,而不是 Vite 入口模板,也不是 prerender 之前的中间产物。 - 覆盖面完整 — 通过
prerender:generate记录每个成功生成的.html路径,逐文件处理。多语言内容页、列表页、详情页各自独立的 prerender 产物都会被覆盖,不会出现「只优化了入口 HTML」的问题。 - 不改 SSR 热路径 — module 在 dev 直接跳过;prod 里未 prerender 的动态路由也不会被这个 hook 碰到。Beasties 只跑在已登记成功的静态页上,开发和运行时 SSR 行为保持原样。
- 绕开 unhead / Nuxt UI 冲突 — 前两条路的问题,本质是在 head 管线尚未稳定时就改写
<head>。构建后处理磁盘上的静态 HTML 时,SSR 渲染已经完成,unhead 和 Nuxt UI 的 inline style 都已写入文件;我们优化的是部署产物,不再与响应阶段的 head 管理抢顺序。 - 失败可隔离 — 单页 Beasties 处理失败只打 warning,不阻断整次构建;外部 CSS 仍保留在 HTML 里,首屏至少有完整样式兜底。
因此第三条路把职责拆清楚了:SSR / prerender 负责生成正确 HTML,Beasties 负责在构建末期给静态 HTML 加速首屏——这与「公开页全量 prerender、首屏等同静态 HTML」的部署目标一致。
🛠️ 具体实现方案
前提:ssr: true 与 nitro.prerender 并存。
dev 是普通 SSR,prod 公开页构建时静态化。
实现分四块:Nuxt / Nitro 配置、Module 接入、Beasties 配置、hydration 对齐。
1. Nuxt / Nitro 配置
nuxt.config.ts 中的 prerender 相关配置(仅生产环境生效;开发环境 routeRules 留空,避免干扰本地调试):
ssr: true,
routeRules: isDev
? {}
: {
"/**": { prerender: true }, // 生产环境公开页全量 prerender
"/_vercel/image": { prerender: false }, // Vercel 图像优化路由,显式排除
},
nitro: {
prerender: {
// 从已 prerender 的 HTML 里扫描站内 <a href>,自动发现并跟进链接
crawlLinks: true,
// HTML 里可能出现 /_vercel/image?…(如 LCP preload),排除以免误 prerender
ignore: [/^\/_vercel\/image/],
},
},
2. Module 配置
modules/build-prerender.ts 在非 dev 环境下做以下三件事:
prerender:routes调用collectPrerenderRoutes(),把内容路由补进 prerender 列表。除crawlLinks: true自动发现外,还显式收集各 locale 的静态页和非 draft 内容文章。多语言站 content 文件路径与公开 URL 往往不完全一致,只靠 crawler 可能漏页。prerender:generate记录成功生成的.html路径到generatedFiles。nitro:build:public-assets遍历generatedFiles,只对这些 prerender 成功的 HTML 跑 Beasties。
选这个 hook 有个非直觉的前提:官方文档说它在「拷贝 public assets 之后、Nitro server 构建完成之前」触发,但这里的 server 构建 指的是 rollup 打包生产 server,不是整个 Nitro 生命周期。实际构建顺序是 prerender 先完成、HTML 已落盘,才到这个 hook,所以 Beasties 在这里读到的就是最终的 prerender 产物。
Beasties 输入的是磁盘上的最终 HTML。若 generatedFiles 为空则跳过;单文件处理失败时只输出 warning,不阻断整次构建。
关键代码结构如下:
// 记录 prerender 成功落盘的 .html 相对路径
const generatedFiles = new Set<string>();
// 补充 crawlLinks 可能漏掉的内容路由(多 locale、非 draft 文章等)
nuxt.hooks.hook("prerender:routes", async ({ routes }) => {
const discovered = await collectPrerenderRoutes(nuxt.options.rootDir);
for (const route of discovered) {
routes.add(route);
}
});
// 先等 Nitro 实例存在,Nitro 初始化后挂载 prerender:generate
nuxt.hooks.hook("nitro:init", (nitro) => {
// 每成功 prerender 一页,登记其 .html 路径
nitro.hooks.hook("prerender:generate", (route) => {
if (!route.fileName?.endsWith(".html") || route.error) return;
generatedFiles.add(withoutLeadingSlash(route.fileName));
});
});
// prerender 全部完成、public assets 拷贝之后,逐文件内联关键 CSS
nuxt.hooks.hook("nitro:build:public-assets", async (nitro) => {
const beasties = new Beasties({
path: nitro.options.output.publicDir,
publicPath: nitro.options.baseURL,
...beastiesConfig,
});
for (const file of generatedFiles) {
const htmlPath = resolve(nitro.options.output.publicDir, file);
const contents = await readFile(htmlPath, "utf-8");
const processed = await beasties.process(contents);
await writeFile(htmlPath, processed); // 覆写磁盘上的 prerender 产物
}
});
3. Beasties 配置
config/beasties.ts 里的配置偏保守,加速首屏但不把外部 CSS 当唯一来源;每项取 false 的原因写在行内:
export const beastiesConfig = {
// 只内联 critical CSS,不改外部 CSS 的 <link> 标签形态,
// 避免和 Nuxt/unhead 的 head hydration 打架
preload: false,
// 不裁减外部 entry.css 里已内联的规则;客户端导航和运行时样式仍靠完整 CSS 兜底
pruneSource: false,
// 不内联 @font-face,也不由 Beasties preload 字体;字体走现有 CSS / 资源策略
fonts: false,
// 只处理从外部 <link> 转来的 style,跳过 SSR 已有的 inline style(如 Nuxt UI 的 #nuxt-ui-colors)
reduceInlineStyles: false,
};
4. data-beasties-container 的 Hydration 处理
Beasties 支持通过 data-beasties-container 缩小 critical CSS 的评估范围。官方建议把它加在 body 内包裹首屏可见内容的最外层容器 上,让 Beasties 只根据该子树匹配 CSS 选择器,从而加速大页面或深层嵌套页面的构建期处理。
若未找到该属性,Beasties 会回退到 <html> 并自动写入 data-beasties-container——效果等同于评估整份文档。
本项目把它写在 app/app.vue 的 <html> 上,主要目的不是缩小评估范围,而是 hydration 对齐:
- Beasties 处理 prerender HTML 后,输出里会保留该属性
- SSR 模板若缺少对应属性,客户端 hydration 可能看到 DOM 差异
如果将来页面变长、构建期 Beasties 耗时上升,更合适的优化是把 data-beasties-container 移到 body 内首屏容器(例如 hero + 导航所在的外层 div),而不是继续放在 <html> 上。
app/app.vue 中 useHead 相关片段:
<script setup lang="ts">
useHead(() => ({
htmlAttrs: {
// Beasties 构建期 prerender 会写入该属性;SSR 输出需保持一致。
"data-beasties-container": "",
},
}));
</script>
📋 小结
在 SSR 开启 + 公开页全量 prerender 的前提下做关键 CSS 内联,关键不在「能不能用 Beasties」,而在 何时、对哪份 HTML 跑。
两条走不通的路:
render:response+ Beasties — 在 SSR 响应链直接改<head>,发生在 unhead 管线之外,易与 Nuxt UI 的 inline style(如#nuxt-ui-colors)冲突,hydration 风险高。vite-plugin-beasties— Vite 构建先于 Nitro prerender,插件只能处理 Vite 入口 HTML,覆盖不了逐路由生成的 prerender 产物。
可行方案: 在 nitro:build:public-assets 对 prerender 落盘 HTML 跑 Beasties — dev 跳过 module,prod 不碰 SSR 热路径。职责分离:SSR / prerender 负责生成正确页面,Beasties 只给部署产物加速首屏。
验证: npm run build 后查看 .output/public/index.html,应能看到内联 <style> 与保留的外部 stylesheet <link>;构建日志应输出 Beasties processed N prerendered HTML file(s)。
边界: Beasties 只缓解 CSS 阻塞首屏渲染;图片 LCP、JS hydration 推迟、API 缓存等仍需分层优化。