理解 Next.js 的 SSR , SSG 实现

拿两个页面跑一次 next build,SSG 和 SSR 的全部差别就摆在 .next 目录里:一个产出了 HTML 文件,另一个没有。顺着这个差别往下看渲染在哪一步分岔、__NEXT_DATA__ 装的是什么、hydrate 接的是哪一头。

预计
5 分钟

SSR 和 SSG 的区别,不用看源码就能看见 —— 它在 .next 目录里是一个文件的有无。

两个页面,除了数据函数的名字之外一模一样:

pages/static.js 和 pages/dynamic.js 的差别只有这一行
// pages/static.js
export async function getStaticProps() {
return { props: { at: 'build-time' } };
}
// pages/dynamic.js
export async function getServerSideProps() {
return { props: { at: 'request-time' } };
}

next build(本文全部产物取自 next@14.2.35,Pages Router):

next build 的产物清单
Route (pages) Size First Load JS
┌ ○ /404 180 B 80 kB
├ ƒ /dynamic 305 B 80.1 kB
└ ● /static 302 B 80.1 kB
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses getStaticProps)
ƒ (Dynamic) server-rendered on demand

.next/server/pages/ 底下看这两个页面各留下了什么:

.next/server/pages/ —— 注意谁有 .html
dynamic.js ← SSR 页面只有代码
static.js
static.html ← SSG 页面多了这两个
static.json

这就是全部的区别。getStaticProps 在构建时被调用过一次,渲染结果作为一个 .html 文件躺在磁盘上;getServerSideProps 的页面只留下一个模块,每次请求进来才现跑。

同一趟渲染,只是发生的时刻不同

两条路走的渲染代码是同一份。Pages Router 的渲染入口是 next/dist/server/render.js 里的 renderToHTML,最终落到 React 的服务端渲染 API 上。值得一提的是这个文件里 renderToStringrenderToReadableStream 两个名字都出现了 —— 别默认 Pages Router 只有同步那一条路,具体走哪个要看这次渲染的形态。页面模块本身怎么找出来的,靠 next/dist/server/require.jsrequirePage —— 把路由算成磁盘路径再加载。

分岔只在「谁来触发这一趟」:

  • SSR:请求到达 → getServerSideProps 取数 → renderToHTML → 响应。每个请求走一遍。
  • SSGnext build 期间 → getStaticProps 取数 → renderToHTML → 写进 .html 文件。之后所有请求都只是把这个文件发出去。

构建期这一趟由构建流程调度,不是主进程直接跑的。想在源码里对上号,看 next/dist/build/index.js 里的 pagesStaticWorkersappStaticWorkers(Pages Router 和 App Router 各一组子进程),以及 next/dist/build/utils.js 里的 buildStaticPaths / buildAppStaticPaths。顺带说一句:网上不少文章会提到一个叫 buildStaticPages 的函数,next/dist 整个目录里搜不到这个名字。

SSG 的代价就藏在「构建时跑一次」里:数据被冻进磁盘上的 HTML,不重新构建就停在那一天。revalidate 是在这个前提下开的口子 —— 给静态产物一个过期时间,过期后后台重新生成一份,而不是把决定挪回运行时。

NEXT_DATA 是两端之间唯一的桥

服务端渲染出的 HTML 是死的:标签都在,事件监听器一个都没有,因为函数没法序列化成字符串。客户端要接上这棵树,就得知道服务端当时用的是什么数据 —— 重新请求一遍不行,那样两边算出来的树可能不一样。

Next 的做法是把那份数据原样塞进 HTML。它是一个 id__NEXT_DATA__<script> 标签(不是某个元素的属性,这点常被写错),typeapplication/json,所以浏览器不会把它当脚本执行:

真实产物 · .next/server/pages/static.html 里的那一段
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"at":"build-time"},"__N_SSG":true},
"page":"/static","query":{},"buildId":"YGTPgxp-K9VL5Mw3HsrTb",
"isFallback":false,"gsp":true,"scriptLoader":[]}
</script>

pageProps 就是 getStaticProps 返回的那个 props。旁边的 __N_SSG: true 是构建期打的预渲染标记,Nextjs 是如何实现 production ready 的 react里讲的就是这个标记从哪来 —— 它在这儿露了个面,说明「这页是构建期生成的」这件事一路传到了浏览器。buildId 决定了静态资源的 URL 前缀,也是判断「客户端和服务端是不是同一次构建」的依据。

客户端启动后读这个 JSON,用它初始化 React,然后接管已有的 DOM。接管用的是 hydrateRoot,不是 ReactDOM.hydrate —— next/dist/client/index.js 里搜得到前者,后者在 Next 13 和 14 的这个文件里已经不存在了。这是 React 18 换根 API 带来的连锁变化,不只是改个名字:hydrateRoot 之后的更新走 startTransitionroot.render()

两端算不出同一棵树就是 hydration mismatch。典型来源是渲染里混了只有一端才有的东西 —— Date.now()Math.random()typeof window 的分支。服务端那份 HTML 已经发出去了,客户端发现对不上,只能把这一块丢掉重渲染。

从 HTML 可见到页面可点之间的这段空窗,是 SSR 固有的成本,不是 Next 的实现问题。React 18 拿 Suspense 边界去切它 —— 那套机制在 React 18 五:Selective Hydration 里。

客户端跳转要的不是 HTML,是那个 .json

前面文件清单里还有个 static.json,内容是:

.next/server/pages/static.json
{"pageProps":{"at":"build-time"},"__N_SSG":true}

它就是 __NEXT_DATA__props 那一坨,单独存了一份。用户从站内点 <Link> 跳到这个页面时,React 应用已经在跑了,不需要再要一份完整 HTML —— 只要这份数据,配上早就下载好的页面组件代码,客户端自己渲染。

所以同一个 SSG 页面有两种进入方式,走两条不同的产物:直接输 URL 或刷新拿 .html,站内跳转拿 .json。调试「为什么刷新正常、点进来就不对」这类问题时,先分清用户走的是哪条。

App Router 把分岔点从函数挪到了 fetch

以上全是 Pages Router。App Router 从 13.4 起稳定,它不是在这套东西上加功能,是换了一套:

getStaticPropsgetServerSideProps 在 App Router 里不存在。静态还是动态不再由「你导出了哪个函数」决定,而是从路由段推导 —— 用没用到动态 API(cookies()headers())、fetch 是怎么配的缓存。getStaticPaths 是四个数据函数里唯一有直接继任者的,换成了 generateStaticParams

这里有个跨版本的坑值得单记:Next 13 / 14 里 fetch 默认缓存(文档写的是默认 force-cache),Next 15 起默认不再缓存。同一批变更里 Route Handlers 的 GET 也不再默认缓存。照着 14 的教程在 15 上写,页面不会报错,只是数据比你以为的新、请求比你以为的多。

反过来,本文这套 Pages Router 的东西没有被废弃,两套路由可以在同一个项目里共存。判断你在读的文章讲的是哪一套,最快的信号就是有没有出现 getStaticProps 这个词。