只给一条路由开 SSR:一个静态博客的局部动态化 moments ssr Astro SSR Cloudflare Swup React 前端工程 前端
3491 字
17 分钟

只给一条路由开 SSR:一个静态博客的局部动态化

先说结论#

/moments/ 之前是一个 client:only 的 React 岛:浏览器加载完,再去 s.bangwu.top 取图文,然后渲染。对人没问题,对搜索爬虫和关掉 JS 的访问者,它就是一个空壳。

我把这一条路由改成了按请求 SSR,其他页面继续静态预渲染。SSR 本身大概一个下午就通了,真正花时间的是它之后暴露出来的东西:

  1. Worker 里没有 ResizeObserver,而 200 OK 不代表页面渲染完整
  2. Worker 体积不由代码量决定,由这条路由 import 了哪个 layout 决定
  3. SSR 不等于可索引,无限滚动出来的内容依然不可发现
  4. 滚动状态的边界比 SSR 难——返回丢内容、快速滑动连拉 15 页、预加载把「布局变高」当成「用户想往下看」
  5. Swup 跨布局只替换 <main>,旧页面外壳会留在 DOM 里

一句话总结:SSR 不是把渲染地点挪一下,而是把浏览器里默认成立的一堆事情,变成需要自己负责的。

先交代边界:下面是这个博客自己的改造记录,站点是 Astro 5 + @astrojs/cloudflare + Swup + Masonic。数据都来自本地 Worker 实测和 Playwright 回归,不是估算。

原来为什么是个空壳#

图文页原来是这样挂载的:

<MemosFeed client:only="react" />

client:only 的含义是「不要服务端渲染,等到浏览器再挂」。所以首屏 HTML 里没有一条 memo,只有加载姿态的容器。人看到的是骨架屏,爬虫看到的是空 div。

为什么不全站 SSR#

有三个选项,我当时是这么比的:

方案改动量代价
全站 output: 'server'一行配置每篇文章都按请求渲染,缓存、Worker 体积、可用性全部要重新想
只给 /moments/ 加 prerender = false一个路由要处理 layout 体积、水合和降级
不做 SSR,继续 CSR零详情页永远不可索引,内容更新后也没有稳定 URL

选第二个。博客 99% 的流量在第一秒就命中静态 HTML,没必要为了一个 feed 把全站变成动态。

---
export const prerender = false;
---

这一行是 Astro 的局部退出预渲染:加上 adapter 之后,只有标了它的路由会在 Worker 里跑,其余照旧在构建时生成。文档写得很清楚,我就不复述了。

改造的最小闭环#

三件事:服务端取第一页、客户端接管后续、失败能退回原流程。

---
let initialData: InitialMemosData | undefined;
try {
initialData = await loadInitialMemos(baseUrl);
} catch (error) {
console.error("[moments] failed to load initial memos", error);
}
---
<MemosFeed baseUrl={baseUrl} initialData={initialData} client:load />

client:only 换成 client:load,initialData 就成了「首屏有没有真实内容」的开关。服务端拿不到数据时就不传,组件按老路自己加载,页面不会白屏。

数据加载和视图转换抽到了 src/utils/memos.ts,Worker 和浏览器共用同一份——否则你很快就会有两套字段映射,然后它们会慢慢不一样。

坑一:Worker 里没有 ResizeObserver#

改完第一次测,/moments/ 返回 200,但 HTML 里没有 memo 正文。

原因是 Masonic 的 useResizeObserver、useContainerPosition 在渲染期就直接 new ResizeObserver(...)。Cloudflare Worker 里没有这个浏览器 API,React 的服务端渲染中途抛错,Astro 把已经渲染出来的部分吐了出来——状态码还是 200。

这次最有用的教训就是这句:200 不等于成功。 只看状态码的检查会放过它。

修法不是给 Worker 打 polyfill。ResizeObserver 是布局 API,Worker 根本没有布局,塞一个假对象只会把错误藏起来,等哪天真的出问题再还给你。做法是把依赖它的东西整体下沉到一个只在水合后挂载的子组件:

