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",我会过这几项:是否用了 useState、useEffect 或事件处理;是否依赖 window、document、localStorage;是否依赖只能在浏览器跑的第三方库;是否真的需要把父组件也拖成客户端组件。只要是个小按钮要交互,就把按钮单独拆成 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,攒够经验再说要不要往更重的项目推。评估的意义就在这儿——先看清方向,再决定用多大的步子跟上去。