最近连着写了几个 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.xml、llms.txt、index.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 是把 generateStaticParams 用 try/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 了
这条最气人,因为它不报错~
thdl 的 src/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:38、running-time.tsx:31、dashboard/work-form.tsx:319)。毕竟它只是把警告闭嘴,问题本身还在那儿呢。
第三种最常见。thdl 的 layout.tsx 里,suppressHydrationWarning(:36)和 next-themes 的 ThemeProvider(:41)是配套的,缺一个就报。acgn-flow 的 ef73bb4 一次列了 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,可见这玩意儿是会漏的捏…
静态站也一样逃不掉。bbps 的 src/contexts/locale-context.tsx:96-104,effect 里必须重新读一次 localStorage:
// 首次挂载:按偏好跳转(直接读 localStorage,避免 hydration 闭包捕获到服务端快照 'system')
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as LocalePref | null
// ...
}, [])根源在同文件 :74-76 的 getPrefServerSnapshot() 恒返回 '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-next 的 src/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 改名成 proxy。vns-next 里的文件就是 src/proxy.ts,导出的函数也叫 proxy,仓库里压根不存在 src/middleware.ts。文件头 :4 的注释是「尾斜杠自管重定向(Next 16 的 middleware 更名 proxy)」。
另一个是 revalidateTag 现在需要显式传第二个参数(profile),缺省会走废弃告警路径。这条是在另一个项目升级 Next 16 时撞到的,同一个仓库里并存着两种写法,一处传对象、一处传字符串,两种都合法,就是风格没统一而已啦。
反正大概率我还会再踩一遍,所以先记下来…