if (!hydrated) {
return (
<div className="memos-ssr-grid">
{items.map((item) => <MemoCard key={item.id} data={item} />)}
</div>
);
}
return <MemosMasonry items={items} ... />; // Masonic 只在这里出现

服务端输出的是 CSS 多列布局,浏览器水合之后再换成瀑布流。代价是首屏会重排一次,收益是 SSR 路径完全不依赖浏览器 API——这个交换我觉得值。

坑二:Worker 体积是架构问题,不是构建问题#

Build 是绿的,部署失败。

一开始会本能地怀疑 pnpm install、依赖版本、构建缓存。实际原因和这些都没关系:/moments/ 直接复用了全站的 MainGridLayout,它带 SideBar,而分类/标签要读全部文章数据;再加上 astro-icon 默认把整套图标库打进 SSR bundle。

当时的 dry-run 大概是这个量级:

MainGridLayout ~14 MiB
astro-icon ~14 MiB

而同一条路由要进的是同一个 Worker 脚本。当时免费计划的脚本上限是 3 MiB(按压缩后算)。我写这篇时去翻了文档,这一项已经改成 64 MiB 未压缩、没有压缩后上限了——限制会变,但下面这句话不变:一条 SSR 路由的 import 就是它的部署预算。

第一版修法是写一个轻量的 MomentsLayout:保留 banner、导航、正文、页脚,不加载分类/标签侧栏。体积降到 1605.03 KiB / gzip 328.77 KiB,部署过了。

但我顺手把顶部导航换成了纯文本——这是一次视觉上的倒退,截图一看就不对。第二版才是正解:保留原来的 Navbar,只把 astro-icon 从「整套图标库」收成项目实际用到的白名单。

icon({
include: {
"fa6-brands": ["github", "creative-commons"],
"fa6-solid": ["arrow-rotate-left", "arrow-up-right-from-square", "chevron-right"],
"material-symbols": [
"home-outline-rounded", "search", "palette-outline",
"dark-mode-outline-rounded", "menu-rounded", "keyboard-arrow-up-rounded",
// ...只列真正用到的
],
"simple-icons": ["leetcode", "neteasecloudmusic"],
},
}),

三个阶段放在一起看:

版本SSR 路由依赖dry-run
复用 MainGridLayoutSideBar + 全量图标超限
轻量 layout + 文本导航无 SideBar1605.03 KiB / gzip 328.77
恢复原 Navbar + 图标白名单无 SideBar,精确图标1647.73 KiB / gzip 341.25

所以现在的流程里多了一道门:

Terminal window
pnpm build && pnpm exec wrangler deploy --dry-run

构建通过只说明代码能编译,dry-run 通过才说明能上线。这两个信号经常不一致。

坑三:SSR 不等于可索引#

列表页 SSR 只解决「首屏有 5 条」。往下滚出来的那些,爬虫永远看不到,也没有可以分享的 URL。

所以每条 memo 要有自己的页面:

/moments/{id}/

详情在服务端取 GET /api/v1/memos/{id},直接输出正文、全部图片、作者和时间,再补 canonical、SocialMediaPosting 和 BreadcrumbList。关键是状态码别撒胡椒面:

if (loadState === "not-found") Astro.response.status = 404;
if (loadState === "error") Astro.response.status = 502;
const robots = loadState === "ok" ? "index, follow" : "noindex, nofollow";

上游挂了返回 502 而不是 404,是因为搜索引擎会把 404 记成「这个 URL 没了」,等上游恢复了你得自己再求它回来。

还有一个很容易漏的点:动态路由不会出现在构建时的 sitemap 里。 Astro 的 sitemap 集成在构建期跑,那时候这些 URL 还不存在。只能另开一个运行时 sitemap:

export const prerender = false;
export const GET: APIRoute = async ({ site }) => {
// 分页拉全部公开 memo,输出 <urlset>
};

然后在 robots.txt 里声明它。写的时候顺手发现一个 N+1:列表每页 5 条,162 条 memo 就是 33 次上游请求。给 loader 加了一个 pageSize 参数,列表继续 5 条(瀑布流需要),sitemap 走 100 条一页。

坑四:返回列表时,卡片和位置都没了#

这是用户报过来的问题,原话我记着:加载很多内容之后点进详情,返回图文时,已经加载的内容会消失,回不到之前的状态。

原因不复杂:Swup 缓存的是 HTML,不是 React 岛的内存。返回时 MemosFeed 被重新挂载,useState 从初始值开始,缓存里那份 HTML 也还是首屏的 5 条。

修法是 sessionStorage 存一份快照:已加载的 items、nextPageToken、hasMore、scrollY,带 version 和 30 分钟 TTL。看起来是十分钟的活,实际撞了两个更麻烦的东西。

新实例会把旧缓存覆盖成 0#

加完缓存,状态还是丢。

因为 Swup 返回时,旧 island 的监听器还没卸载。它拿着自己那份 scrollY = 0,把刚恢复好的缓存写了回去。所以每个 feed 实例需要一个 owner token,只有当前实例能写:

if (feedWindow.__bangwuMomentsFeedOwner !== feedInstanceIdRef.current) return;

再加一道「恢复未完成」的门:restoreComplete 为 false 的时候禁止写缓存。这两条都不是优雅设计,是跟真实时序对出来的。

点击的瞬间,scrollY 已经是 0#

这条最隐蔽。我们是在点击链接的时候保存状态,但 Swup 会先把页面滚回顶部再导航;等 click handler 去读 window.scrollY,读到的已经是 0。日志里,刚存下的 1156 变成了 0。

修法很土,但正确:不要在导航瞬间读位置,用 scroll listener 记下来的最后一个值。

const latestScrollYRef = useRef(0);
useEffect(() => {
const update = () => { latestScrollYRef.current = window.scrollY; };
window.addEventListener("scroll", update, { passive: true });
return () => window.removeEventListener("scroll", update);
}, []);

验证结果:15 张卡片 → 进详情 → 返回,卡片还是 15 张,scrollY 从 1156 到 1156。浏览器后退走的是同一条路径,也测了。

坑五:预加载分不清「用户想往下看」和「布局变高了」#

原来的实现用的是 useInfiniteLoader,totalItems 设成 items.length + 5,threshold 也是 5。水合之后,Masonic 认为有 5 个「缺失项」,于是用户一根手指都没动,它就去拉下一页。

实测:不滚动,1.5 秒内两次分页请求,卡片从 5 涨到 10。

第二个是 sentinel 的幻觉。布局变高之后,观察点还在 expanded root margin 里,IntersectionObserver 会再回调一次,于是一页接一页地串下去。

第三个是快速滑动:一次高速手势,在约 1 秒里触发了 15 次分页请求。

现在的规则是这么写的:

const PRELOAD_ROOT_MARGIN = "0px 0px 640px 0px";
const PRELOAD_COOLDOWN_MS = 700;
const SCROLL_BURST_IDLE_MS = 600;
const MAX_LOADS_PER_SCROLL_BURST = 2;

翻译一下:

  • 用真实 sentinel,只观察底部 640px
  • 只认 isTrusted 的滚动事件,程序化恢复滚动不算
  • 每次成功加载后,至少再推进约 1/3 屏(上限 320px),才允许下一页
  • 一页 700ms 冷却;一次连续滚动最多 2 页;停顿 600ms 才算新一轮
  • 页面短到不能滚动时,允许自动补一页,避免用户卡死

优化前后的实测对比:

指标优化前优化后
未滚动时的分页请求20
未滚动时的卡片数155
轮播图片 DOM 节点15315
首屏图片请求4310
加载中的 skeleton03
一次快速滑动触发的请求151

