首页 / React 学习笔记 / 44

REACT · Vol.VII · LESSON 44 · AI 时代的前端进阶

Next.js 服务端组件:一次范式转移

进阶核心#Next.js#RSC

先算两笔账:SPA 取数的成本

上一篇第 43 篇说,三阶段的终点是全栈闭环,缝合线叫服务端组件(RSC)。缝合之前,先看清它要治的病。第 32 篇的取数模式——页面挂载、useEffect 发请求、loading、渲染——是标准 SPA 姿势,把账摊开看有两笔。

第一笔:取数瀑布。用户看到的每个字节,都要走完「下载 HTML → 下载 JS → 执行 → 发请求 → 等响应」整条链;嵌套取数更糟——A 请求的结果决定 B 请求的参数,只能串行:

ProfilePage.tsx · 第 32 篇的模式:两连跳,串行瀑布(类型定义从略)
import { useEffect, useState } from "react";

export default function ProfilePage() {
  const [profile, setProfile] = useState<Profile | null>(null);
  const [orders, setOrders] = useState<Order[]>([]);

  useEffect(() => {
    // 第一跳:拿用户
    fetch("/api/me").then((r) => r.json()).then(async (me) => {
      setProfile(me);                // 第一次渲染:页面这时才「活」
      // 第二跳:拿 uid 再查订单——串行多等一个来回
      const res = await fetch(`/api/orders?uid=${me.id}`);
      setOrders(await res.json());   // 第二次渲染
    });
  }, []);

  if (!profile) return <p>加载中……</p>;
  return (
    <div>
      <h2>{profile.name}</h2>
      <ul>{orders.map((o) => <li key={o.id}>{o.no}</li>)}</ul>
    </div>
  );
}

首屏 = 两次网络往返 + JS 执行 + 两个接口耗时。而接口就在你自己部署的服务器上——数据明明坐在服务端旁边,却要等浏览器跑来「领」

第二笔:bundle 膨胀。SPA 把整棵组件树打包成 JS 发给浏览器,哪怕 90% 的组件是纯展示、一个事件都没有。第 39 篇的代码分割是「少发」,治标——纯展示的组件根本不需要发。

后端类比:让离数据最远的一端发起数据访问

SPA 的默认部署形状,相当于把整段 Service 层下发给浏览器,让它替服务端跑一遍取数流程——而连接池、内网带宽、密钥全在服务端手里。RSC 要做的,就是把取数这件事搬回数据旁边。

App Router:组件默认在服务端执行

Next.js 14/15 的 App Router(app/ 目录)里,组件默认是 Server Component:写成 async 函数,直接 await 取数,取完直接 return JSX。没有 useEffect,没有首屏 loading,没有跨域:

app/posts/page.tsx · 没写 'use client',所以在服务端跑
import Link from "next/link";

export default async function PostsPage() {
  // 这段 fetch 在服务器上执行——不是浏览器!
  const res = await fetch("https://api.example.com/posts");
  const posts: Post[] = await res.json();

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>
          <Link href={`/posts/${p.id}`}>{p.title}</Link>
        </li>
      ))}
    </ul>
  );
}

浏览器视角:发出去的请求,拿回来的 HTML 已经带着完整内容——取数、渲染都在服务端完成后才发货。两笔账,一笔销掉(一次 await 搞定,没有瀑布),一笔大幅缓解(这个组件零 JS 发往浏览器)。

后端类比:async Controller 直接 return UI

Spring MVC 里你写 @GetMapping("/posts") 返回视图名 + Model,模板引擎在服务端填好 HTML 再发出去。Server Component 就是「async Controller 直接 return UI」——数据不出内网、密钥不进 bundle、省掉只为自家页面服务的转发层,SEO 也顺带变好(HTML 自带内容)。

'use client':一条客户端边界

组件默认在服务端跑,那 useState、useEffect、onClick 这些浏览器专属的东西怎么办?在文件顶部写一行 'use client',声明「从这里开始进入客户端」。这行声明叫客户端边界(client boundary)。

两个语义钉准。其一,'use client' 标的是「边界的入口」,不是「在哪渲染」:从被声明的文件开始,它 import 进来的所有子组件整棵子树都进 bundle(第 39 篇的分包思维又用上了)。其二,边界内外能力互斥:边界才有 Hooks、事件、浏览器 API(第 9 篇的生命周期全在里面);边界才有 async 取数、直连数据库、读密钥。看组合:

app/posts/page.tsx · 服务端:取数与布局
import LikeButton from "./LikeButton";

export default async function PostsPage() {
  const posts = await db.post.findMany();  // 直连数据库(示意写法)

  return (
    <main>
      {posts.map((p) => (
        <article key={p.id}>
          <h3>{p.title}</h3>
          <LikeButton initialCount={p.likes} />  // 跨边界:只能传可序列化数据
        </article>
      ))}
    </main>
  );
}
app/posts/LikeButton.tsx · 客户端:交互与状态
"use client";

import { useState } from "react";

export default function LikeButton({ initialCount }: { initialCount: number }) {
  const [likes, setLikes] = useState(initialCount);  // Hooks 只在边界内合法(第 4 篇)

  return <button onClick={() => setLikes(likes + 1)}>赞 {likes}</button>;
}

能力边界一张表钉死:

能力Server 组件Client 组件
async/await 直接取数可以,默认姿势不行,走 useEffect / 框架取数(第 32/33 篇)
直连数据库、读密钥可以不行(密钥想带也带不过去)
useState / useEffect / useRef不行可以(第 9/12 篇)
onClick / onChange 事件不行(第 6 篇的事件没地方挂)可以
发往浏览器的 JS整棵边界子树

接着是高频报错:跨边界的 props 必须可序列化——string、number、boolean、普通对象、数组、Date 可以;函数、class 实例传不过去,直接报 Functions cannot be passed directly to Client Components。想传「行为」?用第 25 篇的组合思想:把服务端渲染好的 JSX 作为 children 穿过边界,这是官方推荐解法。

后端类比:边界是一条 RPC 接口,只能传 DTO

跨边界传 props 本质是跨进程通信:能传 DTO(可序列化数据),传不了行为——你从没想把一个 lambda 序列化后发给远端服务。服务端组件像 Controller/Service,客户端组件是「送出去的富客户端部件」:部件里可以带数据,不能带服务端的方法。

一句心法:「默认服务端,交互才上客户端」。写组件前先问——它需要 useState 或 onClick 吗?不需要,就让它留在服务端,少一份 bundle。

直连数据库,与「岛」的组合套路

先做架构减法。传统 SPA 里页面要调接口,于是写一个只做转发的 BFF Controller:浏览器 → BFF → Service → DB。RSC 时代,Server Component 在服务端执行、直接查库(上一站 page.tsx 里的 db.post.findMany()),这层「为自家页面转发」的接口变薄甚至消失——对外 API(给 App、第三方用的)照旧存在,但不再为自家页面而写。交互区域则拆成小客户端组件——「岛」:

app/posts/FavoriteButton.tsx · 客户端岛:交互时再回服务端
"use client";

import { useState } from "react";

export default function FavoriteButton({ postId }: { postId: number }) {
  const [saved, setSaved] = useState(false);

  async function save() {
    await fetch(`/api/posts/${postId}/favorite`, { method: "POST" });
    setSaved(true);
  }

  return <button onClick={save} disabled={saved}>{saved ? "已收藏" : "收藏"}</button>;
}

这个套路叫岛屿(islands):服务端渲染页面主体这片「海面」,把必须交互的区域拆成小客户端组件嵌进去——受控输入、轮播、拖拽这些有事件的地方就是岛(第 24 篇的受控组件天然是岛)。和第 37 篇的 memo 对比着记:memo 省的是重复渲染,岛屿省的是根本不发,量级完全不同。

服务端组件渲染的页面主体 零 JS · 直连数据库取数 · 密钥不出内网 点赞按钮 客户端 筛选下拉 客户端 收藏 客户端 评论输入框 客户端 只有岛进 bundle 主体零 JS 有事件的地方就有岛,其余留在服务端
图 1岛屿模式:服务端渲染主体海面,交互组件是嵌在海面上的小岛——只有小岛进 JS bundle。
反例预警:把整页都标上 'use client',RSC 就白开了——等于花钱买了台服务器,继续跑 SPA。发现自己在页面顶部写 'use client',先想想是不是只有那个搜索框需要它。

收官:缝合,然后开始流

最后补一块拼图:服务端取数有快有慢,把慢的部分包在 <Suspense fallback={骨架}> 里,Next 先把页面其余部分发出去,数据到了再补上——这就是 streaming SSR。它的机制下一篇第 45 篇正好展开,因为 LLM 响应本质上是同一件事:服务端不断产生,前端不断接收

收一张「组件放哪一端」的速查表,写代码前对一眼:

需求放哪端原因
查数据库、调内部服务、读密钥Server内网与密钥天然不可外泄,还省一跳转发
纯展示(文章、列表、页脚)Server(默认)零 JS 发往浏览器,bundle 直接受益
点击、输入、拖拽等交互Client事件只存在于浏览器(第 6 篇)
受控表单输入Client受控组件依赖 useState(第 24 篇)
轮播、主题切换等浏览器能力ClientlocalStorage 等浏览器 API(第 12 篇)
核心要点
  • RSC 治两笔账:取数瀑布(服务端一次 await 搞定)和 bundle 膨胀(纯展示组件零 JS)。
  • App Router 下组件默认是 Server Component:async + await 直接取数,等于「async Controller 直接 return UI」。
  • 'use client' 是客户端边界的入口:边界内才有 Hooks、事件、浏览器 API;边界以下整棵子树进 bundle。
  • 跨边界 props 只能传可序列化数据,函数传不过去;传「UI」用 children 与组合(第 25 篇)。
  • 组合套路:服务端页面负责取数与布局,交互区域拆成小客户端「岛」——只有岛进 bundle。

本章回顾

范式转移的本质,是把「代码搬到浏览器里等数据」换成「组件搬到数据旁边渲染」。SPA 时代,页面在浏览器里醒来才发现自己没数据,只能一层层发请求去要;RSC 时代,组件在服务端执行完——数据已查好、HTML 已渲染——才发货,bundle 里只剩真正交互的岛。对你的 Java 直觉来说这不算新知识:Controller、Service、视图渲染被重新装进一个 async 函数。而「服务端不断产生、客户端不断接收」的极致形态,就是 LLM 的逐 token 响应——下一篇第 45 篇,我们用 SSE 把它一路流到打字机界面上。

Comments · 评论