纯客户端渲染的站,HTML 里只有一个空 <div id="root">。搜索引擎拿到空页、分享链接没有摘要、RSS 无从生成——这三件事都不是"以后再补"能解决的,所以在构建期把每个路由渲染成真正的 HTML。
流程本身很短:
vite build 产出 dist/index.html(带哈希的模板)
vite build --ssr 产出 dist-ssr/entry.js
node scripts/prerender.mjs
1. 读模板,保留 script / link 标签
2. 逐路由 renderToString
3. 替换 head 的 title + description
4. 写进 dist/<path>/index.html真正花时间的不是第 2 步,是怎么知道第 2 步真的成功了。
渲染不报错,页面可能是空的
renderToString 只要不抛异常就会返回字符串。而下面三种情况都不抛异常:
| 现象 | 产物里的痕迹 | 后果 |
|---|---|---|
| 路由级懒加载没解析 | <template id="B: / <div hidden id="S: | 流式 SSR 的分块结构,正文在 JS 里 |
| 骨架屏还在 | aria-busy="true" | 爬虫看到的是一排灰条 |
| 正文被条件挡掉 | 文本长度接近 0 | 页面有标题、没有内容 |
第一种最阴:本地开发时懒加载正常,只有静态渲染那一趟才露出来。
三道闸门
scripts/prerender.mjs 在写文件之前依次检查,任何一道不过就 process.exit(1):
if (html.includes('<template id="B:') || html.includes('<div hidden id="S:')) {
console.error(`✗ ${route.path} 渲染结果含 Suspense 占位块(有懒加载没解析),静态 HTML 里没有正文`)
process.exit(1)
}
if (html.includes('aria-busy="true"')) {
console.error(`✗ ${route.path} 渲染结果仍是骨架 fallback`)
process.exit(1)
}
const textLength = html.replace(/<[^>]+>/g, '').trim().length
if (textLength < 200 && !route.noindex) {
console.error(`✗ ${route.path} 渲染出的正文只有 ${textLength} 字符,疑似空页`)
process.exit(1)
}失败信息里带上路由,字数那道还带上实际字数,是因为这条报错通常出现在加完新页面之后:知道是哪一个、差多少,才不用二分查找。
noindex 是例外,但不是免检
/local 的正文要等浏览器读到 IndexedDB 才有,静态渲染时必然是空的。所以字数闸门对它跳过,/archive(旧地址跳转页)同理。
但前两道仍然生效:Suspense 占位和骨架都意味着"渲染没跑完",跟页面类型无关。例外只放宽一条,不放宽全部。
顺手产出的三件套
同一趟构建还会写 sitemap.xml、rss.xml、robots.txt:
- sitemap 过滤掉
noindex的路由——列进去再标 noindex 是自相矛盾; - RSS 只收笔记与复盘,生活页没有 feed;
robots.txt里写死 sitemap 地址,省得每次手动提交。
预渲染和 hydration 必须算同一道题
静态产物和客户端拿到的是同一棵 DOM 树,任何"每次运行结果不同"的表达式都会造成 mismatch。三条约束:
- 不要用
Date.now()/Math.random()渲染内容。需要构建时刻就用__BUILD_TIME__(vite define注入),两端同一个数。 - 不要用
localeCompare排序。它依赖运行时 ICU,Node 和浏览器的结果可能不同——排序一变,列表顺序就错位。lib/content.ts里的排序全是数值比较。 - 主题的服务端快照固定为
dark。浏览器首帧再由内联脚本改,静态 HTML 因此稳定可缓存。
留言
还没有人说话读到这里,说两句。不用登录,昵称随便填。