background blurbackground mobile blur

1/1/1970

我是如何在 2 天内实现 SSG 的

嗨!一年前我还觉得这事根本做不到。结果我刚刚在 2 天内给 Foony 搞定了静态站点生成(Static Site Generation,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 schema 和一点点 SEO 的东西。

整体思路其实很简单:

  1. 直接搭在现有 React SPA 上:不用迁移,只是在构建时额外做一次 SSG 生成
  2. 使用 renderToReadableStream:React 18 的流式 SSR API 原生支持 Suspense
  3. 生成静态 HTML 文件:在构建时预渲染路由,再作为静态文件提供出来,路由列表用我们的 SitemapGenerator 拿
  4. 对现有代码改动极少:大部分组件可以原样工作

核心实现放在 client/src/generators/GenerateShellSsgFromSitemap.ts。它读 sitemap,用 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 实际输出了什么

当你在 Suspense 下用 renderToReadableStream 的时候,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:0S:0 通过数字一一对应(从 0 开始计数)。

如果没有 JavaScript,搜索引擎(看着你呢,Bing)和 LLM 看到的会是几乎一片空白的页面,只有一个模板占位符。这就完全违背 SSG 的初衷了!

我没找到什么优雅的方法来去掉这些 Suspense 边界,所以就走了另一条路:先写一堆测试,再写一个 resolveSuspenseBoundaries 函数,把它们替换掉。这比直接解析 HTML 然后用类似 JSDOM 去跑脚本快多了。而且,更关键的是,我后续还有别的计划:让搜索引擎 / LLM 在没有 JavaScript 的前提下,也能看到内容清楚、可读性好的页面,同时还能支持 Suspense 和客户端水合。

测试这个转换

我先从写测试开始:把关掉 JavaScript 时 DOM 里长什么样,还有开着 JavaScript 时 DOM 应该长什么样,这两种实际例子抓出来,然后一并丢给一个 LLM 生成测试数据,这方面它还挺拿手的。 这些测试放在 client/src/generators/ssr/renderRoute.test.ts 里,用来确保转换逻辑是对的。测试覆盖了:

  • 简单的边界替换(博客列表)
  • 模板和结束注释之间还有内容的复杂边界
  • 多个边界同时存在的情况
  • 没有注释标记的边界
  • 各种边缘情况

这种“伪 TDD”在这种有明确输入输出的场景里,其实挺有用的。

这跟“因为 Robert C. Martin 说要 TDD 所以啥都 TDD”完全不是一回事(那样只会把你团队开发速度拖死)。UI 或者经常变化的代码区域,千万别全都搞 TDD!

解决方案:resolveSuspenseBoundaries

测试准备好之后,我让 LLM 写了 resolveSuspenseBoundaries 这个函数的实现。为了避免正则太脆,我用了 cheerio 来解析 HTML,虽然如果全用正则的话,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 看到的就不再是一片空空如也的页面,而是一整页完整渲染好的内容。

现在我们在完全不开 JavaScript 的情况下,SSG 也能正常工作了! <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" }} />

在这个最小可用的水合版本里,我遇到了一个挺特别的挑战:我既想要水合,又想在开发者不用管 Suspense 边界的前提下,让搜索引擎和 LLM 拿到好的 SEO 效果。

挑战点在哪

React 的水合是非常“字面”的:如果首次渲染时 DOM 不长成 React 预期的样子,你就会在控制台里看到一个好看但几乎没啥帮助的错误信息,然后 React 会直接把整棵树扔掉、重新渲染一遍,连个 diff 都不给你看是哪里不一样。

在我们这里,SSG 还把问题放大了好几倍:

  1. 我们在服务端后处理时,把 React 18 流式 Suspense 的那些标记给清理/替换掉了(对机器人很友好)
  2. 客户端在时间 (t = 0) 时,并不总能拿到和服务端渲染时完全一致的数据(SSG 的数据、博客元信息等等)
  3. 我们的 i18n 默认是“懒加载”的,也就是说,如果你不记录服务端用了哪些翻译并在 React 渲染前注入它们,首次渲染时可能会缺翻译

当时怎么解决的(最初方案:脱水)

一开始我试了一个挺“聪明”和“可爱”的办法:用命令模式把解析 HTML、替换 Suspense 边界的操作都记录下来,同时生成一份“反向操作指令”,好在客户端把 HTML 还原回 React 期望的样子,好顺利水合。

我原本希望这样一来,index.html 里能少传很多字节。但跟大部分“聪明”的方案一样,这个方法最后翻车了:浏览器会在 HTML 上做各种细微改动,比如多加或少加一个 ;/,这就直接把我们基于字符串下标的替换给整乱了。

理论上你可以想办法把这些浏览器小动作全都考虑进去,但我实在不想上线这么脆的东西。

所以我干脆不再尝试把“已经解析过的 HTML”逆向还原成 React 的流式输出,而是用了一个非常直接的办法:

把原始、还没处理过的 HTML 打包进一个 <script type="text"> 里。

这个“脱水”方案是能正常工作的,不过后来我又多花了一天,把它换成了一个更好的做法。

更好的方案:关键路径 Suspense 替换

最初版上线后,我还是在少数场景里遇到了 Suspense 边界的问题。这时候我才意识到,其实有一个更干净、更简单的解决方式。于是我把“脱水”方案换成了 关键路径 Suspense 边界替换,这个方案:

  • 在水合前加载好关键路径:在 SSR 时被预加载过的组件,会在客户端水合前被识别出来并一并预加载
  • 更容易维护:不需要碰 React 内部实现,也不需要做 AST 解析(脱水方案那套要解析并恢复 HTML)
  • 传输字节更少:我们不再需要把 React 原始的 SSR 响应塞进一个 script 标签里一起发
  • 避免潜在闪烁:不需要先“脱水再重水合” HTML,自然也就不会有那种视觉闪一下的问题

实现上,我们会在 SSR 时,通过 SSRLazyComponentTracker 记录哪些懒加载组件已经被预加载过,把它们的 import 路径放进水合用的数据里,然后在水合前同步预加载这些组件。这样关键路径上的组件就能直接渲染出来,不再依赖 Suspense 边界,输出也就和 SSR 完全一致。

对剩下的部分,我们让客户端的第一次渲染表现得就像 SSR/SSG 一样。也就是说,用同一份输入,并且在调用 hydrateRoot 之前,就把这些输入同步准备好。这个是通过我们打包的 “ssg-data” 完成的。

具体调整是这样的:

  1. 把 SSR 用到的输入,打包进一个文本脚本里

    • 在 SSG 阶段,我们会在 Vite 的入口模块之前,插入一个 <script type="text/foony-ssg" id="foony-ssg-data">...</script>
    • 这个脚本里包含:
      • html:我们最终写进静态文件、已经解析完 Suspense 的 HTML
      • ssgData:给 SSR wrapper 用的序列化 SSGData。我后面打算把它改成 Proxy 之类的,只打包实际访问到的数据
      • translationData:SSR 过程中用到的翻译 key-value 对
  2. 在水合前把这些输入注入进去

    • main.tsx 里,我们同步地:
      • #root.innerHTML 设成那段已解析过的 HTML(这样 DOM 和水合逻辑看到的是一模一样的东西)
      • SSGDataProvider 包住 App,让组件在第一次渲染时,就能拿到和 SSR 一样的 SSGData
  3. 把 i18n 变成“秒出结果”,通过注入翻译值实现

    • 我们会记录 SSR 过程中真正用到的翻译对象,并把它们放到 SSG 的 script 里
    • 客户端启动时,通过 LocaleQueryer.inject() 这个专门的方法,直接把这些翻译塞进 LocaleQueryer 的缓存里,这样第一次渲染时翻译已经就位了

到这里,客户端的第一次渲染就能和 SSR 用的是同一份数据了。

useIsSSRMode() 这个 hook 已经在 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;
}

这个 hook 在 SSR 和客户端第一次渲染(水合阶段)时返回 true,在组件挂载完成后再切换为 false。像 UserBannerNavbarDialog 这些组件已经在用它来避免水合不一致的问题了。

  1. 给 React 打个小补丁,让 diff 更好看一点

我本来是想直接用 hydration-overlay 的。但它没人维护了,只支持到 React 18,而且也不太适合直接上生产。我就让 LLM 把整个 repo 克隆下来当参考,然后在它的基础上几分钟内撸了一个极简版水合 overlay。我不需要太花哨的东西,只要在开发时能看出来哪里不一致就够了。

这个新 overlay 还挺简陋的,所以 diff 也没做到 特别 完美。React 会删掉注释、在 style 属性后面加 ;、改空白字符等等,而这些现在都还没在 overlay 里处理。我们的 overlay 还会把 HTML 注释也算进去,但 React 在水合比对时是忽略注释的。

<img alt="新的水合差异高亮 overlay" 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 帮我写了成套测试(当然输入输出是我提供的),保证转换逻辑靠谱。

结语

Foony 现在已经有可用的 SSG 了。页面对搜索引擎和 LLM 来说是完整渲染好的,同时方案本身也足够轻量、好维护。SSG 路由的水合比我想象中花的时间要长(3 天),另外我又多花了一天把最初的“脱水”方案替换成关键路径 Suspense 边界替换。新方案更好维护、发的字节更少,也不需要脱水/重水合 HTML,自然也就避免了视觉闪烁。

我到现在还有点惊讶,居然只花了 2 天就把一个定制 SSG 方案跑起来了。但很多时候,最合适的方案就是那个最简单的。

接下来要做的事情包括:把水合匹配再打磨一下,可能的话再对 React 做点小补丁,让调试信息更友好。但就目前来说,Foony 已经有了可用的 SSG。接下来几周我会一直盯着 Google Search ConsoleBing Webmaster Tools,看看这次改动对我们的 SEO 到底有多大影响。

8 Ball Pool online multiplayer billiards icon