楼层: 首页/ 软件技术/ 前端进阶/ React Server Components 与 Streaming SSR
RSC

React Server Components 与 Streaming SSR

RSC · Server / Client Boundary · Suspense Streaming · Server Actions

Server Components 是 React 近十年最大的一次模型变化,也是被误解最多的一次。它要解决的核心问题有两个:一是把"只为了取数据而写的组件"从客户端 bundle 里彻底删掉,二是让服务端能直接连数据库、不用再绕一圈 API。但它不是"SSR 的升级版",也不意味着"以后都写服务端组件"——它是一套服务端与客户端的分工约定。这一章讲清边界怎么划、数据怎么流、缓存怎么算,以及那几个"一写就报错"的经典坑。

RSC 到底在解决什么问题

先看传统 SPA 和 SSR 各自的痛,你才能理解 RSC 的设计动机。传统 SPA:首屏要等 JS 下载、解析、执行完才渲染;SSR:服务端渲染出 HTML 解决了首屏,但 hydrate 时又要把同样的组件在客户端跑一遍,而且数据还是要通过 API 拿。

维度纯 CSR(SPA)传统 SSRRSC + Streaming
首屏 HTML基本没有内容,等 JS 执行有完整 HTML,首屏快有 HTML,且可以"先返回骨架、数据到了再流式补上"
客户端 JS 体积全部组件都打进 bundle全部组件都要 hydrate,bundle 一样大服务端组件的代码完全不进 bundle,体积显著变小
数据获取客户端 fetch,瀑布式请求服务端预取,但组件里还是要写 getServerSideProps 之类的胶水组件里直接 await,可以并行,可以流式
能否直连数据库不能,必须走 API能,但要经过框架约定的数据函数能,直接在组件里查库,不需要中间 API 层
密钥安全危险:环境变量容易被打进前端包安全安全:服务端组件里的代码根本不会到浏览器
交互能力完整完整(hydrate 之后)只有客户端组件有交互;服务端组件不能有 state 和事件

论关键洞察:不是所有组件都需要在浏览器里跑

① 回想一下你的页面里有多少组件是"纯展示"的:商品标题、文章正文、列表项、页脚、面包屑……它们拿到数据只是渲染 HTML,没有任何交互。但传统 CSR/SSR 会把它们的代码连同依赖的库、格式化函数、甚至 markdown 解析器一起打进 bundle,然后让浏览器再执行一遍。

② 更糟的是,数据只能通过 API 绕一圈:组件想拿数据,就得先请求 /api/products,服务端再去查库。为了这一层 API,你要写路由、定 DTO、做鉴权、处理错误。如果这个"API"根本没有别的消费者,那它就是纯粹的中间层成本。

③ RSC 的思路:把这类组件标记为"服务端组件",它们在服务端执行、把渲染结果(一种叫 RSC Payload 的序列化格式)流给客户端。客户端只拿到"渲染结果",不需要这些组件的代码,也不需要它们的依赖。这就是 bundle 变小的根本原因,也是它和 SSR 的本质区别——SSR 是"在服务端先渲染一遍,客户端还要再渲染一遍";RSC 是"这个组件的代码永远不到客户端"。

Server / Client 边界与 'use client'

这是最容易搞混的一点:在 App Router 里,所有组件默认都是服务端组件。'use client' 不是"把某个组件变成客户端组件",而是在模块图上划一条边界线——从这一行开始往下,所有 import 进来的模块都进入客户端 bundle。

边界线的实际含义:一个页面里两种组件共存

