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">重新驗證此頁面佈局</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">從 Server Action 渲染 ReactNode</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>載入中</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">從 Server Action 渲染 ReactNode</button>
</form>
)
}
我們可以看到,當我們點擊按鈕時,頁面渲染了一個 Suspense 組件,展示了載入中。隨後,等待異步組件加載完成,展示了 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>打字機完成。</p>
</div>,
)
})()
return <Suspense fallback={<div>載入中</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