REACT · Vol.VII · LESSON 44 · AI 时代的前端进阶
Next.js 服务端组件:一次范式转移
先算两笔账:SPA 取数的成本
上一篇第 43 篇说,三阶段的终点是全栈闭环,缝合线叫服务端组件(RSC)。缝合之前,先看清它要治的病。第 32 篇的取数模式——页面挂载、useEffect 发请求、loading、渲染——是标准 SPA 姿势,把账摊开看有两笔。
第一笔:取数瀑布。用户看到的每个字节,都要走完「下载 HTML → 下载 JS → 执行 → 发请求 → 等响应」整条链;嵌套取数更糟——A 请求的结果决定 B 请求的参数,只能串行:
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,没有跨域:
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 发往浏览器)。
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 取数、直连数据库、读密钥。看组合:
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>
);
}
"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 穿过边界,这是官方推荐解法。
跨边界传 props 本质是跨进程通信:能传 DTO(可序列化数据),传不了行为——你从没想把一个 lambda 序列化后发给远端服务。服务端组件像 Controller/Service,客户端组件是「送出去的富客户端部件」:部件里可以带数据,不能带服务端的方法。
直连数据库,与「岛」的组合套路
先做架构减法。传统 SPA 里页面要调接口,于是写一个只做转发的 BFF Controller:浏览器 → BFF → Service → DB。RSC 时代,Server Component 在服务端执行、直接查库(上一站 page.tsx 里的 db.post.findMany()),这层「为自家页面转发」的接口变薄甚至消失——对外 API(给 App、第三方用的)照旧存在,但不再为自家页面而写。交互区域则拆成小客户端组件——「岛」:
"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 省的是重复渲染,岛屿省的是根本不发,量级完全不同。
收官:缝合,然后开始流
最后补一块拼图:服务端取数有快有慢,把慢的部分包在 <Suspense fallback={骨架}> 里,Next 先把页面其余部分发出去,数据到了再补上——这就是 streaming SSR。它的机制下一篇第 45 篇正好展开,因为 LLM 响应本质上是同一件事:服务端不断产生,前端不断接收。
收一张「组件放哪一端」的速查表,写代码前对一眼:
| 需求 | 放哪端 | 原因 |
|---|---|---|
| 查数据库、调内部服务、读密钥 | Server | 内网与密钥天然不可外泄,还省一跳转发 |
| 纯展示(文章、列表、页脚) | Server(默认) | 零 JS 发往浏览器,bundle 直接受益 |
| 点击、输入、拖拽等交互 | Client | 事件只存在于浏览器(第 6 篇) |
| 受控表单输入 | Client | 受控组件依赖 useState(第 24 篇) |
| 轮播、主题切换等浏览器能力 | Client | localStorage 等浏览器 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 · 评论