基于流式数据的类似 chatgpt 的打字机式输出

11,302 阅读2分钟

参考链接: stackoverflow.com/questions/6…

实现:前端实现打字机式输出,请求结束识别,可中断数据传输。后端返回的数据需为流数据,响应头 Content-Typetext/event-stream。 以下方案均可在最新版 Chrome 上使用。

方案一 fetch

借助 ReadableStream API (MDN docs here)。

const postData = {foo: 'bar'}
const url = '<http://test.com>'
let controller = new AbortController()

const loadData = async () => {
  try {
    const response = await fetch(url, {
      method: 'post',
      headers: {
        'Content-Type': 'application/json;charset=utf-8'
      },
      body: JSON.stringify(postData),
      signal: controller.signal
    })

    const reader = (response as any).body.getReader()
    let data = ''
    while (true) {
      const {done, value} = await reader.read()
      if (done) {
        setLoading(false)
        break
      }
      data += new TextDecoder().decode(value)
      setData(data)
    }
  } catch {
    message.error('请求失败')
  }
}

loadData()

// 中断接收
// controller.abort()

每次只返回并读取一点数据。

方案二 使用 XHR

const postData = {foo: 'bar'}
const url = '<http://test.com>'

var xhr = new XMLHttpRequest()
xhr.open('POST', url, true)
xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
xhr.onprogress = function () {
  setData(xhr.responseText)
}
xhr.onloadend = function () {
  setLoading(false)
}
xhr.onerror = function () {
  message.error('请求失败')
}
xhr.send(JSON.stringify(postData))

// 中断接收
// xhr.abort()

xhr.responseText 每次都返回当前全部信息,包括前面已返回的。

方案三 axios——基于方案2

const postData = {foo: 'bar'}
const url = '<http://test.com>'
let controller = new AbortController()

axios({
  method: 'post',
  url,
  data: postData,
  responseType: 'stream',
  onDownloadProgress: function (progressEvent: AxiosProgressEvent) {
    setData(progressEvent.event.currentTarget.responseText)
  },
  signal: controller.signal
}).then(() => {
  setLoading(false)
}).catch(() => {
  message.error('请求失败')
})

// 中断接收
// controller.abort()

如果是 Get 请求,可以使用 EventSource

数据处理

看到评论有伙伴提到了数据处理,再补充一下。我们收到的流数据是一段段的字符串。一般情况下,开始传递的时候会先发送一段 data:\n\n\n\n/ 表示要开始传递了,接下来每行文本的格式也是以 data: 开头,中间是具体传递的内容,然后以 \n\n\n 结束。具体的事件流格式可以参考这篇文章

所以我们要获取到准确的信息需要对每次返回的文本进行截取处理。比如这样:

const content = progressEvent.event.currentTarget.responseText 
  .replace(/^data:\n\n\n\n/, '')
  ?.split('data:')
  ?.map((item: string) => item.replace(/\n\n$/, ''))
  ?.join('')

这种截取方式适用于每次收到的文本都包含前面信息的,也就是如果我们要传递这么一段文字 “我的心却在怦怦跳,因为我对一切都充满了期望:丛林里的花,咆哮的野兽。”,第一次接收到的是 data:我的心却在怦怦跳,\n\n,第二次接收到的就是 data:我的心却在怦怦跳,因为我对一切都充满了期望:\n\n,以此类推,最后接收到的是整段文字。但还有一种方式是,每次接收到的内容不包含上次的,这种方式下第二次接收到的就是 data:因为我对一切都充满了期望:\n\n。这点需要注意一下。

然后,如果我们想要传递 JSON 数据,收到的也只能是 JSON 字符串,需要用 JSON.parse 转换成 JSON 对象格式。所以,JSON 数据不太方便流式输出,因为只能等全部数据全部传完,才可使用 JSON.parse 进行转换。当然,不怕麻烦的话,也可以在过程中通过一些字符匹配获取内容。