KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 一页被判成静态:`next build` 表里那个符号是怎么来的 — keel 龙骨

这一章回答:○ 和 ƒ 到底在说什么、哪些代码算"碰了请求"、哪些不算——以及为什么你在渲染函数里写了一句 new Date(),Next 照样把这一页标成"静态"。

这一章回答:○ 和 ƒ 到底在说什么、哪些代码算"碰了请求"、哪些不算——以及为什么你在渲染函数里写了一句 new Date(),Next 照样把这一页标成"静态"。

现场:我明明取了当前时间,构建表却给了我一个圈

手上有一个页面,渲染函数第一行就是取当前时间:

export default function Page() {
  const now = new Date().toISOString();
  return <b data-probe="now">{now}</b>;
}

按直觉,这页"每次打开都应该显示一个新的时刻",所以你在跑完 next build、翻到它那一行时,心里准备看到的是动态标记。实际躺在那儿的是:

├ ○ /dynamic-time

一个空心圈。再往下两行,另一个页面只做了一件更"无害"的事——把 URL 上的 ?q= 读出来打印——它拿到的却是实心 ƒ:

├ ƒ /search

先别往下翻,给出你的预测: 决定一个页面是 ○ 还是 ƒ 的,是"这段代码算出来的值会不会每次都变",还是别的什么东西?如果赌前者,你就输在 new Date() 这一页上;如果赌后者,那这个"别的"具体是什么,你能一句话说出来吗?

好在 next build 每次都会把答案直接印出来。本课整套实验在同一台 Windows 机器上跑,Next.js 16.3.8(Turbopack 是默认打包器,构建日志首行写着 ▲ Next.js 16.3.8 (Turbopack))、react / react-dom 19.3.0、Node 22.22.2。实验台是一个只有 12 条业务路由的小 app/,另外单独起了一个 mock 源监听 127.0.0.1:3999,它每被打到一次就 hits += 1,把全部命中时刻记在 /log 里。构建表长这样:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/revalidate
├ ○ /client-boundary
├ ƒ /cookies-read
├ ○ /dynamic-time
├ ○ /fetch-default
├ ƒ /fetch-force
├ ƒ /fetch-nostore
├ ƒ /fetch-revalidate
├ ○ /hydration-mismatch
├ ƒ /search
├ ○ /static-time
└ ƒ /stream

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

14 行,7 个 ○、7 个 ƒ——这个配比没有玄机,它就是本实验台摆出来的形状。

这一章就顺着这张表往下:先看清这两个符号的分界线,再逐条对照"哪些写法把页面推到了哪一边"。 上一章(01 首屏是谁渲染的)讲的是四种渲染方式凭什么分开,这一章把镜头收到最窄的一处:构建期到底读了什么,才敢给一页盖上"静态"的章。

一、○ 和 ƒ 是同一张表上的两个字

先读表尾那两行注释,它们就是官方定义:

这张表不是配置文件,也不是某个开关的读数,它是构建期把每条路由都"预演"一遍之后落下来的判定结果。也就是说,你在表里看到的那个字符,是"构建这台机器当时真的试着去渲染了这页、然后发现它能不能提前定稿"的结论。它可读——这是这一章所有方法的立足点:你不用去猜 Next 的心事,你有一个每跑一次就重印一遍的判定清单。

把这两个符号记成一句话:○ = 构建期就能把 HTML 写死;ƒ = 构建期写不死,只能等到请求。 后面所有反直觉的例子,都是在这句话上做加减法。

二、/dynamic-time:渲染函数里取当前时间,它还是 ○

/dynamic-time 是整章第一个反例,源码短到不能再短——渲染函数里取一次时钟,别的什么都不做,既没有 force-dynamic,也没有读任何请求:

// app/dynamic-time/page.jsx
export default function Page() {
  const now = new Date().toISOString();
  return <b data-probe="now">{now}</b>;
}

判定结果我们已经看到了:○ Static。所以真正的判据不是"值会不会变",而是别的东西。new Date()、Math.random() 这一类和"当前时钟、随机源"有关的调用,Next 一概不认它们是动态 API。 直觉在这里翻车的机制很朴素:这两个函数读的是 JS 运行时自己的时钟和随机源,它们跟"这次请求带来了什么"完全无关。而 Next 的判定问的恰恰是后一个问题——这次渲染的输出,依赖不依赖外部请求携带的信息? 依赖,就是 ƒ;不依赖,就是 ○。

跑起来看,这个"不依赖"落到什么程度。用运行时探针把这一页请求两次,第二次拿到的值还是同一个:

/data-probe="now" 的值:  2026-10-06T12:22:12.641Z
刷新任意多次:            不变
本路由区间内 mock 源命中:  +0
x-nextjs-cache:          HIT
cache-control:           s-maxage=31536000

那个时间戳 2026-10-06T12:22:12.641Z 不是"你请求它的时刻",是"构建它的时刻"。这一刻就是这份 HTML 被生产出来的那一秒,之后无论刷新多少次,浏览器拿到的都是这份写死了的字节。值在构建期被冻结了。

把 /dynamic-time 和隔壁的 /static-time 摆一起看,这条结论会更清楚。/static-time 只是在模块顶层取了一次时间当常量:

const RENDERED_AT = new Date().toISOString();

两者判定一样是 ○,运行时证据也一样(区间 +0、x-nextjs-cache: HIT、s-maxage=31536000)。区别只在"取值的时机":一个在模块求值期取,一个在渲染函数里取——但对 Next 的判定来说,这两处都发生在"构建期把页面渲染一遍"的同一次过程里,取到的都是那一刻的时钟。所以"我把 new Date() 从模块顶挪进渲染函数,它就该变成动态了吧"这个念头,在这里是错的。

这条事实有一个很实际的下游后果:在自托管(next start)的部署里,用户在页面上看到的那个"当前时间",永远是"你部署那一刻的时间"。 它不随请求变、不随刷新变,只有重新构建才会动。你要的是"每次请求都新的时间",那这页就必须碰一个请求(比如读 cookies()),否则构建这台机器会一直替你回放旧值。

三、什么算"碰了请求"

把反例放一边,来看被判定成 ƒ 的两条入口——它们都把"输出依赖请求"这件事摆得很直白。

第一条:读 cookies()。

import { cookies } from "next/headers";

export default async function Page() {
  const c = await cookies();
  const all = c.getAll().map((x) => `${x.name}=${x.value}`).join("; ") || "(无 cookie)";
  return <code data-probe="cookies">{all}</code>;
}

/cookies-read 在表里是 ƒ Dynamic。原因没有任何争议:cookie 是每个访问者各自带上来的,同一份 HTML 不可能同时满足所有访问者,构建期当然写不死。这条几乎所有人第一次就能猜对。

第二条:只读 searchParams。这条才是真正扎人的。

export default async function Page({ searchParams }) {
  const sp = await searchParams;
  return <b data-probe="q">{String(sp.q ?? "(空)")}</b>;
}

/search 在表里是 ƒ Dynamic。请注意这一页做了什么、又没做什么:它只是把 URL 上的查询串读出来打印,没有读 cookie、没有读身份、没有 fetch、没有任何"这个人是谁"的信息。即便如此,只读查询串,就足以让整页退化成动态。 逻辑和 cookie 那条同源——?q=abc 和 ?q=xyz 要给出两份不同的输出,这份输出就没法在构建期一次定稿。

这两条合起来给出判定的分水岭:分水岭是"渲染时有没有碰请求",不是"代码里有没有时间或随机数"。 碰了请求(cookie、查询串、请求头、no-store 的 fetch……),ƒ;没碰,哪怕你满页 Math.random(),也还是 ○。

补一句边界:和 cookies() 同一族的 headers() 在本实验台没有单独取证,理由与量法都记在 ## 生产边界,正文不替它下结论。

四、force-dynamic 的作用面是整页

前面 /stream、/fetch-force、/fetch-revalidate 三条路由都带了同一行:

export const dynamic = "force-dynamic";

这一行是路由级的显式声明,它的作用是直接告诉判定:"别替我算,这页就是动态的。" /stream 在表里是 ƒ,声明生效。要看清它的作用面——它压的是整页的判定,不是某一次 fetch、也不是某一个组件。加了它,这一页整体离开静态那一列。

force-dynamic 不是唯一能把页面推成 ƒ 的东西。看 /fetch-nostore:

const res = await fetch(SRC, { cache: "no-store" });

它没有写 force-dynamic,只是给 fetch 挂了一个 cache: "no-store"。结果 /fetch-nostore 在表里也是 ƒ Dynamic。这条值得单独记:fetch 级的 no-store 会把整页拖成动态。 你只改了 fetch 的一个选项,倒下的却是整条路由的判定。(它和 force-dynamic 是两个不同的触发器、同一个落点,这条在 03 fetch 的四种写法 里会拆到底。)

