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。

spa-way.tsx(SPA 惯性写法) tsx
'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>                // 数据回来才有内容
}
pages/products/[id].tsx(Pages Router) tsx
// 取数被抽到页面级的特殊导出函数里,和组件分离
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
}
app/products/[id]/page.tsx(App Router) tsx
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,数据是局部变量,渲染出的即最终内容。取数粒度从"页面级"变成"组件级"。

SPA · 浏览器发起取数 浏览器 API 端点 数据库 网络① 网络② 网络③ 网络④ 回浏览器渲染 RSC · 服务端就地取数 服务端组件 async · await 数据 数据库 直接连 · 无往返 浏览器 收渲染结果 渲染好的结果发回浏览器(一次)
图 4.1同一份数据,SPA 要四段网络往返(浏览器→API→DB→回程),RSC 在服务端就地取数、只把渲染结果发回浏览器。注意:差异不在"快一点点",而在往返次数——SPA 的取数被渲染推到了浏览器侧,RSC 把取数留在了离数据库最近的地方。
想一想

一个 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 查订单)时,串行才是必要的、也是正确的。换句话说,串行要留给真实的数据依赖,不要留给书写顺序。

waterfall.tsx(反例:顺序 await) tsx
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
}
parallel.tsx(正例:Promise.all) tsx
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
}
瀑布 · 顺序 await A 取数 200ms B 取数 200ms C 取数 200ms t ≈600ms 并行 · Promise.all A 200ms B 200ms C 200ms ≈200ms
图 4.2三个各 200ms 的独立请求:串行首尾相接累加到约 600ms,并行同时起跑、总耗时等于最长的一段约 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 + 多处调用 tsx
// 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 传下去。

<Layout> <Page> <子组件> getUser() cache 记忆化 调用① 调用② 调用③ 数据库 只 1 次 同一次渲染内去重
图 4.3三个组件各调一次 getUser(),请求在记忆化函数处汇聚,底层数据库只被打一次。注意:去重的作用域是"同一次渲染"——渲染结束记忆即清空,这与跨请求的持久缓存(05 章)是两件事。

4.4取数 + Suspense:流式数据

把慢的取数封装进一个 async 组件并用 <Suspense> 包它(03 章),快的数据先渲染,慢的数据 ready 后流式补上,避免一个慢查询拖住整页。

机制(接 03 章)

§4.2 解决了"并行发请求",但还有一种拖累并行救不了:某个查询天生就慢(如一份重聚合报表 2s)。若在页面顶层 await 它,即便和别的请求并行,整页的渲染也要等到最慢那个 resolve 才能产出——慢查询阻塞了整页外壳,用户在那 2s 里什么都看不到。

出路是把"取数位置"和"渲染时机"两章接起来:不要在页面顶层 await 最慢的那个,而是把它隔进一个独立的 async 子组件,再用 <Suspense> 把这个子组件包住。03 章讲过 Suspense 的流式机制——边界内的组件还在 await 时,服务端先把边界外的内容连同 fallback 渲染并发出,浏览器立即可见;慢数据 ready 后,那一块再流式补上替换掉 fallback。慢查询从此只阻塞它自己那一小块,不再拖住整页。

app/dashboard/page.tsx tsx
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

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 为什么 Server Component 能直接 await 数据,而客户端组件(02 章)只能在 useEffect 里取数?
  2. 两个互相独立的查询,写成两个连续的 await,会发生什么?怎么改?
  3. layout 和 page 都需要"当前用户",各写了一次取数调用。默认会打两次数据库吗?靠什么避免?
  4. (设计层)一个页面有一个 50ms 的标题查询和一个 2s 的报表查询。怎么组织取数与渲染,让用户 50ms 就看到标题、报表用骨架占位后补上?
答案(先做完再展开)
  1. 因为服务端的渲染过程可以异步,React 允许 Server Component 的渲染函数是 async 并返回 Promise,渲染时会等它 resolve,于是能就地 await 出带数据的结果。客户端的 reconcile 必须同步、渲染函数不能返回 Promise,所以 SPA 只能把取数挪到渲染之后的 useEffect 里,先渲染空态再 setState 填入。
  2. 会形成瀑布:第二个 await 要等第一个 resolve 才开始,两段耗时相加,即使第二个并不依赖第一个的结果。改法:用 Promise.all([fetchA(), fetchB()]) 同时发起,总耗时取最长的那个而非相加。串行只应留给真实的数据依赖。
  3. 默认不会打两次(前提是用了去重)。靠 request memoization:内置 fetch 在同一次渲染内对相同 URL+选项自动去重;直连数据库这类 fetch 拦不到的调用,用 React.cache() 包裹获得同样的按参数去重。作用域仅限"同一次渲染"。
  4. 顶层只 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)。