Chapter 04
数据获取:取数下沉到组件树
上一章讲了渲染的时机:静态、动态、流式。渲染要有数据可渲染——这一章讲数据从哪来。App Router 把数据获取下沉到组件树里:异步的 Server Component 直接 await,不再需要 SPA 里 useEffect+fetch 那一圈,也不再是 Pages Router 的页面级 getServerSideProps。
本章你将建立的 schema
- 异步 Server Component 直接
await数据(fetch/ ORM / 任意 Promise)——取数在组件树里就地发生 - 顺序
await会形成请求瀑布;用Promise.all或并行渲染消除 - 同一次渲染里对相同请求自动去重(request memoization),不必手动缓存同一份数据
- 把慢查询包进
<Suspense>(接 03 章),让其余内容先渲染、数据 ready 再流式补上
4.1数据在组件里取
Server Component 可以是 async 函数,在渲染时直接 await 数据源,取来的数据当作普通变量用在 JSX 里。
客户端组件的渲染必须同步——React 在浏览器里同步 reconcile 出一棵树,渲染函数不能返回 Promise。所以 SPA 取数只能挪到渲染之后:在 useEffect 里 fetch,先渲染一个空态或骨架,数据回来再 setState 触发二次渲染填进去。这就是"先白屏/骨架,再填内容"的根源。
Server Component 在服务端渲染,而服务端的渲染过程本身可以异步——React 允许一个 Server Component 的渲染函数是 async 并返回 Promise,渲染时会等它 resolve。于是组件能在渲染中直接 await,渲染出的就是已经带数据的结果,没有空态那一拍。更关键的是少了"浏览器→API→数据库"的往返:组件就在能直接连数据库的服务端跑,await db.query() 是一次进程内(或同机房)调用,不再绕经浏览器发起的网络请求。
同一个"取产品再渲染"的需求,三种模型写出来差别如下。第一段是 SPA 的惯性写法,后两段分别是 Pages Router 与 App Router。
'use client'
import { useEffect, useState } from 'react'
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null)
useEffect(() => {
// 代价 1:要先开一个 /api/products/[id] 端点给浏览器调
// 代价 2:渲染发生在数据之前——这一拍只能渲染空态
fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct)
}, [id])
if (!product) return <Skeleton /> // 先白屏 / 骨架
return <h1>{product.name}</h1> // 数据回来才有内容
}
// 取数被抽到页面级的特殊导出函数里,和组件分离
export async function getServerSideProps(
ctx: { params: { id: string } }
) {
const product = await db.product.find(ctx.params.id)
return { props: { product } } // 数据从页面顶层一路 props 往下传
}
export default function ProductPage(
{ product }: { product: Product }
) {
return <h1>{product.name}</h1> // 组件自己不取数,只收 props
}
import { db } from '@/lib/db'
// 没有 'use client',是 Server Component;async 渲染函数能直接 await
export default async function ProductPage(
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const product = await db.product.find(id) // 取数就在组件里,无中转端点
return <h1>{product.name}</h1> // 渲染出的就是带数据的结果
}
spa-way取数在渲染之后(useEffect 只在挂载后跑),所以必有空态那一拍;而且要额外维护一个 /api 端点供浏览器绕一圈去调。
Pages取数被抽到页面级的 getServerSideProps,与组件分离——数据只能从页面顶层取好、再一路 props 往下传,子组件无法各自就地取数。
App取数下沉进组件本身。async function 的 Server Component 直接 await,数据是局部变量,渲染出的即最终内容。取数粒度从"页面级"变成"组件级"。
一个 Server Component 里写 const product = await db.product.find(id)。这次数据库查询的代码,会被打进发给浏览器的 bundle 吗?用户能在浏览器的 Network 面板看到这次查询吗?
展开答案(先停 10 秒)
都不会。Server Component 的代码(含 db 的 import 和这次查询)只在服务端执行,不进客户端 bundle——这正是 02 章的结论:Server Component 不下载到浏览器。浏览器拿到的是渲染后的结果(RSC payload),里面没有 db 的任何痕迹,Network 面板也看不到这次数据库查询,因为它根本不是浏览器发起的请求。
4.2瀑布 vs 并行
父组件 await 完才渲染子组件、子组件再 await,会把本可并行的请求串成瀑布;用 Promise.all 同时发起独立请求可消除等待。
瀑布的根因是数据依赖与渲染顺序耦合。await 是阻塞点:写两条连续的 await a、await b,第二条要等第一条 resolve 才开始——即使 b 根本不用 a 的结果。同理,父组件 await 没回来就不会渲染子组件,于是子组件里的 await 也发不出去,请求被一层层串起来。
判据很清楚:若 A、B 相互独立,应在同一层先一起发起、再一起等——Promise.all([fetchA(), fetchB()]) 让两个请求同时在途,总耗时取最长的那个而非两者之和。只有当 B 真的依赖 A 的结果(如先拿到用户、再用 user.id 查订单)时,串行才是必要的、也是正确的。换句话说,串行要留给真实的数据依赖,不要留给书写顺序。
export default async function Dashboard() {
// user 和 stats 互相独立,却被串成了瀑布
const user = await getUser() // 200ms,先等它
const stats = await getStats() // 再等 200ms —— 它本不必等 user
return <Header user={user} stats={stats} />
// 总耗时 ≈ 200 + 200 = 400ms
}
export default async function Dashboard() {
// 先一起发起(不 await),再一起等 —— 两个请求同时在途
const [user, stats] = await Promise.all([
getUser(), // 200ms ┐
getStats(), // 200ms ┘ 并发
])
return <Header user={user} stats={stats} />
// 总耗时 ≈ max(200, 200) = 200ms
}
两个互相独立的查询,各耗 200ms。写成两个连续的 await,整段渲染在取数上花多久?改成 Promise.all 后又是多久?
展开答案(先停 10 秒)
顺序 await:约 400ms——第二个 await 要等第一个 resolve 才开始,两段耗时相加。
Promise.all:约 200ms——两个请求同时发起、同时在途,总耗时取最长的那个(max(200, 200)),而非两者之和。两个查询互相独立是这个优化成立的前提;若第二个依赖第一个的结果,则只能串行,400ms 是必然的。
4.3请求记忆化
在同一次渲染里,对相同 URL+选项的 fetch(或用 React.cache() 包裹的函数)只真正执行一次,重复调用返回记忆化结果。
取数下沉到组件树后,会冒出一个新问题:一个页面里多个组件常常都需要同一份数据。比如 layout 要"当前用户"渲染头像,page 也要"当前用户"渲染欢迎语,某个深处的子组件还要它做权限判断。每个组件就地取数,朴素地看就是各查一次同一份数据。
React 在单次渲染内按参数对请求去重,解决这件事:内置 fetch 被自动包裹——同一次渲染里相同 URL+选项的 fetch 第一次真正发出、其余返回同一个在途/已完成的 Promise;自定义的异步函数(如直连数据库的 ORM 调用,fetch 拦不到)则用 React.cache() 显式包裹,获得同样的按参数去重。于是同一份数据在一次渲染里只打一次源,组件可以各取所需而不必把数据从顶层一路 props 钻下去。
边界要划清:request memoization 只覆盖"同一次渲染",渲染一结束即失效,下一个请求重新来过。它不是跨请求的持久缓存——那是另一套机制(缓存默认行为见 05 章),本章不展开。
// lib/get-user.ts —— 直连数据库,fetch 拦不到,用 React.cache 包裹
import { cache } from 'react'
import { db } from '@/lib/db'
export const getUser = cache(async () => {
return db.user.current() // 同一次渲染内,相同入参只真正执行一次
})
// app/layout.tsx —— 第 1 次调用:真正查库
export default async function Layout({ children }) {
const user = await getUser()
return <><Avatar src={user.avatar} />{children}</>
}
// app/page.tsx —— 第 2 次调用:命中记忆化,不再查库
export default async function Page() {
const user = await getUser() // 同一次渲染,返回上面那次的结果
return <h1>欢迎,{user.name}</h1>
}
cache()把 getUser 包成记忆化版本。layout 与 page 在同一次渲染里各调一次,但 db.user.current() 只真正执行一次——第二次调用拿到的是第一次的结果。无需把 user 从顶层手动 props 传下去。
getUser(),请求在记忆化函数处汇聚,底层数据库只被打一次。注意:去重的作用域是"同一次渲染"——渲染结束记忆即清空,这与跨请求的持久缓存(05 章)是两件事。4.4取数 + Suspense:流式数据
把慢的取数封装进一个 async 组件并用 <Suspense> 包它(03 章),快的数据先渲染,慢的数据 ready 后流式补上,避免一个慢查询拖住整页。
§4.2 解决了"并行发请求",但还有一种拖累并行救不了:某个查询天生就慢(如一份重聚合报表 2s)。若在页面顶层 await 它,即便和别的请求并行,整页的渲染也要等到最慢那个 resolve 才能产出——慢查询阻塞了整页外壳,用户在那 2s 里什么都看不到。
出路是把"取数位置"和"渲染时机"两章接起来:不要在页面顶层 await 最慢的那个,而是把它隔进一个独立的 async 子组件,再用 <Suspense> 把这个子组件包住。03 章讲过 Suspense 的流式机制——边界内的组件还在 await 时,服务端先把边界外的内容连同 fallback 渲染并发出,浏览器立即可见;慢数据 ready 后,那一块再流式补上替换掉 fallback。慢查询从此只阻塞它自己那一小块,不再拖住整页。
import { Suspense } from 'react'
// 慢查询被隔进独立 async 组件——它自己 await,不在页面顶层
async function SlowAnalytics() {
const data = await getAnalytics() // 2s 的重聚合报表
return <Report data={data} />
}
export default async function Dashboard() {
// 顶层只 await 快数据,且并行
const [user, menu] = await Promise.all([getUser(), getMenu()])
return (
<main>
<Header user={user} /> {/* 快:立即渲染 */}
<Sidebar items={menu} /> {/* 快:立即渲染 */}
<Suspense fallback={<ReportSkeleton />}>
<SlowAnalytics /> {/* 慢:先出骨架,2s 后流式补上 */}
</Suspense>
</main>
)
}
SlowAnalytics慢查询被关进它自己的 async 组件,await 发生在组件内部,而非 Dashboard 顶层——这是慢查询不阻塞整页的前提。
Suspense包住慢组件:Header、Sidebar 用快数据立即渲染并发给浏览器,报表位置先显示 ReportSkeleton;2s 后报表数据 ready,那一块流式补上。这正是 03 章流式渲染在取数上的落点。
若把 SlowAnalytics 的 await getAnalytics() 直接挪到 Dashboard 顶层(哪怕和 getUser 一起放进 Promise.all),用户多久能看到 Header?
展开答案(先停 10 秒)
要等约 2s。一旦 getAnalytics() 的 await 回到顶层,Dashboard 的渲染就被它卡住——放进 Promise.all 只让它和别的请求并发,但 Dashboard 这个组件本身仍要等 Promise.all 整体 resolve(即最慢的 2s)才能产出任何 JSX,Header 跟着一起被推迟。把慢查询隔进 Suspense 边界,才能让 Header 不被它拖住、立即可见。这就是"取数位置"决定"首屏时机"的地方。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 为什么 Server Component 能直接
await数据,而客户端组件(02 章)只能在useEffect里取数? - 两个互相独立的查询,写成两个连续的
await,会发生什么?怎么改? layout和page都需要"当前用户",各写了一次取数调用。默认会打两次数据库吗?靠什么避免?- (设计层)一个页面有一个 50ms 的标题查询和一个 2s 的报表查询。怎么组织取数与渲染,让用户 50ms 就看到标题、报表用骨架占位后补上?
答案(先做完再展开)
- 因为服务端的渲染过程可以异步,React 允许 Server Component 的渲染函数是
async并返回 Promise,渲染时会等它 resolve,于是能就地await出带数据的结果。客户端的 reconcile 必须同步、渲染函数不能返回 Promise,所以 SPA 只能把取数挪到渲染之后的useEffect里,先渲染空态再setState填入。 - 会形成瀑布:第二个
await要等第一个 resolve 才开始,两段耗时相加,即使第二个并不依赖第一个的结果。改法:用Promise.all([fetchA(), fetchB()])同时发起,总耗时取最长的那个而非相加。串行只应留给真实的数据依赖。 - 默认不会打两次(前提是用了去重)。靠 request memoization:内置
fetch在同一次渲染内对相同 URL+选项自动去重;直连数据库这类fetch拦不到的调用,用React.cache()包裹获得同样的按参数去重。作用域仅限"同一次渲染"。 - 顶层只
await那个 50ms 的标题查询(标题立即渲染并发给浏览器);把 2s 的报表隔进一个独立 async 子组件,用<Suspense fallback={骨架}>包住它。标题随外壳 50ms 即出,报表先显示骨架、2s 后数据 ready 再流式补上。关键是不在顶层await那个慢查询,否则它会阻塞整页外壳。
四块数据的仪表盘:快的立即出,慢的各自独立,用户只查一次
做一个仪表盘:顶部用户信息(快)、左侧菜单(快)、中间一个很慢的聚合报表(2s)、右侧一个中速的通知列表(600ms)。要求:首屏尽快可见;慢的两块各自独立加载、互不拖累;而且"当前用户"在整个页面里只查一次(用户信息要它,通知列表也要按用户过滤)。怎么组合 async 组件 + Promise.all + React.cache + <Suspense>?
提示(卡住再展开)
四件工具各管一层,对号入座:
① React.cache 管"只查一次":把 getUser 用 cache() 包裹,顶部信息和通知列表各自调用 getUser(),同一次渲染内只真正查一次库。
② Promise.all 管"快数据并发":顶层 await Promise.all([getUser(), getMenu()]),让用户信息和菜单同时在途、一起 50–100ms 出来,构成立即可见的外壳。
③ async 组件 + Suspense 管"慢的各自独立":报表和通知列表分别隔进各自的 async 子组件(<SlowReport />、<Notifications />),各自用独立的 <Suspense> 包住——两个边界互不相干,600ms 的通知不必等 2s 的报表,谁 ready 谁先流式补上。
反模式:把报表和通知放进同一个 Suspense 边界,会让两块绑在一起、一起等最慢的 2s;或在顶层 await 报表,会把整页外壳一起卡住 2s(见 §4.4 的 predict)。