普通视图

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

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…

BFF 与 Next.js 的联系与组合

作者 codingWhat
2026年2月5日 01:13

最近在写一个Next.js的应用,其中API Routes被我当做轻量BFF,去做一些数据加工与处理,那么BFF 与 Next.js到底有什么样的联系呢?

一、概念先厘清

1. BFF(Backend for Frontend)

BFF 是为前端专门提供的一层后端,职责包括:

  • 接口聚合:把多个后端/微服务的调用合并成前端需要的少数接口。
  • 数据裁剪与适配:把后端 DTO 转成前端需要的结构,减少字段、改名、扁平化等。
  • 鉴权与会话:在 BFF 统一做登录态校验、权限校验,前端只关心「调一个接口」。
  • 协议与形态统一:前端只和 BFF 用 REST/JSON 等约定好的方式通信,不直接面对内部 RPC、消息队列等。

BFF 不负责 HTML 渲染,只负责「给前端提供数据接口」。

2. Next.js 与 SSR

Next.js 是一个 React 全栈框架,除了做前端 SPA,还提供:

  • 服务端渲染(SSR):在服务器上执行 React,生成 HTML 再返回给浏览器,首屏即完整内容,利于 SEO 和首屏性能。
  • API Routes:在 pages/api 下写接口,运行在 Node 里,对外提供 HTTP API,可当「小后端」用。

所以:

  • SSR 解决的是「谁在哪儿把页面渲染出来」的问题(服务器渲染 vs 浏览器渲染)。
  • BFF 解决的是「谁给前端提供接口、怎么聚合与适配」的问题。

二者一个偏「渲染与页面」,一个偏「接口与数据」,可以单独用,也可以一起用。


二、A项目中的 BFF 层:

在我们工作中,前端使用的接口由JAVA编写的BFF分层应用提供,在分层应用中去调用真正的内网接口。

2.1 架构概览

  • Web 层:Controller 暴露 HTTP 接口,路径多为 /xxx/api
  • Service 层:Facade 封装对下游业务服务的调用。

前端/移动端只认 BFF 的 URL,不直接调业务中台。

2.2 Controller:面向前端的接口与鉴权

以xxx申报记录查询为例(MobilexxxController):

@Controller
@RequestMapping("/xxx/xxxx/api")
public class MobilexxxController extends MobilexxxBaseController {

    @RequestMapping(value = "/query", method = RequestMethod.POST)
    @ResponseBody
    public MobilexxxDTO<Object> query(@RequestBody MobileuxxDTO<ZxxxRequestDTO> requestDTO) {
        // 1. 获取用户账户信息
        AccountDTO accountDTO = getAccount();
        // 2. 验证用户会话(BFF 统一鉴权)
        validateHelper.accountSessionCheck(accountDTO, ...);
        // 3. 从统一入参中取出业务数据,并注入用户信息
        ZxxxRequestDTO request = requestDTO.getData();
        request.setXm(accountDTO.getAccount().getName());
        // ...
        // 4. 调用 Facade,再统一封装返回格式
        Response<List<ZxxxResponseDTO>> response = mobilexxxFacade.query(request);
        return ResponseHandler.procResponseResult(response);
    }
}

可以看到 BFF 在这里做了:

  • 统一入参/出参MobilexxxDTO / MobileuxxDTO,前端只面对一种请求/响应形态。
  • 会话与鉴权getAccount()validateHelper.accountSessionCheck() 在 BFF 完成,前端无感知。
  • 数据注入:把当前用户姓名、证件号等写入请求再交给下游,前端不用传敏感信息。

2.3 Facade:聚合下游服务

Facade 不实现业务,只做「转发 + 聚合」:

@Service
public class MobilexxxFacade {
    @Autowired
    private RestfulClient restfulClient;
    @Autowired
    private RestfulUrlHelper restfulUrlHelper;

    public Response<List<ZxxResponseDTO>> query(ZxxRequestDTO request) {
        return restfulClient.postForJson(
            restfulUrlHelper.getxxxnwUrl("xxxx/query"),
            request, Response.class, List.class, ZxxResponseDTO.class);
    }
}

其他 Facade同理:BFF 通过 RestfulClient 调远端服务,对前端只暴露「一个接口、一种协议」。

小结:app 这一层是典型的 BFF——独立部署的 Java 服务,只提供 API,不负责页面渲染;职责是鉴权、聚合、适配,方便前端/移动端调用。


三、B项目中的 Next.js 与 SSR:

xxx-Next 是 Next.js 应用,同时用到了 SSRAPI Routes(充当轻量 BFF)

3.1 SSR:首屏服务端取数与渲染

使用 getServerSideProps 每次请求时在服务端拉取数据并渲染:

export default function GetData(props: PropsType) {
  const { err, data, msg = '' } = props
  // ...
  return (
    // ...
  )
}

