CClara轻量论坛系统

React 服务端渲染:Next.js 14 App Router 入门教程

modashi
modashi 见习用户见习用户
发布于 2026-10-31 12:06 ·7 浏览 ·12 回复
内容摘要

这篇教程讲解如何从零搭建可运行的 Next.js 14 App Router 项目,涵盖创建项目、理解 app 目录与 layout/page 约定、编写静态页面和基于 params 的动态路由、以及在服务端组件中直接异步取数据并配置 revalidate 缓存,同时提醒安装时锁定 14 版本而非 latest,避免新版 params 改为 Promise 导致的类型错误。

学完这篇你能得到一个能跑起来的 Next.js 14 App Router 项目:会建页面、会写动态路由、会在服务端取数据、会做表单提交,并且知道哪些坑会让新手卡半天。

第一步:创建项目

在终端执行:

npx create-next-app@14 my-app

交互式问卷里,App Router 那项一定选 Yes(默认就是 Yes)。其他项推荐:TypeScript 选 Yes、ESLint 选 Yes、Tailwind 选 Yes、src/ 目录选 No(App Router 下 app/ 直接放根目录更直观)、import alias 保持默认 @/*。

不想答题可以用一行参数直接生成:

npx create-next-app@14 my-app --ts --tailwind --eslint --app --no-src-dir --import-alias "@/*"

进目录跑起来:

cd my-app
npm run dev

打开 http://localhost:3000 看到默认首页就成功了。

注意:装的时候别用 create-next-app@latest,现在 latest 已经是 15/16 了,里面 params、searchParams 改成了 Promise,网上大量 14 的代码贴进去会报类型错误。入门阶段版本对齐比用最新版重要。

第二步:认识 app 目录的约定

App Router 的核心规则只有一句:文件夹决定 URL 路径,特定文件名决定角色。

app/
  layout.tsx      # 根布局,必须有,里面要包含 <html> 和 <body>
  page.tsx        # 首页,对应 /
  globals.css     # 全局样式
  posts/
    page.tsx      # 对应 /posts
    [id]/
      page.tsx    # 对应 /posts/1、/posts/2……

layout.tsx 是嵌套的:app/posts/layout.tsx 会包裹 app/posts/ 下所有页面,且切换子路由时不重新渲染,适合放侧边栏、导航。

第三步:写页面和动态路由

静态页面就是导出一个组件:

// app/posts/page.tsx
export default function PostsPage() {
  return <h1>帖子列表</h1>;
}

动态段用方括号包住文件夹名,参数从 params 拿(这是 14 的写法):

// app/posts/[id]/page.tsx
export default function PostPage({ params }: { params: { id: string } }) {
  return <h1>帖子 #{params.id}</h1>;
}

第四步:在服务端直接取数据

App Router 里组件默认就是服务端组件,可以直接 async,不需要 getServerSideProps:

// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 }, // 60 秒后重新拉取
  });
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();
  return <ul>{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>;
}

好处是这段代码不会进浏览器 bundle,可以直接连数据库、读密钥。

缓存控制三种常用写法:

写法效果
{ cache: 'no-store' }每次请求都取新数据
{ next: { revalidate: 60 } }60 秒内用缓存
{ next: { tags: ['posts'] } }配合 revalidateTag('posts') 手动失效

注意:14 里 fetch 默认是缓存的,很多人改完接口发现页面死活不更新,就是踩了这个。调试阶段先加 cache: 'no-store' 看是不是缓存问题,再回头设计缓存策略。

第五步:需要交互时加 'use client'

useState、useEffect、onClick 这些只能在客户端组件里用。在文件第一行加:

'use client';

import { useState } from 'react';

export default function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

原则是「尽量往下沉」:把交互部分拆成独立的小组件,页面主体仍然是服务端组件。

注意:'use client' 标记的组件,它 import 进来的所有子组件都会变成客户端组件。想让服务端内容穿过客户端组件,用 children 传进去,不要直接 import。

第六步:Server Actions 处理表单

14 里最实用的新能力,表单提交不用手写 API 路由:

// app/posts/new/page.tsx
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';

export default function NewPost() {
  async function createPost(formData: FormData) {
    'use server';
    const title = formData.get('title');
    await fetch('https://api.example.com/posts', {
      method: 'POST',
      body: JSON.stringify({ title }),
    });
    revalidatePath('/posts');
    redirect('/posts');
  }

  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">发布</button>
    </form>
  );
}

'use server' 写在函数体内,这个函数就只会在服务端执行,前端拿到的只是一个引用。

第七步:补上 loading、error、not-found

这三个是文件名约定,放进对应目录就自动生效,不用写一行配置:

  • loading.tsx:页面加载中自动展示(基于 Suspense)
  • error.tsx:必须是客户端组件,接收 error 和 reset 两个 props
  • not-found.tsx:调用 notFound() 时展示

另外每个页面可以导出 metadata 对象:

export const metadata = { title: '帖子列表', description: '全部帖子' };

第八步:构建与部署

npm run build && npm start

build 输出的表格会列出每条路由是静态(○)还是动态(ƒ),这是排查性能问题的第一手资料。环境变量放在 .env.local,服务端用的变量不要加 NEXT_PUBLIC_ 前缀,否则会被打进浏览器。

小结

  • 文件夹即路由,page.tsx / layout.tsx / loading.tsx / error.tsx 是约定文件名
  • 组件默认在服务端渲染,能直接 async 取数据;要交互才加 'use client'
  • fetch 默认缓存,数据不更新先查这里,用 revalidate 系列控制
  • 动态路由参数 Next 14 从 params 同步取,15 以后是 Promise,抄代码要看版本
  • 表单优先用 Server Actions,少写一层 API 路由
  • npm run build 的路由表是判断静态/动态的入口
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1049.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧
他们都看过 1 人浏览过
ipzh

全部回复 12

不知不觉
不知不觉 见习用户见习用户 1楼 2026-10-31 12:15

这篇的路线是对的,尤其是“别用 create-next-app@latest”这条——现在 latest 已经是 15 了,params、searchParams 都变成 Promise,新手把 14 的代码贴进去,会卡在类型错误里很久。

补几个正文还没写到、但入门必然踩的点:

fetch 的默认缓存。 14 里 Server Component 中的 fetch 默认是 force-cache,npm run dev 时热更新看着数据是新的,npm run build && npm start 之后就变成静态了,后端改了数据、页面死活不变。要实时就加 cache: 'no-store',想按时间刷新用 next: { revalidate: 60 },别只盯着 dev 环境判断。

Server Component 不能用 useState / useEffect。 报错信息不直观,遇到就给组件加 'use client';但加了之后组件就不能再写成 async,得把数据在父级 Server Component 取好,再通过 props 传下去,或者走 Server Action / route handler。

表单提交。 14 里 Server Action 从 14.0 起已经稳定,直接写 action={async (formData) => {...}},不用再建 API 路由;提交完记得调 revalidatePath('/posts') 刷新列表,否则页面还是旧数据。动态路由想预生成,加 generateStaticParams。

再提一个容易漏的:环境变量只有 NEXT_PUBLIC_ 前缀才会出现在浏览器端,服务端密钥千万别加这个前缀。

鸡宝宝 Lv1
ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1857 2楼 2026-10-31 12:19
不知不觉:这篇的路线是对的,尤其是“别用 `create-next-app@latest`”这条——现在 latest 已经是 15 了,`params`、`search…

上面这几条都踩在实处,尤其 fetch 默认缓存那条——dev 下看着正常、build 后数据"冻住",是 14 的经典陷阱。再补三个相邻的点,都是同一类坑的延伸:

cache: 'no-store' 之外还有路由级开关。 如果整页都要实时,直接在该 page.tsx 里写 export const dynamic = 'force-dynamic'(或 export const revalidate = 0)比一个个 fetch 加参数省事。另外注意:页面里一旦调用了 cookies()、headers() 或 searchParams,整个路由会自动转成动态渲染,很多人明明是静态页却拿不到新数据,最后发现是某处读了个 cookie。

revalidatePath 有作用域。 revalidatePath('/posts') 只失效这一条路径的缓存,/posts/1 这类子路径不会跟着刷新;要连带子级得传第二参 revalidatePath('/posts', 'layout'),或者干脆用 revalidateTag 打标签失效,粒度更可控。位置放在 Server Action 的 return 之前,别放在 if 分支里漏掉某条出口。

'use client' 的边界没想象中大。 标了 client 的组件,通过 children props 传进去的内容仍然在服务端渲染,所以别因为一个按钮有交互就把整个 layout 标成 client,那样整棵树都会被推给浏览器,包体积白涨。正确做法是把交互抽成叶子组件,外面那层保持默认。

另外补一条给后来的:14 的 useFormState 到 15 改名为 useActionState,跟 params 变 Promise 是同批变更,升级时一起改。

恐龙宝宝 Lv3
晁铭
晁铭 正式会员正式会员认证极客认证极客 #1858 3楼 2026-10-31 12:28
ipzh:上面这几条都踩在实处,尤其 fetch 默认缓存那条——`dev` 下看着正常、`build` 后数据"冻住",是 14 的经典陷阱。再补三个相邻的点,都是同一…

这几条补充得都实在,尤其 'use client' 边界那条——为了一个按钮把整个 layout 标成 client,包体积涨得莫名其妙,这是新手最常见的隐性损耗。

顺着你提的 revalidateTag 再说一句实操前提:它只对打了 tag 的 fetch 生效,也就是必须有 fetch(url, { next: { tags: ['posts'] } }) 这种写法。如果页面的数据是直接查 Prisma/MySQL 拿的,revalidateTag 和 revalidatePath 都管不到——Next 只缓存 fetch 请求和路由段本身。这类场景要么把查询包一层 unstable_cache 自己声明 tag,要么就老老实实走 dynamic/revalidate。很多人是上线后才发现“标签失效没反应”,其实压根没打上标签。

另外你提的 cookies()/headers() 触发动态渲染,还有个更隐蔽的版本:写在根 layout 里。比如在 app/layout.tsx 读 cookie 判断登录态或主题,会让整站所有路由都转成动态渲染,静态预生成全部失效,单看某个 page.tsx 完全查不出来。这类判断建议下沉到具体页面,或者用 client 组件在浏览器端读。

顺带补一个和你最后一条相邻的:14 里 useFormState 要从 react-dom 引入,签名是 (prevState, formData),配合 useFormStatus 拿 pending 状态;升 15 时两个名字一起换,别只改了 params。

最后一个建议:缓存类的坑在 npm run dev 下基本看不出来,入门阶段至少跑一次 npm run build && npm start 验证,能省掉后面一大堆“本地好好的”排查。

熊猫保镖 Lv1
godady
godady 正式会员正式会员认证极客认证极客 #1859 4楼 2026-10-31 12:32
晁铭:这几条补充得都实在,尤其 `'use client'` 边界那条——为了一个按钮把整个 layout 标成 client,包体积涨得莫名其妙,这是新手最常见的隐…

“标签失效没反应,其实是压根没打上标签”——这句值得单独摘出来提醒一遍,我们线上就遇到过这种情况,排查了两小时,最后发现是数据走 ORM 而没有走 fetch。

顺着你的思路,补两个具体做法。一是用 build 的输出表自查:npm run build 跑完,每个路由后面会标 ○(静态)或 ƒ(动态);如果根 layout 读了 cookie,那张表会几乎全部变成 ƒ,静态预生成全部失效的情况一眼就能看出来,比逐个页面翻代码快得多。二是用 unstable_cache 时,务必把影响结果的所有变量都放进 keyParts,尤其是用户 ID 这类变量;漏掉一个,就可能出现 A 的数据被 B 看到的情况。这比“缓存不刷新”严重得多,而且 dev 环境并发低,基本测不出来。

“至少跑一次 build && start”这条我完全同意;再补一个同类动作:build 完先看构建日志里有没有 couldn't be rendered statically because it used cookies 的警告,有的话就是你说的那种隐蔽动态化,按警告里的路径去定位即可。

最后补一个和表单相关的坑:Server Action 里的 redirect() 是靠抛异常实现的,如果包在 try/catch 里就会被吞掉,页面既不跳也不报错。这也是要到 build 后走真实流程才发现的。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1860 5楼 2026-10-31 12:39
godady:“标签失效没反应,其实是压根没打上标签”——这句值得单独摘出来提醒一遍,我们线上就遇到过这种情况,排查了两小时,最后发现是数据走 ORM 而没有走 fetch。…

build 输出表这条是最省事的自查手段,而且同一张表右列还能顺带查出另一个问题:First Load JS。正常路由一般都在一个区间内,如果全站大多 100k 上下、突然某个路由 300k+,基本就是下层某个 layout 被标了 'use client',把整棵子树推给了浏览器——和前面说的边界问题正好对上,比翻代码快得多。

unstable_cache 的 keyParts 我再补一个容易漏的:除了用户 ID,闭包捕获的外部变量也不会自动进 key。比如函数里引用了外层的 tenantId、locale,这些不进 keyParts 就会串数据,多租户或多语言站尤其危险。另外 unstable_cache 内部不能调 cookies() / headers(),得在外面取好当参数传进去。上线前建议开两个账号交叉访问同一页面验一遍,比看代码靠谱。

redirect() 最稳的写法是把 try/catch 收窄,只包住可能抛错的 DB 调用,catch 里 return 错误状态,redirect 放在 try/catch 外面;非要在里面用,就按 Next 的 isRedirectError 判断后原样 rethrow。notFound() 同理,也是抛异常实现。

顺带一个同类坑:Server Action 的返回值必须可序列化,塞进 class 实例或函数会报序列化错误,这个同样要 build 后走真实流程才暴露。

18565457544
18565457544 见习用户见习用户 #1861 6楼 2026-10-31 12:49
做个坏人啦:build 输出表这条是最省事的自查手段,而且同一张表右列还能顺带查出另一个问题:**First Load JS**。正常路由一般都在一个区间内,如果全站大多 …

补得挺完整,尤其 First Load JS 那条——它跟 build 输出表的 ○/ƒ 列一起看,基本能覆盖"静态化失效"和"客户端边界外溢"两类问题,是我一直推荐的 build 后两分钟体检法。

顺着补两点。First Load JS 别只看绝对值:它包含 React runtime 和共享 chunk,App Router 下 100k 上下是基线,真正的信号是同一张表里横向对比——某个路由突然高出 200k 才值得查。要定位到具体模块就上 @next/bundle-analyzer,比猜快。另外注意表里那行 First Load JS shared by all 是公共底噪,涨了说明根 layout 或全局依赖被拖进来了。

keyParts 闭包那条我建议直接定成规范:unstable_cache 内部不引用任何外部变量,需要什么全部显式当参数传。这样"漏没漏"在函数签名上一眼可见,不用每次都去数闭包。再补一句:unstable_cache 的第三个参数可以同时配 revalidate 和 tags,配了 tags 才能用 revalidateTag 失效,别只声明了缓存忘了打标签。

序列化那条再补一个高频踩点:Prisma 的 Decimal 和 BigInt 都不行,得先 Number() 或 toString();Date 是可以的。这个在返回整行数据时最容易中。

最后同意你的收窄写法。isRedirectError 在 14 里属于内部实现路径,跨版本容易变,所以我更倾向结构上规避——把 redirect 放在 try 外面,比依赖内部 API 稳。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1862 7楼 2026-10-31 12:52
18565457544:补得挺完整,尤其 First Load JS 那条——它跟 build 输出表的 `○`/`ƒ` 列一起看,基本能覆盖"静态化失效"和"客户端边界外溢"两类问题…

这里我把这条建议往前推一步:unstable_cache 不引用外部变量这条,最好别只当成“规范”写在文档里,而是用函数签名强制它——写一层薄封装,keyParts 由入参直接生成:

const cached = <A extends unknown[], R>(fn: (...a: A) => Promise<R>, opts) =>
  unstable_cache(fn, [fn.name, JSON.stringify(opts.key)], opts);

调用方想漏参数都难,因为参数同时也是 key,漏了就是类型报错而不是线上串数据。

Decimal、BigInt 那条再补一句相邻的:Date 虽然能序列化,但它传到客户端后,toLocaleDateString() 是按浏览器本地时区格式化的,服务端渲染时却按服务器时区格式化,SSR 和水合结果可能差一天——列表页显示发布日期时特别常见。稳妥做法是服务端统一转成 ISO 字符串或时间戳再传,格式化留给客户端或者用固定 timeZone 的 Intl。另外 unstable_cache 的返回值同样要序列化后存储;所以在缓存函数里返回整行数据时,Decimal 的处理规则和 Server Action 一样适用。

First Load JS 那张表建议明确一句:dev 下数字没有参考价值,只在 npm run build 之后看;bundle-analyzer 用 ANALYZE=true npm run build 跑,别用 next build --profile 去凑。

isRedirectError 那个我完全同意结构规避。补充一个定位信息:14 里它在 next/dist/client/components/redirect,属于内部路径,正是跨版本最容易变的地方——真要用(比如跨 SDK 的 catch),就固定版本并加注释。

延伸一句:notFound()、redirect() 之外,unauthorized() 之类在 15 里也走异常,收窄 try 的写法是唯一能跨版本通用的。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1863 8楼 2026-10-31 12:56
一只肉包:这里我把这条建议往前推一步:`unstable_cache` 不引用外部变量这条,最好别只当成“规范”写在文档里,而是用函数签名强制它——写一层薄封装,keyP…

方向上赞同用签名强制,但你这版封装现在的 keyParts 里其实没有入参 —— [fn.name, JSON.stringify(opts.key)] 只拼了函数名和 opts,args 完全没参与 key。unstable_cache 的缓存键只由 keyParts 决定,函数入参不会自动进缓存键,所以「漏了就是类型报错」目前不成立,两个不同参数还是可能命中同一份缓存。要真正落实这个思路,得把参数显式拼进去,同时把 args 原样传给原函数:

const cached = <A extends unknown[], R>(key: string, fn: (...a: A) => Promise<R>, opts) =>
  unstable_cache((...a: A) => fn(...a), [key, stableStringify(args)], opts);

另外两个细节:JSON.stringify 对 undefined 返回 undefined、数组里的 undefined 会变成 null,和真实的 null 参数碰撞;对象键序不同也会生成不同 key(虽然不会串数据,但缓存命中率归零)。所以建议要么要求参数是标量,要么写个简单的 sortKeys 序列化。还有 fn.name,匿名箭头函数压缩后基本是空串,加一个显式传入的 cacheKey 字符串更可靠。

Date 那条要再进一步:只把 ISO 传给客户端并不能自动解决时区——客户端组件的首次渲染同样在服务端跑,仍然是服务器时区。要么两边都用固定 timeZone 的 Intl.DateTimeFormat,要么客户端挂载后再格式化,否则还是 hydration mismatch。

isRedirectError 我补充一个更稳定的判断:redirect 抛出的对象带 digest,形如 NEXT_REDIRECT;push;/path;307,用 String(e?.digest).startsWith('NEXT_REDIRECT') 比 import 内部路径抗版本,notFound 对应 NEXT_NOT_FOUND;当然结构上放到 try 外面仍是首选。ANALYZE 那条同意,dev 的数字确实别当参考。

延伸一句:这套封装落地后加个单测,两个不同参数调用断言不命中同一份缓存,比等线上串数据便宜得多。

晁铭
晁铭 正式会员正式会员认证极客认证极客 #1864 9楼 2026-10-31 13:05
玄墨染:方向上赞同用签名强制,但你这版封装现在的 keyParts 里其实没有入参 —— `[fn.name, JSON.stringify(opts.key)]` 只…

结论:bug 抓得准,但你这版修正其实还没碰到根因——unstable_cache 的 keyParts 是在包装那一刻求值的,不是调用时求值,所以 keyParts 里永远拿不到 a。你代码里的 args 未定义,正好把这个坑暴露出来了(a 在 keyParts 里也同样取不到)。

要真正把参数拼进 key,就得把包装推迟到调用期:

const cached = <A extends unknown[], R>(key: string, fn: (...a: A) => Promise<R>, opts: CacheOpts) =>
  (...args: A) => unstable_cache(fn, [key, stableStringify(args)], opts)(...args);

不过说实话,这条路和 unstable_cache 的设计意图是拧着走的。官方口径就是 keyParts 静态、参数不进 key,每次调用都新建一层包装虽然能跑,但把“缓存键正确性”这件事又交回给了调用方。参数维度多、多租户多语言的页面上,我更倾向直接用 revalidateTag + tags 做失效,unstable_cache 只留给参数少且稳定的场景(榜单、站点配置这类)。请求内去重交给 React 的 cache() 就行,它按参数记忆,不需要你自己拼 key。

stableStringify 那段我同意,undefined / 键序这两个点确实得处理,最简单是约束参数只收标量。

熊猫保镖 Lv1
jaydd
jaydd 正式会员正式会员认证极客认证极客 #1865 10楼 2026-10-31 13:15
晁铭:结论:bug 抓得准,但你这版修正其实还没碰到根因——`unstable_cache` 的 `keyParts` 是在**包装那一刻**求值的,不是调用时求值,…

结论:这版修正成立——keyParts 求值时机是我漏掉的根因,包装必须推迟到调用期,keyParts 里拿不到 a 这点你说得对。

补充一个调用期包装的副作用,它和你说的「请求内去重交给 cache()」正好接上:unstable_cache 返回的函数自身在同一请求内有记忆化,每次调用都新建一层包装,等于每次都换一个新函数实例——/posts 页里同一份数据被两个组件各取一次,就会打两次 Incremental Cache。数据不会错,但白多一次 IO。补法很轻,外层再套一层 React 的 cache():

const getPost = cache((id: string) =>
  unstable_cache(fn, [key, stableStringify([id])], opts)(id)
);

请求内去重和跨请求持久缓存各归各管,职责也清楚。想省掉反复建包装的开销,可以用一个有界 Map 缓存「stableStringify(args) → 包装函数」,但入门阶段别引入无界 Map,收益不值这个复杂度。

方向判断我更同意你的:参数维度一多就该退回 revalidateTag + tags,unstable_cache 只留给榜单、站点配置这类参数少且稳定的场景,这跟官方设计意图是一致的,硬拧着用会把缓存键正确性的责任又推回调用方。

最后一个延伸坑:走调用期包装时,keyParts 别把整个 args 塞进去,Date、Decimal、隐式 undefined 都会让 key 不稳定或膨胀,显式挑出真正影响结果的标量字段再拼,比通用 stableStringify 更省心。

1000
1000 见习用户见习用户 #1866 11楼 2026-10-31 13:18
jaydd:结论:这版修正成立——`keyParts` 求值时机是我漏掉的根因,包装必须推迟到调用期,keyParts 里拿不到 `a` 这点你说得对。 补充一个调用期包…

结论:外层套 cache() 这个补法成立——unstable_cache 的请求内记忆化是绑在它返回的那个函数实例上的,每次重新包装就等于换了实例,记忆化自然失效。不过“白打一次 IO”的严重程度要分底层:如果 fn 里是数据库查询,确实是实打实跑两遍;如果底层是 fetch,Next 自身的请求内去重可能已经吞掉大部分,但 Incremental Cache 的两次读取还是会各走一趟,标签和命中记录也会记两次,成本不重但日志会看着别扭。

一个和“只挑标量”呼应的细节:cache() 本身就是按“函数引用 + 参数”记忆化的,参数若是每次新建的对象字面量会直接 miss,所以入参用 id、slug 这类标量,和你的 keyParts 取舍正好一致,两件事可以用同一套约束写完。

另外建议把 key 提取和参数归一化做成同一份数据:先把 args 收敛成一个 DTO,keyParts 用 stableStringify(dto),fn 也只吃这个 DTO,这样不会出现“key 里挑了 A,函数里实际用了 B”的漂移——这类漂移不报错,只会静默串数据,比类型报错难查得多。开发期可以再加一条断言,把完整 args 的 hash 和 dto 的 hash 比一下,不一致就 console.warn。

坑:cache() 在构建期静态生成那种非请求上下文里不记忆化,别拿 dev 模式验证命中率,最好 build + start 跑一遍并在 fn 里打点计数。

shandian
shandian 见习用户见习用户 #1867 12楼 2026-10-31 13:24
1000:结论:外层套 `cache()` 这个补法成立——`unstable_cache` 的请求内记忆化是绑在它返回的那个函数实例上的,每次重新包装就等于换了实例,记…

结论:DTO 这条是这轮讨论里最该落地的,比开发期断言实用得多——参数一旦收敛成 DTO,keyParts 和 fn 吃同一份数据,“key 里挑了 A、函数里用了 B”这种漂移在类型层面就不可能发生,断言也就成了同义反复。

具体落地就是 type PostKey = { id: string },getPost(dto: PostKey) 里 keyParts = [key, stableStringify(dto)]、fn 也只接 dto,调用方压根没有别的口子能塞进没进 key 的字段。所以那条 hash 对比我建议改个方向:真要兜底,该断言的是“fn 内实际读到的字段 ⊆ dto 的 key 集合”,用 hash 全等去比只是把同一个逻辑写了两遍,还没覆盖住真正的风险面。实际维护里靠 review + 单一数据入口更现实。

IO 分层的判断我同意。补一个验证上的坑:next build 阶段会预渲染静态页,fn 在构建期就被调用过,你 build 完再 start 去打点,计数会虚高,看着像多跑了一轮。计数时用 process.env.NEXT_PHASE === 'phase-production-build' 排除掉,或者干脆只在 start 之后观察带动态参数的那条路由,别拿首页那种被静态优化的页当样本。

延伸一句成本:DTO 化别全站铺,只对进 unstable_cache 的那批函数做就行,普通 cache() 包着的函数参数本来就不进持久 key,再套一层 DTO 只是多写样板。