Next.js 14 重点更新:Turbopack、Server Actions 与部分预渲染

先说清楚我的位置:团队日常主力还是 Vue,做电商中后台。Next.js 对我们更像隔壁生态的一扇窗,偶尔有对外的营销站、活动页会用到 React 这套,所以我一直留着一条眼线盯它。上周(10 月 26 号)Next.js 14 发布,我抽了两个晚上,把手头那个还在 Pages Router 上的小项目当靶子,逐条对着 changelog 试了一遍,算是给自己出一份评估结论:值不值得跟,跟的话先跟哪部分。

评估这种东西,最怕被"新特性列表"牵着走。14 这个版本从名字看是大版本,但真正压实的其实是三个方向:Turbopack 收拾开发体验,Server Actions 让数据变更更靠近服务端,部分预渲染继续把静态内容和动态内容糅进同一次渲染里。这三条恰好都戳在我那个项目的痛点上——本地开发越来越慢,表单提交绕着一层 API Route,页面又同时挂着静态内容和登录后才有的动态数据。所以对我来说,这次不是"要不要用新玩具",而是"这三个方向是不是我未来两年该押的方向"。

一个前提要摆在最前面:这些能力几乎都长在 App Router 上。App Router 是今年 5 月(13.4)才转正的,之前一直是 beta,我们当时观望没敢碰。现在 14 出来,等于官方在告诉你 App Router 才是主路。如果项目还在 Pages Router,我不会为了追版本立刻迁;但它这套渲染路数,越早摸清越不亏。

Turbopack:快是真的,但要验兼容

14 继续推基于 Rust 的 Turbopack,主打大型项目的本地启动和热更新速度。官方给的数字很好看,我自己在那个项目上试,冷启动确实肉眼可见地快了,改一行样式到看见结果的等待也短了一截。

这种收益别小看。一次等三五秒不算什么,可一天下来改几百次,累积的损耗是实打实的。开发体验这东西,慢的时候你未必意识到,快过一次之后就再也回不去了。

但 Turbopack 不是魔法。它在 14 里覆盖的还是 next dev,生产构建仍然走 Webpack,官方自己也说 Turbopack 还在 beta。项目里要是有大量动态导入、复杂的 Babel 插件、自定义 Webpack 配置或者非标准资源处理,都得单独验一遍兼容性。我试的时候就撞到一个 SVG 加载的写法在 Turbopack 下行为不一致,回退到默认 dev 才正常。

所以我升级构建工具时习惯跑两个维度。本地开发体验这一维,我会挨个确认:

  • 热更新是否正常
  • CSS 和 CSS Modules 是否正常
  • SVG、图片、字体等资源是否正常
  • 自定义构建配置是否仍然生效
  • 开发环境和生产构建结果是否一致

生产产物一致性那一维,则看 next build、核心页面、部署平台的产物对不对得上。开发快是好事,但如果 dev 和 build 表现不一致,反而制造了一类最难查的问题——本地一切正常,上线就出岔。别只看启动速度快就急着合并。

Server Actions:把数据变更写回服务端

Server Actions 在 14 里稳定了。这是我这次最感兴趣的一块,因为它触及的是一个我在 Vue 里也天天面对的老问题:表单提交的胶水代码太多。

以前提交一个表单,链路通常是这样:前端监听 submit,调 API Route,服务端处理,客户端再刷新状态。四段代码,三处要维护类型对齐。Server Actions 允许把服务端函数直接绑到表单动作上:

1export default function Page() {
2  async function create(formData: FormData) {
3    "use server";
4
5    const title = String(formData.get("title") || "").trim();
6
7    if (!title) {
8      throw new Error("标题不能为空");
9    }
10
11    // 在服务端写入数据库
12  }
13
14  return (
15    <form action={create}>
16      <input name="title" />
17      <button type="submit">提交</button>
18    </form>
19  );
20}

创建、更新、删除这类直接动作,确实省掉了大半胶水。

但我评估下来有件事必须先想清楚:Server Action 不是让你把业务全塞进组件文件。它是"服务端入口函数",不是业务层本身。比如创建文章,Action 负责读 FormData、校验权限、调 createPostService,至于数据库写入、日志、通知、缓存刷新,该在 service 层就在 service 层。入口可以靠近页面,业务规则还得有自己的家。

Server Action 做完变更还有一件容易漏的事:缓存失效。很多"提交成功但页面还是旧数据"的投诉,根子不在写库,而在服务端缓存或路由缓存没刷。我的做法是把读取点和失效点成对设计——哪里读了列表,哪里就得知道变更后该刷列表还是刷详情:

1"use server";
2
3import { revalidatePath, revalidateTag } from "next/cache";
4
5export async function createPost(formData: FormData) {
6  const title = String(formData.get("title") || "").trim();
7
8  await createPostService({ title });
9
10  revalidateTag("posts");
11  revalidatePath("/posts");
12}

这里千万别图省事全站刷新。列表、详情、用户信息各自该对应哪个 tag 或路径要分清楚,否则一次小提交连累一堆页面缓存失效,你辛苦攒的性能收益又被自己打回去了。

错误处理,跨了客户端到服务端这一步就不能省

Server Action 写起来像本地函数调用,但它实实在在跨了客户端和服务端。权限、参数校验、数据库错误、重复提交,一样都跑不掉。

我倾向把校验和错误结构统一成一个返回契约,而不是让异常随意冒到页面上:

1"use server";
2
3type ActionResult = {
4  ok: boolean;
5  message?: string;
6};
7
8export async function createPost(formData: FormData): Promise<ActionResult> {
9  const title = String(formData.get("title") || "").trim();
10
11  if (!title) {
12    return { ok: false, message: "标题不能为空" };
13  }
14
15  try {
16    await savePost({ title });
17    return { ok: true };
18  } catch {
19    return { ok: false, message: "保存失败,请稍后重试" };
20  }
21}

客户端拿到统一结构,就能用一套方式展示成功和失败,不必到处 try/catch。

重复提交也别指望框架替你兜。表单写简单了,用户连点、网络慢、服务端超时这些不会消失。客户端可以用 pending 状态禁用按钮——React 18 的 useFormStatus 配合 form 拿 pending 挺顺手:

1"use client";
2
3import { useFormStatus } from "react-dom";
4
5export function SubmitButton() {
6  const { pending } = useFormStatus();
7
8  return (
9    <button type="submit" disabled={pending}>
10      {pending ? "提交中..." : "提交"}
11    </button>
12  );
13}

但这只是第一道防线。服务端该做的幂等或唯一约束一个都不能省——比如给创建请求带一个客户端生成的 requestId,服务端用它做去重,或者直接在业务表上加唯一约束兜底。前端禁用按钮防的是"手抖连点",防不了"刷新重放"和"两个标签页同时提交"。我评估任何一个"让表单更好写"的方案时,都会先问它有没有把这层责任偷偷推给前端。

还有一个 App Router 下才会遇到的细节:Server Action 抛出的错误,默认会被 error.tsx 边界接住。这意味着你如果只是想把"标题不能为空"这种可预期的校验失败展示在表单上方,就不该 throw,而应该走前面那个 ActionResult 返回值的路子;throw 留给真正的异常,让它去触发 error.tsx 那层兜底。这两条路混着用,用户体验会很割裂——有的错误弹全屏错误页,有的错误留在表单里。评估阶段就把"什么算可预期失败、什么算异常"划清楚,比事后统一好做得多。

部分预渲染:先看它想解决什么

先把状态说明白:部分预渲染(Partial Prerendering,PPR)在 14 里还是 preview,得手动打开 experimental.ppr 才能试,官方明确说别上生产。所以我对它的态度是"提前理解方向",不是现在就拿它重构页面。

它的思路是:页面里稳定的部分先尽快返回,动态内容随后流式补上。本质上是想在"静态页够快"和"动态页够灵活"之间找一个平衡点。对电商、内容站、SaaS 首页这类页面价值很大,因为它们天生就是静态结构加个性化内容的混合体。

