本文首发于个人博客
一开始看到 chatgpt 的流式渲染,有点好奇流式渲染是如何实现的,无意之间发现 vercel 的库:ai,仔细学习了它的代码,写的小而精,把它看明白了之后,想着写一篇文章来输出一下。
所以我就根据 ai 这个库,来一步步带大家来实现前端乞丐版 chatgpt ,把这篇文章看完之后,也可以去看看 ai 这个库,代码是写的真不错(会发现代码都是抄它的,哈哈哈哈)!!!
本文会使用 Next13,不熟悉也没关系,用到的 api 不多,不懂的 api 可以查看 Next13 文档。
注意:需要申请有一个 openai 的 apiKey,不然就无法调用接口哦。
源代码在这里
效果展示:
先看看一个概念:网络流,平时可能用不到。流是一种用于访问数据的数据结构,比如说:文件、接口返回的数据等等。
使用流有两个好处:
- 可以处理大量数据,流可以将它们分成更小的部分(chunk),可以一次处理一个(chunk)。
- 可以使用相同的数据结构、流,同时处理不同的数据,这使得代码变得更加复用。
在网络流中,一个 chunk 通常是:
- 文本流:string
- 二进制流:Uint8Arrays
网络流主要有三种:
- ReadableStream:用于从数据源中读取数据。执行此操作的代码成为消费者。
- WritableStream:用户将数据写入。执行此操作的代码成为生产者。
- TransformStream 由两个流组成:
- 它从其可写端(WritableStream)接收输入。
- 它将输出发送到它的可读端,一个 ReadableStream。
本文中只会使用到 ReadableStream 和 TransformStream。
ReadableStream 可以从各种来源读取数据块,类型声明如下:
这三个属性的作用是:
- :返回一个 Reader,可以从 ReadableStream 读取的对象,返回的 Reader 类似于迭代器。
- :每个 ReadableStream 一次只能有一个活动的 Reader,当 Reader 在使用时,ReadableStream 被锁定并且不能被调用。
- :将其 ReadableStream 连接到 ReadableWritablePair(一个 TransformStream)。它返回一个新的 ReadableStream(类比一下:把它理解成一个数组的 map 方法)。
下面来看看 的返回类型:
- :在一个活动的 Reader 中,这个方法取消关联的 ReadableStream。
- :停用 Reader 并解锁流。
- 返回来两个属性的 ReadableStreamReadResult 的 Promise:
- :布尔值,false 表示可以读取,true 表示最后一个块。
- :块(chunk)。
师傅,别念 api 了,再念人都要傻了,赶紧来一个 demo 吧。
以下是通过 getReader 方式来读取 ReadableStream 的小例子。
- A 行:不能直接读取 readableStream,需要调用来获取 Reader。
- B 行:在 之后,readableStream 被锁定,所以 B 行打印的是 true,如果想再次调用,必须调用(E 行)。
- C 行:返回属性 done 和 value,如果 done 为 true,表示是最后一个块,
- D 行:可以对返回的 value 进行操作,这里是将返回的 value 全部都加在一个字符串里。
如果想通过 ReadableStream 读取外部源,可以将其包装在适配器对象中并将该对象传递给构造函数 ReadableStream。
以下是类型声明:
- 调用构造函数后立即调用 start 方法。
controller 的参数类型如下:
- :添加 chunk 到 ReadableStream 的内部队列。
- :关闭 ReadableStream,消费者仍然可以清空队列,在那之后,流结束。
自定义 ReadableStream demo
ReadableStream 是异步可迭代的,可以使用来进行迭代。
使用控制器创建一个包含两个块的流(A 和 B 行),关闭流(C 行)很重要,否则永远不会结束。
转化流:
- 通过其可写端(WritableStream)接受输入。
- 然后它可能会或可能不会转换输入。
- 结果可以通过 ReadableStream 读取,它是可读的。
使用最常见的方式是。
将 readableStream 传输到 transformStream 的可写端,并进行转换返回其可读端。
换句话说: 创建了一个新的 ReadableStream,它是 ReadableStream 的转换版本,类似于数组的 map。
一个简单的 demo:
TextEncoder.encode():将字符串作为输入,并返回 Uint8Array 包含 UTF-8 编码的文本。
使用了内置 TransformStream:,作用就是将接收到的二进制流转换为可读的文本流(Uint8Array -> string)。
自定义 TransformStream
跟上面的 ReadableStream 类似,如果要自定义 TransformStream,也可以传递适配器对象给构造函数 TransformStream。
它具有以下类型:
上面属性的解释:
- :在调用构造函数后立即被调用,可以在转换之前做一些准备。
- 执行实际的转化 。接受一个输入块,并可以使用 controller 将一个或多个转换后的输出块排队。
该 contrller 具有以下类型:
- :添加 chunk 到 TransformStream 的可读端(输出)。
- :关闭 TransformStream 的可读端(输出)并在可写端(输入)出错。如果转换器对可写端(输入)的剩余块不感兴趣并想跳过他们,则可以使用它。
小 demo
说了一大堆 api,来一个简单的例子:
Fetch API 是一种用于获取和发送网络资源的现代 Web API。它提供了一种替代 XMLHttpRequest 的方式,可以更简单、更灵活地进行网络请求。
Fetch API 使用 Promise 对象来返回请求结果,可以轻松地将其与 async/await 结合使用。
简单的小例子:
这里要用的是 fetch 返回的属性,它返回的是。
用到了上面提到的 ReadableStream,也算是简单的回顾一下。
需要注意的是:返回的是二进制流,后面会再提到。
讲到这里,终于把前置的知识熟悉一下,我知道你很急,但是你先别急。
我们来进入实战环节。
首先使用初始化一个 Next13 项目。
- What is your project named? openai-stream
- Would you like to use TypeScript with this project? Yes
- Would you like to use ESLint with this project? Yes
- Would you like to use Tailwind CSS with this project? Yes
- Would you like to use directory with this project? No
- Use App Router (recommended)? Yes
- Would you like to customize the default import alias? No
初始化之后就会安装 TypeScript、Eslint 和 Tailwind CSS。
第一步就开始画 UI。
UI 的话主要有两个部分:
- 消息列表展示。
- input 输入框。
MessageCard 渲染信息
创建类型文件定义关于 message 的类型。
MessageCard 组件用来渲染输入和 openai 返回的信息。
基础页面 + input 输入框
下一步画基础的页面和 input 输入框。
直接在里面书写即可。
先 mock 消息列表,看看展示效果咋样。
在 app 目录下新建文件,用来处理 api 请求。
可以直接访问 http://localhost:3000/api/chat 可以看到返回的数据。
关于 Next.js 的 Route 可以查看相关文档,不过多赘述。
需要发送 POST 请求将 message 传递给 openai,通过就可以处理 POST 请求。
就是对于 Response 的简单封装,将状态码置为 200。
创建文件用来处理网络流,使用 ReadableStream 先 mock 两条数据。
下一步写一个 hook 来进行页面交互,创建文件。
装包
需要安装一些依赖包:nanoid 和 swr。
- nanoid 是可以生成唯一 ID 的库。
- swr 是用于数据请求和缓存的库。
类型
将 CreateMessage 和 UseChatOptions 添加到文件,定义好 use-chat 的类型声明。
use-chat 具体逻辑
下面来写对应的 use-chat 具体逻辑:
使用来声明一个状态,第二个参数(fetcher)传入 null,表示不需要进行网络请求,可以把它当成本地状态来处理,返回的 mutate 函数可以对这个状态进行更新。
小小的工具函数
新建文件用来保存两个工具函数: nanoid、createChunkDecoder。
- nanoid:使用 customAlphabet 函数创建了自定义的 ID 生成器 nanoid,用于生成唯一 ID。
- createChunkDecoder:用于将 Uint8Array 类型的数据块解码成字符串(Uint8Array -> string)。
请求接口
接下来就是发送网络请求到,请求成功之后将数据渲染出来。
事件处理
接下来就是将上面的 hook 逻辑和视图绑定在一起。
连通性验证
不出意外的报 bug 了。
问题在于方法返回的是一个二进制流(Uint8Array),fetch 提供了、、等方式将二进制流转化为其他数据格式。
刚刚在里面推到队列里面的是字符串,所以就会报错。
有两种解决方式。
- enqueue 推到队列的类型改成二进制形式:
创建了 TextEncoder 对象,用于将字符串编码为 Uint8Array 对象。
- 采用 TransformStream 可以对输入的数据进行转换处理:
通过使用 pipeThrough 方法,将 AIStream 的输出流连接到 createCallbacksTransformer 的输入流,实现了数据的转换和传递。
实现将 string 转换为 Uint8Array 对象的流处理过程。
本文后面会使用第二种方式。
我们来看看效果: