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)
    },
  })
相关推荐
用户21411832636025 分钟前
dify案例分享-deepseek赋能从 Excel 表格到统计图,一键生成代码不是梦
前端
Winwin6 分钟前
老帅 Webpack 和新将 Vite 的 PK
前端·前端工程化
best66612 分钟前
预检请求是什么?
前端
一袋米扛几楼9812 分钟前
【JavaScript 】1. 什么是 Node.js?(JavaScript 服务器环境)
服务器·javascript·node.js
前端加油站13 分钟前
单元测试入门与进阶
前端·单元测试
前端付杰18 分钟前
第八节: 全面理解vue3: 工具函数的核心作用与使用方法
前端·javascript·vue.js
Mr_sun.19 分钟前
Node.js与VUE安装
前端·vue.js·node.js
Tonychen20 分钟前
【React 源码阅读】为什么 React Hooks 不能用条件语句来执行?
前端·react.js·源码阅读
Cutey91623 分钟前
Vuex vs Pinia
前端·vue.js·面试
Sallywa29 分钟前
全局替换的思路历程
前端