拿一个文章详情页举例:标题、正文、作者信息可以静态生成;阅读数、推荐位、登录态可以动态加载。用户不该因为一个个性化模块慢,就连主体内容都看不到。

落到代码上,它逼你把动态部分显式地包进 Suspense:

1export default function ProductPage() {
2  return (
3    <main>
4      {/* 静态外壳,构建时就能预渲染 */}
5      <ProductHeader />
6      <ProductDescription />
7
8      {/* 动态部分,运行时流式补上 */}
9      <Suspense fallback={<PriceSkeleton />}>
10        <LivePrice />
11      </Suspense>
12      <Suspense fallback={<RecoSkeleton />}>
13        <Recommendations />
14      </Suspense>
15    </main>
16  );
17}

我评估下来,PPR 真正要求你做的,不是改配置,而是重新拆页面。以前很多页面习惯一个顶层请求拿齐所有数据,现在得想清楚:哪些能先回来,哪些挂 Suspense,哪些失败了不影响主体。这套拆分能力,就算你暂时不用 PPR,用在普通的 Suspense 流式渲染上也一样吃得到,所以我把它当成一次页面结构的梳理契机,而不是等一个稳定版。

有一处评估时容易忽略:fallback 的骨架屏得和真实内容尺寸接近,否则动态内容补上来时会把页面推得跳一下,落到用户眼里就是布局抖动。静态外壳的意义本就是"先给用户一个稳定的框架",骨架屏尺寸不对会把这份稳定又抵消掉。所以做 PPR 或流式渲染,骨架屏不是随便画个灰块,是要按真实内容占位的。

App Router 带来的新默认,才是这次真正要跟的东西

14 继续强化 App Router。它不只是目录结构换了个样,而是把数据获取的方式和组件的分工整个改了。

Server Component 适合读数据、渲染稳定内容;Client Component 适合交互、状态、浏览器 API。一个我反复见到的误区,是图方便在顶层加 "use client",结果整棵树都变成客户端渲染,Server Component 的价值直接归零。

更稳的做法是把客户端组件压到真正需要交互的最小范围:

1// Server Component
2export default async function PostPage() {
3  const post = await getPost();
4
5  return (
6    <article>
7      <h1>{post.title}</h1>
8      <LikeButton postId={post.id} />
9    </article>
10  );
11}

LikeButton 尽管是 Client Component,文章主体不必为了一个点赞按钮陪着一起下水。

我评估时专门盯了这条切分线,因为它决定了发给浏览器的 JS 有多少。判断一个组件该不该 "use client",我会过这几项:是否用了 useStateuseEffect 或事件处理;是否依赖 windowdocumentlocalStorage;是否依赖只能在浏览器跑的第三方库;是否真的需要把父组件也拖成客户端组件。只要是个小按钮要交互,就把按钮单独拆成 Client Component;除非整页都要读浏览器宽度或订阅事件,才考虑把更大一块提升为客户端组件。客户端组件切得越小,发给浏览器的 JS 越少,Server Component 的价值也越保得住。

这里还有个我从 Vue 视角看过去觉得挺妙、也挺容易踩的点:Server Component 可以把已经取好的数据当 props 传给 Client Component,但传过去的东西必须是可序列化的。函数、类实例、Date 之外的复杂对象都不能直接从 Server Component 传给 Client Component。评估时我拿一个带方法的领域对象试了下,直接报错——最后得在 Server Component 里先把它拍平成纯数据再传。这条约束反过来是好事,它逼着你想清楚"客户端到底需要哪些字段",而不是把整个后端对象一股脑塞过去。对习惯了 Vue 里父子随便传引用的人来说,这是一个需要重新适应的默认假设。

另外,Server Component 里想读 cookie、header 这类请求级数据,用的是 next/headers 里的 cookies()headers(),而不是浏览器的 document.cookie。这也是评估迁移成本时容易漏的一块:原来在客户端读登录态的逻辑,搬到 Server Component 得换一套 API。

