Nuxt4:SSR + 全量 prerender + 内联关键 CSS 踩坑

Nuxt4:SSR 开启 + 公开页全量 prerender + 内联关键 CSS: render:response + beasties → unhead / Nuxt UI inline style ❌ vite-plugin-beasties → 只处理 Vite 入口 HTML 而非逐页 prerender 产物 ❌ nitro:build:public-assets + beasties ✅
更新于
architecture

💡 背景

本博客基于 Nuxt 4 + Nuxt Content + Nuxt UI,生产环境的部署模式是:

  • ssr: true ,SSR 保持开启,开发体验和动态路由不受影响
  • 公开内容页在构建时全量 prerender,首屏等同静态 HTML

想在这个基础上内联关键 CSS,有三条看起来合理的路:

  1. render:response + beasties:在 SSR 响应阶段改 HTML
  2. vite-plugin-beasties:在 Vite 构建期处理
  3. 在 nitro:build:public-assets 里对 prerender 产物跑 Beasties

这篇文章记录为什么前两条路走不通、为什么第三条路可行,以及具体实现细节(踩坑 data-beasties-container hydration)。

❌ 为什么前两条路走不通

1. render:response + beasties

render:response 是 Nitro 的 SSR 响应钩子,在服务端渲染完成、响应发出前触发。若在 build 时的 render:response 上挂 Beasties,会产生:

  1. unhead 风险 — Beasties 在响应阶段直接改 HTML 字符串(插入 inline <style>、改写 <link>),发生在 unhead 管理 <head> 的管线之外,输出顺序无法保证一致,客户端 hydration 时容易出现 <head> 结构错乱或样式重复。
  2. 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 全部落盘之后,对静态产物做后处理。

  1. 时序对 — Nitro 先逐路由 prerender,把每页的 .html 写到 output.publicDir,然后才进入 nitro:build:public-assets。Beasties 读到的就是最终要上 CDN 的那份 HTML,而不是 Vite 入口模板,也不是 prerender 之前的中间产物。
  2. 覆盖面完整 — 通过 prerender:generate 记录每个成功生成的 .html 路径,逐文件处理。多语言内容页、列表页、详情页各自独立的 prerender 产物都会被覆盖,不会出现「只优化了入口 HTML」的问题。
  3. 不改 SSR 热路径 — module 在 dev 直接跳过;prod 里未 prerender 的动态路由也不会被这个 hook 碰到。Beasties 只跑在已登记成功的静态页上,开发和运行时 SSR 行为保持原样。
  4. 绕开 unhead / Nuxt UI 冲突 — 前两条路的问题,本质是在 head 管线尚未稳定时就改写 <head>。构建后处理磁盘上的静态 HTML 时,SSR 渲染已经完成,unhead 和 Nuxt UI 的 inline style 都已写入文件;我们优化的是部署产物,不再与响应阶段的 head 管理抢顺序。
  5. 失败可隔离 — 单页 Beasties 处理失败只打 warning,不阻断整次构建;外部 CSS 仍保留在 HTML 里,首屏至少有完整样式兜底。

因此第三条路把职责拆清楚了:SSR / prerender 负责生成正确 HTML,Beasties 负责在构建末期给静态 HTML 加速首屏——这与「公开页全量 prerender、首屏等同静态 HTML」的部署目标一致。

🛠️ 具体实现方案

前提:ssr: truenitro.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 环境下做以下三件事:

  1. prerender:routes 调用 collectPrerenderRoutes(),把内容路由补进 prerender 列表。除 crawlLinks: true 自动发现外,还显式收集各 locale 的静态页和非 draft 内容文章。多语言站 content 文件路径与公开 URL 往往不完全一致,只靠 crawler 可能漏页。
  2. prerender:generate 记录成功生成的 .html 路径到 generatedFiles
  3. 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.vueuseHead 相关片段:

<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 缓存等仍需分层优化。

Christina 的博客

关于技术、学习与生活的记录与分享。

浏览量
访客数
更新于:

©2026 Christina 的博客. 保留所有权利。