只给一条路由开 SSR:一个静态博客的局部动态化
先说结论
/moments/ 之前是一个 client:only 的 React 岛:浏览器加载完,再去 s.bangwu.top 取图文,然后渲染。对人没问题,对搜索爬虫和关掉 JS 的访问者,它就是一个空壳。
我把这一条路由改成了按请求 SSR,其他页面继续静态预渲染。SSR 本身大概一个下午就通了,真正花时间的是它之后暴露出来的东西:
- Worker 里没有
ResizeObserver,而200 OK不代表页面渲染完整 - Worker 体积不由代码量决定,由这条路由 import 了哪个 layout 决定
- SSR 不等于可索引,无限滚动出来的内容依然不可发现
- 滚动状态的边界比 SSR 难——返回丢内容、快速滑动连拉 15 页、预加载把「布局变高」当成「用户想往下看」
- 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 MiBastro-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 |
|---|---|---|
复用 MainGridLayout | SideBar + 全量图标 | 超限 |
| 轻量 layout + 文本导航 | 无 SideBar | 1605.03 KiB / gzip 328.77 |
| 恢复原 Navbar + 图标白名单 | 无 SideBar,精确图标 | 1647.73 KiB / gzip 341.25 |
所以现在的流程里多了一道门:
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 才算新一轮
- 页面短到不能滚动时,允许自动补一页,避免用户卡死
优化前后的实测对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 未滚动时的分页请求 | 2 | 0 |
| 未滚动时的卡片数 | 15 | 5 |
| 轮播图片 DOM 节点 | 153 | 15 |
| 首屏图片请求 | 43 | 10 |
| 加载中的 skeleton | 0 | 3 |
| 一次快速滑动触发的请求 | 15 | 1 |
图片那两行是顺手的收获:每个轮播原来把几十张 <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 的隐性成本:你为了体积拆出一套外壳,就得同时维护两套外壳之间的导航边界。
我是怎么验证的
这次的坑几乎都不在构建阶段暴露,所以验证方式比平时重一点:
- 看线上行为,不看产物。
pnpm build之后用wrangler dev起本地 Worker,curl/moments/,确认 HTML 里真的有 memo 正文。构建成功和页面正确是两件事。 - 人为制造失败。 把 API 延迟 1 秒,断言分页期间出现 3 张 skeleton;把分页打成 503,断言已有卡片不被清空、重试能恢复。
- 用真实导航跑回归。 Playwright:列表加载到 15 张 → 进详情 → 返回,断言卡片数是 15、
scrollY是 1156;再压一轮快速滑动,统计请求数。 - 每次 dry-run。
wrangler deploy --dry-run看上传体积。 - 常规三道门。
astro check、Biome、pnpm build。
如果重来一次
- 先量体积预算,再决定 layout 边界。 SSR 路由 import 什么,就等于往 Worker 里塞什么。
- SSR 只保证「首屏有内容」。 「可索引」要靠独立 URL、canonical、结构化数据和运行时 sitemap 一起完成。
- 状态的优先级:URL > sessionStorage > 内存。 SPA 导航里,内存状态是最不可靠的一层。
- 任何自动加载都必须有人为意图。 由布局变化触发的加载,最后都会变成请求风暴。
- 跨布局导航不要交给 SPA router。 它只负责替换容器,不负责替换世界观。
最后
真正落到仓库里的改动不算大:一个轻量 layout、两个路由、一个 sitemap endpoint、一个 feed 组件。加起来不到两天。
但这两天里,大部分时间不是在写 SSR,而是在回答同一个问题:这份状态到底该由谁拥有。
渲染搬到 Worker 之后,顺带把「谁是当前实例」「谁的滚动位置算数」「什么时候可以写缓存」这些原本由浏览器帮你糊过去的问题,全搬到了台面上。这也是我对「局部动态化」最真实的体感:成本不在那一个 prerender = false,在它周围所有默认假设失效的地方。
代码在 markbang/blog。