

1/1/1970
我如何在 2 天内实现了 SSG
大家好!一年前,我以为这是不可能的事。但我刚刚花了 2 天时间为 Foony 实现了静态站点生成(SSG),我对此感到非常兴奋。这也不是我第一次尝试为 Foony 解决 SSG 问题。过去我研究过 NextJS、Vike、Astro、Gatsby 以及其他几种解决方案。我甚至错误地启动过 NextJS,但很快就遇到了 Foony 的 SPA 复杂性和数千个文件带来的困难。迁移会是一场噩梦,可能需要几个月时间。这也会给其他在网站上工作的人增加额外的复杂性,因为他们必须学习 NextJS 及其各种怪癖。
我想要的是轻量级、易于实现的方案。这个方案应该能让我们继续以一贯的方式编写代码,而无需考虑 SSG(除了 useMediaQuery,这个真没办法绕过)。下面我会详细说明为什么我选择了一个定制方案、我遇到的具体挑战(尤其是 React 的 Suspense 边界),以及我是如何解决这些问题的。
为什么不用标准方案?
当我第一次考虑给 Foony 添加 SSG 时,我自然而然地考虑了 NextJS(行业标准)、Vike 和 Astro。
NextJS:迁移成本太高
NextJS 很强大,但它需要对 Foony 现有的 React SPA 进行大规模迁移。我们有数千个文件、复杂的路由逻辑,以及大量自定义基础设施。迁移到 NextJS 意味着:
- 重写整个路由系统
- 重构游戏和组件的加载方式
- 仅仅是为了恢复功能对等就需要数月的工作
- 可能给用户带来破坏性变更
- 改变图片处理方式
- 构建时间显著变慢(可能需要 5 到 30 分钟。我没有具体的数字佐证,只有这条 5 年前的 GitHub 讨论)
- 整个团队需要学习新东西(NextJS),开发速度永远地变慢
- 每次 NextJS 决定做破坏性变更时都要迁移代码
我甚至错误地启动过 NextJS,但很快意识到迁移成本太高。这种复杂性不值得。
Vike:同样复杂
Vike(以前叫 vite-plugin-ssr)有类似的问题。虽然它比 NextJS 更灵活,但仍然需要对我们的代码库进行大量重构。学习曲线和迁移成本与收益不成正比。
Astro:架构不匹配
Astro 适合内容密集型网站,但 Foony 是一个复杂的多人游戏平台。我们需要实时更新、WebSocket 连接和动态 React 组件。Astro 的架构根本不适合我们要构建的东西。
解决方案:定制 SSG
受到前几天为 i18n 实现的"伪 SSG"方案的鼓舞,我决定为 Foony 的 SSG 选择一个小巧、轻量、定制的解决方案。
我的"伪 SSG"方法是从带有博客文章的页面中提取博客内容(
/posts路由和游戏页面),将它们准确地放置在客户端渲染的位置,专门帮助搜索引擎和 LLM 理解 Foony。它还应用了 ld+json 模式和一些小的 SEO 内容。
方法很简单:
- 基于现有 React SPA 构建:无需迁移,只需在构建时添加 SSG 生成。
- 使用
renderToReadableStream:React 18 的流式 SSR API 原生支持 Suspense。 - 生成静态 HTML 文件:在构建时预渲染路由,并将其作为静态文件提供,使用我们的 SitemapGenerator 获取路由列表。
- 对现有代码库的改动最小:大多数组件保持原样即可工作。
核心实现位于 client/src/generators/GenerateShellSsgFromSitemap.ts。它读取站点地图,使用 React 的 renderToReadableStream 渲染每个路由,并将 HTML 写入静态文件。简单,正合我意!
最终速度也相当快。大约 2,800 个路由在 10 秒内完成渲染。不错。这比 NextJS、Gatsby 和 Astro 都要快得多。 <img alt="SSG 控制台日志显示耗时" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />
我可以一直谈论简洁性。即使在大公司可能因为"缺乏复杂度"而无法获得晋升,简洁的代码依然是优美的、可维护的,而且总体上对开发速度更有益。这是我非常欣赏 Zen 原则的一点。
Suspense 边界问题
现在我有了 SSG,内容也出现在 HTML 中了……但页面是空白的!这是怎么回事?! <img alt="SSG 空白页面" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
原来 renderToReadableStream 仍然会有 Suspense 边界,即使你 await stream.allReady 也是如此。我猜这是因为它是一个"流",设计上是在字节接收时传递给客户端的。
React 输出的内容
当你把 renderToReadableStream 与 Suspense 一起使用时,React 输出的 HTML 是这样的:
<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
<!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>
<template id="B:0"> 是内容应该被填入的占位符。<div hidden id="S:0"> 包含实际渲染的内容。B:0 通过编号(从 0 开始)与 S:0 匹配。
如果没有 JavaScript,搜索引擎(说的就是你,Bing)和 LLM 看到的将是几乎空白的页面,只有模板占位符。这完全违背了 SSG 的目的!
我没有看到任何干净的方式去除这些 Suspense 边界,所以我的解决方案是写一些测试和一个 resolveSuspenseBoundaries 函数来替换这些内容。这比解析 HTML 并用 JSDOM 之类的东西执行脚本要快。而且更重要的是,这是我所计划的一个要求:在没有 JavaScript 的情况下,为搜索引擎/LLM 提供一个漂亮、可读的网站,同时支持客户端的 Suspense 边界和水合。
测试转换
我首先通过抓取 DOM 中的一些示例(我现有的样子,JavaScript 禁用)和我想要的样子(JavaScript 启用)来为转换编写测试。我把这些喂给一个 LLM,让它来生成测试,这是它相当擅长的事情。
这些测试位于 client/src/generators/ssr/renderRoute.test.ts,确保转换正确工作。测试覆盖了:
- 简单边界替换(博客列表)
- 模板和闭合注释之间有内容的复杂边界
- 多个边界
- 没有注释标记的边界
- 边缘情况
这种"TDD"对于这种有预期输入和输出的用例其实非常有用。
这与"因为 Robert C. Martin 这么说所以一切都用 TDD"(这会减慢团队的开发速度)不能混淆。你不应该在 UI 或代码中不断变化的部分使用 TDD!
解决方案:resolveSuspenseBoundaries
测试到位后,我让 LLM 编写了 resolveSuspenseBoundaries 函数。我选择了 cheerio 来避免正则表达式的脆弱性,尽管使用正则表达式会将 SSG 时间缩短约 40%。
export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
const originalHtml = html;
const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];
// Collect hidden divs with their content and positions.
const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
$('div[hidden][id^="S:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const content = $(el).html() || '';
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset === 'number' && typeof endOffset === 'number') {
hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
}
});
if (hiddenDivs.size === 0) {
return {html: originalHtml, didResolveSuspense: false};
}
// Find templates (B:0) and replace them with the matching hidden content (S:0),
// following React’s internal $RV behavior.
$('template[id^="B:"]').each((_, el) => {
const id = $(el).attr('id');
if (!id) {
return;
}
const boundaryId = id.substring(2);
const divInfo = hiddenDivs.get(boundaryId);
if (!divInfo) {
return;
}
const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
return;
}
const templateIndex = startOffset;
const templateLength = endOffset - startOffset;
const afterTemplate = originalHtml.substring(templateIndex + templateLength);
const closingCommentMatch = afterTemplate.match(/<!--\/[amp;]-->/);
const removeEndIndex = closingCommentMatch
? templateIndex + templateLength + closingCommentMatch.index!
: templateIndex + templateLength;
const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1;
const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex);
const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex);
operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex});
operations.push({index: templateIndex, removeLength: 0, insertText: divContent});
operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex});
operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex});
operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex});
});
operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength));
let resultHtml = originalHtml;
for (const operation of operations) {
resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength);
}
return {html: resultHtml, didResolveSuspense: true};
}
这确保搜索引擎和 LLM 看到的是完全渲染好的页面,而不是几乎空白的页面。
现在我们的 SSG 在没有 JavaScript 的情况下也能正常工作了!
<img alt="Foony 博客的无 JavaScript SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />
长期来看,React 可能会改变它们的 Suspense 格式。一旦我对懒加载页面(因此需要 Suspense 边界)有了更好的解决方案,我可能会移除 Suspense 解析代码。
水合策略(更新:这花了 3 天 + 1 天额外时间)
水合很有挑战性。我知道这一点。但是,经过一些工作后,我成功让它工作起来了!
水合总耗时:3 天,加上 1 天额外时间来替换脱水方法。
最棘手的部分是让第一个最小可工作的水合运行起来。一旦我设法用导航栏渲染出"Hello World",我就有信心,是的,这可能不需要一整个月!
<img alt="Foony 的 Hello World 与导航栏成功水合" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />
为了实现第一个最小可工作的水合,我有一个独特的挑战:我想要水合,但我也想为搜索引擎和 LLM 提供良好的 SEO,同时不让开发者去考虑 Suspense 边界。
挑战
React 水合非常严格:如果 DOM 看起来不像 React 期望的首次渲染样子,你会在控制台中看到一条漂亮但几乎无用的错误消息,React 会扔掉一切并从头重新渲染。甚至连 diff 都没有,让你不知道哪里出了问题!
在我们的情况下,SSG 在几个方面让这个问题变得更糟:
- 我们对 HTML 进行后处理,以删除/解析 React 18 流式 Suspense 的产物(这对爬虫很好)。
- 客户端在时刻 (t = 0) 时并不总是拥有与服务器渲染完全相同的数据(SSG 数据、博客元数据等)。
- 我们的 i18n 默认是"懒加载"的,这意味着除非你记录 SSG 使用了哪些翻译并在 React 渲染前注入它们,否则首次渲染时翻译可能会缺失。
行得通的方法(初始方案:脱水)
起初,我尝试了一些聪明又可爱的东西:我使用命令模式记录用于解析 HTML Suspense 边界的命令,并返回反向转换命令,以便我能将 HTML 恢复为 React 进行水合所需的样子。
我希望通过这种命令方法可以在 index.html 中传输更少的字节。但是,正如大多数聪明的解决方案一样,这失败了,因为浏览器会以微妙的方式修改 HTML,例如删除或添加 ; 或 /,这会扰乱替换索引。
从技术上讲,你或许可以考虑这些微妙的浏览器改动,但我不会推出这样脆弱的东西。
我没有试图将 Suspense 边界转换"反转"回 React 的流式标记,而是做了一件超级简单的事情:
在 <script type="text"> 中捆绑原始的、未解析的 HTML。
这种"脱水"方法奏效了,但我多花了一天用更好的方案替换它。
更好的方法:关键路径 Suspense 边界替换
在初始实现之后,我仍然遇到一些 Suspense 边界的问题。这时我意识到有一个更干净、更好、更简单的解决方案。我用关键路径 Suspense 边界替换取代了脱水方法,它:
- 在水合前加载关键路径:在 SSR 期间预加载的组件被识别出来,并在调用
hydrateRoot 之前在客户端预加载
- 更易维护:不需要 React 内部细节或 AST 解析(脱水方法需要解析和恢复 HTML)
- 传输更少字节:我们不再在 script 标签中捆绑 React 的原始 SSR 响应
- 避免潜在的闪烁:无需脱水/再水合 HTML,消除了潜在的视觉闪烁
实现会跟踪在 SSR 期间预加载了哪些懒加载组件(通过 SSRLazyComponentTracker),将它们的导入路径包含在水合数据中,并在水合前同步预加载它们。关键路径组件直接渲染,不带 Suspense 边界,完全匹配 SSR 输出。
对于其他所有内容,我们让首次客户端渲染充当 SSR/SSG。这意味着使用相同的输入,并在 hydrateRoot 之前同步提供这些输入。这是通过我们的"ssg-data"捆绑实现的。
具体来说,调整如下:
将 SSR 输入捆绑到一个文本脚本中
- 在 SSG 期间,我们在 Vite 模块入口点之前注入一个
<script type="text/foony-ssg" id="foony-ssg-data">...</script>。
- 该脚本包含:
html:我们实际在静态文件中提供的解析后的 HTML
ssgData:SSR 包装器使用的序列化 SSGData。我计划将其更新为 Proxy 之类的,这样只有访问过的数据才会被包含。
translationData:我们在 SSR 期间触及的翻译键值数据
在水合前注入这些输入
- 在
main.tsx 中,我们同步执行:
- 将
#root.innerHTML 设置为序列化的解析 HTML(这样 DOM 与水合看到的完全一样)
- 用
SSGDataProvider 包装应用,使组件在首次渲染时拥有相同的 SSGData
通过注入翻译值使 i18n 即时可用
- 我们记录在 SSR 期间访问的实际翻译对象,并在 SSG 脚本中传输它们。
- 在客户端,我们通过专用的
LocaleQueryer.inject() 方法将它们直接注入到 LocaleQueryer 的缓存中,这样翻译就能立即可用。
就这样,首次渲染拥有了与 SSR 相同的数据!
useIsSSRMode() 钩子已经在 client/src/generators/ssr/isSSRMode.ts 中实现:
export function useIsSSRMode(): boolean {
const [isSSRMode, setIsSSRMode] = React.useState(true);
React.useEffect(() => {
// After mount (hydration complete), switch to client mode
setIsSSRMode(false);
}, []);
return isSSRMode;
}
这个钩子在 SSR 期间和首次客户端渲染(水合)时返回 true,然后在挂载后切换为 false。UserBanner、Navbar 和 Dialog 等组件已经使用它来防止水合不匹配。
- 修补 React 以获得更好的 diff
我本希望可以直接使用 hydration-overlay。但它没有积极维护,只支持到 React 18,而且不是生产级的。所以我让一个 LLM 克隆了仓库作为灵感来源,然后它在几分钟内创建了一个最小的水合覆盖层。我不需要什么花哨的东西,只需要一个能在开发期间显示的东西,以便我能弄清楚哪里出了问题。
这个新的覆盖层非常基础,所以 diff 并不完美。React 会去除注释,在 style 属性后添加 ;,修改空白等等,这些我们的覆盖层(目前)并未考虑。我们的覆盖层还包括 React 在水合时忽略的 HTML 注释。
<img alt="我们新的水合覆盖层" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />
但它已经足够好,可以让我们弄清楚需要修复什么。
<img alt="我们的 SSG 与客户端首页渲染用于 React 水合的 diff" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />
数据一览
让你大致了解一下这次实现涉及的内容:
- 2 天的工作(从开始到 SSG 工作正常)。这只是在假期期间略超 24 小时。
- 4 天的工作让水合在没有异步翻译竞态或
useMediaQuery 干扰的情况下表现良好。
- 1 天额外时间用关键路径 Suspense 边界替换替代了脱水方法(更简单、更少字节、无潜在闪烁)。
- 约 200 行核心 SSG 生成代码(
GenerateShellSsgFromSitemap.ts)
- 约 120 行 Suspense 边界解析代码(
renderRoute.tsx 中的 resolveSuspenseBoundaries)。注:这后来被关键路径方法取代
- 约 50 行 SSR 工具代码(
isSSRMode.ts)
- 约 100 行测试代码(
renderRoute.test.ts)
- 约 150 行 SSR 的 polyfill(
setupSSREnvironment)
- 对现有组件改动极小(主要是添加
useIsSSRMode() 检查)
这个解决方案轻量且可维护。它不需要框架迁移,而且与我们现有的 React SPA 协同工作。
关键收获
有时定制方案更好
并非每个问题都需要框架。对于 Foony 来说,一个小巧的定制 SSG 解决方案是正确的选择。它具有:
- 轻量:没有沉重的依赖或框架开销
- 可维护:简单的代码,我们能理解
- 灵活:易于按需修改和扩展
- 兼容:无需迁移即可与我们现有的 React SPA 一起工作
React 的流式 SSR 有怪癖
React 的 renderToReadableStream 在处理 Suspense 方面很不错,但它有怪癖。即使使用 await stream.allReady,你仍然会在输出中得到 Suspense 边界。这不是 bug,而是流式设计使然。但对于 SSG,我们需要完全解析的 HTML。感觉 React 团队没有以一种干净的方式处理这种情况,这是一个失误。
我的解决方案是对 HTML 进行后处理并解析边界。它不漂亮,但它快速且足够灵活,适合我的用例。
TDD 对 LLM 可能很有用
HTML 转换容易出错。一个小 bug 就可能破坏整个 SSG 输出和最终用户体验。我让 LLM 编写了全面的测试(在我的输入下)以确保转换正确工作。
结论
SSG 现在在 Foony 上正常工作了。页面对搜索引擎和 LLM 完全渲染,解决方案可维护且轻量。SSG 路由的水合花的时间比我预期的长(3 天),我又多花了一天用关键路径 Suspense 边界替换替代了初始的脱水方法。新方法更易维护,传输更少的字节,并避免了脱水/再水合 HTML 可能造成的视觉闪烁。
我仍然惊讶于仅用 2 天就实现了一个定制的 SSG 方案。但有时正确的解决方案就是最简单的那个。
未来的工作包括完善水合匹配,以及可能修补 React 以获得更好的调试。但现在,Foony 已经有了可工作的 SSG。我会在接下来的几周里关注 Google Search Console 和 Bing 网站管理员工具,看看这对我们的 SEO 有什么影响。