sse+markdown流渲染且实现打字机效果

1,835 阅读4分钟

最近要做一个类似 DeepSeek 聊天框回复效果的 Agent 聊天界面,包含 markdown 流数据渲染,实现打印机效果。

实现打印机效果

原版是另一个大佬写的,因为实现完这个功能很久才写的这篇文章,已经找不到原贴了。 由于原版的结束队列不满足我这边的要求,所以 done 方法改了一下,自动等打印完再结束队列,且加了个回调函数。

主要解决问题:接口流数据返回的时间间隔短,可能1s内就返回了十几条数据,调用 done 的时机是 onclose,在流数据1s返回了所有数据并走进了 onclose 的情况下,原版 done 是直接结束了打字机队列消费并且一次性输出全部内容,这个时候页面上就会一下子闪现出全部内容,没有时间来实现打印机效果渲染。

// 打字机队列
export class Typewriter {
  private queue: string[] = []
  private consuming = false
  private timer: ReturnType<typeof setTimeout> | null = null
  private doneTimer: ReturnType<typeof setTimeout> | null = null
  constructor(private onConsume: (str: string) => void, public callBack?: () => void) {}

  // 输出速度动态控制
  adjustSpeed() {
    const TIME_ELAPSED = 2000
    const MAX_SPEED = 200
    const speed = TIME_ELAPSED / this.queue.length
    if (speed > MAX_SPEED) {
      return MAX_SPEED
    }
    else {
      return speed
    }
  }

  // 添加字符串到队列
  add(str: string) {
    if (!str)
      return
    str = str.replaceAll('\\n', '\n')
    this.queue.push(...str.split(''))
  }

  // 消费
  consume() {
    if (this.queue.length > 0) {
      const str = this.queue.shift()
      str && this.onConsume(str)
    }
  }

  // 消费下一个
  next() {
    this.consume()
    // 根据队列中字符的数量来设置消耗每一帧的速度,用定时器消耗
    this.timer = setTimeout(() => {
      this.consume()
      if (this.consuming) {
        this.next()
      }
    }, this.adjustSpeed())
  }

  // 开始消费队列
  start() {
    this.consuming = true
    this.next()
  }

  // 自动等打印完再结束消费队列,且有传回调函数的话执行回调函数
  done() {
    if (this.queue.length === 0) {
      clearTimeout(this.doneTimer as ReturnType<typeof setTimeout>)
      clearTimeout(this.timer as ReturnType<typeof setTimeout>)
      this.consuming = false
      this.callBack?.()
    }
    else {
      this.doneTimer = setTimeout(() => {
        this.done()
      }, 1000)
    }
  }
}

SSE请求

@microsoft/fetch-event-source 是一个由微软开发的 JavaScript 库,旨在提供更灵活、功能更强大的服务器发送事件(Server-Sent Events, SSE)。它结合了浏览器原生的 fetch API 和 EventSource 的特性,允许开发者通过 HTTP 流(HTTP Streaming)实现实时数据传输,同时支持更多自定义配置(如请求头、身份认证、错误重试等)

  • 对比原生 API 的优势
特性@microsoft/fetch-event-source原生 EventSource
HTTP 方法支持 GET/POST/PUT 等仅 GET
自定义请求头
请求体支持任意数据(如 JSON)不支持
错误重试可配置的重试逻辑有限的重试
流控制可手动暂停/恢复不支持
页面隐藏时行为可配置是否保持连接默认暂停

使用 fetch-event-source,安装后引入

npm install @microsoft/fetch-event-source

import { fetchEventSource } from '@microsoft/fetch-event-source'

function StreamRequest(path: string, options: any, { onopen, onmessage, onclose }: any) {
  return new Promise((resolve, reject) => {
    fetchEventSource(path, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      ...options,
      async onopen(response) {
        onopen && onopen(response)
      },
      onmessage(msg) {
        onmessage && onmessage(msg)
      },
      onclose() {
        onclose && onclose()
        resolve('close')
      },
      onerror(err) {
        reject(err)
        throw new Error(err || 'onerror')
      },
      openWhenHidden: true, // 解决切页后重发
    })
  })
}

块渲染

由于打印机效果渲染 HTML 时如果直接刷新整个 innerHTML ,会导致前面已经渲染出来的节点操作会被后渲染的节点刷新影响,所以这里要实现动态更新 markdown。

// import { insertCursor, removeCursor } from '.'

/** 核心函数, 对比节点的内容 实现动态更新 markdown 的 div 而不是用 innerHTML 的属性全部刷新 */
export function updateDOMNode(oldNode: HTMLElement, newNode: HTMLElement) {
  // 递归比较更新新、旧节点的子节点
  function _diffAndUpdate(before: HTMLElement, after: HTMLElement) {
    // 情况 1:更新文本内容
    if (
      before
      && before.nodeType === Node.TEXT_NODE
      && after.nodeType === Node.TEXT_NODE
    ) {
      if (before.nodeValue !== after.nodeValue) {
        before.nodeValue = after.nodeValue
      }
      return
    }

    // 情况 2:新旧节点标签名不同,替换整个节点
    if (!before || before.tagName !== after.tagName) {
      // 克隆新节点
      const newNode = after.cloneNode(true)
      if (before) {
        // 替换旧节点
        before?.parentNode?.replaceChild(newNode, before)
      }
      else {
        // 若不存在旧节点,直接新增
        after?.parentNode?.appendChild(newNode)
      }
      return
    }

    // 情况 3:递归对比和更新子节点
    const beforeChildren = Array.from(before.childNodes)
    const afterChildren = Array.from(after.childNodes)

    // 遍历新节点的子节点,逐个与旧节点的对应子节点比较
    afterChildren.forEach((afterChild, index) => {
      const beforeChild = beforeChildren[index]
      if (!beforeChild) {
        // 若旧节点的子节点不存在,直接克隆新节点的子节点并添加到 before
        const newChild = afterChild.cloneNode(true)
        before.appendChild(newChild)
      }
      else {
        // 若旧节点的子节点存在,递归比较和更新
        _diffAndUpdate(beforeChild as HTMLElement, afterChild as HTMLElement)
      }
    })

    // 删除旧节点中多余的子节点
    if (beforeChildren.length > afterChildren.length) {
      for (let i = afterChildren.length; i < beforeChildren.length; i++) {
        before.removeChild(beforeChildren[i])
      }
    }
  }

  // 从根开始
  // removeCursor(oldNode)
  // insertCursor(oldNode)
  // insertCursor(newNode)
  _diffAndUpdate(oldNode, newNode)
}

markdown-it + 代码高亮 + 代码收缩

npm install markdown-it npm install highlight.js npm install markdown-it-collapsible

import MarkdownIt from 'markdown-it'
import MarkdownItCollapsible from 'markdown-it-collapsible'
import hljs from 'highlight.js'
import 'highlight.js/styles/base16/material-palenight.css'
import 'highlight.js/styles/base16/material-palenight.min.css'

const md: MarkdownIt = MarkdownIt({
  html: true,
  linkify: true,
  breaks: true,
  highlight(str: string, lang: string) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return `<pre><code class="hljs">${
                 hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
                 }</code></pre>`
      }
      catch (__) {}
    }

    return `<pre><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>`
  },
}).use(MarkdownItCollapsible)
调用流数据接口,解析数据
const typewriter = new Typewriter((str: string) => {
  streamingText.value += str

  if (markdownRef.value) {
    const tmpDiv = document.createElement('div')
    tmpDiv.innerHTML = md.render(streamingText.value) // 只渲染当前的块

    removeCursor(markdownRef.value)
    updateDOMNode(markdownRef.value, tmpDiv)
    scrollToBottom(markdownRef)
  }
}, () => {
  streaming.value = false
})

streamRequest('xxx', { input: prompt }, { method: 'GET' }, {
    /* 请求打开 */
    onopen() {
      typewriter.start() // 开始打字
    },
    /* 收到消息 */
    onmessage(message) {
      if (message.data) {
        const dataObj = JSON.parse(message.data)
        dataObj?.content && typewriter.add(dataObj.content)
      }
    },
    onclose() {
      typewriter.done()
      removeCursor(markdownRef.value)
    },
    onerror() {
      removeCursor(markdownRef.value)
    },
  })