background blurbackground mobile blur

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 内容。

方法很简单:

  1. 基于现有 React SPA 构建:无需迁移,只需在构建时添加 SSG 生成。
  2. 使用 renderToReadableStream:React 18 的流式 SSR API 原生支持 Suspense。
  3. 生成静态 HTML 文件:在构建时预渲染路由,并将其作为静态文件提供,使用我们的 SitemapGenerator 获取路由列表。
  4. 对现有代码库的改动最小:大多数组件保持原样即可工作。

核心实现位于 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 在几个方面让这个问题变得更糟:

  1. 我们对 HTML 进行后处理,以删除/解析 React 18 流式 Suspense 的产物(这对爬虫很好)。
  2. 客户端在时刻 (t = 0) 时并不总是拥有与服务器渲染完全相同的数据(SSG 数据、博客元数据等)。
  3. 我们的 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"捆绑实现的。

具体来说,调整如下:

  1. 将 SSR 输入捆绑到一个文本脚本中

    • 在 SSG 期间,我们在 Vite 模块入口点之前注入一个 <script type="text/foony-ssg" id="foony-ssg-data">...</script>
    • 该脚本包含:
      • html:我们实际在静态文件中提供的解析后的 HTML
      • ssgData:SSR 包装器使用的序列化 SSGData。我计划将其更新为 Proxy 之类的,这样只有访问过的数据才会被包含。
      • translationData:我们在 SSR 期间触及的翻译键值数据
  2. 在水合前注入这些输入

    • main.tsx 中,我们同步执行:
      • #root.innerHTML 设置为序列化的解析 HTML(这样 DOM 与水合看到的完全一样)
      • SSGDataProvider 包装应用,使组件在首次渲染时拥有相同的 SSGData
  3. 通过注入翻译值使 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,然后在挂载后切换为 falseUserBannerNavbarDialog 等组件已经使用它来防止水合不匹配。

  1. 修补 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 ConsoleBing 网站管理员工具,看看这对我们的 SEO 有什么影响。

8 Ball Pool online multiplayer billiards icon