上一篇文章写过给博客加「阅读体验三件套」,这次是第二梯队改造:阅读时长估算、热门页、PWA、搜索联想、评论数徽标、移动端目录、代码块语言标签、版权声明、数据库自动备份、自托管访问统计——十项一起上。功能本身都不难,难的是过程中踩的几个坑,值得单独记一篇。
坑一:category/tag 页集体 500,凶手是 DYNAMIC_SERVER_USAGE
现象
上线后某天点分类页:
https://www.wxbuluo.com/category/32?_rsc=xxx 500 (Internal Server Error)
tag 页同样炸。pm2 错误日志里只有一行被生产模式脱敏的报错:
⨯ [Error: An error occurred in the Server Components render...] {
digest: 'DYNAMIC_SERVER_USAGE'
}
背景:为什么代码里会有这个雷
之前为了根治 CI 构建期跑 shiki 高亮导致 OOM,做过一次「B 方案」改造:
// category/[id]/page.tsx
export const revalidate = 600;
export function generateStaticParams() {
return []; // 构建期不预渲染,首次访问按需生成 + 缓存
}
export default async function CategoryPage({ params, searchParams }) {
const { id } = await params;
const { page: pageParam } = await searchParams; // ← 雷
...
}
分页读页码用的是 await searchParams——这是动态 API。而这条路由声明了 revalidate,属于 ISR:首次访问时 Next 处于"缓存填充"上下文,此时 await 动态 API 直接抛 DYNAMIC_SERVER_USAGE,页面 500。
文章页为什么没事?它没有分页,不碰 searchParams。所以这颗雷从 B 方案上线那天就埋下了,潜伏到有人点开分类页才爆。
排查路上的两个弯路
弯路一:怀疑 next/script 的 beforeInteractive。 当时正在接统计脚本,时间上高度可疑。后来把变量全部隔离测试才排除——不过顺手学到一件事:beforeInteractive 确实要求服务端注入 HTML 的特殊时机,能不用就不用。
弯路二:Suspense 包裹无效,而且本地验证给了假阳性。 按 Next 文档直觉,把消费 searchParams 的子树包进 <Suspense> 让动态部分流式渲染。本地 build 测试通过——但部署后依然 500。复盘才发现本地的"通过"是假象:本地 BFF 链路不通,getCategory() 返回空走了 notFound() 提前退出,根本没渲染到 Suspense 子树。Next 16 非 PPR 模式下,ISR prerender 的动态 API 检测不看 Suspense 边界。
教训:验证修复必须让完整链路真正执行到出问题的代码路径,"没报错"和"修好了"是两回事。
正解:把分页从 query 挪进路径
/blog?page=2 → /blog/page/2
/category/32?page=2 → /category/32/page/2
每个模块拆成两个路由文件复用同一个视图组件,页面不再消费 searchParams,ISR 完整恢复;顺带每页有了独立 URL,对 SEO 更友好。实在离不开 query 的搜索页,显式 export const dynamic = "force-dynamic",数据层靠 fetch 自带的 next.revalidate 缓存兜底。
一条经验法则:ISR 路由里不要碰 searchParams/cookies/headers 这些动态 API;需要按请求变化的维度,优先把它编码进路径。
坑二:自托管统计,三次换型才跑通
想要一个不依赖第三方的访问统计,过程比想象中曲折。
umami:镜像拉不到 + Prisma 连不上 MySQL
- ghcr.io 匿名拉取被拒(denied),换南京大学镜像源
ghcr.nju.edu.cn解决 - 容器起来了,但 umami 内置 Prisma 引擎连 MySQL 8.0.27 报
received invalid response: 4a。试了mysql_native_password专用账号、检查协议版本,全无效——这是 Prisma 引擎与环境兼容性问题,继续调是深坑 - 及时止损:自托管统计的可选项不止一个,别在一棵树上吊死
Fathom Lite:Go 单二进制 + SQLite
换成 usefathom/fathom,Docker Hub 又直连超时,docker.1ms.run 代理解决。容器一次启动成功,SQLite 存储,省掉所有数据库兼容性烦恼。
但 tracker.js 才是真·三连坑
Fathom Lite v1.2.1 的 tracker 是老式队列设计,官方文档又写得含糊,我前后错了三次:
- 裸引 tracker 报 TypeError:第一行就读
window.fathom.q,要求页面预先有一段官方 shim 定义队列函数 - 加了 shim 还是不上报:读源码发现它通过
document.getElementById("fathom-script")找自己的标签、从src推导/collect上报地址——我用 next/script 生成的标签没有这个 id,上报地址推导为空 - 补了 id 还是没数据:再读源码,
trackPageview在整个文件里只有定义没有调用点,站点 ID 也只认fathom('set','siteId',…)命令而非 data-site 属性——这个版本的 tracker 根本不会自动上报
最终的正确接入:
<script> (function(){ window.fathom = window.fathom || function(){ (window.fathom.q = window.fathom.q || []).push(arguments); }; window.fathom("set", "siteId", "wxbuluo"); window.fathom("trackPageview"); // 首屏 PV })(); </script> <script src="https://analytics.wxbuluo.com/tracker.js" id="fathom-script" defer></script>
还有个 SPA 特有问题:客户端路由切换不会重新加载页面,PV 会漏。加一个监听 pathname 变化的客户端组件在路由变化时补调 fathom('trackPageview'),注意首屏去重。
排查期间最有用的一招:直接读 tracker.js 源码(压缩后不到 3KB)+ 对照 nginx 访问日志看 /collect 有没有被请求——把「前端没发」和「后端没收」一刀切开,定位快得多。
坑三:compose 迁移之后,nginx 把统计请求打到了 API 网关
gin 和 fathom 原来都是裸 docker run 起的,启动参数只存在于运行时状态里,机器一重建就得靠回忆。于是把它们纳入 /docker/docker-compose.yml 统一编排。
迁移完发现 analytics 子域 404,且返回的是 Go 默认的 404 page not found——这是 gin 的口音!真相:nginx 对 proxy_pass http://fathom:8080 这种域名形式会在启动/reload 时解析 DNS 并缓存结果,容器重建换了内部 IP 后,nginx 还拿着旧 IP 在发请求,而那个 IP 已经被新起的 gin 占了。
处理:重建容器后记得 nginx -s reload 强制重新解析。如果上游经常变动,改用变量 + resolver 127.0.0.1 valid=10s(Docker 内嵌 DNS)让 nginx 每次请求动态解析。
其他小坑备忘
- @giscus/react 包装组件不支持
onMetadataReceived回调(类型定义里根本没有),评论数徽标改为原生监听 iframe 的 postMessage,记得校验e.origin === "https://giscus.app" - 同名导出函数重复声明(新旧两版
getRank共存)tsc 会直接拦下,重构前先全局搜一遍 - 小内存服务器千万别在容器里跑 Go 编译,一次构建能把整台机压到 sshd 无响应——本地交叉编译 + 预编译 Dockerfile 是正道
写在最后
这轮改造最大的感受有两点:
- 潜伏雷比爆炸雷可怕。searchParams 那颗雷从埋下到引爆隔了很多天,中间没有任何报错。改造路由元数据(generateStaticParams、revalidate、dynamic)这类"影响所有页面"的东西时,要立刻把相关页面全部人工过一遍。
- 读源码比搜引擎快。tracker 三连坑的最终答案不在任何教程里,就在那 3KB 压缩 JS 里。npm 包再黑盒,解压格式化一下也就几百行。