// ============ app/products/[id]/page.tsx —— 默认是服务端组件 ============ // 没有 'use client',所以:不能写 useState、不能绑 onClick、可以 async/await import { db } from "@/lib/db"; import AddToCartButton from "./AddToCartButton"; // 客户端组件 import Reviews from "./Reviews"; // 服务端组件 // async 组件:可以直接 await 数据,不用 useEffect export default async function ProductPage({ params }: { params: { id: string } }) { // 直接查库,不需要经过 /api 层,DATABASE_URL 也不会泄漏到浏览器 const product = await db.product.findUnique({ where: { id: params.id } }); if (!product) notFound(); // 触发最近的 not-found.tsx // 格式化用到的库(比如 markdown 渲染器)只留在服务端,不进 bundle const html = await renderMarkdown(product.description); return ( <article> <h1>{product.name}</h1> <div dangerouslySetInnerHTML={{ __html: html }} /> {/* ✅ 服务端组件可以直接渲染客户端组件,并把可序列化的数据当 props 传下去 */} <AddToCartButton productId={product.id} price={product.price} /> {/* ✅ 也可以渲染其他服务端组件,继续 await 数据 */} <Reviews productId={product.id} /> </article> ); } // ============ app/products/[id]/AddToCartButton.tsx —— 客户端组件 ============ "use client"; // 边界线!这行必须是最顶部(注释之前也可以) import { useState } from "react"; import { addToCart } from "@/lib/cart"; // 有交互 → 必须是客户端组件 export default function AddToCartButton({ productId, price }: { productId: string; price: number }) { const [count, setCount] = useState(1); const [pending, setPending] = useState(false); return ( <div> <button onClick={() => setCount(c => c + 1)}>数量 {count}</button> <button disabled={pending} onClick={async () => { setPending(true); await addToCart(productId, count); setPending(false); }} > 加入购物车(¥{price * count}) </button> </div> ); }
能力Server Component(默认)Client Component(加 'use client')
async / await 取数据可以,直接在组件里 await不行。要用 useEffect 或数据请求库
useState / useReducer不可以可以
useEffect / 生命周期不可以可以
事件处理(onClick 等)不可以可以
浏览器 API(window / localStorage)不可以(服务端没有)可以
直接访问数据库 / 读取密钥可以,安全不可以(代码会到浏览器)
能否被另一种组件渲染可以被客户端组件通过 children 传入,但不能被 import可以被服务端组件渲染
代码是否进入客户端 bundle不进(这是最重要的收益)进(以及它 import 的一切)
三条边界规则,记牢就不会写错

规则一:'use client' 是"边界",不是"标记"。你写在文件顶部,它声明的是"这个文件以及它 import 的所有模块都进入客户端"。所以千万不要在一个共享的工具文件顶部随手加 'use client'——它会把整条 import 链拖进 bundle。反过来,如果一个客户端组件要引用服务端组件,不能用 import,只能通过 props 把渲染好的元素传进去。

规则二:props 必须可序列化。服务端组件传给客户端组件的 props,要能被序列化(字符串、数字、布尔、数组、普通对象、Date、Promise 等)。函数、class 实例、Symbol 不能直接传(Server Actions 是例外,它本质是引用)。所以"把一个回调函数传给客户端组件让它在客户端调服务端方法"这种写法,必须用 Server Action 或 route handler。

规则三:把边界尽量往下推。这是实践中最重要的一条。反例:整个页面加 'use client',为了一个按钮的点击,把整个页面的数据获取和渲染都变成客户端逻辑,RSC 的收益全部丢掉。正例:页面(服务端)→ 卡片列表(服务端)→ 加入购物车按钮(客户端)。边界越靠叶子节点,越少代码进 bundle。

Streaming SSR 与 Suspense:先给骨架,再补内容

传统 SSR 有一个尴尬:必须等所有数据都准备好,才能返回 HTML。一个页面里有个慢接口(比如推荐模块要 800ms),整个页面的 TTFB(首字节时间)就被拖到 800ms,用户盯着白屏。Streaming 把这个模型改了:先把不依赖慢数据的部分立即流给浏览器,慢的部分用 Suspense 包起来,数据好了再流式补充,浏览器自动把它填到正确位置。

Streaming 的写法:用 Suspense 把慢的部分隔离出来

// ============ app/products/[id]/page.tsx ============ import { Suspense } from "react"; export default async function ProductPage({ params }: { params: { id: string } }) { // 快数据:直接 await,它会成为首屏 HTML 的一部分 const product = await getProduct(params.id); // 假设 20ms return ( <article> <h1>{product.name}</h1> <p>¥{product.price}</p> {/* 慢数据:不要在这里 await,而是交给子组件 + Suspense 包住 */} {/* 关键:Suspense 的 children 是一个"未 await 的异步组件" */} <Suspense fallback={<Skeleton rows={3} />}> <Recommendations productId={params.id} /> </Suspense> <Suspense fallback={<p>评论加载中…</p>}> <Reviews productId={params.id} /> </Suspense> </article> ); } // ============ app/products/[id]/Recommendations.tsx —— 慢组件 ============ // 它自己 await 慢数据,因为上面没有 await 它,所以它的等待不会阻塞首屏 async function Recommendations({ productId }: { productId: string }) { const list = await slowRecommendApi(productId); // 假设 800ms return <ul>{list.map(i => <li key={i.id}>{i.name}</li>)}</ul>; } // ============ 时序对比(这才是 Streaming 的价值)============ // 传统 SSR : 0ms ──[等 800ms 拿全部数据]──> 800ms 首字节,然后才开始下载 JS // 用户看到白屏 800ms // // Streaming : 20ms 首字节,HTML 立刻到达,浏览器已经能画出标题和价格 // 骨架(Skeleton)立即显示,页面"有内容了" // 820ms 推荐模块的 HTML 块流到达,被自动替换掉骨架 // 感知上的差距是"白屏 800ms" vs "立即有内容 + 局部稍后补齐" // ============ 别忘了 loading.tsx —— 路由级别的自动 Suspense ============ // app/products/[id]/loading.tsx 会自动包住整个 page,页面导航时立刻显示它 export default function Loading() { return <ProductSkeleton />; }