数据获取和缓存,是 App Router 里最需要重新学的一块

评估这一版时,我花时间最长的地方是数据获取和缓存这套默认行为。App Router 把 fetch 做了扩展,默认会对请求做缓存,这跟 Pages Router 里 getServerSideProps 那种"每次请求都重新取"的直觉完全不同。

1// 默认会被缓存,构建后当静态数据用
2const staticData = await fetch("https://api.example.com/config");
3
4// 显式声明每次请求都重新取
5const dynamicData = await fetch("https://api.example.com/me", {
6  cache: "no-store",
7});
8
9// 定时重新验证
10const feed = await fetch("https://api.example.com/feed", {
11  next: { revalidate: 60 },
12});

这三种模式对应三种页面性格:几乎不变的配置走默认缓存,登录后个性化数据走 no-store,能容忍一定延迟的列表走 revalidate。评估迁移成本时,这块是我标红的地方——因为"页面显示旧数据"的问题,十有八九出在没搞清默认缓存行为,而不是接口本身错了。从 Vue 那套"取数据就是取数据"的直觉切过来,这里需要重新建立一套直觉。

配合前面 Server Action 里的 revalidateTag,可以给 fetch 打 tag,让写操作精准地失效对应的读缓存:

1const posts = await fetch("https://api.example.com/posts", {
2  next: { tags: ["posts"] },
3});

读的时候打 tag、写的时候按 tag 失效,读写两端就对上了。这套设计我评估下来是喜欢的,但它有学习成本,团队里得先统一约定,否则各写各的 tag,最后失效逻辑一样会乱。

升级前,先看运行环境和第三方库

14 对 Node.js 版本有要求,也带了一些弃用和行为变化。真要升,我会先把这几处逐一核对:

  • Node.js 版本是否达标
  • next/font 的使用方式有没有变
  • next/image 配置是否还兼容
  • 自定义 webpack 配置能不能继续生效
  • App Router 和 Pages Router 的混用情况
  • 中间件与运行时配置
  • 部署平台是否支持目标版本

别只改版本号就上线,至少跑完整构建加核心页面回归。用了 SSR、鉴权、支付回调、后台管理的项目,服务端行为要重点验。

还有一处我吃过亏、会专门查的:第三方库对 App Router 和 Server Component 的支持。很多组件库、富文本、图表、埋点 SDK 默认依赖 window,只能待在 Client Component 里。不是不能用,而是要想清楚哪些代码只能待在客户端组件里跑,否则在 Server Component 里直接 import 就会构建报错或运行时炸。

处理办法通常是给这类库包一层客户端壳,把 "use client" 加在壳上,或者用动态导入关掉 SSR:

1import dynamic from "next/dynamic";
2
3// 一个重度依赖 window 的图表库,关掉 SSR 只在客户端渲染
4const Chart = dynamic(() => import("./Chart"), { ssr: false });

评估阶段就把这些库分好类,能省掉迁移期一大半的返工——哪些天然支持服务端、哪些必须客户端、哪些干脆要找替代品,早点列清楚,排期才准。

我的评估结论

站在一个 Vue 团队旁观者的位置,我给这次 Next.js 14 的结论是这样的:方向对,值得跟,但跟的顺序要排好。

我不会先看"能不能用上新特性"。我会先问:项目有没有理清服务端和客户端的分工,第三方库是不是重度依赖 window,构建产物和开发环境表现是否一致,部署平台支不支持目标版本。这些是地基。Turbopack、Server Actions、PPR 都很诱人,但它们一进项目,动的是整条工程链路,不是某个页面的小改动。

14 把开发体验、数据变更、渲染性能这三条线继续往一处收,这个大方向我认。至于我们自己,Vue 那边 3.3 今年也在补 TS 支持和 defineModel,主力我还是放在那边;Next.js 这套先在对外的营销站小范围试 App Router 和 Server Actions,攒够经验再说要不要往更重的项目推。评估的意义就在这儿——先看清方向,再决定用多大的步子跟上去。