惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

美团技术团队
IT之家
IT之家
博客园 - Franky
博客园_首页
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
量子位
阮一峰的网络日志
阮一峰的网络日志
月光博客
月光博客
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
博客园 - 三生石上(FineUI控件)
M
MIT News - Artificial intelligence
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI
Stack Overflow Blog
Stack Overflow Blog
小众软件
小众软件
Jina AI
Jina AI
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - 叶小钗
Apple Machine Learning Research
Apple Machine Learning Research
B
Blog RSS Feed

定の栈

AI 写得出功能,写不好 Dockerfile:Next.js 自托管踩坑记录 多个 AI 一起写代码时,我给它们划的边界 9 个 commit,没有一个人类作者:AI 独自写完一个项目之后 让 Claude 少写错数据层:Prisma、Drizzle 和几条结构化做法 我的 CLAUDE.md 只有一行:约定文件到底该写多少 同一个月,把主页搬出 Next.js,又把 Hugo 站搬进来 macOS Tahoe 如何用自己的视频做动态壁纸(替换 Aerial 方法) Podman Compose 常用命令速查 创建 Swap 文件 Cursor 2025 Hugo 中文阅读时间计算模版 手动重加载不蒜子计数 入坑舞萌 DX 历时两月终抵 w0 与首鸟加 多邻国 600 天连胜 原神五周年纪念 Asuna 成年生日 告别绝区零 站长18周岁啦! 一位动漫迷的追番日志与热情之旅 起飞日志 Reflector 镜像列表更新常用命令与配置文件 个人自用 rsync 文件同步常用命令 历时千日原神,深渊终抵满星 Arch Linux 个人常用命令记录 《三体III:死神永生》 《三体II:黑暗森林》 《千恋*万花》与现代物理学概念奇妙碰撞后产生出的糟糕想法 Minecraft 15 周年骨折价补票入正 网页添加 Live2D 看板娘 记第二次清醒控梦体验
Next.js 渲染模式排查笔记:force-dynamic、hydration 与构建...
Asuna · 2026-07-21 · via 定の栈

最近连着写了几个 Next.js 项目,功能本身都挺顺,结果全卡在同一类问题上,本地 next dev 好好的,一 build 或者一上线就出事。这些坑清一色不属于「代码写错了」,而属于「渲染模式没搞对」,报错信息还经常指向别处,特别费时间…

这里记录下我踩过的几个,尽量把文件行号都写上~

坑一:构建期是没有数据库的

vns-next 是 Galgame 资源站,Prisma + Postgres,部署走 Docker Compose 自托管。本地跑得好好的,一进 Dockerfile 的多阶段构建就炸。原因是 Next 默认会在 next build 阶段把 RSC 页面静态预渲染一遍,而 builder 那一层根本没有数据库容器,任何查库页面必然在 build 时抛错。

修法在 commit b07e20d:根 layout 里加一行 export const dynamic = "force-dynamic"sitemap.ts 加上 feed.xmlllms.txtindex.xml 三个 route handler 同步声明。

// src/app/layout.tsx:11-13
// 全站动态渲染:内容页在请求时查库(Docker 构建环境没有数据库,
// 静态预渲染会在 build 阶段连库失败);热点查询已有内存缓存兜底
export const dynamic = "force-dynamic";

值得记一笔的是代价:git show --stat b07e20d 显示 5 files changed, 16 insertions(+),其中真正的声明就是 5 个文件各一行,剩下 11 行全是注释和空行。把整站从静态切成动态就这么点东西,反过来说,也就意味着你可以毫无察觉地把整站切成动态捏…

验证手段我觉得比修复本身更值得抄:commit body 里写的是「已用死库 DATABASE_URL 本地复现构建通过」。就是拿一个连不上的 DATABASE_URL 在本地把 Docker 构建环境复现出来,先看它挂,再验它好。不然改完只能靠推到服务器上赌~

不想整站转动态的话还有两条路。mikiacg 是把 generateStaticParamstry/catch 整个包住:

// src/app/video/tag/[slug]/page.tsx:25-36
export async function generateStaticParams() {
  try {
    const popularTags = await prisma.tag.findMany({
      where: { videos: { some: { video: { status: "PUBLISHED" } } } },
      take: 50,
      orderBy: { videoCount: "desc" },
      select: { slug: true },
    });
    return popularTags.map((tag) => ({ slug: tag.slug }));
  } catch {
    return [];
  }
}

构建期连不上库就预渲染 0 个页面,全部退化成运行时生成,但至少 build 不会红。thdl 走的是另一条:首页声明了 revalidate = 60,查询前先 let latest = [] 打底,再用 try{}catch{latest = []} 兜住 DB 失败,src/app/(site)/page.tsx:18-27 就是这个写法。

坑二:写了 revalidate 不等于就 ISR 了

这条最气人,因为它不报错~

thdlsrc/app/(site)/resources/page.tsx:6 老老实实写了 export const revalidate = 30,可同一个文件第 11 行是 const sp = await searchParams。用了 searchParams 的页面在 Next 16 下会被判定动态渲染,那条 revalidate 就是一句无效声明。

同仓库的 [slug]/page.tsx:17 一样写了 revalidate = 30,里面调了 getSession(),而 src/lib/get-session.ts 内部是 auth.api.getSession({ headers: await headers() })。读了 headers(),路由照样被打成动态,ISR 同样落不下来。

更离谱的一例在 bbps。这站是 output: 'export' 纯静态导出,git show 99b11c53^:src/app/blog/[id]/page.tsx 能看到第 7 行赫然是 export const revalidate = 600。要注意那会儿这个文件还在现役用 WordPress API 拉数据,这行是当时特意为 WP 拉取写的,不是什么上个时代剩下的垃圾,只不过在 output: 'export' 下它从写下的第一天起就没生效过…

这类声明的共同点是:不报错、不告警、tsc 也过,只会静静地不生效… 想确认到底有没有生效,还是得看 next build 输出里那个路由前面的标记就行。

坑三:hydration 三连

hydration mismatch 我按现象归了个类,基本逃不出这三种:

现象根因我的解法
持久化状态首帧闪回默认值localStorage 与 SSR 首帧不一致mounted 门控,挂载前恒返回默认值
秒级计时器 / 时间戳报 mismatch服务端和客户端的 Date.now() 必然不同suppressHydrationWarning
切主题白屏或报错next-themes 往 <html> 注入 class<html suppressHydrationWarning>

第一种,vns-next 用 zustand persist 存设置,解法抽成了一个 hook:

// src/stores/settings.ts:57-70
/**
 * 水合安全地读取单个设置项:挂载前恒返回默认值,
 * 避免 localStorage 持久化状态与 SSR 首帧不一致(hydration mismatch)。
 */
export function useSettingValue<K extends keyof SettingsData>(key: K): SettingsData[K] {
  const value = useSettings((s) => s[key]);
  const [mounted, setMounted] = useState(false);
  useEffect(() => { setMounted(true); }, []);
  return mounted ? value : defaultSettings[key];
}

第二种没得治,页脚那个「已运行 X 天 X 时 X 分 X 秒」的计时器,服务端渲的秒数跟客户端对不上是物理规律。src/components/layout/running-time.tsx:30-31 直接认了:

// 挂载前渲染占位,避免 SSR 与客户端时间不一致导致的 hydration 报错
return <span suppressHydrationWarning>{text ?? "…"}</span>;

但这个属性我是当消耗品用的,vns-next 全站只有 3 处(layout.tsx:38running-time.tsx:31dashboard/work-form.tsx:319)。毕竟它只是把警告闭嘴,问题本身还在那儿呢。