论Streaming 的两个容易忽略的前提

① 它依赖 HTTP 的分块传输。服务端先 flush 一部分 HTML,后续继续写。所以反向代理必须关闭响应缓冲(Nginx 里要确认没有开 proxy_buffering on 并且没有配 compression 把整个响应憋住),否则"流式"会在代理层被吃掉,退化成"等全部完成后一次性返回"。这是上线后"流式没生效"的第一大原因。

② Suspense 的边界要在"数据边界"上,而不是随便包。如果你把整个页面都包进一个 Suspense,那就等于没有流式(还是要等最慢的那个)。正确做法是按数据块的耗时分组:快的一起进首屏,慢的各自独立包 Suspense,互不阻塞。另外,多个 Suspense 并行时,谁先好谁先显示——这也是为什么要把"评论"和"推荐"分成两个 Suspense,而不是包在一起。

③ 别为了流式而流式。如果页面所有数据都在 100ms 内拿完,拆成一堆 Suspense 只会让骨架闪一下再被替换,反而造成视觉抖动。判断标准:某个数据块的耗时明显长于其他(比如超过 300ms 的差距),才值得单独包。

App Router 的约定文件与缓存语义

Next.js 的 App Router 用"约定文件"替代了手写配置。理解这张表,你就知道该把什么代码放在哪个文件里。同时,缓存是 App Router 里最反直觉的部分,必须单独拿出来讲。

约定文件作用要点
layout.tsx共享布局,在路由切换时保持挂载不重新渲染适合放导航栏、侧边栏、全局 Provider。注意:layout 里不要放需要随路由变化的状态,它不会重新执行。可以做数据获取(比如取当前用户),但不会因导航而刷新。
page.tsx路由对应的页面内容路由切换时重新渲染。是 async 组件的主要落点。
loading.tsx该路由段的加载态,自动包一层 Suspense用户点击导航后立刻显示,是感知速度的关键。它包住的是整个 page,所以想更细粒度就用页内的 <Suspense>。
error.tsx该路由段的错误边界必须是客户端组件(需要 reset 函数来重试)。只捕获渲染期的错误,捕获不到 layout 自身的错误(那要用上层的 error)。
not-found.tsx404 页面配合代码里调用 notFound() 触发。适合"资源不存在"这类语义明确的场景。
route.ts同目录下定义 API 端点当你要给"外部系统或客户端 JS"提供接口时才需要它。服务端组件能直接查库的话,通常不必写 route.ts。
middleware.ts请求进入前的中间件(鉴权、重定向、A/B)运行在 Edge Runtime,能力受限(不能连数据库)。适合做"读 Cookie 决定跳转"这类轻逻辑。

Server Actions 与缓存:写操作怎么写、数据什么时候失效

// ============ app/products/[id]/actions.ts —— Server Actions ============ "use server"; // 声明这个文件里导出的函数都是服务端动作,可以被客户端直接调用 import { revalidatePath, revalidateTag } from "next/cache"; import { db } from "@/lib/db"; import { auth } from "@/lib/auth"; // 服务端动作:本质是"由框架生成的、类型安全的 RPC 调用" // 客户端调用它时,实际是发了一个 POST 请求到服务端 export async function addComment(productId: string, content: string) { // 鉴权必须在服务端做。别指望"按钮没渲染出来"就是安全的 const session = await auth(); if (!session) throw new Error("未登录"); // 服务端校验,客户端校验只是体验优化 if (!content.trim() || content.length > 500) throw new Error("内容不合法"); await db.comment.create({ data: { productId, content, userId: session.userId }, }); // 关键:告诉 Next.js"这个路径的缓存数据已经旧了,下次请求重新渲染" revalidatePath(`/products/${productId}`); // 如果数据是用 tag 标记的,也可以按 tag 失效(粒度更细,更推荐) revalidateTag(`product-${productId}`); } // ============ 客户端组件里怎么调用(两种方式)============ "use client"; import { addComment } from "./actions"; import { useFormStatus } from "react-dom"; // 方式一:渐进增强的表单(推荐)。不写 onClick,用 form action // 好处:即使 JS 还没加载完,表单也能提交(浏览器原生表单行为) export function CommentForm({ productId }: { productId: string }) { return ( <form action={(formData) => addComment(productId, formData.get("content") as string)}> <textarea name="content" required maxLength={500} /> <SubmitButton /> </form> ); } // 用 useFormStatus 拿提交状态,做 pending 效果(不用自己管 loading state) function SubmitButton() { const { pending } = useFormStatus(); // 必须在 form 的子组件里调用 return <button disabled={pending}>{pending ? "提交中…" : "发表评论"}</button>; } // 方式二:事件里直接调用(需要自己处理错误与 loading) async function handleClick() { try { await addComment(productId, text); toast.success("评论成功"); } catch (e) { toast.error(e.message); } } // ============ 缓存的四种控制(这是 App Router 最容易懵的地方)============ // 1) 默认行为:Next 15 起 fetch 默认不再缓存(caching 语义有调整) const res = await fetch(url); // 显式声明才缓存:no-store / force-cache 二选一 const fresh = await fetch(url, { cache: "no-store" }); // 每次都取新的 const cached = await fetch(url, { cache: "force-cache" }); // 一直用缓存 // 2) 定时重新验证:适合"内容变化不快,能容忍延迟"的数据 const r = await fetch(url, { next: { revalidate: 60 } }); // 60 秒内复用缓存 // 3) 按 tag 失效:写操作后精确让相关缓存过期(最推荐) const r2 = await fetch(url, { next: { tags: [`product-${id}`] } }); // 然后在 Server Action 里 revalidateTag(`product-${id}`) // 4) 整段路由失效:粗粒度,改动相关页面时用 revalidatePath("/products/[id]", "page");
缓存这一块最容易误判的两件事

第一:以为"用了 Server Action 页面就会自动更新"。不会。Server Action 执行完写库之后,当前页面上的数据还是旧的缓存,除非你显式调用 revalidatePath / revalidateTag。现象是"我提交了评论,刷新才看到"。所以 Server Action 的最后一步永远是"失效相关缓存",这是标准动作。

第二:以为用户相关数据可以缓存。如果你的页面用 cookies() 或 headers() 读了登录态,这段渲染就变成动态的、不可缓存的——这是对的,也是必须的。千万不要把"某个用户的数据"放进共享缓存,那会导致 A 用户看到 B 用户的内容,是严重的数据泄漏。凡是涉及身份的数据,一律走动态渲染或按用户维度区分缓存键。

RSC 与 SWR / React Query 的分工

学到这里很多人会问:"那我还需要 SWR / React Query 吗?"需要,但它们负责的领域不重叠。把两者混淆,会写出"明明已经是服务端数据了还在客户端重新请求一遍"的代码。

数据场景用 RSC(服务端组件直接取)用 SWR / React Query(客户端)
页面首屏的正文数据✅ 最合适。减少 API 层、减少 bundle、TTFB 可控❌ 会造成"先空壳、再请求、再渲染"的额外往返
SEO 相关的数据✅ 必须在服务端拿到并渲染成 HTML❌ 爬虫看不到客户端请求的数据
需要密钥的接口调用✅ 密钥安全❌ 密钥会暴露
实时性强的数据(股价、在线人数)❌ 服务端渲染的数据一到浏览器就"旧"了✅ 客户端轮询 / WebSocket 更合适
用户交互触发的数据(搜索、筛选、无限滚动)❌ 每次都往返服务端,体验差✅ 客户端缓存、乐观更新、自动重试
需要乐观更新(点赞立刻变红)❌ 服务端渲染不适合✅ React Query 的 mutation + optimistic update 是为此设计的
列表分页 / 无限加载❌ 每个分页都要服务端往返✅ 客户端缓存分页结果,loading 状态更好控制
跨页面共享的缓存数据(当前用户信息)⚠️ 每次导航都要重新取(或依赖框架缓存)✅ 客户端缓存能跨路由复用