export async function getServerSideProps(context: any) {
  const { id = '' } = context.params
  const data = await getDataById(id)  // 服务端请求接口
  return { props: data }
}

流程是:

  1. 用户请求 /data/xxx;
  2. Next 在服务器执行 getServerSideProps,调用 getDataById(id);
  3. 拿到数据后,在服务器上渲染 React,得到 HTML;
  4. 把 HTML 和序列化好的 props 一起返回给浏览器;

这样首屏就是「带数据的完整 HTML」,无需等客户端再发请求才出内容,这就是 SSR。这里 Next.js 的角色是「渲染层 + 在服务端发起数据请求」,数据来源也可以换成调用上面那套 Java BFF。

3.2 API Routes:"薄" BFF

点击提交按钮时,指向的是 Next 自己的接口:

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  // 数据加工
  const data = genData(req.body)  // 把表单 body 转成后端需要的结构
  const resData = await postData(data)
  // ...
}

这里 Next.js 的 API Route 做的是:

  • 接收: 传入的数据,如表单数据;
  • 转换genData(req.body) 把表单字段整理成接口需要的结构;
  • 转发postData(data) 再请求真正的后端。

也就是说,API Route 在这里扮演了一层薄 BFF:对前端暴露简单的一个 POST /api/data,内部做参数适配和转发,前端不直接调外部后端。

3.3 数据流小结

  • :浏览器 → Next 服务端 → getServerSidePropsgetDataById → 外部接口→ 服务端渲染 HTML → 返回。
  • :浏览器 POST 到 Next 的 /api/data → API Route 整理参数并 postData() → 再请求后端→ 根据结果处理。

SSR 解决「从哪拿数据、在哪渲染」;API Route 解决「提交时谁来做参数转换和转发」。两者都在 Next 里,但职责不同。


四、BFF 与 Next.js(SSR)的区别

维度 BFF(如 app 中的 Java 层) Next.js(SSR + API Routes)
主要职责 为前端提供聚合/适配后的 API 渲染 HTML 页面 + 可选的 API 端点
是否渲染 不渲染,只返回 JSON 等数据 服务端执行 React,输出 HTML
典型部署 独立服务(如 Java 进程、容器) Node 进程,同一应用里既有页面也有 API
技术栈 任意后端语言(本项目为 Java) Node + React(Next.js)
鉴权位置 常在 BFF 统一做 可在 API Route 或 getServerSideProps 里做
适用场景 多端复用同一套接口、多后端聚合 需要 SEO、首屏性能的前端应用

简言之:BFF 是「专门给前端的接口层」;Next.js SSR 是「在服务器上把页面渲染出来的方式」。一个偏数据与接口,一个偏页面与渲染。


五、BFF 与 Next.js 的联系与组合

  1. Next.js API Routes 本身可以当作轻量 BFF
    如 xxx-Next 的 /api/data:接收整理参数,再调后端。适合逻辑简单、不需要多语言/多后端聚合的场景。

  2. Next.js 的 SSR 可以消费 BFF
    getServerSidePropsgetStaticProps 里,用 getDataById 这类函数去请求「真正的 BFF」(例如 app 里的 xxx/xxx/api),而不是直接调业务中台。这样:

    • 鉴权、聚合在 BFF 完成;
    • Next 只负责「要什么数据、怎么渲染」,职责清晰。
  3. 组合方式示例

    • 把 xxx-Next 里 ajax.tsHOSTgetDataById 的 URL 指向 app 的 BFF 地址,数据就从 Java BFF 来。
    • 提交仍可先到 Next 的 /api/data,再由 API Route 调 BFF 的提交接口,这样前端只和 Next 打交道,BFF 由 Next 在服务端/API 里调用。

于是可以形成:浏览器 ↔ Next.js(SSR + API Routes)↔ BFF(app)↔ 业务服务。BFF 管「接口与数据」,Next 管「页面与一次转发/适配」。


六、总结

  • BFF:面向前端的接口层,做聚合、适配、鉴权,不负责渲染;本项目中 app 的 Controller + Facade 是典型实现。
  • Next.js SSR:在服务端按请求拉数据并渲染 React 成 HTML,首屏快、利于 SEO;xxx-NextgetServerSideProps 就是 SSR。
  • 区别:BFF 是「接口层」,Next.js(SSR)是「渲染方式」;BFF 可独立于前端技术栈部署,Next 是前端框架自带服务端能力。
  • 联系:Next 的 API Routes 可当薄 BFF 用;SSR 的数据来源可以、也适合来自 BFF;二者组合能同时获得「清晰的数据接口层」和「更好的首屏与 SEO」。

结合项目:app 负责「给前端/移动端提供统一、安全、好用的 API」;xxx-Next 负责「用 Next.js 把页面做出来(SSR),并用 API Route 做提交时的薄 BFF」。理解这一点,就能在架构上分清 BFF 与 Next.js(SSR)各自解决什么问题、如何配合使用。

❌
❌