用了几年 Next.js 之后,我对它的态度反而更克制了

Next.js 最容易被卖点打动的地方是“一个框架全给你”:路由、SSR、打包、图片优化、部署链路都内置好了。但这套默认能力是否值得要,取决于项目本身要不要“服务端先算好”这件事——这是我这几年判断要不要上 Next 时越来越看重的一条分界线,比框架本身好不好用更重要。

判断的核心问题很朴素:这个项目有没有“未登录的陌生人需要尽快看到内容”这种场景? 有,Next 大概率划算;没有,尤其是纯后台、纯内部工具、纯登录后应用,往往不划算。

纯中后台不该默认上 Next

典型的反例是公司内部配置平台:登录进去就是表格、表单、权限、审批流,没有 SEO 需求,用户都是内部同事,首屏慢两百毫秒不会有人投诉。这类项目如果默认 create-next-app,很容易走进一个尴尬局面——几乎每个页面顶上都要写 'use client':表格要交互,表单要状态,弹窗要事件。服务端组件那套能力基本用不上,反而要时不时绕开 App Router 的数据获取和缓存约定,因为这些约定是为“服务端先算好”设计的,而后台的数据全靠登录后调接口,跟服务端预渲染没什么关系。

这种项目换成 Vite + React Router 通常更轻:构建快、上手不用绕弯子、部署就是一份静态资源丢到 Nginx 后面。Next 的服务端能力,在一个登录后才可见、数据全靠接口的后台里,大部分时候是负担而不是收益——你要多理解一套 App Router 的渲染模型、缓存分层、'use client' 该画在哪一层这类规则,换来的价值却兑现不了几分。

对纯后台类项目,我现在的默认选择反而是先问“要不要服务端”,不要就直接上 Vite 那一套,省下的评估和踩坑成本比省下的几行脚手架配置大得多。

内容型站点是 Next 的主场

对外的产品官网、博客这类站点,情况完全相反:要被搜索引擎读到,首屏要快,图片多,内容又不是每秒都在变。

这正好是 Next 的主场。页面用静态生成,配一个按小时重新验证的策略,用户访问起来接近纯静态页,运营改了内容也不用每次手动全量构建:

1const posts = await fetch('https://api.example.com/posts', {
2  next: { revalidate: 3600 },
3}).then((res) => res.json())

next/image 顺手解决了我以前在移动端最头疼的事——列表缩略图直接用原图、几 MB 往下载。这些能力单独看都不稀奇,但凑在一个框架里、还都是默认可用,确实省了我很多自己搭轮子的时间。

Next 的价值不在“它能 SSR”,而在它把内容型站点的一堆琐碎问题打了个包——静态生成、按时间重新验证、图片优化、路由级代码分割默认就绑在一起。后台用不上这个包,内容站用得上,这是判断要不要上 Next 时比“喜不喜欢这个框架”更实在的标准。

客户端边界不能随手画

用 Next 的项目里,客户端组件的边界画在哪一层,直接决定多少代码会被打进浏览器,这条线不能图省事随手画。

一个常见的错误是在很靠上的布局组件上写 'use client',理由是“反正下面都要交互”。后果是它底下整棵子树都被拖进了客户端世界,bundle 明显变大,hydration 也变慢。更稳的做法是默认写服务端组件,只在真正需要状态、事件、浏览器 API 的那个小组件上才加 'use client',把这条线尽量往下压:

1// 默认服务端:只取数、渲染
2export default async function PostList() {
3  const posts = await getPosts()
4  return posts.map((p) => <PostRow key={p.id} post={p} action={<LikeButton id={p.id} />} />)
5}
1'use client'
2// 只有这个按钮需要交互,客户端代码就停在这里
3export function LikeButton({ id }) { /* ... */ }

这个调整看着小,但它决定了多少代码会被打进浏览器。review 代码时看到顶层布局挂 'use client',值得多问一句:真的整棵树都需要吗?