第三种最常见。thdllayout.tsx 里,suppressHydrationWarning:36)和 next-themes 的 ThemeProvider:41)是配套的,缺一个就报。acgn-flowef73bb4 一次列了 5 条:layout.tsx:109 的 body 加属性、header 里把 motion.span 换成纯 span、header 用 CSS 的 max-h/opacity 替掉条件渲染、加 mounted 门控 auth UI、还给 PageWrapper 和 FadeIn 也补了 mounted。结果两分钟后 46c6101 又补了一处 settings-panel 的 next-themes mismatch,可见这玩意儿是会漏的捏…

静态站也一样逃不掉。bbpssrc/contexts/locale-context.tsx:96-104,effect 里必须重新读一次 localStorage:

// 首次挂载:按偏好跳转(直接读 localStorage,避免 hydration 闭包捕获到服务端快照 'system')
useEffect(() => {
  const stored = localStorage.getItem(STORAGE_KEY) as LocalePref | null
  // ...
}, [])

根源在同文件 :74-76getPrefServerSnapshot() 恒返回 'system'useSyncExternalStore 那个值被 effect 的闭包捕获住了,直接用就永远是服务端快照。

再补一个更冷的:home 那次 68e9570,static export 把构建时的年份烤进了页脚 HTML,客户端一跨年就 mismatch,只好在 app-footer.tsx:38 上按了个 suppressHydrationWarning。这站后来整个迁出了 Next,那行属性就再没人管过了…

坑四:SSR 端 dayjs 的 locale 会注册到另一个实例上

这条最阴,因为在客户端看着完全是对的~

mikiacg 的 commit 99be0f1:副作用式的 import "dayjs/locale/zh-cn" 在 Next 服务端的 ESM/CJS interop 下,会把 locale 注册到与业务代码不同的那个 dayjs 实例上。dayjs.locale("zh-cn") 找不到就 fallback 回 en,于是 SSR 吐出来的 HTML 是「16 days ago」而不是「16 天前」。

修法是三重保险:

// src/lib/format.ts:1-8
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
// 直接 import locale 对象并手动 register;副作用 import "dayjs/locale/zh-cn" 在 Next.js
// server 端的 ESM/CJS interop 下会把 locale 注册到另一个 dayjs 实例上,导致 fromNow() 始终输出英文
import zhCnLocale from "dayjs/locale/zh-cn";

dayjs.extend(relativeTime);
dayjs.locale(zhCnLocale, undefined, true);

然后 :33 的调用现场再兜一次底:return dayjs(date).locale("zh-cn").fromNow();。这类靠模块副作用做全局注册的库,在 RSC 里我现在都会多瞄一眼…

坑五:入场动画能把整页卡在半透明

vns-nextsrc/app/template.tsx 全文 14 行,注释里写清了为什么不用 framer-motion:

用 CSS 动画(tw-animate-css)而非 framer-motion:opacity/transform 跑在合成器线程,
rAF 被节流/冻结(后台标签页、嵌入式 webview、低性能设备)时也不会把整页卡在半透明中间态,
且动画不可用时元素静态即为最终可见态。prefers-reduced-motion 用 motion-reduce 降级。

JS 驱动的入场动画大多是 rAF 把 opacity 从 0 推到 1,一旦 rAF 被浏览器节流甚至冻结,动画停在 0.3 就再也不动了,用户看到的是一整页半透明。CSS 动画跑在合成器线程上不吃这套,而且退一万步动画彻底不可用时,元素的静态样式就是最终可见态。

这条理由在这个仓库里重复出现了 5 处,commit 123f722 的 body 里也记了这个修复~

顺手记两条 Next 16 的改名

一个是 middleware 改名成 proxyvns-next 里的文件就是 src/proxy.ts,导出的函数也叫 proxy,仓库里压根不存在 src/middleware.ts。文件头 :4 的注释是「尾斜杠自管重定向(Next 16 的 middleware 更名 proxy)」。

另一个是 revalidateTag 现在需要显式传第二个参数(profile),缺省会走废弃告警路径。这条是在另一个项目升级 Next 16 时撞到的,同一个仓库里并存着两种写法,一处传对象、一处传字符串,两种都合法,就是风格没统一而已啦。

反正大概率我还会再踩一遍,所以先记下来…