你打开商品详情页,转了 3 秒菊花才看到内容------慢在哪?
你点个"加购物车",页面卡了一下才反应------又慢在哪?
你加了几个功能,整个站越来越肥,首屏白得让人以为断网了------还是慢在哪?
大部分人的第一反应是:肯定是代码写得不够优雅,赶紧
useMemo、useCallback一把梭。错了。
真正把你拖垮的,往往不是"组件重渲染了 2 次还是 3 次",而是两座更致命的大山:
- 请求瀑布:本来能并行的请求,被你写成了串行,用户白等 600ms 起步
- 客户端屎山:本来能在服务端干的活,全塞客户端,每个页面多背 300KB JS
Vercel 把这事儿说得很直白:你优化的顺序错了,再怎么抠细节都是白干。
先看两个要命的数字
数字 1:一个瀑布 = 白等 600ms
什么叫"请求瀑布"?看这段代码:
tsx
async function loadPage() {
const user = await fetchUser() // 等 200ms
const product = await fetchProduct() // 又等 200ms
const inventory = await fetchInventory() // 再等 200ms
}
这三个请求明明互不依赖,结果你愣是让用户等了 600ms。改成并行呢?
tsx
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
典型需求: 商品详情页、列表页、仪表盘
用来预防数据在客户端一层层触发,形成等待链。
tsx
// ❌ 错误示范(客户端瀑布)
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 在服务端并行拿数据
tsx
// ✅ 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
典型需求: 加购物车、提交表单、点赞、删除
杜绝把一次写操作拆成多段:写完拉数据、拉完算状态、算完再渲染......
tsx
// ❌ 错误示范(客户端拉长等待链)
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 一把梭
tsx
// ✅ 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 }
}
tsx
// ✅ 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。
tsx
// ✅ 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
很多人会写成这样:
tsx
// ❌ 多此一举
'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 一圈?
这么干的后果:
- 多一跳网络请求(更容易瀑布)
- 多一次序列化/反序列化
- 多一层错误处理
正确姿势:直接调业务逻辑
tsx
// ✅ 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 | 只放必须的,别啥都塞客户端 |
拿不准?回到两句话:
- 会不会多等 600ms?(瀑布)
- 会不会多背 300KB?(包体税)
实战目录长这样
bash
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、虚拟滚动------等你把这两座大山移平了再说。
记住:能并行就并行,能服务端就服务端。
别等页面慢得用户骂娘了,才想起来"哦对,我好像写了个瀑布"。
参考: