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

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

实现打印机效果

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

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

js 复制代码
// 打字机队列
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请求

这里用了 fetch-event-source,安装后引入

npm install @microsoft/fetch-event-source

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

js 复制代码
/** 核心函数, 对比节点的内容 实现动态更新 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
    }

    // 遍历新节点的子节点,逐个与旧节点的对应子节点比较
    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])
      }
    }
  }

  // 从根开始
  _diffAndUpdate(oldNode, newNode)
}
markdown-it + 代码高亮 + 代码收缩

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

js 复制代码
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)
调用流数据接口,解析数据
js 复制代码
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)
    },
  })
相关推荐
come1123418 分钟前
Vue 响应式数据传递:ref、reactive 与 Provide/Inject 完全指南
前端·javascript·vue.js
前端风云志39 分钟前
TypeScript结构化类型初探
javascript
musk12121 小时前
electron 打包太大 试试 tauri , tauri 安装打包demo
前端·electron·tauri
翻滚吧键盘1 小时前
js代码09
开发语言·javascript·ecmascript
万少2 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL2 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl022 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang2 小时前
前端如何实现电子签名
前端·javascript·html5
海天胜景2 小时前
vue3 el-table 行筛选 设置为单选
javascript·vue.js·elementui
今天又在摸鱼2 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js