混合使用的正确姿势:服务端给首屏,客户端管交互

// ============ 服务端组件:拿首屏数据,同时把初始数据传给客户端组件 ============ export default async function ProductsPage() { const initial = await db.product.findMany({ take: 20 }); // 关键技巧:把首屏数据传给客户端组件作为 initialData(或 React Query 的 hydrate) // 这样客户端不用再请求一次,也不会有 loading 闪烁 return <ProductList initialProducts={initial} />; } // ============ 客户端组件:用 initialData 起步,后续交互走客户端缓存 ============ "use client"; import useSWR from "swr"; export function ProductList({ initialProducts }: { initialProducts: Product[] }) { const [keyword, setKeyword] = useState(""); // fallbackData = 首屏数据:首帧直接渲染,不发请求 const { data, isLoading } = useSWR( keyword ? ["/api/products", keyword] : null, // 没搜索词就不请求 ([url, kw]) => fetch(`${url}?q=${kw}`).then(r => r.json()), { fallbackData: initialProducts } // ← 关键:用服务端数据兜底 ); return ( <div> <input value={keyword} onChange={e => setKeyword(e.target.value)} placeholder="搜索商品" /> {isLoading ? <Spinner /> : <ul>{data?.map(p => <li key={p.id}>{p.name}</li>)}</ul>} </div> ); } // ============ 分工原则(一句话)============ // "首屏要看到的、SEO 要的、需要密钥的" → 服务端组件取 // "用户操作之后才需要的、需要实时刷新的、需要乐观更新的" → 客户端数据库管 // 交界处用"服务端取首屏 + 传 initialData 给客户端缓存"这一招,避免重复请求

论什么数据适合放服务端、什么放客户端

① 判据一:数据在"这次页面渲染时"是否已经确定?商品详情、文章正文、权限菜单——渲染时就确定了,放服务端。搜索关键词变化后的结果、滚动加载的下一页——渲染时还不知道,放客户端。

② 判据二:数据变化频率高不高?一秒变一次的在线人数放服务端没意义(HTML 一到浏览器就过时了),应该客户端订阅。一天变一次的配置数据放服务端 + 定期 revalidate 最省事。

③ 判据三:请求是否依赖"用户此刻的操作"?依赖,就是客户端的事。RSC 擅长的是"给定 URL,渲染出确定的页面";交互过程中的数据流属于客户端。

常见坑:那些一写就报错的地方

坑一:把含 useState 的组件当服务端组件

报错信息一般是 You're importing a component that needs useState. It only works in a Client Component,或者更绕的 Only plain objects can be passed to Client Components。原因:文件里用了 useState/useEffect/onClick,但没加 'use client'。解决办法是加上指令,但加之前先想一秒:这个组件真的需要整体变成客户端组件吗?更好的做法常常是"把交互的那一小块抽成独立的客户端组件,页面其余部分留在服务端"。另外注意:'use client' 必须在该文件的最顶部(在所有 import 之前),写在中间会无效。

坑二:在服务端组件里 import 客户端专用库

比如在服务端组件里 import { motion } from 'framer-motion'、import Swiper from 'swiper',或者在模块顶层访问了 window、document。表现是构建报错,或者运行时报 window is not defined。因为服务端没有浏览器环境。解法有三条:① 把用到这类库的部分抽成客户端组件(首选);② 确实需要在服务端组件里延迟到客户端才初始化,用 next/dynamic 配 ssr: false;③ 如果只是访问浏览器 API,确保这行代码在 useEffect 里(那只可能在客户端组件中)。养成习惯:看到 window is not defined,第一反应是"我是不是在服务端组件里碰了浏览器 API"。

坑三:过度 streaming 导致布局抖动

把页面上每一个数据块都包一层 <Suspense>,结果是:页面刚出现时一堆骨架在闪,然后一个个被内容替换,每一次替换都会让下面的内容往下跳。用户想点某个按钮,手指刚要落下,按钮被顶上去了——这就是布局抖动(CLS),体验比"多等 200ms"更糟。解法:① 给 Suspense 的 fallback 预留与最终内容相近的高度(骨架尺寸要对齐真实内容);② 把耗时相近的数据放进同一个 Suspense(减少替换次数);③ 只给"明显更慢"的模块单独包(通常超过 300ms 差距才值得);④ 关键交互元素(比如主要按钮)尽量放首屏、别放在会跳动的区域里。