背后的机制是:'use client' 不是“这个组件在客户端跑”,而是“从这里开始,往下的整棵导入树都进客户端 bundle”。它是一道模块边界,不是组件边界。一旦某个文件标了 'use client',它 import 的所有东西——包括只是拿来格式化日期的工具函数、体积巨大的 markdown 解析库——都会被打包器顺着依赖图拽进浏览器。顶层挂一行,等于把整个 node_modules 里能被引用到的部分都判了“客户端”。

跟直觉反着来的一点是:服务端组件可以作为 children 或 props 传进客户端组件,而不会被“传染”成客户端组件。原因是父子之间传的是已经渲染好的 React 元素(一棵序列化后的树),不是组件函数本身。所以下面这种写法,<Comments> 仍然在服务端渲染、它的依赖也不会进 bundle,哪怕它被塞进了一个 'use client' 的壳里:

1// Tabs.tsx —— 客户端:只管交互
2'use client'
3import { useState } from 'react'
4
5export function Tabs({ left, right }: { left: React.ReactNode; right: React.ReactNode }) {
6  const [tab, setTab] = useState<'l' | 'r'>('l')
7  return (
8    <>
9      <nav>
10        <button onClick={() => setTab('l')}>评论</button>
11        <button onClick={() => setTab('r')}>相关</button>
12      </nav>
13      {tab === 'l' ? left : right}
14    </>
15  )
16}
1// page.tsx —— 服务端:把服务端组件当插槽传进去
2import { Tabs } from './Tabs'
3import { Comments } from './Comments'   // 仍然是服务端组件
4import { Related } from './Related'
5
6export default async function Page() {
7  return <Tabs left={<Comments />} right={<Related />} />
8}

记住这条“插槽下沉”的写法,比单纯把 'use client' 往下压更有用——很多看起来必须客户端化的布局,其实只要把需要交互的那层做成壳、把内容当 children 传进去就行。判断顺序应该是:先假设全是服务端,遇到 onClick/useState/useEffect/浏览器 API 才停下来,把那一个组件切成客户端,并尽量让它接收 children 而不是自己去 import 内容。

缓存不是一层,是四层叠在一起

Next 的 App Router 里最容易让人栽跟头的是缓存,根源是它不是"一个缓存",而是四层叠在一起,每层失效方式都不一样。一个常见场景是:后台改了配置、保存提示成功了,但前台页面上的数字刷新也不变;本地开发环境往往复现不出来,因为开发环境的缓存行为和生产不一样。根因通常是 Server Action 改完数据后,对应页面的数据没有重新验证,旧的还被缓存着,补一句重新验证就好了:

1'use server'
2import { revalidatePath } from 'next/cache'
3
4export async function updateConfig(formData) {
5  await saveConfig(formData)
6  revalidatePath('/dashboard')
7}

这类问题真正麻烦的地方在于,它“看起来一切正常”:接口成功、没报错、本地也正常,只有线上的真实用户看到旧数据。稳妥的做法是把页面按数据新鲜度分层来想——营销页、文档尽量长缓存;列表页分钟级重新验证;订单、账户、权限这类直接走动态、不缓存;后台改完即时生效的,一定要想清楚谁来触发重新验证。不为了“全部实时”放弃所有缓存,也不为了性能把强一致的数据缓存太久。

拆开来看,App Router 的缓存分四层,每层失效方式都不一样:

  • Request Memoization:同一次渲染里对同一个 fetch 自动去重,请求结束就没了,只活在单次请求内。
  • Data Cachefetch 结果的持久缓存,跨请求、跨用户,受 revalidaterevalidatePath/Tag 控制——上面那个场景栽的就是这层,数据存住了没人去敲它失效。
  • Full Route Cache:构建/首次访问时把静态路由的 HTML 和 RSC Payload 整个存下来,Data Cache 一失效它才跟着重建。
  • Router Cache:浏览器内存里的客户端缓存,软导航时直接复用,连服务器都不打。它有自己的过期时间,跟服务端那几层完全独立。

理解这个分层,最直接的收益是知道该敲哪一层。把按路径失效换成按 tag 失效通常更省事,因为一份数据往往散落在好几个页面上,挨个写 revalidatePath 既容易漏又耦合路由结构。给 fetch 打 tag、改完按 tag 一次性失效,干净得多:

