Server Components 基础

SPA 应用渲染流程

简单服务器组件渲染流程

想要使用 RSC 必须要添加 condition 环境变量

1
node --conditions=react-server --watch server/app.js

在 RSC 架构中,组件被分为两类:

  • 服务端组件 (RSC):在服务器运行,直接读数据库,禁止使用 useState 或 useEffect。

  • 客户端组件 (CC):在浏览器运行,可以使用所有 Hook。

在编写服务端组件时,由于 Node.js 环境里依然能引用到完整的 react 包,可能会不小心写下 useState。这时程序不会报错,但会导致逻辑混乱。React 团队通过 exports 条件导出 解决了这个问题: 当 Node.js 开启了 react-server 条件时, import React from ‘react’ 拿到的其实是一个 阉割版的 React,它根本没有 useState 这些导出。一旦你误用,代码在服务器编译阶段就会直接报错,从而保证了 RSC 的纯净性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// React package.json 条件导出定义
{
"exports": {
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
},
"./package.json": "./package.json",
"./jsx-runtime": {
"react-server": "./jsx-runtime.react-server.js",
"default": "./jsx-runtime.js"
},
"./jsx-dev-runtime": "./jsx-dev-runtime.js"
}
}

创建一个 Server Component

当服务器拦截到客户端页面请求之后, 并不是像 SSR 渲染一样,直接返回渲染后的HTML字符串。而是会通过一个特定的API以流的方式返回给浏览器。

1
2
3
4
5
6
7
8
import { renderToPipeableStream } from 'react-server-dom-esm/server'

app.get('/rsc/:id', async (req, res) => {
const { pipe } = renderToPipeableStream(<App/>);
pipe(res);
})

// transfer-encoding chunked

在服务端 (Server Side) 任务是 序列化 (Serialization)。 它将 React 组件树(JSX)转化成一种特殊的、可流式传输的文本格式,最终返回的流的格式如下:

1
2
3
4
5
6
7
8
9
2:"$Sreact.suspense"
1:{"name":"App","env":"Server","owner":null}
0:D"$1"
4:{"name":"SearchResultsFallback","env":"Server","owner":"$1"}
3:D"$4"
3:[["$","li","0",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","1",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","2",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","3",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","4",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","5",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","6",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","7",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","8",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","9",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","10",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","11",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"]]
6:{"name":"SearchResults","env":"Server","owner":"$1"}
5:D"$6"
8:{"name":"ShipFallback","env":"Server","owner":"$1"}

通过 RSC Parser 查看被解析后的格式

在客户端 (Client Side),任务是 反序列化与重建 (Reconstruction)。 它通过 createFromFetch 方法接收服务端传来的流,并将其重新“缝合”到浏览器当前的 React 树中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Suspense, createElement as h, startTransition, use } from 'react'
import { createRoot } from 'react-dom/client'
import { createFromFetch } from 'react-server-dom-esm/client'

const initialContentFetchPromise = fetch(`/rsc/something`)
const initialContentPromise = createFromFetch(initialContentFetchPromise)

function Root() {
const content = use(initialContentPromise)
return content
}

startTransition(() => {
createRoot(document.getElementById('root')).render(<Root/>)
})

数据获取

RSC的组件只会在服务端执行,所以它甚至可以直接在组件内部访问数据库,所有的数据获取直接写在组件中,就像是正常的后端代码一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ShipList.tsx (这是一个服务端组件)
import { db } from './db';

// 1. 注意:这是一个异步函数组件
export async function ShipList({ search }: { search: string }) {

// 2. 直接访问数据库!没有 fetch,没有 API 路由,没有加载状态管理
// 这段代码永远不会发送到浏览器,数据库凭证也不会泄露
const ships = await db.ship.findMany({});

return (
<SomeComponent/>
);
}

使用 Suspense

享用 RSC 组件带来的优点,必须要配额使用 Suspense 组件,下面这个代码示例中只有根组件使用了 Suspense

1
2
3
4
5
6
7
8
9
10
11

// 客户端代码,请求服务器接口,获取入口组件
import { createFromFetch } from 'react-server-dom-esm/client'
const initialContentFetchPromise = fetch(`/rsc/${initialLocation}`)
const initialContentPromise = createFromFetch(initialContentFetchPromise)

function Root() {
const content = use(initialContentPromise)
return content
}
createRoot(document.getElementById('root')).render(<Suspense fallback={"loading..."}><Root/></Suspense>)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 入口服务器组件 app.js
export function App({ shipId, search }){
return <div>
<List/>
<Detail/>
<div>
}

export function List(){
const data = await fetch("/data")
return <ul></ul>
}

export function Detail(){
const data = await fetch("/detail")
return <p></p>
}

