普通视图

发现新文章,点击刷新页面。
昨天以前首页

Next.js 请求最佳实践 - vercel 2026一月发布指南

作者 却尘
2026年2月9日 16:41

你打开商品详情页,转了 3 秒菊花才看到内容——慢在哪?

你点个"加购物车",页面卡了一下才反应——又慢在哪?

你加了几个功能,整个站越来越肥,首屏白得让人以为断网了——还是慢在哪?

大部分人的第一反应是:肯定是代码写得不够优雅,赶紧 useMemouseCallback 一把梭。

错了。

真正把你拖垮的,往往不是"组件重渲染了 2 次还是 3 次",而是两座更致命的大山:

  • 请求瀑布:本来能并行的请求,被你写成了串行,用户白等 600ms 起步
  • 客户端屎山:本来能在服务端干的活,全塞客户端,每个页面多背 300KB JS

Vercel 把这事儿说得很直白:你优化的顺序错了,再怎么抠细节都是白干。

先看两个要命的数字

数字 1:一个瀑布 = 白等 600ms

什么叫"请求瀑布"?看这段代码:

async function loadPage() {
  const user = await fetchUser()      // 等 200ms
  const product = await fetchProduct() // 又等 200ms  
  const inventory = await fetchInventory() // 再等 200ms
}

这三个请求明明互不依赖,结果你愣是让用户等了 600ms。改成并行呢?

const [user, product, inventory] = await Promise.all([
  fetchUser(),
  fetchProduct(), 
  fetchInventory()
])
// 总共只等 200ms

省了 400ms,比你优化一百个 useMemo 都管用。

还有更骚的操作:你的代码先 await fetchUserData(),但后面某个分支根本用不到这数据——结果不管走不走那个分支,都得先等完这个请求。白等的典范。

数字 2:每页多背 300KB = 长期税

你今天为了"方便",把数据请求、状态管理、第三方库全塞客户端。爽是爽了,代价是什么?

每个用户每次访问,都要下载这 300KB 的 JS,解析它,执行它。

这不是一次性成本,这是"长期税"——你写一次,所有用户永远买单,直到有人受不了来重构。

明确一点:Vercel 把这两件事排在所有性能优化的最前面,标注为 CRITICAL。

什么 useMemo、组件拆分、虚拟滚动——都得往后稍稍。因为你瀑布多 600ms,用户根本活不到看你"优雅的重渲染控制"。

请求选型

别管什么 RSC、Server Component、Server Action 这些名词吓人。你只需要记住:遇到场景,先问两句话。

第一问:这么干会不会让用户白等(制造瀑布)?
第二问:这么干会不会让包体越滚越大(养长期税)?

两个都不会?那才轮到你聊别的细节。

场景 1:打开页面就要数据 → 用 RSC

典型需求: 商品详情页、列表页、仪表盘

用来预防数据在客户端一层层触发,形成等待链。

// ❌ 错误示范(客户端瀑布)
function ProductPage() {
  const [user, setUser] = useState(null)
  const [product, setProduct] = useState(null)
  
  useEffect(() => {
    fetchUser().then(setUser)  // 先等这个
  }, [])
  
  useEffect(() => {
    if (user) {
      fetchProduct(user.id).then(setProduct) // 再等这个
    }
  }, [user])
  
  return <div>...</div>
}

这代码每一行都在喊"我在制造瀑布"。

正确姿势:RSC 在服务端并行拿数据

// ✅ app/products/[id]/page.tsx
export default async function ProductPage({ params }) {
  // 并行发起,一起等
  const [user, product, inventory] = await Promise.all([
    getUser(),
    getProduct(params.id),
    getInventory(params.id)
  ])
  
  return <ProductDetail user={user} product={product} inventory={inventory} />
}

核心思路: 能并行就并行,别让服务端也写出瀑布。数据拿齐了再渲染,客户端收到的就是带数据的 HTML。

场景 2:用户点按钮要写数据 → 用 Server Action

典型需求: 加购物车、提交表单、点赞、删除

杜绝把一次写操作拆成多段:写完拉数据、拉完算状态、算完再渲染……

// ❌ 错误示范(客户端拉长等待链)
async function handleAddCart() {
  await fetch('/api/cart', { method: 'POST', ... }) // 等
  const newCart = await fetch('/api/cart').then(r => r.json()) // 又等
  setCart(newCart) // 状态更新
  toast.success('已加入购物车') // 最后才提示
}

正确姿势:Server Action 一把梭

// ✅ app/products/actions.ts
'use server'