于是可以提炼出一条页级的规律:一个页面里只要有一处碰了请求,整页就是 ƒ。 判定从来不下到组件粒度——它不是"这页的这部分静态、那部分动态",而是"这页能不能在构建期整体定稿"。你可以把 ○ 理解成"整页通过了构建期的定稿检查",把 ƒ 理解成"没通过,交给请求时处理"。

五、静态页和动态页在响应头里长得完全不一样

判定不只是构建日志里那一个字,它在运行时响应头上留着可以直接读的痕迹。把两类路由各请求两次,把 x-nextjs-cache 和 cache-control 并排摆出来:

路由 第1次页面 hits 第2次页面 hits 本路由区间内源命中 x-nextjs-cache cache-control
/static-time — — +0 HIT s-maxage=31536000
/dynamic-time — — +0 HIT s-maxage=31536000
/fetch-default 2 2 +0 HIT s-maxage=31536000
/client-boundary — — +0 HIT s-maxage=31536000
/fetch-nostore 3 4 +2(每次请求都打源) (无) private, no-cache, no-store, max-age=0, must-revalidate
/fetch-force 5 5 +1(只第一次打源) (无) private, no-cache, no-store, max-age=0, must-revalidate
/search?q=abc — — +0 (无) private, no-cache, no-store, max-age=0, must-revalidate
/cookies-read — — +0 (无) private, no-cache, no-store, max-age=0, must-revalidate

横着读这张表,静态那一组和动态那一组是两副面孔:

这两组头是一眼可判的判据:拿到一个页面的响应,先看它有没有 x-nextjs-cache: HIT、再看 cache-control 是 s-maxage=… 还是 no-store 那一串,你基本就能反推出它构建时是 ○ 还是 ƒ。动手段会把这条变成一条你能自己敲的命令。

有一点必须先记住,否则第五节的表会被误读:这张业务表里的 +0 / +1 / +2 是"mock 源在本次两次请求区间内被打到几次",不是页面判定的直接读数。 判定看第 4 列那个字,命中数只是佐证——它告诉你这份产物到底是"回放"还是"现打源"。(这张表的另一半含义属于下一章,这里只用它读响应头。)

六、有客户端组件的页面,照样是 ○

这是最容易被"想当然"划过的一条。/client-boundary 里引了一个文件头写着 "use client" 的子组件:

// app/client-boundary/counter.jsx
"use client";
import { useState } from "react";

export default function Counter({ label }) {
  const [n, setN] = useState(0);
  return (
    <p data-probe="client-counter">
      <button onClick={() => setN((x) => x + 1)}>{label}: {n}</button>
    </p>
  );
}

它有一个按钮、有 useState、有 onClick——"一看就是要在浏览器里跑的东西"。很多人由此推出"这页是动态的"。但表里 /client-boundary 是 ○ Static,/hydration-mismatch(同样带 "use client")也是 ○ Static。

原因还是那一句分水岭:判定只看"渲染这一页时有没有碰请求",而客户端组件的存在不改变这一点。 "use client" 决定的是这段代码被打进哪份 bundle、在哪里执行水合,它不决定首屏的 HTML 长什么样。客户端组件的首屏输出照样在构建期被预渲染进 HTML——服务端先把它的初始状态(n = 0)渲染成 HTML 发给浏览器,同时把那段组件代码单独切进客户端 chunk,等浏览器到了再现收拾(水合)。所以你会看到一个静态回放的页面,里面的按钮却在浏览器里能正常点、点两次真的加到 2。

一句话总结这一节:"use client" 不是动态标记。 有客户端组件的页面可以完完全全是 ○ Static,这是本实验台的实测,不是推测。客户端组件与水合这条线的细节属于 05 服务端 / 客户端边界 与 06 线上的水合,这里只需要记住它对判定的影响是零。

七、fetch 不带选项:整页被预渲染,构建期真的打了一次源

最后回到那张表里最不起眼的一行:

// app/fetch-default/page.jsx
const SRC = "http://127.0.0.1:3999/tick";

export default async function Page() {
  const res = await fetch(SRC);
  const tick = await res.json();
  return <b data-probe="hits">{tick.hits}</b>;
}