它的渲染流程是:

  • root.render 触发,React 进入 Root 组件。

  • use(initialContentPromise) 被调用。此时 fetch 刚刚开始,连第一个 RSC 数据块(Chunk)可能还没解析完。

  • initialContentPromise 处于 Pending 状态。use 钩子会直接“抛出(throw)”这个 Promise。

  • React 捕获到异常,立刻中断 Root 的渲染,转而渲染 loading…

  • 服务器传回了第一批数据(比如 App 的外壳)。

  • createFromFetch 解析了这部分数据,initialContentPromise 的状态被更新。React 被“唤醒”,重新尝试渲染 Root。

  • 这次 use 返回了内容(即包含了两个异步服务端组件占位符的 content)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "type": "ul",
    "key": null,
    "props": {
    "children": {
    "_payload": {
    "status": "pending",
    },
    }
    },
    }

    但很快发现:内容里那两个服务端组件因为没用 Suspense 包裹,它们共用了根部的挂起逻辑。如果这两个组件的数据还没到,React 会再次因为它们而挂起,所以继续显示根组件的 loading…,导致 App 外壳必须等到所有子组件加载成功之后才能显示。

所以对于异步组件尽可能的细粒度的控制加载,通过 Suspense 及时渲染已经加载的组件

1
2
3
4
5
6
7
export function App({ shipId, search }){
return <div>
<Suspense fallback={"loading..."}><List/></Suspense>
<Suspense fallback={"loading..."}><Detail/></Suspense>
<Detail/>
<div>
}

组件共享数据

因为 createContext API只能在客户端执行,所以在服务器组件中想要组件共享数据需要使用 nodeJS 中的一个API

1
2
3
4
5
6
7
8
9
10
//list-storage.js
import { AsyncLocalStorage } from 'node:async_hooks'

export const listData = new AsyncLocalStorage()

// 回调函数执行作用域中可以直接访问到保存的storage
listData.run(data, () => {
const { pipe } = renderToPipeableStream(<App/>)
pipe(res)
})
1
2
3
4
5
6
7
8
// List.js 
// 在其他组件中,消费数据
import { listData } from 'list-storage.js'

export default List() {
const data = listData.getStore();
return <ul></ul>
}

Client Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { EditableText } from './edit-text.js'
export async function ShipDetails() {
const { shipId } = shipDataStorage.getStore()
const ship = await getShip({ shipId })
const shipImgSrc = getImageUrlForShip(ship.id, { size: 200 })
return (
<div>
<EditableText text={ship.name} />
<img src={shipImgSrc} alt={ship.name} />
<h2>{ship.name}</h2>
<p>{ship.description}</p>
</div>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'use client'
export function EditableText({ id, shipId, initialValue = '' }) {
const [edit, setEdit] = useState(false)
const [value, setValue] = useState(initialValue)
const inputRef = useRef(null)
const buttonRef = useRef(null)
return (
<div>
{edit ? (
<input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} />
) : (
<span>{value}</span>
)}
<button ref={buttonRef} onClick={() => setEdit(!edit)}>
{edit ? 'Save' : 'Edit'}
</button>
</div>
)
}

当一个服务器组件需要交互的时候,必然会产生一些状态或是函数,如果想要实时验证这些状态,最好的办法是让这些操作发生在客户端而不是服务端,虽然客户端通可以过某种通讯机制与服务端通信,但是服务器是无状态的,这会非常浪费资源。

虽然状态与函数可以序列换,但是 RSC 的设计原则是:服务器组件不应该暴露任何状态或函数给客户端组件。 这就要求我们必须把这些状态和函数放在客户端组件中,可以通过 'use client' 标识一个组件是客户端组件, React 会在将这个组件发送给客户端前,把他处理为可以序列化的代码,并在 RSC Payload 中引用。

b:I[“/edit-text.js”, “EditableText”]
那如何把一个有 use client 标识的组件标记为我们能知道加载路径的状态呢

使用 react-server-dom-esm/node-loader, 需要配合 import { register } from 'node:module register是node中的一个特殊的api,可以在引用组件的文件的时候,和代码被执行之前,提供钩子做一些处理

获取所有的 export 并删除所有的import , 用 import {registerClientReference} from 'react-server-dom-es/server 包装,第一个参数是一个函数, 直接抛出错误,说明不能在服务器调用,因为这个一个 use client 组件,第二个参数是一个字符串,是这个组件在服务器的绝对路径, 第三个参数是组件的名称

registerClientReference 函数会返回一个react 组件说明

1
2
3
4
5
{
"$$type": "Symbol(react.client.reference)",
// 用#标识 组件名称
"id": "/absolute/path/to/edit-text.js#EditableText",
}

在客户端使用的时候,不可以在 客户端组件内直接 import 服务端组件,因为服务端组件可以访问数据库等敏感资源

1
2
3
4
5
6
// 错误写法
'use client'
import { ShipDetails } from './ShipDetails.js'
export function Detail() {
return <ShipDetails/>
}

正确的写法是使用组合的模式

1
2
3
4
'use client'
export function Detail({ShipDetails}) {
return ShipDetails
}
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信