[{"data":1,"prerenderedAt":1341},["ShallowReactive",2],{"blog-zh-2026-06-15-prerender-beasties":3},{"id":4,"title":5,"body":6,"category":1321,"date":1322,"description":1323,"draft":1324,"extension":1325,"lang":1326,"meta":1327,"navigation":343,"path":1334,"seo":1335,"sitemap":1336,"slug":1337,"stem":1338,"updated":1339,"__hash__":1340},"blog/blog/zh/2026-06-15-prerender-beasties.md","Nuxt4：SSR + 全量 prerender + 内联关键 CSS 踩坑",{"type":7,"value":8,"toc":1305},"minimark",[9,14,23,33,36,57,60,64,69,79,119,123,130,133,137,143,200,207,211,222,225,229,241,431,435,443,485,493,499,502,953,957,968,1066,1074,1084,1094,1108,1116,1125,1136,1219,1223,1233,1238,1261,1272,1295,1301],[10,11,13],"h2",{"id":12},"背景","💡 背景",[15,16,17,18,22],"p",{},"本博客基于 ",[19,20,21],"strong",{},"Nuxt 4 + Nuxt Content + Nuxt UI","，生产环境的部署模式是：",[24,25,26,30],"ul",{},[27,28,29],"li",{},"ssr: true ，SSR 保持开启，开发体验和动态路由不受影响",[27,31,32],{},"公开内容页在构建时全量 prerender，首屏等同静态 HTML",[15,34,35],{},"想在这个基础上内联关键 CSS，有三条看起来合理的路：",[37,38,39,45,51],"ol",{},[27,40,41,44],{},[19,42,43],{},"render:response + beasties","：在 SSR 响应阶段改 HTML",[27,46,47,50],{},[19,48,49],{},"vite-plugin-beasties","：在 Vite 构建期处理",[27,52,53,56],{},[19,54,55],{},"在 nitro:build:public-assets"," 里对 prerender 产物跑 Beasties",[15,58,59],{},"这篇文章记录为什么前两条路走不通、为什么第三条路可行，以及具体实现细节（踩坑 data-beasties-container hydration）。",[10,61,63],{"id":62},"为什么前两条路走不通","❌ 为什么前两条路走不通",[65,66,68],"h3",{"id":67},"_1-renderresponse-beasties","1. render:response + beasties",[15,70,71,75,76,78],{},[72,73,74],"code",{},"render:response"," 是 Nitro 的 SSR 响应钩子，在服务端渲染完成、响应发出前触发。若在 build 时的 ",[72,77,74],{}," 上挂 Beasties，会产生：",[37,80,81,106],{},[27,82,83,86,87,90,91,94,95,98,99,102,103,105],{},[19,84,85],{},"unhead 风险"," — Beasties 在响应阶段直接改 HTML 字符串（插入 inline ",[72,88,89],{},"\u003Cstyle>","、改写 ",[72,92,93],{},"\u003Clink>","），发生在 ",[72,96,97],{},"unhead"," 管理 ",[72,100,101],{},"\u003Chead>"," 的管线之外，输出顺序无法保证一致，客户端 hydration 时容易出现 ",[72,104,101],{}," 结构错乱或样式重复。",[27,107,108,111,112,115,116,118],{},[19,109,110],{},"Nuxt UI 冲突"," — Nuxt UI 通过 ",[72,113,114],{},"#nuxt-ui-colors"," 等 inline style 写入主题色；Beasties 的 ",[72,117,101],{}," 改写可能与之冲突，进一步放大 hydration 不一致。",[65,120,122],{"id":121},"_2-vite-plugin-beasties","2. vite-plugin-beasties",[15,124,125,126,129],{},"这个插件挂在 Vite 构建阶段，处理 Vite 输出的 HTML 文件。但在 Nuxt + Nitro 的构建链里，Vite 先完成客户端打包，Nitro 再执行 prerender——逐路由生成独立的 ",[72,127,128],{},".html"," 文件。",[15,131,132],{},"插件拿到的是 Vite 的入口 HTML，不是 Nitro prerender 后的多页产物；时序上 Vite 在前、Nitro prerender 在后，处理过的入口 HTML 也不等于最终的各页 prerender 产物。",[10,134,136],{"id":135},"为什么nitrobuildpublic-assets-beasties-没问题","✅ 为什么nitro:build:public-assets + beasties 没问题",[15,138,139,140],{},"核心差异：",[19,141,142],{},"不在 SSR 响应链里改 HTML，而是在 prerender 全部落盘之后，对静态产物做后处理。",[37,144,145,162,175,181,194],{},[27,146,147,150,151,153,154,157,158,161],{},[19,148,149],{},"时序对"," — Nitro 先逐路由 prerender，把每页的 ",[72,152,128],{}," 写到 ",[72,155,156],{},"output.publicDir","，然后才进入 ",[72,159,160],{},"nitro:build:public-assets","。Beasties 读到的就是最终要上 CDN 的那份 HTML，而不是 Vite 入口模板，也不是 prerender 之前的中间产物。",[27,163,164,167,168,171,172,174],{},[19,165,166],{},"覆盖面完整"," — 通过 ",[72,169,170],{},"prerender:generate"," 记录每个成功生成的 ",[72,173,128],{}," 路径，逐文件处理。多语言内容页、列表页、详情页各自独立的 prerender 产物都会被覆盖，不会出现「只优化了入口 HTML」的问题。",[27,176,177,180],{},[19,178,179],{},"不改 SSR 热路径"," — module 在 dev 直接跳过；prod 里未 prerender 的动态路由也不会被这个 hook 碰到。Beasties 只跑在已登记成功的静态页上，开发和运行时 SSR 行为保持原样。",[27,182,183,186,187,190,191,193],{},[19,184,185],{},"绕开 unhead / Nuxt UI 冲突"," — 前两条路的问题，本质是",[19,188,189],{},"在 head 管线尚未稳定时","就改写 ",[72,192,101],{},"。构建后处理磁盘上的静态 HTML 时，SSR 渲染已经完成，unhead 和 Nuxt UI 的 inline style 都已写入文件；我们优化的是部署产物，不再与响应阶段的 head 管理抢顺序。",[27,195,196,199],{},[19,197,198],{},"失败可隔离"," — 单页 Beasties 处理失败只打 warning，不阻断整次构建；外部 CSS 仍保留在 HTML 里，首屏至少有完整样式兜底。",[15,201,202,203,206],{},"因此第三条路把职责拆清楚了：",[19,204,205],{},"SSR / prerender 负责生成正确 HTML，Beasties 负责在构建末期给静态 HTML 加速首屏","——这与「公开页全量 prerender、首屏等同静态 HTML」的部署目标一致。",[10,208,210],{"id":209},"️-具体实现方案","🛠️ 具体实现方案",[15,212,213,214,217,218,221],{},"前提：",[72,215,216],{},"ssr: true"," 与 ",[72,219,220],{},"nitro.prerender"," 并存。\ndev 是普通 SSR，prod 公开页构建时静态化。",[15,223,224],{},"实现分四块：Nuxt / Nitro 配置、Module 接入、Beasties 配置、hydration 对齐。",[65,226,228],{"id":227},"_1-nuxt-nitro-配置","1. Nuxt / Nitro 配置",[15,230,231,236,237,240],{},[19,232,233],{},[72,234,235],{},"nuxt.config.ts"," 中的 prerender 相关配置（仅生产环境生效；开发环境 ",[72,238,239],{},"routeRules"," 留空，避免干扰本地调试）：",[242,243,248],"pre",{"className":244,"code":245,"language":246,"meta":247,"style":247},"language-ts shiki shiki-themes github-light github-light github-dark","ssr: true,\nrouteRules: isDev\n  ? {}\n  : {\n      \"/**\": { prerender: true }, // 生产环境公开页全量 prerender\n      \"/_vercel/image\": { prerender: false }, // Vercel 图像优化路由，显式排除\n    },\n\nnitro: {\n  prerender: {\n    // 从已 prerender 的 HTML 里扫描站内 \u003Ca href>，自动发现并跟进链接\n    crawlLinks: true,\n    // HTML 里可能出现 /_vercel/image?…（如 LCP preload），排除以免误 prerender\n    ignore: [/^\\/_vercel\\/image/],\n  },\n},\n","ts","",[72,249,250,270,278,288,297,316,332,338,345,354,362,368,380,386,419,425],{"__ignoreMap":247},[251,252,255,259,263,267],"span",{"class":253,"line":254},"line",1,[251,256,258],{"class":257},"snPdu","ssr",[251,260,262],{"class":261},"sxrX7",": ",[251,264,266],{"class":265},"sBjJW","true",[251,268,269],{"class":261},",\n",[251,271,273,275],{"class":253,"line":272},2,[251,274,239],{"class":257},[251,276,277],{"class":261},": isDev\n",[251,279,281,285],{"class":253,"line":280},3,[251,282,284],{"class":283},"s8jYJ","  ?",[251,286,287],{"class":261}," {}\n",[251,289,291,294],{"class":253,"line":290},4,[251,292,293],{"class":283},"  :",[251,295,296],{"class":261}," {\n",[251,298,300,304,307,309,312],{"class":253,"line":299},5,[251,301,303],{"class":302},"sIIMD","      \"/**\"",[251,305,306],{"class":261},": { prerender: ",[251,308,266],{"class":265},[251,310,311],{"class":261}," }, ",[251,313,315],{"class":314},"sCsY4","// 生产环境公开页全量 prerender\n",[251,317,319,322,324,327,329],{"class":253,"line":318},6,[251,320,321],{"class":302},"      \"/_vercel/image\"",[251,323,306],{"class":261},[251,325,326],{"class":265},"false",[251,328,311],{"class":261},[251,330,331],{"class":314},"// Vercel 图像优化路由，显式排除\n",[251,333,335],{"class":253,"line":334},7,[251,336,337],{"class":261},"    },\n",[251,339,341],{"class":253,"line":340},8,[251,342,344],{"emptyLinePlaceholder":343},true,"\n",[251,346,348,351],{"class":253,"line":347},9,[251,349,350],{"class":257},"nitro",[251,352,353],{"class":261},": {\n",[251,355,357,360],{"class":253,"line":356},10,[251,358,359],{"class":257},"  prerender",[251,361,353],{"class":261},[251,363,365],{"class":253,"line":364},11,[251,366,367],{"class":314},"    // 从已 prerender 的 HTML 里扫描站内 \u003Ca href>，自动发现并跟进链接\n",[251,369,371,374,376,378],{"class":253,"line":370},12,[251,372,373],{"class":257},"    crawlLinks",[251,375,262],{"class":261},[251,377,266],{"class":265},[251,379,269],{"class":261},[251,381,383],{"class":253,"line":382},13,[251,384,385],{"class":314},"    // HTML 里可能出现 /_vercel/image?…（如 LCP preload），排除以免误 prerender\n",[251,387,389,392,395,398,401,405,409,411,414,416],{"class":253,"line":388},14,[251,390,391],{"class":257},"    ignore",[251,393,394],{"class":261},": [",[251,396,397],{"class":302},"/",[251,399,400],{"class":283},"^",[251,402,404],{"class":403},"sujWL","\\/",[251,406,408],{"class":407},"s3JiQ","_vercel",[251,410,404],{"class":403},[251,412,413],{"class":407},"image",[251,415,397],{"class":302},[251,417,418],{"class":261},"],\n",[251,420,422],{"class":253,"line":421},15,[251,423,424],{"class":261},"  },\n",[251,426,428],{"class":253,"line":427},16,[251,429,430],{"class":261},"},\n",[65,432,434],{"id":433},"_2-module-配置","2. Module 配置",[15,436,437,442],{},[19,438,439],{},[72,440,441],{},"modules/build-prerender.ts"," 在非 dev 环境下做以下三件事：",[37,444,445,461,475],{},[27,446,447,452,453,456,457,460],{},[19,448,449],{},[72,450,451],{},"prerender:routes","\n调用 ",[72,454,455],{},"collectPrerenderRoutes()","，把内容路由补进 prerender 列表。除 ",[72,458,459],{},"crawlLinks: true"," 自动发现外，还显式收集各 locale 的静态页和非 draft 内容文章。多语言站 content 文件路径与公开 URL 往往不完全一致，只靠 crawler 可能漏页。",[27,462,463,467,468,470,471,474],{},[19,464,465],{},[72,466,170],{},"\n记录成功生成的 ",[72,469,128],{}," 路径到 ",[72,472,473],{},"generatedFiles","。",[27,476,477,481,482,484],{},[19,478,479],{},[72,480,160],{},"\n遍历 ",[72,483,473],{},"，只对这些 prerender 成功的 HTML 跑 Beasties。",[15,486,487,488,492],{},"选这个 hook 有个非直觉的前提：官方文档说它在「拷贝 public assets 之后、Nitro server 构建完成之前」触发，但这里的 ",[489,490,491],"em",{},"server 构建"," 指的是 rollup 打包生产 server，不是整个 Nitro 生命周期。实际构建顺序是 prerender 先完成、HTML 已落盘，才到这个 hook，所以 Beasties 在这里读到的就是最终的 prerender 产物。",[15,494,495,496,498],{},"Beasties 输入的是磁盘上的最终 HTML。若 ",[72,497,473],{}," 为空则跳过；单文件处理失败时只输出 warning，不阻断整次构建。",[15,500,501],{},"关键代码结构如下：",[242,503,505],{"className":244,"code":504,"language":246,"meta":247,"style":247},"// 记录 prerender 成功落盘的 .html 相对路径\nconst generatedFiles = new Set\u003Cstring>();\n\n// 补充 crawlLinks 可能漏掉的内容路由（多 locale、非 draft 文章等）\nnuxt.hooks.hook(\"prerender:routes\", async ({ routes }) => {\n  const discovered = await collectPrerenderRoutes(nuxt.options.rootDir);\n  for (const route of discovered) {\n    routes.add(route);\n  }\n});\n\n// 先等 Nitro 实例存在，Nitro 初始化后挂载 prerender:generate\nnuxt.hooks.hook(\"nitro:init\", (nitro) => {\n  // 每成功 prerender 一页，登记其 .html 路径\n  nitro.hooks.hook(\"prerender:generate\", (route) => {\n    if (!route.fileName?.endsWith(\".html\") || route.error) return;\n    generatedFiles.add(withoutLeadingSlash(route.fileName));\n  });\n});\n\n// prerender 全部完成、public assets 拷贝之后，逐文件内联关键 CSS\nnuxt.hooks.hook(\"nitro:build:public-assets\", async (nitro) => {\n  const beasties = new Beasties({\n    path: nitro.options.output.publicDir,\n    publicPath: nitro.options.baseURL,\n    ...beastiesConfig,\n  });\n\n  for (const file of generatedFiles) {\n    const htmlPath = resolve(nitro.options.output.publicDir, file);\n    const contents = await readFile(htmlPath, \"utf-8\");\n    const processed = await beasties.process(contents);\n    await writeFile(htmlPath, processed); // 覆写磁盘上的 prerender 产物\n  }\n});\n",[72,506,507,512,538,542,547,582,601,620,631,636,641,645,650,673,678,701,736,752,758,763,768,774,800,818,824,830,839,844,849,866,883,907,928,943,948],{"__ignoreMap":247},[251,508,509],{"class":253,"line":254},[251,510,511],{"class":314},"// 记录 prerender 成功落盘的 .html 相对路径\n",[251,513,514,517,520,523,526,529,532,535],{"class":253,"line":272},[251,515,516],{"class":283},"const",[251,518,519],{"class":265}," generatedFiles",[251,521,522],{"class":283}," =",[251,524,525],{"class":283}," new",[251,527,528],{"class":257}," Set",[251,530,531],{"class":261},"\u003C",[251,533,534],{"class":265},"string",[251,536,537],{"class":261},">();\n",[251,539,540],{"class":253,"line":280},[251,541,344],{"emptyLinePlaceholder":343},[251,543,544],{"class":253,"line":290},[251,545,546],{"class":314},"// 补充 crawlLinks 可能漏掉的内容路由（多 locale、非 draft 文章等）\n",[251,548,549,552,555,558,561,564,567,570,574,577,580],{"class":253,"line":299},[251,550,551],{"class":261},"nuxt.hooks.",[251,553,554],{"class":257},"hook",[251,556,557],{"class":261},"(",[251,559,560],{"class":302},"\"prerender:routes\"",[251,562,563],{"class":261},", ",[251,565,566],{"class":283},"async",[251,568,569],{"class":261}," ({ ",[251,571,573],{"class":572},"sP4rz","routes",[251,575,576],{"class":261}," }) ",[251,578,579],{"class":283},"=>",[251,581,296],{"class":261},[251,583,584,587,590,592,595,598],{"class":253,"line":318},[251,585,586],{"class":283},"  const",[251,588,589],{"class":265}," discovered",[251,591,522],{"class":283},[251,593,594],{"class":283}," await",[251,596,597],{"class":257}," collectPrerenderRoutes",[251,599,600],{"class":261},"(nuxt.options.rootDir);\n",[251,602,603,606,609,611,614,617],{"class":253,"line":334},[251,604,605],{"class":283},"  for",[251,607,608],{"class":261}," (",[251,610,516],{"class":283},[251,612,613],{"class":265}," route",[251,615,616],{"class":283}," of",[251,618,619],{"class":261}," discovered) {\n",[251,621,622,625,628],{"class":253,"line":340},[251,623,624],{"class":261},"    routes.",[251,626,627],{"class":257},"add",[251,629,630],{"class":261},"(route);\n",[251,632,633],{"class":253,"line":347},[251,634,635],{"class":261},"  }\n",[251,637,638],{"class":253,"line":356},[251,639,640],{"class":261},"});\n",[251,642,643],{"class":253,"line":364},[251,644,344],{"emptyLinePlaceholder":343},[251,646,647],{"class":253,"line":370},[251,648,649],{"class":314},"// 先等 Nitro 实例存在，Nitro 初始化后挂载 prerender:generate\n",[251,651,652,654,656,658,661,664,666,669,671],{"class":253,"line":382},[251,653,551],{"class":261},[251,655,554],{"class":257},[251,657,557],{"class":261},[251,659,660],{"class":302},"\"nitro:init\"",[251,662,663],{"class":261},", (",[251,665,350],{"class":572},[251,667,668],{"class":261},") ",[251,670,579],{"class":283},[251,672,296],{"class":261},[251,674,675],{"class":253,"line":388},[251,676,677],{"class":314},"  // 每成功 prerender 一页，登记其 .html 路径\n",[251,679,680,683,685,687,690,692,695,697,699],{"class":253,"line":421},[251,681,682],{"class":261},"  nitro.hooks.",[251,684,554],{"class":257},[251,686,557],{"class":261},[251,688,689],{"class":302},"\"prerender:generate\"",[251,691,663],{"class":261},[251,693,694],{"class":572},"route",[251,696,668],{"class":261},[251,698,579],{"class":283},[251,700,296],{"class":261},[251,702,703,706,708,711,714,717,719,722,724,727,730,733],{"class":253,"line":427},[251,704,705],{"class":283},"    if",[251,707,608],{"class":261},[251,709,710],{"class":283},"!",[251,712,713],{"class":261},"route.fileName?.",[251,715,716],{"class":257},"endsWith",[251,718,557],{"class":261},[251,720,721],{"class":302},"\".html\"",[251,723,668],{"class":261},[251,725,726],{"class":283},"||",[251,728,729],{"class":261}," route.error) ",[251,731,732],{"class":283},"return",[251,734,735],{"class":261},";\n",[251,737,739,742,744,746,749],{"class":253,"line":738},17,[251,740,741],{"class":261},"    generatedFiles.",[251,743,627],{"class":257},[251,745,557],{"class":261},[251,747,748],{"class":257},"withoutLeadingSlash",[251,750,751],{"class":261},"(route.fileName));\n",[251,753,755],{"class":253,"line":754},18,[251,756,757],{"class":261},"  });\n",[251,759,761],{"class":253,"line":760},19,[251,762,640],{"class":261},[251,764,766],{"class":253,"line":765},20,[251,767,344],{"emptyLinePlaceholder":343},[251,769,771],{"class":253,"line":770},21,[251,772,773],{"class":314},"// prerender 全部完成、public assets 拷贝之后，逐文件内联关键 CSS\n",[251,775,777,779,781,783,786,788,790,792,794,796,798],{"class":253,"line":776},22,[251,778,551],{"class":261},[251,780,554],{"class":257},[251,782,557],{"class":261},[251,784,785],{"class":302},"\"nitro:build:public-assets\"",[251,787,563],{"class":261},[251,789,566],{"class":283},[251,791,608],{"class":261},[251,793,350],{"class":572},[251,795,668],{"class":261},[251,797,579],{"class":283},[251,799,296],{"class":261},[251,801,803,805,808,810,812,815],{"class":253,"line":802},23,[251,804,586],{"class":283},[251,806,807],{"class":265}," beasties",[251,809,522],{"class":283},[251,811,525],{"class":283},[251,813,814],{"class":257}," Beasties",[251,816,817],{"class":261},"({\n",[251,819,821],{"class":253,"line":820},24,[251,822,823],{"class":261},"    path: nitro.options.output.publicDir,\n",[251,825,827],{"class":253,"line":826},25,[251,828,829],{"class":261},"    publicPath: nitro.options.baseURL,\n",[251,831,833,836],{"class":253,"line":832},26,[251,834,835],{"class":283},"    ...",[251,837,838],{"class":261},"beastiesConfig,\n",[251,840,842],{"class":253,"line":841},27,[251,843,757],{"class":261},[251,845,847],{"class":253,"line":846},28,[251,848,344],{"emptyLinePlaceholder":343},[251,850,852,854,856,858,861,863],{"class":253,"line":851},29,[251,853,605],{"class":283},[251,855,608],{"class":261},[251,857,516],{"class":283},[251,859,860],{"class":265}," file",[251,862,616],{"class":283},[251,864,865],{"class":261}," generatedFiles) {\n",[251,867,869,872,875,877,880],{"class":253,"line":868},30,[251,870,871],{"class":283},"    const",[251,873,874],{"class":265}," htmlPath",[251,876,522],{"class":283},[251,878,879],{"class":257}," resolve",[251,881,882],{"class":261},"(nitro.options.output.publicDir, file);\n",[251,884,886,888,891,893,895,898,901,904],{"class":253,"line":885},31,[251,887,871],{"class":283},[251,889,890],{"class":265}," contents",[251,892,522],{"class":283},[251,894,594],{"class":283},[251,896,897],{"class":257}," readFile",[251,899,900],{"class":261},"(htmlPath, ",[251,902,903],{"class":302},"\"utf-8\"",[251,905,906],{"class":261},");\n",[251,908,910,912,915,917,919,922,925],{"class":253,"line":909},32,[251,911,871],{"class":283},[251,913,914],{"class":265}," processed",[251,916,522],{"class":283},[251,918,594],{"class":283},[251,920,921],{"class":261}," beasties.",[251,923,924],{"class":257},"process",[251,926,927],{"class":261},"(contents);\n",[251,929,931,934,937,940],{"class":253,"line":930},33,[251,932,933],{"class":283},"    await",[251,935,936],{"class":257}," writeFile",[251,938,939],{"class":261},"(htmlPath, processed); ",[251,941,942],{"class":314},"// 覆写磁盘上的 prerender 产物\n",[251,944,946],{"class":253,"line":945},34,[251,947,635],{"class":261},[251,949,951],{"class":253,"line":950},35,[251,952,640],{"class":261},[65,954,956],{"id":955},"_3-beasties-配置","3. Beasties 配置",[15,958,959,964,965,967],{},[19,960,961],{},[72,962,963],{},"config/beasties.ts"," 里的配置偏保守，加速首屏但不把外部 CSS 当唯一来源；每项取 ",[72,966,326],{}," 的原因写在行内：",[242,969,971],{"className":244,"code":970,"language":246,"meta":247,"style":247},"export const beastiesConfig = {\n  // 只内联 critical CSS，不改外部 CSS 的 \u003Clink> 标签形态，\n  // 避免和 Nuxt/unhead 的 head hydration 打架\n  preload: false,\n\n  // 不裁减外部 entry.css 里已内联的规则；客户端导航和运行时样式仍靠完整 CSS 兜底\n  pruneSource: false,\n\n  // 不内联 @font-face，也不由 Beasties preload 字体；字体走现有 CSS / 资源策略\n  fonts: false,\n\n  // 只处理从外部 \u003Clink> 转来的 style，跳过 SSR 已有的 inline style（如 Nuxt UI 的 #nuxt-ui-colors）\n  reduceInlineStyles: false,\n};\n",[72,972,973,988,993,998,1007,1011,1016,1025,1029,1034,1043,1047,1052,1061],{"__ignoreMap":247},[251,974,975,978,981,984,986],{"class":253,"line":254},[251,976,977],{"class":283},"export",[251,979,980],{"class":283}," const",[251,982,983],{"class":265}," beastiesConfig",[251,985,522],{"class":283},[251,987,296],{"class":261},[251,989,990],{"class":253,"line":272},[251,991,992],{"class":314},"  // 只内联 critical CSS，不改外部 CSS 的 \u003Clink> 标签形态，\n",[251,994,995],{"class":253,"line":280},[251,996,997],{"class":314},"  // 避免和 Nuxt/unhead 的 head hydration 打架\n",[251,999,1000,1003,1005],{"class":253,"line":290},[251,1001,1002],{"class":261},"  preload: ",[251,1004,326],{"class":265},[251,1006,269],{"class":261},[251,1008,1009],{"class":253,"line":299},[251,1010,344],{"emptyLinePlaceholder":343},[251,1012,1013],{"class":253,"line":318},[251,1014,1015],{"class":314},"  // 不裁减外部 entry.css 里已内联的规则；客户端导航和运行时样式仍靠完整 CSS 兜底\n",[251,1017,1018,1021,1023],{"class":253,"line":334},[251,1019,1020],{"class":261},"  pruneSource: ",[251,1022,326],{"class":265},[251,1024,269],{"class":261},[251,1026,1027],{"class":253,"line":340},[251,1028,344],{"emptyLinePlaceholder":343},[251,1030,1031],{"class":253,"line":347},[251,1032,1033],{"class":314},"  // 不内联 @font-face，也不由 Beasties preload 字体；字体走现有 CSS / 资源策略\n",[251,1035,1036,1039,1041],{"class":253,"line":356},[251,1037,1038],{"class":261},"  fonts: ",[251,1040,326],{"class":265},[251,1042,269],{"class":261},[251,1044,1045],{"class":253,"line":364},[251,1046,344],{"emptyLinePlaceholder":343},[251,1048,1049],{"class":253,"line":370},[251,1050,1051],{"class":314},"  // 只处理从外部 \u003Clink> 转来的 style，跳过 SSR 已有的 inline style（如 Nuxt UI 的 #nuxt-ui-colors）\n",[251,1053,1054,1057,1059],{"class":253,"line":382},[251,1055,1056],{"class":261},"  reduceInlineStyles: ",[251,1058,326],{"class":265},[251,1060,269],{"class":261},[251,1062,1063],{"class":253,"line":388},[251,1064,1065],{"class":261},"};\n",[65,1067,1069,1070,1073],{"id":1068},"_4-data-beasties-container-的-hydration-处理","4. ",[72,1071,1072],{},"data-beasties-container"," 的 Hydration 处理",[15,1075,1076,1077,1079,1080,1083],{},"Beasties 支持通过 ",[72,1078,1072],{}," 缩小 critical CSS 的评估范围。官方建议把它加在 ",[19,1081,1082],{},"body 内包裹首屏可见内容的最外层容器"," 上，让 Beasties 只根据该子树匹配 CSS 选择器，从而加速大页面或深层嵌套页面的构建期处理。",[15,1085,1086,1087,1090,1091,1093],{},"若未找到该属性，Beasties 会回退到 ",[72,1088,1089],{},"\u003Chtml>"," 并自动写入 ",[72,1092,1072],{},"——效果等同于评估整份文档。",[15,1095,1096,1097,1100,1101,1103,1104,1107],{},"本项目把它写在 ",[72,1098,1099],{},"app/app.vue"," 的 ",[72,1102,1089],{}," 上，主要目的不是缩小评估范围，而是 ",[19,1105,1106],{},"hydration 对齐","：",[24,1109,1110,1113],{},[27,1111,1112],{},"Beasties 处理 prerender HTML 后，输出里会保留该属性",[27,1114,1115],{},"SSR 模板若缺少对应属性，客户端 hydration 可能看到 DOM 差异",[15,1117,1118,1119,1121,1122,1124],{},"如果将来页面变长、构建期 Beasties 耗时上升，更合适的优化是把 ",[72,1120,1072],{}," 移到 body 内首屏容器（例如 hero + 导航所在的外层 div），而不是继续放在 ",[72,1123,1089],{}," 上。",[15,1126,1127,1131,1132,1135],{},[19,1128,1129],{},[72,1130,1099],{}," 中 ",[72,1133,1134],{},"useHead"," 相关片段：",[242,1137,1141],{"className":1138,"code":1139,"language":1140,"meta":247,"style":247},"language-vue shiki shiki-themes github-light github-light github-dark","\u003Cscript setup lang=\"ts\">\n  useHead(() => ({\n    htmlAttrs: {\n      // Beasties 构建期 prerender 会写入该属性；SSR 输出需保持一致。\n      \"data-beasties-container\": \"\",\n    },\n  }));\n\u003C/script>\n","vue",[72,1142,1143,1166,1179,1184,1189,1201,1205,1210],{"__ignoreMap":247},[251,1144,1145,1147,1151,1154,1157,1160,1163],{"class":253,"line":254},[251,1146,531],{"class":261},[251,1148,1150],{"class":1149},"sovSZ","script",[251,1152,1153],{"class":257}," setup",[251,1155,1156],{"class":257}," lang",[251,1158,1159],{"class":261},"=",[251,1161,1162],{"class":302},"\"ts\"",[251,1164,1165],{"class":261},">\n",[251,1167,1168,1171,1174,1176],{"class":253,"line":272},[251,1169,1170],{"class":257},"  useHead",[251,1172,1173],{"class":261},"(() ",[251,1175,579],{"class":283},[251,1177,1178],{"class":261}," ({\n",[251,1180,1181],{"class":253,"line":280},[251,1182,1183],{"class":261},"    htmlAttrs: {\n",[251,1185,1186],{"class":253,"line":290},[251,1187,1188],{"class":314},"      // Beasties 构建期 prerender 会写入该属性；SSR 输出需保持一致。\n",[251,1190,1191,1194,1196,1199],{"class":253,"line":299},[251,1192,1193],{"class":302},"      \"data-beasties-container\"",[251,1195,262],{"class":261},[251,1197,1198],{"class":302},"\"\"",[251,1200,269],{"class":261},[251,1202,1203],{"class":253,"line":318},[251,1204,337],{"class":261},[251,1206,1207],{"class":253,"line":334},[251,1208,1209],{"class":261},"  }));\n",[251,1211,1212,1215,1217],{"class":253,"line":340},[251,1213,1214],{"class":261},"\u003C/",[251,1216,1150],{"class":1149},[251,1218,1165],{"class":261},[10,1220,1222],{"id":1221},"小结","📋 小结",[15,1224,1225,1226,1229,1230,474],{},"在 ",[19,1227,1228],{},"SSR 开启 + 公开页全量 prerender"," 的前提下做关键 CSS 内联，关键不在「能不能用 Beasties」，而在 ",[19,1231,1232],{},"何时、对哪份 HTML 跑",[15,1234,1235],{},[19,1236,1237],{},"两条走不通的路：",[24,1239,1240,1254],{},[27,1241,1242,1247,1248,1250,1251,1253],{},[19,1243,1244,1246],{},[72,1245,74],{}," + Beasties"," — 在 SSR 响应链直接改 ",[72,1249,101],{},"，发生在 unhead 管线之外，易与 Nuxt UI 的 inline style（如 ",[72,1252,114],{},"）冲突，hydration 风险高。",[27,1255,1256,1260],{},[19,1257,1258],{},[72,1259,49],{}," — Vite 构建先于 Nitro prerender，插件只能处理 Vite 入口 HTML，覆盖不了逐路由生成的 prerender 产物。",[15,1262,1263,1266,1267,1271],{},[19,1264,1265],{},"可行方案："," 在 ",[19,1268,1269],{},[72,1270,160],{}," 对 prerender 落盘 HTML 跑 Beasties — dev 跳过 module，prod 不碰 SSR 热路径。职责分离：SSR / prerender 负责生成正确页面，Beasties 只给部署产物加速首屏。",[15,1273,1274,1277,1278,1281,1282,1285,1286,1288,1289,1291,1292,474],{},[19,1275,1276],{},"验证："," ",[72,1279,1280],{},"npm run build"," 后查看 ",[72,1283,1284],{},".output/public/index.html","，应能看到内联 ",[72,1287,89],{}," 与保留的外部 stylesheet ",[72,1290,93],{},"；构建日志应输出 ",[72,1293,1294],{},"Beasties processed N prerendered HTML file(s)",[15,1296,1297,1300],{},[19,1298,1299],{},"边界："," Beasties 只缓解 CSS 阻塞首屏渲染；图片 LCP、JS hydration 推迟、API 缓存等仍需分层优化。",[1302,1303,1304],"style",{},"html pre.shiki code .snPdu, html code.shiki .snPdu{--shiki-light:#6F42C1;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sxrX7, html code.shiki .sxrX7{--shiki-light:#24292E;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sBjJW, html code.shiki .sBjJW{--shiki-light:#005CC5;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s8jYJ, html code.shiki .s8jYJ{--shiki-light:#D73A49;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sIIMD, html code.shiki .sIIMD{--shiki-light:#032F62;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sCsY4, html code.shiki .sCsY4{--shiki-light:#6A737D;--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sujWL, html code.shiki .sujWL{--shiki-light:#22863A;--shiki-light-font-weight:bold;--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}html pre.shiki code .s3JiQ, html code.shiki .s3JiQ{--shiki-light:#032F62;--shiki-default:#032F62;--shiki-dark:#DBEDFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sP4rz, html code.shiki .sP4rz{--shiki-light:#E36209;--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sovSZ, html code.shiki .sovSZ{--shiki-light:#22863A;--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":247,"searchDepth":272,"depth":272,"links":1306},[1307,1308,1312,1313,1320],{"id":12,"depth":272,"text":13},{"id":62,"depth":272,"text":63,"children":1309},[1310,1311],{"id":67,"depth":280,"text":68},{"id":121,"depth":280,"text":122},{"id":135,"depth":272,"text":136},{"id":209,"depth":272,"text":210,"children":1314},[1315,1316,1317,1318],{"id":227,"depth":280,"text":228},{"id":433,"depth":280,"text":434},{"id":955,"depth":280,"text":956},{"id":1068,"depth":280,"text":1319},"4. data-beasties-container 的 Hydration 处理",{"id":1221,"depth":272,"text":1222},"architecture","2026-06-15T00:00:00.000Z","Nuxt4：SSR 开启 + 公开页全量 prerender + 内联关键 CSS：\nrender:response + beasties → unhead / Nuxt UI inline style ❌\nvite-plugin-beasties → 只处理 Vite 入口 HTML 而非逐页 prerender 产物 ❌\nnitro:build:public-assets + beasties ✅",false,"md","zh",{"tags":1328},[1329,1330,1331,1332,1333],"nuxt","prerender","beasties","critical-css","frontend-architecture","/blog/zh/2026-06-15-prerender-beasties",{"title":5,"description":1323},{"loc":1334},null,"blog/zh/2026-06-15-prerender-beasties","2026-06-21T00:00:00.000Z","lQ7_qmOU6EQ9TMk_jrqlc8g3U0msWmvZT_ouAds_r48",1784966032135]