记
本章小结

① RSC 解决两件事:把纯展示组件的代码从 bundle 里删掉;让服务端组件直连数据源,省掉中间 API 层。

② 它和 SSR 的核心区别:SSR 客户端还要把所有组件再渲染一遍,RSC 的服务端组件代码永远不到客户端。

③ App Router 里组件默认是服务端组件;'use client' 划的是一条边界线,它 import 的整条链都会进客户端。

④ 客户端组件的 props 必须可序列化;服务端组件不能 import 到客户端组件里,只能通过 children/props 传入。

⑤ 把边界尽量推到叶子节点,别给整个页面加 'use client'。

⑥ Streaming + Suspense 让慢数据不阻塞首屏;前提是代理不缓冲响应,且 Suspense 要按"数据边界"划。

⑦ 约定文件:layout 不重渲染、page 重渲染、loading 自动包 Suspense、error 必须是客户端组件。

⑧ Server Action 写完数据后必须 revalidate,否则页面还是旧数据;涉及身份的数据绝不能进共享缓存。

⑨ RSC 管"渲染时就确定的数据",SWR/React Query 管"交互产生的数据";交界处用 initialData 传递首屏数据,避免重复请求。

⑩ 三个高频坑:忘了 'use client'、在服务端组件里 import 浏览器专用库、过度 streaming 造成布局抖动。

本章自测

自测 · RSC 与 Streaming SSR

1.(概念题)RSC 和 SSR 到底有什么区别?

看答案

答案:SSR 是"在服务端先渲染一遍 HTML,然后客户端把同样的组件再执行一遍做 hydrate"——组件代码始终都在客户端 bundle 里,只是提前产出 HTML 让首屏快。RSC 是"服务端组件的代码永远不进入客户端 bundle"——服务端执行完把渲染结果序列化流给客户端,客户端只负责把结果填进 DOM。所以两者解决的问题不同:SSR 主要解决首屏渲染速度与 SEO;RSC 主要解决"bundle 太大"和"取数据要绕 API 层"。两者可以组合使用(App Router 里就是组合的)。

2.(边界题)我在页面组件里写了 useState 报错。直接加 'use client' 有什么问题?更好的做法是什么?

看答案

答案:加 'use client' 能修好报错,但副作用是这个文件以及它 import 的一切都进入了客户端 bundle——如果这是一个大页面,它依赖的格式化库、markdown 解析器、甚至一些大数据处理工具都会被打包给浏览器,RSC 的收益全丢。更好的做法:把需要交互的那一小块抽成独立的客户端组件(比如把这个按钮/表单抽到单独文件),页面本身保持服务端组件。心法:'use client' 应该尽量出现在叶子节点,而不是页面顶层。

3.(缓存题)我在 Server Action 里成功往数据库插了一条评论,但页面上看不到,要刷新才出现。为什么?

看答案

答案:因为写库成功了,但页面用的还是缓存的渲染结果,没有失效。App Router 的缓存不会自动感知你的数据库变了。解法是在 Server Action 里显式调用:① revalidatePath(`/products/${id}`) 让整段路由重新渲染;或 ② 更细粒度地给数据打 tag(fetch(url, { next: { tags: [...] } }))然后 revalidateTag(...)。标准动作:每个写操作的 Server Action,最后一行都应该是缓存失效。另外要检查这段渲染有没有被标记成静态——如果它读了 cookies(),本来就是动态的,那就说明是别的地方的缓存问题。

4.(性能题)我给页面每个模块都加了 Suspense 和骨架屏,结果产品说"页面一直在跳"。怎么改?

看答案

答案:这是过度 streaming 引起的布局抖动(CLS)。改法:① 给骨架预留正确的高度,让 fallback 和最终内容占位接近,替换时不推挤下方内容(最常见的原因是骨架只有一行、真实内容是五行);② 合并 Suspense,把耗时接近的数据放进同一个边界,减少替换次数(一次替换比三次好);③ 只给明显慢的模块单独包,快数据直接 await 进首屏;④ 把主要交互元素放在稳定的位置,别放在会被推挤的区域。判断标准:如果某个数据的耗时和其他模块差距不到 200~300ms,就不值得单独开一个 Suspense。