它 fetch 了一个外部地址,没传任何选项,也没读 cookie / headers / searchParams。判定结果:○ Static。这就意味着——整个页面在构建期就被渲染定稿了,连那次 fetch 也是在构建期执行的。

这不是推理,mock 源留下了直接证据。它的 hits 计数在实验里的轨迹是:

手工 curl 一次         → hits = 1
next build 期间        → hits = 2
第二条命中时刻          → 2026-10-06T12:22:12.676Z
                        (落在构建窗口 Generating static pages ... 11/11 期间)

hits 从 1 涨到 2,多出来的那一次,时间戳 12:22:12.676Z 紧挨着 /dynamic-time 被写死的 12:22:12.641Z——两件事发生在同一秒里,这就是"构建这台机器在生成静态页的过程中,真的替这页打了一次源"的铁证。

而一旦定稿,后面就再也不打源了:运行时探针请求两次,页面里的 hits 两次都是 2(构建期的那个值),本路由区间内源命中 +0,响应头是 x-nextjs-cache: HIT + s-maxage=31536000。页面级缓存直接回放构建产物,fetch 一次都没有再发生。

这一页是通往下一章的桥:同样是 fetch,把选项换个写法,判定和命中数会走上完全不同的路。这一章只需要带走结论——"fetch 不带选项"的结果,是"整页在构建期定稿、构建时打一次源、之后一直回放"。 至于另外三种写法把结果存在哪里、存多久、什么时候失效,是 03 章 的全部内容。

本章脉络

flowchart TD
  A["next build 扫一遍 app/"] --> B{"渲染这一页时<br/>有没有碰请求?"}
  B -->|"没碰"| C["○ Static<br/>prerendered as static content"]
  B -->|"碰了"| D["ƒ Dynamic<br/>server-rendered on demand"]
  C --> E["产物里有一份 .html<br/>x-nextjs-cache HIT<br/>cache-control s-maxage=31536000"]
  D --> F["没有 .html<br/>每次请求现渲染<br/>private, no-cache, no-store, max-age=0, must-revalidate"]
  A --> G["不算碰请求"]
  G -->|"new Date / Math.random"| C
  G -->|"模块级常量"| C
  G -->|"fetch 不带选项"| G2["整页在构建期定稿<br/>构建时打源一次"]
  G2 --> C
  G -->|"页面里 use client 子组件"| C
  A --> H["算碰请求"]
  H -->|"cookies()"| D
  H -->|"只读 searchParams"| D
  H -->|"fetch cache no-store"| D
  A --> I["export const dynamic = force-dynamic"]
  I --> D

图里在说什么。 顶上那个菱形是本章唯一的分岔口——判定只问一句"渲染时有没有碰请求",不问别的。左边一整列是"看着像动态、其实不算"的东西:new Date() / Math.random()(读的是运行时自己的时钟与随机源)、模块级常量、以及页面里带 "use client" 子组件——它们的落点全是 ○。中间那条 fetch 不带选项 有点特别:它自己也不碰请求,所以整页在构建期定稿,而且构建期真的打了一次源,之后一路回放。右边才是真会推到 ƒ 的东西:读 cookies()、只读 searchParams、fetch 级 no-store,以及显式声明 force-dynamic。整张图的关键是:所有分岔都发生在构建期,页面打开时这一支已经定死,运行时改不回来。

生产边界

动手:可观察结果

把下面五件事各做一遍。每一步的答案都必须来自命令输出,不能来自记忆。

  1. 把表数一遍。 跑一次 next build,在输出里找到 Route (app) 那张表,数出 ○ 和 ƒ 各几行。再去 app/ 里逐条核对:被标 ƒ 的路由,是不是都碰了 cookie / 查询串 / no-store / force-dynamic 里的至少一项?有反例就记下来。
  2. 看静态页的头。 起 next start,然后 curl -sI http://127.0.0.1:3000/static-time(-I 只取响应头)。找 x-nextjs-cache 和 cache-control 两行。再去请求 /cookies-read,看同一个位置变成了什么。两串头的差别,就是第五节的表。
  3. 刷新一个"取当前时间"的页面。 反复刷新 /dynamic-time,用 curl 或浏览器读那个 now 字段。它会变吗?然后对着构建日志里那一刻,回答:这个值是哪一秒定下来的?
  4. 读 mock 源的流水账。 请求 http://127.0.0.1:3999/log,把 hits 和 log 打印出来。/fetch-default 那一页在构建时留下的那条记录,时间戳是不是落在 Generating static pages 的窗口里?之后你请求它几次,hits 涨过吗?
  5. 给客户端组件那一页再确认一次。 打开 /client-boundary,按两下那个按钮。它是静态回放的页面,按钮却能加到 2——这就是"有客户端组件 ≠ 动态"最直观的一帧。

