写在前面

如果你还在把 Next.js 当成一个「支持 SSR 的 React 框架」,那你的认知至少落后了两个大版本。从 Pages Router 到 App Router,Next.js 的渲染模型经历了一次彻底的重写。这篇文章不讲 Hello World,不教你怎么搭项目,只聚焦三个核心渲染机制:SSR(服务端渲染)、RSC(React Server Components)、Streaming(流式渲染),以及它们背后的架构取舍。

SSR:不是新东西,但实现方式变了

Pages Router 时代的 SSR

在 Pages Router 时代,SSR 的逻辑很直观:用户请求一个页面,服务端调用 getServerSideProps 获取数据,然后通过 renderToString 把整个组件树渲染成 HTML 字符串,一次性发给客户端。

tsx
// Pages Router 的 SSR
export async function getServerSideProps(context) {
  const data = await fetch('https://api.example.com/posts')
  const posts = await data.json()
  return { props: { posts } }
}

export default function Page({ posts }) {
  return (
    <ul>
      {posts.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  )
}

这个模型的瓶颈很明显:瀑布式的阻塞。服务端需要等待所有数据就绪才能生成 HTML,浏览器需要等待整个 HTML 和 JS 加载完毕才能 hydration,页面中任何一个慢接口都会拖慢全局。更隐蔽的问题是,renderToString 是同步的——你没法在渲染过程中做任何并行的事情。

App Router 的 SSR:async component 原生支持

App Router 把 SSR 推进了一大步:组件本身可以是 async function。

tsx
// App Router 的服务端组件,无需 getServerSideProps
async function getPosts() {
  const res = await fetch('https://api.example.com/posts')
  return res.json()
}

export default async function Page() {
  const posts = await getPosts()
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

这看起来只是语法糖,但背后的架构变化是根本性的:组件即数据源。不再有 getServerSideProps 和组件之间的割裂,数据获取变成了组件树的一部分,每个组件自己声明数据依赖。

RSC:Server Components 才是真正的变革

如果说 SSR 是优化,RSC 就是范式转移。这两个概念经常被混为一谈,但它们的本质完全不同。

SSR vs RSC 的本质区别

维度 SSR RSC
运行时机 每次请求时在服务端渲染 在服务端运行,结果序列化为特殊格式
产物 HTML 字符串 RSC Payload(一种紧凑的数据流格式)
客户端交互 需要 hydration 才能交互 Server Components 不发送 JS 到客户端
状态 每次请求重新执行 无状态,不持有客户端状态

RSC 的核心思路是:有些组件根本不应该出现在客户端。一个渲染 Markdown 的博客正文、一个查询数据库的面板、一个调用内部 API 的组件——它们不涉及交互,不需要 useState,不需要 useEffect,那为什么要把它们的 JS 发到客户端再跑一遍 hydration?

tsx
// 这个组件只在服务端运行,不会出现在客户端 bundle 中
import { db } from '@/lib/db'
import { remark } from 'remark'

export async function ArticleContent({ id }: { id: string }) {
  const article = await db.article.findUnique({ where: { id } })
  const html = await remark().process(article.content)

  // 这段 JS 永远不会发送到客户端
  return (
    <article
      className="prose"
      dangerouslySetInnerHTML={{ __html: String(html) }}
    />
  )
}

RSC Payload 不是 HTML,而是一种紧凑的流式格式,描述了组件树的结构和最终渲染结果。客户端收到后,可以直接合并到客户端组件树中,不需要额外执行任何 JS。

客户端组件和服务端组件的边界

在 App Router 中,文件顶层的 'use client' 指令声明了客户端组件的边界。关键原则:客户端组件可以引入服务端组件,但不能反过来。

Streaming:让每个组件独立呼吸

如果说 RSC 改变了什么被渲染,Streaming 改变了什么时候被渲染。

Suspense 作为流式边界

在 App Router 中,Suspense 不仅是一个 loading 状态管理器,更是一个流式边界。RevenueChart 和 RecentOrders 会并行请求数据,各自独立 streaming 到客户端,哪个接口先返回,哪个区块就先出现在页面上。

从架构角度看 Streaming

传统的 SSR 是"全部或 nothing"——服务端要等到完整的 HTML 生成完毕才开始传输。Streaming 把渲染过程从批处理变成了管道。

code
传统 SSR:
请求 → 获取所有数据 → renderToString → 发送完整 HTML → hydration

Streaming SSR:
请求 → 获取部分数据 → 渲染部分内容 → 发送 → 继续获取 → 继续渲染 → 继续发送...

这个变化在慢接口或复杂页面上效果显著。假设一个页面有三个数据源:一个 50ms,一个 200ms,一个 800ms。传统 SSR 的 TTFB 至少是 800ms(最慢的接口决定一切)。Streaming 模式下,客户端可以在 50ms 后就看到第一部分内容。

缓存策略与架构总结

Next.js 15 引入了 'use cache' 指令,让缓存从"配置项"变成了"组件声明"。

把三个机制放在一起看,架构层次就很清晰了:

这三层是正交的,可以独立调整。你可以在 RSC 基础上同时使用 Streaming 和缓存,也可以只取其中一部分。

实战建议

  1. 默认用 Server Components,只在需要交互时加 'use client'
  2. 为每个独立的数据源包裹 Suspense 边界
  3. 使用 fetch 的缓存参数控制数据新鲜度
  4. 避免在客户端组件中包裹大体积的服务端组件树
  5. SSR 不是银弹——不依赖 SEO 的内部工具页面,SPA 或许更简单

总结

Next.js 的渲染机制已经从"服务端生成 HTML,客户端 hydration"进化到了"服务端组件 + 客户端组件混合渲染,数据流式到达"。理解 SSR、RSC 和 Streaming 的本质差异和配合方式,是写出高性能 Next.js 应用的基础。