🔑 关键洞察
✦ AI·GEN这篇记录了作者把 koimsurai.com 的部落格,从一个 100% CSR 的 React SPA(真人只拿到空壳、爬虫靠 user-agent 硬塞 meta)一路改造成 SSG/ISR 的完整渲染策略与踩坑。从选型谈起——为什么是 SSG hybrid、为什么评估过 Next 却选了 TanStack Start(RR7 差点雀屏中选,被一句「我想要 ISR」翻盘);接着是实作:内文烤进 HTML、重互动留 client、MDX 走 server 编译加 client eval(一条之后想改成 dynamic-import ESM 来拿掉的路);再到 prerender 生了 111 个档却一个都没被送出、于是改走纯 ISR,用 Rust middleware 做发文即刷新(还差点被浏览计数端点清光全站快取);最后是进场体验的除错长征:无样式闪烁、双渲染、被卷动平滑握住的卷动,以及一个被自己按住重想才翻出的错误诊断。全篇也一并科普了 SSR、SSG、CSR、ISR、hydration 各是什么。
每个部落格都要回答同一个问题:一篇文章,怎么从资料库走到读者的萤幕上?我这台站,前两年用的是最偷懒的答案:。这篇是把那个答案重写三次的故事:先搬去 SSG、再被自己的 prerender 打脸改走 ISR、最后为了「进文章不要闪一下」跟渲染时序缠斗了一整晚。
先讲清楚几个名词,整篇都绕着它们转:
- SSR():每次请求都由伺服器即时把 HTML 渲染好再送出,首屏就有内容。
- SSG():build 时就把页面预先渲染成静态 HTML 档,之后直接送档案。
- CSR():浏览器拿到空壳再自己画,也就是我原本那套。
- ISR():介于两者之间,先送预生成的静态 HTML(快),背景定期或触发时重新生成(新)。
尽量忠实呈现整个过程,包括我判断错、以及被自己按住重想的地方。
背景:一个对爬虫隐形的部落格,和一个想全 Rust 的我#
改造前,这站是一个很正常的 React 19 SPA:BrowserRouter + <Routes>、native fetch + 手刻快取,SSR 用量是 0,百分之百 CSR。真人打开首页,拿到的是一个空的根节点 + 一包 JS,内容全靠浏览器长出来;而爬虫能看到 meta,是因为 serve 层用 user-agent 侦测到 bot,才把 <title>、OG、JSON-LD 硬塞进静态 HTML。换句话说:文章的 body,从来没有被伺服器渲染过。 对一个「内容就是全部」的部落格,这个架构是反的。
同一时间我心里还有另一件事:我想把后端全部改成 Rust。 这件事得先给没背景的读者铺一下,因为它其实跟「让部落格变快」一点关系都没有。
顺带一提,我当初最在意的 SEO 症状,其实是 Google 把「Koimsurai」自动更正成「Katsurai」(一家京都猪排店)。查下来那是品牌实体辨识问题,SSG 修不了它。但「真人跟爬虫都该拿到真正被 render 过的内文」这件事,还是值得做。
选型:SSR / SSG / CSR,以及为什么不是 Next#
摊开来就三条路:维持现状(全 CSR + bot meta shim)、混合 SSG(内文页 prerender、仪表板留 CSR)、或整站 SSR。对一个部落格,答案意外地偏 SSG,而且它反而更贴合我那个「全 Rust」的定义:
- SSG 的产物就是一堆静态 HTML,Rust 后端直接 serve 就好,production 零渲染 Node;
- 整站 SSR 会在 prod 多一个常驻的 Node 渲染 process,反而稀释「全 Rust」;
- 而我的站天然分两种内容:半静态的文章/静态页(要 SEO)跟即时仪表板(now / 观影 / 音乐,一直变、根本不需要 SEO)。正好:内文走 SSG、仪表板留 CSR。
那 Next.js 呢?我也请人评估过把网站从 React 迁过去,但最后没选它。对 Next 的怨我其实早就在别的专案上累积了:一个用 Next 的 Tauri 桌面 app,建置慢到我受不了;NAS 前端更惨,被 Next 16.0.6 的一个 漏洞入侵、塞了挖矿程式。
评估下来它赢不过 TanStack Start,框架短名单于是收敛成 TanStack Start vs framework mode。至于 Next 的招牌 ,在我这也用不上:资料全在 Rust 后端,server component 顶多 await fetch(rustApi),RSC 的好处直接被抵消。
真正把天平压向 的,是一件事:
NOTE
是「我想要 ISR」翻的盘
React Router 7 的 framework mode 其实差点雀屏中选。我本来就在 RR7 上,切 framework mode 拿 SSG 的迁移摩擦最小(那 83 个 <Link>、24 个 useNavigate 几乎原封不动)。但 RR7 framework mode 没有原生 ISR;要 ISR 就得接受 prod 多那层 Node 渲染。而我确实很想要 ISR(发文即刷新、不用等 rebuild),再加上「把最潮的 TanStack 全家桶跑一遍」对我这种个人 vessel 专案本来就是正当的 dogfood 动力。router 这题就这样定给 Start 了。
顺带说一句这套有多新:TanStack Start v1.0 是 2026 年 3 月才出的、Vite 用的是 8.x(底层换成 Rolldown)、而它底下的伺服器引擎 用的还是 v3 的 beta(TanStack Start 直接整合了它)。整套是 bleeding edge。后面会看到,这个「新」既是乐趣,也咬了我好几口。
至于更省 JS 的 ?第一轮就因为我有 three.js、Monaco 这种重互动而略过(React-first 才合现实);后来为了「拿掉 eval 上 CSP」我又认真评估过它一次,结论下面会讲。最终的心智模型很干脆:「SSG/ISR 的首屏 + SPA 式的后续导航」。初次载入是伺服器预生成好的 HTML(SEO 与首屏的红利来源), 之后站内切页仍像 SPA,两边的好处都拿。
实作:内文烤进 HTML,重互动留 client#
先验 PoC:用真的文章跑 prerender,确认内文烤进了 HTML:/blog/39/index.html 38KB,body 里有完整的 <article>、标题、表格。四项全绿。核心结论一句话:内文 prerender、重互动留 CSR。 正式做的时候,渲染管线长这样:
两个关键决定。第一个是要 prerender 哪些页,从 API 列出来:不写死 5 条 /en/blog/:id,而是一条动态路由 $locale/blog/$id 处理所有语言;build 时打 /api/posts 拿每篇的 available_locales,只生真的有的语言, 也照这个产、绝不造假。
第二个是内文烤进 HTML、重的互动留 client:那个一千八百行、拉进 mermaid / shiki 的互动版 BlogPost,走 lazy + ,保证 three.js / mermaid 这种在 Node 里会炸的东西永不进伺服器端的那包程式(server bundle)。
这条路踩了一串 server bundle 与 hydration 的坑,挑几个有代表性的:
- LinkCard 把整个 BlogPost 拖下水。
History、AboutSite两页import { LinkCard } from './BlogPost'——LinkCard 本身完全能 SSR(它就是连结预览卡,没碰 window),但它跟 mermaid 一起关在那个两千行档案里,一 import 就把整包 mermaid 拖进 server bundle。解法是把 LinkCard 抽成独立的 SSR-safe 模组、跟 mermaid 脱钩。 - 导览列讲韩文的 。 全站外壳(Header/Footer)挂在
__root、却在每页的LocaleProvider外面,于是外壳的翻译 fall back 到全域 i18next 实例。多页一起 prerender 就漏语言:SSR HTML 是<html lang=en>、Hero 英文(对),导览列却是韩文。SSR 导览列 ko、client 导览列 en → #418。解法:在 root 包一层以网址判语言的 provider,让外壳也拿到对的语系。 - 旧 Service Worker 阴魂不散。 旧 SPA 有 PWA plugin 注册了会 precache 旧资产的 SW;新架构没有。回访者拿到旧外壳 + 新 HTML → 样式坏掉 + #418。解法:serve 层送一支会自我了断的
/sw.js(反注册 + 清快取 + reload)。
还有几个渲染路径上的细坑,顺带记着,因为它们每一个都跟「SSR 与 client 要逐字一致」有关:
- 标题冒号自动切主副标。 schema 没有副标栏位,前端一个
splitTitle在第一个冒号切开:主标进h1、副标进p。纯呈现层,document.title跟og:title还是用完整标题,SEO 不受影响。 - scroll-spy 被脚注 id 劫持。 原本追踪「页面上所有带
id的元素」,结果脚注、alert 那些user-content-fn-…的 id 把 active 状态抢走,目录高亮直接消失。解法:只看「目录真的有列到的标题 id 集合」。锚点也补了scroll-margin-top,免得跳转后标题被 sticky header 盖住。 - mermaid 的 ELK layout 在 SSR 站会 circular JSON 崩溃。 切成 Adaptive(ELK)排版时直接丢
Converting circular structure to JSON。挖到底:@mermaid-js/layout-elk会对整张 ELK 图做JSON.stringify,而这站的<html>是 TanStack Start 用 React 渲的,documentElement上带着__reactFiber,序列化就撞到循环参照。这是 React SSR 站特有的坑。解法是一个引用计数的 guard,在 render 期间暂时把JSON.stringify换成会跳过 DOM 节点的版本。
prerender 生了 111 个档,一个都没被送出#
实作到一半,撞上一个很荒谬的发现。docker build 里的 prerender 跑得又快又干净(7.8 秒、111 个档案、零错误),.output/public/blog/index.html 也确实生出来了——可是实际请求 /blog/index.html 一律回 404,而且连打两次 /en 的 md5 不一样。意思是:每一次请求都在重新 SSR,那 111 个档一个都没被送出过。 根因是 Nitro 注册静态资产的清单,在 prerender 写档之前就扫完了。生了一堆档却没人送,纯粹浪费 build 时间、还让 build 期得连上线的站捞文章清单。
于是我把 prerender 整个拿掉,改走纯 ISR。这里先科普 ISR 的机关,因为官方那条路在我这走不通:
WARNING
官方 ISR 是靠 CDN 跑的,self-host 没 CDN 就不会重生
TanStack Start 官方 ISR 的机制是:build 时 prerender + 在回应打上 Cache-Control: stale-while-revalidate,而背景重生由 CDN 执行。我是自架 nginx、DNS-only、刻意不挂在别人的 CDN proxy 底下——没有 CDN,那个 header 就没有执行者,背景重生完全不会发生。
能救的是 Nitro 的 :它内建 SWR,一行 routeRules 就搞定,而且背景重生发生在我自己的 server 里:
// vite.config.start.ts —— ISR 一行搞定,而不是自己刻 100 行
const ISR_ROUTE_RULES = {
...swrRules(ISR_PAGES.flatMap(localeVariants), 3600), // UI 页:1 小时
...swrRules(localeVariants('blog'), 300), // 列表页:发新文要早点出现 → 5 分钟
...swrRules(localeVariants('blog').map((p) => `${p}/**`), 3600), // 文章页:内容几乎不动 → 1 小时
};整个站的请求流变成这样——Rust 是唯一真后端,Node/Nitro 只是一层渲染壳(这正是我「业务逻辑全 Rust、前层 Node 只渲染」的定义落地):
这段有三个坑值得展开:
列表页快取了个空壳。 ISR 快取的 /blog 一开始是空的,因为 Blog 元件在 useEffect 里抓资料,而 useEffect 不在 server 执行 → SSR 只吐一个 loading 骨架屏(prerender 也一样救不了它,它同样不跑 useEffect)。解法是把抓资料搬进路由 loader、用 useLoaderData 喂初始资料:/blog 的 SSR 从 19,541 bytes 的空壳变成 74,242 bytes、含标题与 8 条文章连结。
发文即刷新:on-demand 重生。 光有 TTL 还不够,新文章要能立刻让爬虫看到。做法是一个 Nitro server route /_revalidate(用 header secret 保护;刻意不放 /api/*,因为 nginx 把 /api/ 全送去 Rust,前端根本收不到),Rust 后端在发文/改文成功后 fire-and-forget 打它清快取。而且我把它做成 axum 而不是在 14 个写入端点各挂一次。逐一挂必漏,漏了不报错、只是安静地不更新。
CAUTION
差一个字就会清光全站快取
判断「这是写文章的请求」时,如果照直觉写 path.contains("/posts"),会炸得很安静:因为 /api/posts/:id/view(每次有人浏览任何一篇文章都会打)也含 /posts → 每次有人看文,就清光全站 ISR 快取 → ISR 直接失效、还不会报错。得明确排除 /view、/like、/reactions、/comments,并且写一个 unit test 把它钉死。
快取规则用白名单,不用 /**。 全站包(/**)是 fail-open:日后新增任何读 cookie 或 render 使用者资料的页,都会被预设公开快取而且没人会察觉。白名单则相反,新页预设不快取。明确不快取的:/(要读 cookie / Accept-Language 做语言导向,快取它等于把第一个访客的语言发给全世界)、/admin、/auth。
番外:每个 SSR 页面都静默卡死的那三只叠在一起的 bug
迁 Nitro 那天,每个 SSR 页面都静默卡死(回 000,不报错、不 timeout),而 /api proxy 跟静态资产都 200。查下来是三只各自都能单独挂掉全站的 bug 叠在一起,最致命的一只最阴:nitro@3.0.0 是 9 个月前的旧版。 package.json 写 ^3.0.0-beta,而 semver 的 prerelease 比对规则永远匹配不到那些以日期编号的 beta(latest = 3.0.260610-beta)。这就是前面说「整套 bleeding edge 会咬人」的具体一口。旧版有个 routeRules.swr × ssr-renderer 的冲突,会让请求绕回自己 → 无限自我回圈。升到最新,全绿。
更值得记的是后半:我一度以为修好了,但停下来反问自己一句「这些修复是真的,还是只是绕过症状?」——做了消融测试才发现,5 个「修复」里只有 2 个是真的(升 nitro、SSR 改打本机后端),其他 3 个(server.ts、noExternals、/api proxy)全是在绕旧版 bug,新版根本不需要。那个 /api proxy 甚至是「解决一个不存在的问题」。最后配置塌回官方最小型 plugins: [tanstackStart(), viteReact(), nitro()] + 一份 SWR 白名单。绕过去很容易看起来像修好了;真的修好,得先承认自己可能只是绕过去。
MDX:一条会 eval 的管线,和一笔想还的债#
到这里文章都还是用 react-markdown 渲的。后来我想要 <Note>、<Annot>、<Diff>、<Chart> 这些自订 block(为了让读者的沉浸感够深),就叠了一条 管线(逐篇 opt-in、只有 format=mdx 的文章走)。这条管线是这篇最技术的一段,而且我对它的理解一开始就有个要修正的地方:
IMPORTANT
eval 不是互动带来的,是「文章里有可执行的 JS」带来的
我原本以为「有互动的 block 才需要 eval」,不精准。react-markdown 是解析(把字串解析成 AST 再对应成元件),不执行任何东西;而 MDX 是「编译成 JS 再执行」:文章里 {new Date().getFullYear()} 这种行内表达式,是真的 JavaScript,要在浏览器端执行那段编译出来的 JS。所以会不会踩到 eval,取决于「文章里有没有可执行的 JS」,不是「有没有互动」。
编译只在 server 做。 @mdx-js/mdx 的编译器是 micromark + acorn,很重,不该进 client bundle。所以我用 TanStack 的 :SSR 时 in-process 跑,client 端导览时走 RPC 回 server 拿编译结果,产出一段 function-body 字串(可序列化、可 dehydrate 塞进 HTML)。执行在 client 做:前端拿到那段字串,用 runSync 执行成 React 元件,而 runSync 底层就是 new Function,也就是 。
// server-only:编译器很重,不进 client bundle;产出 function-body 字串可序列化
export const compileMdx = createServerFn({ method: 'POST' })
.handler(async ({ data: source }) => {
const { compile } = await import('@mdx-js/mdx');
return String(await compile(source, { outputFormat: 'function-body', remarkPlugins: [remarkGfm, remarkAlert] }));
});
// client:runSync 同步执行成元件(SSR 与 hydration 都能跑);⚠️ 底层是 new Function = eval
const { default: Content } = runSync(compiled, jsxRuntime);顺带说一段编辑器的岔路:我一度装了 MDXEditor(市面上唯一 MDX 原生的所见即所得编辑器)做 spike,但它有 131 个依赖、而且把我的自订 block 全渲染成一排「齿轮 + 标签」的通用 UI,不是真的元件;我又偏好 Monaco 的样子。后来想通:MDXEditor 从来不是「让 MDX 能动」的必要条件。我用 Monaco 写 MDX 原始码、渲染器(compileMdx + runSync)把它渲出来,这就够了。整个 spike 干净撤掉,留 Monaco。其余几个决定:MDX 编译失败自动退回 markdown(不让一个手残的标签炸掉整篇);MDX 文与 markdown 文共用同一批基础元件(shiki 高亮、mermaid、连结卡、标题锚点)。
那笔想还的债,就在这个 runSync 上:
NOTE
其实我到现在根本还没开 CSP
这个 eval 目前是可控的:内容都是我自己写、自己审过的,不是使用者投稿。而且说来有点好笑:我到现在根本还没开 ,因为 __root.tsx 里有一段 inline 的「防闪烁/intro」script,贸然上 CSP 会把站打坏,得先把它 nonce 化。所以那些 unsafe-eval 的注解,意思是「等我哪天要上严格 CSP,这条 eval 会逼我放行 unsafe-eval」。我不想留着它。
那要怎么收掉这条 eval?我一开始想的是 :把每个互动 block 改写成「CSS + 一支 bundled 的 vanilla JS」,重的 block 用 registry 挂载。但这要重写一堆东西、有行为回归风险。后来想通一条干净太多的路。eval 之所以存在,只是因为我把 MDX 编成了 function-body(那种格式一定要 new Function 才跑得起来)。如果改编成「真正的 ES module」,client 就能用 import() 载入它——而同源的 import() 在严格 CSP script-src 'self' 底下是被允许的(那是模组载入,不是 eval)。 整条管线、所有 block、所有动画完全不动,零重写零回归;islands 只是这条撞墙时的退路。目标流程是这样:
(这条还没动工,存进待办了。也是为了同一个目标,我回头认真评估过 Astro:它原生就是 islands、天生 CSP 友善,但 Astro 是独立框架、独立 build,嵌不进我现有的 TanStack Start,「只在 blog 用 Astro」实务上等于把站拆成两个 app,共用的 Header / 目录 / 反应 / 留言全要重写成 island、i18n / ISR / SEO / Rust 资料层全要重建。杀鸡用牛刀还要拆厨房,dynamic-import 那条 CP 值高太多。)
首帧不再闪:进场除错长征#
以上都上线了,但真正磨到深夜的,是「进文章的那一瞬间」。这一段全是渲染时序的坑,也是整篇的核心。先看结果。左边是当时的惨况,右边是收尾后:
我最早注意到的是:进文章的一瞬间会闪一下,被 Query 快取之后就不会了。但真相不是 Query:那个 ClientOnly 的 fallback 先吐了一个纯 sans-serif、零文章 CSS 的内文,等重的 FullBlogPost chunk(带 BlogPost.css + shiki + mermaid)载入才整个换上去,是全面重绘,不是套个样式。
第一版修法是 idle 预暖那个 chunk,救了站内导览;但直接贴一个 URL 冷开还是会闪,因为 ClientOnly 永远先送 fallback。第二版才治本:把 fallback 重建成跟 FullBlogPost 一模一样的结构、引同一份 CSS——JS 全关下,第一帧就是完整样式的文章。
那个「在原本的 HTML 上加样式」的中间版本,我自己看了也不对:我要的是骨架载入:整个版面(含侧栏、目录)第一帧先当框架出来,各区块各自 loading 填入。于是加了 shimmer 骨架:侧栏与目录先出骨架,主文照样是真的(SEO 不能少)。
我看到的问题很明显是「被页面元素清掉、重跑动画插入」。根因是同一篇内文被渲染了两次:BlogPostPage(SSR 出)→ 然后 FullBlogPost(ClientOnly)挂载后再渲一次盖上去;因为是两棵不同的元件树,React 只能拆掉重建 = 肉眼可见的「清掉再插入」。分两阶段治:
Tier 1(让交接看不见): 进场动画改 initial={false}(别把已经在的内容当全新载入重滑一遍)、目录直接在 loader 里算好(server 端、免费,因为内文已经 SSR)、再把抽 heading / 算阅读时间的逻辑抽成共用 lib,让两个版本逐字一致。交接变成约 95% 无缝。
Tier 2(真身): 先把整棵渲染树扫过、揪出所有 SSR blocker,再动那个一千八百行的档:把 FullBlogPost 变 SSR-safe(localStorage 改「SSR 给预设值、useEffect 补读」、日期补时区、window 存取包 guard、mermaid 的 zoom 包成一个小 ClientOnly 岛)→ 然后把外层的 ClientOnly 整个拿掉 → 变成单次 SSR 渲染、原地 hydrate。双渲染从根消失,0 骨架残留、0 hydration 错误,BlogPostPage 就此变成死码。
reload 一篇文章、锚点把画面拉回原位时,会先往上、瞬间往下、再往上,卡一下。我的直觉是「感觉是有东西把它握住了,而不是高度计算问题」——这个直觉是对的,而我一度往错的方向修。
真因是两个东西叠加:全域的 html { scroll-behavior: smooth } + .post-content 上的 把高度猜错了(猜 1200px,真实约 9648px,差 8 倍)。而 scroll-behavior: smooth 让每一次程式化卷动都变成动画,下一次呼叫打断上一次、在原地重启 → 永远到不了目标;锚点还原用的 scrollTo 就这样被一路拖住。A/B 实测后拍板:拿掉全域的 smooth,目录点击、回顶那种要平滑的地方改用 JS 显式指定。
单次渲染之后,进场动画做得回来了,但本体文字的动画必须用 CSS @keyframes,不能用 framer-motion 的 initial={{opacity:0}}。原因很硬核:Chrome 的 不把 opacity:0 的元素算进去。用 framer 让内文从透明淡入,等于把 LCP 绑死在 hydration 之后。所以本体走 CSS transform(不碰 opacity),framer 只留给退场、stagger 这些不影响 LCP 的地方。
写到这里我也好奇,同样是内容站,大家最后都停在哪一格:
收束:三次改写的帐#
这条渲染长征,其实是三笔不同层级的帐:
- SEO 与首屏,是 SSG/ISR 的红利;hydration,是换来的一整类新 bug。 从此文章内文、标题、hreflang 都真的被 render 进 HTML,爬虫不用跑 JS 就看得到;代价是多了一层 Node 渲染壳,和「SSR 送的跟 client 画的要逐字一致」这种以前不存在的 bug。
- 量测,不要猜。 这一晚最值钱的一件事,是逼自己停下来反问「这是真的修好,还是只是绕过去/猜对了?」。它把我两个凭印象下的错误诊断(那三只 bug 的假修复、卷动的序列化 bug)按了回去,逼我用消融测试和 Playwright 把真相量出来。
- 几条会反复咬人的规则:
useEffect不在 server 跑,所以靠它抓资料的页面,SSR 跟 prerender 都只会吐一个空壳;fail-open 的全站快取规则是颗安全地雷;拿路径前缀清快取,记得排除高频端点。
还有几笔没还完的债,诚实列着:MDX 的 runSync 是 client eval,虽然目前 CSP 根本还没开、暂时无害,但这条 eval 是我上严格 CSP 前想先收掉的。计划是把 MDX 编成 ESM 模组、用 import() 取代 runSync(islands 只是退路);ISR 快取还在记忆体里,每次部署或重启就全站归零(还没接 fs driver);旧的 SEOHead(react-helmet)也还没完全退役。渲染这题大概永远改不完,但至少现在,一篇文章从资料库走到你萤幕上的每一步,我都知道它为什么长这样了。
- TanStack Start —— 全端框架(SSR / server functions / Nitro)官网
- MDX —— 在 Markdown 里写 JSX官网compile / run
- Nitro —— route rules 与 ISR / SWRRoute Rules
还没有留言
✨ 成为第一个留言的人吧