图片那两行是顺手的收获:每个轮播原来把几十张 <img> 全塞进 DOM,loading="lazy" 也拦不住浏览器提前拉。现在只保留当前图、前一张、后一张,滑到哪里补哪里。

skeleton 也补回来了,分三种状态:SSR 首屏有真实内容就不显示骨架;分页时显示 3 张;失败时保留已有卡片 + 「加载失败,点击重试」,而不是把整页换成一个错误提示。

坑六:Swup 跨布局只替换 main#

这个不是我测出来的,是一张截图发现的:桌面端从首页点进图文,左边的分类/标签还在,内容被挤到 banner 下面。

因为 Swup 的配置是 containers: ["main", "#toc"],而两个页面用的是两套 layout:MainGridLayout 有 sidebar,MomentsLayout 没有。切过去之后,旧的 sidebar 外壳留在 DOM 里,新内容就落进了一个错位的容器。

修法不是调 CSS,而是让跨布局的导航走完整刷新,同一布局内继续用 Swup:

<Navbar forceFullNavigation={true} />
<a href="/moments/" data-no-swup={isCrossLayout ? true : undefined}>

data-no-swup 是 Swup 原生支持的,默认的 ignoreVisit 就是 el?.closest('[data-no-swup]')。规则只有一条:

  • 图文 ↔ 首页 / 归档 / 关于:强制整页
  • 图文列表 ↔ memo 详情:继续 SPA,因为状态恢复就靠这条路径

这也是那套轻量 layout 的隐性成本:你为了体积拆出一套外壳,就得同时维护两套外壳之间的导航边界。

我是怎么验证的#

这次的坑几乎都不在构建阶段暴露,所以验证方式比平时重一点:

  1. 看线上行为,不看产物。 pnpm build 之后用 wrangler dev 起本地 Worker,curl /moments/,确认 HTML 里真的有 memo 正文。构建成功和页面正确是两件事。
  2. 人为制造失败。 把 API 延迟 1 秒,断言分页期间出现 3 张 skeleton;把分页打成 503,断言已有卡片不被清空、重试能恢复。
  3. 用真实导航跑回归。 Playwright:列表加载到 15 张 → 进详情 → 返回,断言卡片数是 15、scrollY 是 1156;再压一轮快速滑动,统计请求数。
  4. 每次 dry-run。 wrangler deploy --dry-run 看上传体积。
  5. 常规三道门。 astro check、Biome、pnpm build。

如果重来一次#

  1. 先量体积预算,再决定 layout 边界。 SSR 路由 import 什么,就等于往 Worker 里塞什么。
  2. SSR 只保证「首屏有内容」。 「可索引」要靠独立 URL、canonical、结构化数据和运行时 sitemap 一起完成。
  3. 状态的优先级:URL > sessionStorage > 内存。 SPA 导航里,内存状态是最不可靠的一层。
  4. 任何自动加载都必须有人为意图。 由布局变化触发的加载,最后都会变成请求风暴。
  5. 跨布局导航不要交给 SPA router。 它只负责替换容器,不负责替换世界观。

最后#

真正落到仓库里的改动不算大:一个轻量 layout、两个路由、一个 sitemap endpoint、一个 feed 组件。加起来不到两天。

但这两天里,大部分时间不是在写 SSR,而是在回答同一个问题:这份状态到底该由谁拥有。

渲染搬到 Worker 之后,顺带把「谁是当前实例」「谁的滚动位置算数」「什么时候可以写缓存」这些原本由浏览器帮你糊过去的问题,全搬到了台面上。这也是我对「局部动态化」最真实的体感:成本不在那一个 prerender = false,在它周围所有默认假设失效的地方。

代码在 markbang/blog。

参考#

只给一条路由开 SSR:一个静态博客的局部动态化
https://bangwu.me/posts/moments-ssr/
作者
棒无
发布于
2026-10-17
许可协议
CC BY-NC-SA 4.0