跳到主要内容

静态导出的能力边界:哪些功能在 output export 下不能用

示例文章,梳理 Next.js 静态导出可用与不可用的能力,以及在本仓库里的替代做法。

阅读约 4 分钟
目录(5 节)

这是一篇示例文章,内容来自本仓库的真实约束,不是某次实验的测量报告。需要提醒的是:文中提到的任何功能取舍都以当前技术栈为前提,换平台后结论可能不同。

静态导出改变了什么

output: 'export' 的含义是:构建结束时只产出一份静态目录,部署环境不提供 Node 运行时。这个前提会连带影响一批日常用得很顺手的框架能力。

影响分两类。第一类是「构建期能做,运行期不能做」:读取 Markdown、编译 MDX、代码高亮、生成 sitemap,这些都能在 next build 阶段算完,产物是纯 HTML。第二类是「必须依赖请求才能完成」:读取 cookie、按登录态返回不同内容、接收表单 POST,这些在静态站点里没有执行位置。

判断方法很简单:问自己这段代码在什么时候运行。写在页面顶层的代码在构建期运行一次,写在客户端组件里的事件回调在浏览器里运行,两者都可以;只有「服务器收到请求时运行」的那类代码在静态导出下不存在。

明确的禁区清单

以下是本仓库的硬约束,违反其中任何一条都会在构建阶段暴露,而不是等到线上:

能力静态导出下是否可用本仓库的替代做法
Server Actions不可用表单直接 fetch 第三方端点
cookies() / headers()不可用站点对所有人输出同一份 HTML
redirects / rewrites不可用使用静态路由或托管平台的重定向规则
ISR / revalidate不可用内容变更后重新构建
middleware不可用无鉴权需求,直接不需要
动态路由默认行为不可用详情页声明 dynamicParams = false

还有一条容易被忽略:dynamicParams = false 意味着没有通过 generateStaticParams 生成的 slug 根本不存在,访问它得到的是 404,而不是运行时渲染的降级页面。这要求内容在构建期就完整可靠。

ts
export const dynamicParams = false;
 
export function generateStaticParams() {
  return getAllPosts().map((post) => ({ slug: post.slug }));
}

上面的代码里,只要 getAllPosts 漏掉一篇,那篇文章的地址就会变成死链。所以内容读取必须只有一个入口,不能某个页面自己扫目录。

路由段与保留 slug 的冲突

静态导出还有一个具体问题:分页路由与详情路由同级。/blog/page/2/ 和 /blog/[slug]/ 在文件系统里是同级的,page 是固定段,会优先匹配,但这样一来,任何标题为 page 的文章都会永久失去自己的地址。

同样的冲突也存在于 tags:/blog/tags/[tag]/ 与 /blog/[slug]/ 同级。因此这类名字必须在内容校验阶段就被拦下,而不是靠约定。

本仓库的处理方式是在内容读取时维护一份保留 slug 列表,命中就抛错并指出文件路径。这个检查放在构建期,代价几乎为零,收益是避免发布之后才发现某个 URL 打不开。

没有运行时之后怎么处理交互

静态站点并不是不能交互,只是交互必须发生在浏览器里。分页、筛选、目录高亮、表单提交,全部由客户端组件承担,服务端只负责输出「完整的第一帧」。

这带来两个约束。第一个是首帧必须完整:列表页在服务端渲染全部条目,客户端挂载后再根据 URL 查询参数隐藏不匹配的项,这样即使 JavaScript 没有执行,内容依然可读。第二个是状态要写进 URL:筛选条件用查询参数表达,刷新与分享之后状态不丢失。

示例配图:静态产物与浏览器交互的分工

需要强调的是,这种做法有明确的边界。数据量小的时候,一次性输出全部条目成本可以接受;条目数量增长到几百条之后,首屏 HTML 会明显变大,那时需要重新评估是否引入构建期索引。本仓库内容量还远未到这一步,所以先保持简单。

小结

静态导出不是「阉割版的服务端渲染」,它更像一份契约:构建期负责所有确定性工作,浏览器负责所有交互,中间不允许存在「请求时再决定」的环节。把这条契约写进文档、写进校验脚本,比记住一串禁止清单更可靠。