1// 取数时打标签
2const products = await fetch('https://api.example.com/products', {
3  next: { tags: ['products'] },
4}).then((r) => r.json())
1'use server'
2import { revalidateTag } from 'next/cache'
3
4export async function updateProduct(id: string, data: FormData) {
5  await saveProduct(id, data)
6  // 所有用到 products 这份数据的页面,无论在哪个路由,一起失效
7  revalidateTag('products')
8}

还有个容易漏掉的细节:开发环境(next dev)默认几乎不缓存,而 next start 跑的是带满四层缓存的生产行为。所以“本地复现不出来”几乎是缓存类问题的标配,排查这类问题更可靠的做法不是开 dev,而是 next build && next start 在本地起生产模式,再配合 fetchcache/next 选项逐个确认——能在本地稳定复现,问题就解了一大半。一个更隐蔽的变种是:某个 fetch 不小心被判定成了静态(比如没带任何动态信号),整张路由被 Full Route Cache 固化,无论怎么 revalidateTag 数据都不动,因为根本没走到那层取数逻辑。这种情况得用 export const dynamic = 'force-dynamic' 或在请求里读 cookies()/headers() 把路由“拽”成动态,才肯重新算。

串行 await 会把服务端渲染写成服务端阻塞

RSC 的渲染规则是:组件里的 await 没 resolve,这棵子树就产不出 HTML。内容型站点的详情页很容易在这里踩坑——一个 async 服务端组件里如果串行 await 了商品、库存、推荐三个接口,即使每个接口单看都不慢,最慢的那个(比如推荐位偶尔抖到 2 秒)也会把整页的首字节顶住,表现为页面偶发性地“白屏好几秒才一次性出来”,而监控看每个接口都正常,问题不在接口本身,而在请求被排成了一条直线。

两个层面一起改。先并行无依赖的请求,别让它们排队:

1export default async function ProductPage({ params }: { params: { id: string } }) {
2  // 三个请求互不依赖,同时发出去,总耗时取决于最慢的那个而不是三者之和
3  const [product, stock, related] = await Promise.all([
4    getProduct(params.id),
5    getStock(params.id),
6    getRelated(params.id),
7  ])
8  return <ProductView product={product} stock={stock} related={related} />
9}

但更关键的是想清楚:推荐位慢,凭什么让用户连标题和价格都看不到?于是把慢的、非关键的部分拆进 Suspense,让框架用流式渲染先把主体推下去,慢的那块占位、好了再补上:

1import { Suspense } from 'react'
2
3export default async function ProductPage({ params }: { params: { id: string } }) {
4  // product 和 stock 都是首屏必需数据,并行取;推荐位慢,拆进 Suspense 不挡首屏
5  const [product, stock] = await Promise.all([
6    getProduct(params.id),
7    getStock(params.id),
8  ])
9  return (
10    <>
11      <ProductHeader product={product} stock={stock} />
12      <Suspense fallback={<RelatedSkeleton />}>
13        {/* 这个组件内部自己去 await getRelated,慢也只慢它自己 */}
14        <Related id={params.id} />
15      </Suspense>
16    </>
17  )
18}

底层是 React 把响应做成了可分块的流Suspense 包裹范围之外的内容先随首个 chunk 发出,包裹范围里面的等数据好了再以后续 chunk 推送,浏览器收到后就地替换占位。所以 Suspense 在 Next 里不只是“加载态语法糖”,它是切分首字节时间的手术刀——哪些必须进首屏、哪些可以晚点补,由 Suspense 包在哪一层决定。服务端组件里随手写 await 需要格外警惕:每一个没包在 Suspense 里的 await,都是在给首屏的关键路径加塞。

静态生成盖多广:generateStaticParams 和按需 ISR

内容型站点用静态生成时,另一个绕不开的问题是“到底要不要在构建时就把所有详情页都生成出来”。generateStaticParams 能在构建阶段告诉 Next 有哪些动态路由参数:

1export async function generateStaticParams() {
2  const posts = await getAllPostSlugs()
3  return posts.map((post) => ({ slug: post.slug }))
4}
5
6export default async function PostPage({ params }: { params: { slug: string } }) {
7  const post = await getPost(params.slug)
8  return <Article post={post} />
9}

这套机制在文章数量可控(几百到几千篇)时很合适:构建时全量生成 HTML,用户访问永远是纯静态命中。但文章数量一旦上到几万甚至更多,构建时间会线性变长,每次发版都要重新跑一遍全量生成,代价越来越不划算。这种规模更适合只在构建时生成一小部分“高频访问”的路径,剩下的交给运行时按需生成——dynamicParams 默认是 true,意味着没在 generateStaticParams 里列出的参数,首次访问时会走一次动态渲染并把结果缓存下来,之后的访问命中的就是缓存而不是每次重新渲染:

1export async function generateStaticParams() {
2  // 只预生成最近一个月的高频文章,其余走按需生成
3  const recentPosts = await getRecentPostSlugs({ days: 30 })
4  return recentPosts.map((post) => ({ slug: post.slug }))
5}
6
7export const dynamicParams = true

如果反过来想让没预生成的路径直接 404 而不是走动态渲染,把 dynamicParams 设成 false 即可,这在“路径必须来自一份已知列表,多余的一律当无效”的场景里更安全,比如商品详情页只允许已上架的 SKU 命中。

元数据和 OG 图也是内容站要认真对待的部分

内容型站点选 Next 的另一层实际收益是 metadata API。以前手写 SEO 相关标签,容易漏 og:image、漏 canonical,多语言站点还要处理 hreflang。App Router 的 generateMetadata 把这些收拢到一个函数里,且支持异步取数:

1export async function generateMetadata({ params }: { params: { slug: string } }) {
2  const post = await getPost(params.slug)
3  return {
4    title: post.title,
5    description: post.excerpt,
6    openGraph: {
7      title: post.title,
8      images: [post.coverImage],
9    },
10    alternates: {
11      canonical: `https://example.com/posts/${params.slug}`,
12    },
13  }
14}

这里有个容易忽略的性能点:如果 generateMetadata 和页面组件各自独立请求了同一份数据(比如都调用了 getPost),Next 会利用前面提到的 Request Memoization 自动去重,同一次渲染里这两处 fetch 只会真正打一次网络请求,不需要手动做缓存或者把数据提到更上层传下来。这也是为什么官方建议数据获取函数直接用 fetch,而不是绕过它自己实现一层网络请求封装——绕过去,Memoization 这层自动去重就吃不到了。

动态 OG 图这一年也是内容站常用的能力,next/og 可以用 JSX 语法直接生成图片,不需要额外的图片处理服务:

1import { ImageResponse } from 'next/og'
2
3export async function GET(request: Request) {
4  return new ImageResponse(
5    (
6      <div style={{ fontSize: 64, background: 'white', width: '100%', height: '100%' }}>
7        Hello
8      </div>
9    ),
10    { width: 1200, height: 630 },
11  )
12}

对博客、文档这类需要在社交媒体分享时展示定制封面图的场景,这个能力省掉了一整套图片生成的基础设施,属于“内容站默认能用上、后台项目基本用不到”的又一个例子。

我现在怎么判断要不要上

几年下来,我对 Next 的判断其实就收敛成几个问题,按顺序问:这个项目有没有陌生人首屏或 SEO 的诉求?内容是不是大体可缓存、不需要秒级实时?团队愿不愿意花成本去理解它的运行时和缓存模型?前两个有一个为“是”,它就值得上;都为“否”,尤其是纯后台,我会更倾向 Vite 那套轻量组合。

Next 不是更高级的 React,它是把一部分复杂度从你手里搬到了框架约定里。搬对了很省心,搬错了,你会花大量时间和它的约定较劲。我现在比几年前克制,但该上的时候也不犹豫——区别只是,我终于会先问“这个项目到底要不要服务端”,而不是默认它需要。