export async function addToCart(productId: string) {
  const user = await getCurrentUser()
  
  // 并行:写库 + 查库存
  const [cart, stock] = await Promise.all([
    db.cart.create({ userId: user.id, productId }),
    db.inventory.findUnique({ where: { productId } })
  ])
  
  revalidatePath('/cart')  // 刷新相关页面
  return { success: true, stock: stock.quantity }
}
// ✅ Client Component 里直接调
'use client'
import { addToCart } from './actions'

function AddToCartButton({ productId }) {
  return (
    <button onClick={async () => {
      const result = await addToCart(productId)
      toast.success(`已加入!剩余 ${result.stock} 件`)
    }}>
      加入购物车
    </button>
  )
}

核心思路: Server Action 就是"组件内的服务端入口"。该并行并行,写完直接 revalidatePath 刷新,别让客户端再发一圈请求。

场景 3:外部系统要打你 → 用 Route Handler (app/api)

典型需求: Stripe webhook、GitHub 回调、给 App 提供 API

为什么不能用 Action? 因为外部系统不认你的组件树,它只认一个 HTTP URL。

// ✅ app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const signature = req.headers.get('stripe-signature')
  
  // 验签
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    signature,
    process.env.STRIPE_WEBHOOK_SECRET
  )
  
  // 并行:写库 + 触发发货
  await Promise.all([
    db.order.update({ where: { id: event.data.object.metadata.orderId }, data: { status: 'paid' } }),
    triggerShipment(event.data.object.metadata.orderId)
  ])
  
  return Response.json({ received: true })
}

核心思路: Route Handler = 对外的 HTTP 边界。但内部逻辑照样要砍瀑布、控包体。

最容易踩的坑:Server Action 里绕圈打自己的 API

很多人会写成这样:

// ❌ 多此一举
'use server'
export async function createOrder(data) {
  const result = await fetch('http://localhost:3000/api/orders', {
    method: 'POST',
    body: JSON.stringify(data)
  })
  return result.json()
}

你人已经在服务端了,为什么还要绕 HTTP 一圈?

这么干的后果:

  • 多一跳网络请求(更容易瀑布)
  • 多一次序列化/反序列化
  • 多一层错误处理

正确姿势:直接调业务逻辑

// ✅ server/order.service.ts
export async function createOrder(data, userId) {
  // 校验、写库、触发通知...
  return db.order.create({ data: { ...data, userId } })
}

// ✅ app/orders/actions.ts
'use server'
import { createOrder } from '@/server/order.service'

export async function createOrderAction(formData) {
  const user = await getCurrentUser()
  return createOrder(parseFormData(formData), user.id)
}

什么时候才该用 /api?

  • 外部系统要调(webhook、移动端)
  • 需要自定义 Response(下载文件、streaming)
  • 需要标准 HTTP 语义(状态码、特殊 header)

内部写操作?直接 Action 调 Service,别绕。

速查表:一张图看完怎么选

场景 用什么 核心原则
打开页面要数据 RSC (Server Component) 并行拿数据,别串行等
页面内写数据 Server Action 别拉长等待链,别推客户端
外部系统回调 Route Handler (/api) 对外边界,内部逻辑照样砍瀑布
交互需要状态 Client Component 只放必须的,别啥都塞客户端

拿不准?回到两句话:

  1. 会不会多等 600ms?(瀑布)
  2. 会不会多背 300KB?(包体税)

实战目录长这样

app/
├── products/
│   ├── page.tsx           # RSC:并行拿数据渲染
│   ├── [id]/page.tsx      # RSC:详情页
│   └── actions.ts         # Server Actions:写操作
│
├── api/
│   └── webhooks/
│       └── stripe/
│           └── route.ts   # Route Handler:外部回调
│
server/                    # 业务逻辑层(Action 和 API 都调这里)
├── product.service.ts     # 组合逻辑、权限、事务
└── product.repo.ts        # 纯数据访问(DB/外部 API)

components/                # UI 组件
├── ProductCard.tsx        # Server Component(默认)
└── AddToCartButton.tsx    # Client Component("use client")

核心思路:

  • app/ 负责路由和 UI 组合
  • server/ 负责业务逻辑(可复用)
  • components/ 负责展示和交互

Action 和 Route Handler 都不写业务细节,都调 server/ 里的函数。这样:

  • 逻辑不重复
  • 测试更好写
  • 重构不伤筋动骨

最后

架构不是"我用了多少高级名词",是"我让用户少等了多少时间"。

先砍 600ms 的瀑布,再砍 300KB 的包体税。剩下的 useMemo、memo、虚拟滚动——等你把这两座大山移平了再说。

记住:能并行就并行,能服务端就服务端。

别等页面慢得用户骂娘了,才想起来"哦对,我好像写了个瀑布"。

参考:

  1. raw.githubusercontent.com/vercel-labs…
  2. github.com/vercel-labs…
❌
❌