完成标志:你能对着任意一条路由,不看构建表就先说出"它多半是 ○ 还是 ƒ",并且给出理由("它读了 cookie" / "它只取了个时间");再跑一次 next build 验证,对得上。

故障注入

五种注入,每一种都会让某个路由的判定翻面。先写下预测,再去跑 next build 看表。

注入 怎么做 观察什么 期望行为
给"取时间"的页加一次读请求 在 /dynamic-time 里加 import { cookies } from "next/headers" 并 await cookies(),其余不动 构建表里这一行的符号 从 ○ Static 翻成 ƒ Dynamic——同样一段 new Date(),只是多碰了一次请求
把读 cookie 的那一页"腾空" 删掉 /cookies-read 里的 cookies(),只留一段静态文本 构建表那一行的符号 从 ƒ Dynamic 翻回 ○ Static——不再碰请求,构建期就能定稿
把查询串写死 把 /search 改成不读 searchParams、直接打印一个常量 q 构建表那一行的符号 从 ƒ Dynamic 翻回 ○ Static——只读查询串足以让整页动态,不读了就回来
给纯静态页加一句显式声明 在 /static-time 顶上加 export const dynamic = "force-dynamic" 构建表那一行的符号 从 ○ 翻成 ƒ——显式声明直接压过"它本来能静态"
把默认 fetch 换成 no-store 把 /fetch-default 的 fetch(SRC) 改成 fetch(SRC, { cache: "no-store" }) 构建表的符号 + 两次请求的源命中区间 从 ○ 翻成 ƒ;区间从 +0 变成每次请求 +1(本实验台两次请求记 +2)——只动 fetch 的一个选项,整页被拖走

第五种注入跑完之后别急着改回去。再请求这一页两次,把 mock 的 /log 拉开看:no-store 那一页是每次请求都真的打了一次源(区间 +2),而原来的 fetch 不带选项那一页是一次都不再打(区间 +0)。这两条之间的差别,是整个下一章的起点。

自测题

  1. 一个页面在渲染函数里写了 Math.random(),没有读任何请求。它会被判成 ○ 还是 ƒ?请给出判据(不是结论),并说明为什么"值每次都变"不是理由。
  2. /static-time 和 /dynamic-time 的判定完全一样(都是 ○),运行时证据也一样(区间 +0、HIT、s-maxage=31536000)。这两页之间唯一的区别是什么?这个区别对"线上用户看到的时间"意味着什么?
  3. 为什么只读 searchParams 就足以让整页判成 ƒ?请用"构建期能不能定稿"这条标准解释,而不是背结论。
  4. /client-boundary 里有一个 useState + onClick 的按钮,它却是 ○ Static。请说清 "use client" 到底决定了什么、又没有决定什么;以及这个按钮在浏览器里为什么还能点。
  5. force-dynamic 和 fetch 级 no-store 都能把页面推成 ƒ。它们的作用面为什么都是整页、而不是某一个 fetch 或某一个组件?如果一页里只有一个小角落读了 cookie,整页会变成什么?
  6. 你拿到一个线上页面的响应头,上面有 x-nextjs-cache: HIT 且 cache-control: s-maxage=31536000。它构建时最可能是哪个符号?如果换成 private, no-cache, no-store, max-age=0, must-revalidate 且没有 x-nextjs-cache,又是哪个?
  7. /fetch-default 在构建期的 mock hits 从 1 涨到 2,之后所有请求区间都 +0。这两条合起来,证明了这个页面的 fetch 分别在什么时候、几次执行?
  8. 有同事说:"我把 new Date() 从模块顶层挪进了渲染函数,这样它每次请求都应该刷新了吧?"请按本章的判定规则指出他错在哪,并给出两种能让这页真的每次都取新时间的改法(提示:其中一种是加一次读请求,另一种是显式声明)。

现在能解释什么

下一步:03 章 · fetch 的四种写法,结果各自存在哪里——这一章说的是"整页要不要在构建期定稿",下一章把问题拆细一层:同一个页面里,fetch 的结果被存在哪个层、存多久、到期后给你的到底是新值还是旧值。

进入 keel 阅读