Note
此小节启发于 https://sdk.vercel.ai/docs/concepts/ai-rsc
以下代码在 Next.js 14.1.2-canary.3 中可以工作,其他版本或许会有改动
此文章首次发布于我正在编写的 聊点不一样的 Next.js 小册。欢迎支持。
在 LLM 项目中,总是能看到流式传输渲染的信息。
我们看一下请求。
发现其实这是一个流式传输的 RSC payload。也就是说 UI 的更新是由服务器的流式传输 RSC payload 驱动的。当流式传输的 RSC payload 读取到下一行就刷新 UI。
这节我们利用 RSC 简单实现一下流式渲染消息流。
Server Action#
开始之前,我们需要知道 Server Action 其实是一个 POST 请求,服务器会调用 Server Action 函数的引用,然后通过 HTTP 请求的方式流式返回执行结果。
在 Server Action 中,你必须要定义一个异步的方法,因为请求是异步的;第二你必须返回一个可以被序列化的数据,例如函数这类则不行。
我们常用 Server Action 刷新页面的数据,例如使用 revalidatePath
。
我们尝试一下。
```tsx filename="app/server-action/layout.tsx" import type { PropsWithChildren } from 'react'export default async ({ children }: PropsWithChildren) => {
return (
{children}
)
}
</Tab>
<Tab label="page.tsx">
```tsx filename="app/server-action/page.tsx"
'use client'
import { useState } from 'react'
import type { ReactNode } from 'react'
import { actionRevalidate } from './action'
export default () => {
return (
<div className="flex flex-col gap-4">
<ServerActionRevalidate />
</div>
)
}
const ServerActionRevalidate = () => {
return (
<form
action={async (e) => {
await actionRevalidate()
}}
>
<button type="submit">Revalidate this page layout</button>
</form>
)
}
import { revalidatePath } from 'next/cache'
export const actionRevalidate = async () => {
revalidatePath('/server-action')
}
</Tab>
</Tabs>
![](https://nextjs-book.innei.in/images/4.gif)
当我们点击按钮时,页面重新渲染了,在页面没有重载的情况下,刷新了最新的服务器时间。
## 使用 Server Action 获取 Streamable UI
脑洞一下,如果我们在 Server Action 返回一个 ReactNode 类型会怎么样。
<Tabs>
<Tab label="page.tsx">
```tsx filename="app/server-action/page.tsx" {21}
'use client'
import { useState } from 'react'
import type { ReactNode } from 'react'
import { actionReturnReactNode } from './action'
export default () => {
return (
<div className="flex flex-col gap-4">
<ServerActionRenderReactNode />
</div>
)
}
const ServerActionRenderReactNode = () => {
const [node, setNode] = useState<ReactNode | null>(null)
return (
<form
action={async (e) => {
const node = await actionReturnReactNode()
setNode(node)
}}
>
<button type="submit">Render ReactNode From Server Action</button>
</form>
)
}
export const actionReturnReactNode = async () => {
return
}
</Tab>
</Tabs>
![](https://nextjs-book.innei.in/images/5.gif)
我们可以看到,当我们点击按钮时,页面渲染了一个 React Node。这个 React Node 是由 Server Action 返回的。
我们知道在 App Router 中可以使用 Server Component。Server Component 是一个支持异步的无状态组件。异步组件的返回值其实是一个 `Promise<ReactNode>`,而 `ReactNode` 是一个可以被序列化的对象。
那么,利用 Supsense + 异步组件会有怎么样的结果呢。
<Tabs>
<Tab label="action.tsx">
```tsx filename="app/server-action/action.tsx" {3,7}
export const actionReturnReactNodeSuspense = async () => {
const Row = async () => {
await sleep(300)
return <div>React Node</div>
}
return (
<Suspense fallback={<div>Loading</div>}>
<Row />
</Suspense>
)
}
```
</Tab>
<Tab label="page.tsx">
```tsx filename="app/server-action/page.tsx"
'use client'
import { useState } from 'react'
import type { ReactNode } from 'react'
import { actionReturnReactNodeSuspense } from './action'
export default () => {
return (
<div className="flex flex-col gap-4">
<ServerActionRenderReactNode />
</div>
)
}
const ServerActionRenderReactNode = () => {
const [node, setNode] = useState<ReactNode | null>(null)
return (
<form
action={async (e) => {
const node = await actionReturnReactNodeSuspense()
setNode(node)
}}
>
<button type="submit">Render ReactNode From Server Action</button>
</form>
)
}
我们可以看到,当我们点击按钮时,页面渲染了一个 Suspense 组件,展示了 Loading。随后,等待异步组件加载完成,展示了 React Node。
那么,利用这个特征我们可以对这个方法进行简单的改造,比如我们可以实现一个打字机效果。
export const actionReturnReactNodeSuspenseStream = async () => {
const createStreamableRow = () => {
const { promise, reject, resolve } = createResolvablePromise()
const Row = (async ({ next }: { next: Promise<any> }) => {
const promise = await next
if (promise.done) {
return promise.value
}
return (
<Suspense fallback={promise.value}>
<Row next={promise.next} />
</Suspense>
)
}) /* Our React typings don't support async components */ as unknown as React.FC<{
next: Promise<any>
}>
return {
row: <Row next={promise} />,
reject,
resolve,
}
}
let { reject, resolve, row } = createStreamableRow()
const update = (nextReactNode: ReactNode) => {
const resolvable = createResolvablePromise()
resolve({ value: nextReactNode, done: false, next: resolvable.promise })
resolve = resolvable.resolve
reject = resolvable.reject
}
const done = (finalNode: ReactNode) => {
resolve({ value: finalNode, done: true, next: Promise.resolve() })
}
;(async () => {
for (let i = 0; i < typewriterText.length; i++) {
await sleep(10)
update(<div>{typewriterText.slice(0, i)}</div>)
}
done(
<div>
{typewriterText}
<p>typewriter done.</p>
</div>,
)
})()
return <Suspense fallback={<div>Loading</div>}>{row}</Suspense>
}
上面的代码中,createStreamableRow
创建了一个被 Suspense 的 Row 组件,利用嵌套的 Promise,只要 当前的 promise 的 value 没有 done,内部的 Suspense 就一直不会被 resolve,那么我们就可以一直往里面替换新的 React Node。
在 update
中我们替换了原来已经被 resolve 的 promise,新的 promise 没有被 resolve,那么 Suspense 就 fallback 上一个 promise 的值。依次循环。直到 done === true
的条件跳出。
效果如下:
那么利用这种 Streamable UI,可以结合 AI function calling,在服务器端按需绘制出各种不同 UI 的组件。
Warning
由于这种流式传输驱动组件更新,服务器需要一直保持长连接,并且每一次驱动更新的 RSC payload 都是在上一次基础上的全量更新,所以在长文本的情况下,传输的数据量是非常大的,可能会增大带宽压力。
另外,在 Vercel 等 Serverless 平台上,保持长连接会占用大量的计算资源,最终你的账单可能会变得很不可控。
上述所有代码示例位于:demo/steamable-ui
参考:https://sdk.vercel.ai/docs/concepts/ai-rsc#create-an-airsc-instance-on-the-server
此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/programming/nextjs-rsc-server-action-and-streamable-ui