Server Action & Streamable UI

此文章首次发布于我正在编写的 聊点不一样的 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 复制代码
import type { PropsWithChildren } from 'react'

export default async ({ children }: PropsWithChildren) => {
  return (
    <div className="m-auto mt-12 max-w-[800px]">
      <div>Layout Render At: {Date.now()}</div>
      {children}
    </div>
  )
}
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>
 )
}
tsx 复制代码
'use server'

import { revalidatePath } from 'next/cache'

export const actionRevalidate = async () => {
 revalidatePath('/server-action')
}

当我们点击按钮时,页面重新渲染了,在页面没有重载的情况下,刷新了最新的服务器时间。

使用 Server Action 获取 Streamable UI

脑洞一下,如果我们在 Server Action 返回一个 ReactNode 类型会怎么样。

tsx 复制代码
  '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>
    )
  }
tsx 复制代码
'use server'

export const actionReturnReactNode = async () => {
  return <div>React Node</div>
}

我们可以看到,当我们点击按钮时,页面渲染了一个 React Node。这个 React Node 是由 Server Action 返回的。

我们知道在 App Router 中可以使用 Server Component。Server Component 是一个支持异步的无状态组件。异步组件的返回值其实是一个 Promise<ReactNode>,而 ReactNode 是一个可以被序列化的对象。

那么,利用 Supsense + 异步组件会有怎么样的结果呢。

tsx 复制代码
   export const actionReturnReactNodeSuspense = async () => {
     const Row = async () => {
       await sleep(300)
       return <div>React Node</div>
     }
     return (
       <Suspense fallback={<div>Loading</div>}>
         <Row />
       </Suspense>
     )
   }
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()  // [!code highlight]
          setNode(node)
        }}
      >
        <button type="submit">Render ReactNode From Server Action</button>
      </form>
    )
  }

我们可以看到,当我们点击按钮时,页面渲染了一个 Suspense 组件,展示了 Loading。随后,等待异步组件加载完成,展示了 React Node。

那么,利用这个特征我们可以对这个方法进行简单的改造,比如我们可以实现一个打字机效果。

tsx 复制代码
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

相关推荐
KaMeidebaby1 小时前
卡梅德生物技术快报|PD1 单克隆抗体定制配套 N 糖全谱质控开发
前端·人工智能·算法·数据挖掘·数据分析
nuIl2 小时前
实现一个 Coding Agent(3):工具调用
前端·agent·cursor
nuIl2 小时前
实现一个 Coding Agent(4):ReAct 循环
前端·agent·cursor
nuIl2 小时前
实现一个 Coding Agent(1):一次 LLM 调用
前端·agent·cursor
nuIl2 小时前
实现一个 Coding Agent(2):让 LLM 流式响应
前端·agent·cursor
copyer_xyf2 小时前
Python 异常处理
前端·后端·python
sugar__salt3 小时前
从栈队列数据结构到JS原型面向对象全解
前端·javascript·数据结构
独特的螺狮粉3 小时前
篮球集训班器具管理系统 - 鸿蒙PC Electron框架完整技术实现指南
前端·javascript·华为·electron·前端框架·开源·鸿蒙
pusheng20253 小时前
IFSJ全英文专访:中国创新力量重塑先进气体感知技术,赋能全球关键基础设施安全
前端·网络·人工智能·物联网·安全
AI_零食4 小时前
番茄钟鸿蒙PC Electron框架完成:状态机、定时器管理与专注力工具设计
前端·javascript·华为·electron·开源·鸿蒙·鸿蒙系统