Vue 渲染流式数据的几种方式

本文主要探讨在 Vue 中,渲染 AI 聊天应用 Chatbot 消息的几种方式。

获取数据的方式是通过 SSE(Server-Sent Events)拿到流式数据(这里的数据为 markdown 格式)进行渲染。

v-html 和 innerHTML

通过 markdown-it 或者 marked 等 markdown 渲染插件,将数据转成 HTML 字符串,直接通过 v-html 或者是 innerHTML 进行渲染。最终也是可以正常渲染出内容。

但是他是缺点十分明显

  • 流式输出时会刷新整个 DOM 的内容,输出效率不高
  • 无法满足在输出时做一些交互;如鼠标悬浮操作

DOMParser

流式输出是从上往下输出的。那可以利用这个特性。做一个比较粗颗粒度的缓存对比。基于上一种方式把 markdown 转换成 HTML 字符串传进来做处理。

  1. 先把整个 HTML 字符串进行 DOMParser.parseFromString
  2. 获取 doc.body.children 也就是一级标签进行缓存
  3. 如果解析出来的数组长度大于缓存数组的长度则为新增。否则为更新
  4. 根据 status 是 upadte 还是 add 。去做 innerHTML 或者是 appendChild
javascript 复制代码
const contentContainer = useTemplateRef('contentContainer')
const tagList = ref([])
const renderStatus = ref('rending') // finished | rending | ...

/**
 * 解析 HTML 字符串中的一级标签,返回原始标签字符串数组
 * @param {string} htmlString - 要解析的 HTML 字符串
 * @returns {Array<string>} - 一级标签字符串数组
*/
const parseTopLevelTagsToArray = (htmlString) => {
    if (!htmlString) return []
    const parser = new DOMParser()
    const doc = parser.parseFromString(htmlString, 'text/html')
    // 获取 body 元素的直接子元素(一级标签)
    const bodyChildren = doc.body.children
    const tagStrings = []
    for (let i = 0; i < bodyChildren.length; i++) {
      const element = bodyChildren[i]
      // 添加标签字符串
      tagStrings.push(element.outerHTML)
    }
    return tagStrings
}

/**
 * 使用 appendChild 方法渲染
 * @param {string} tagString - 要渲染的标签字符串
 * @param {string} status - 渲染状态,ADD 表示添加,UPDATE 表示更新
*/
const renderTagsWithAppendChild = (tagString, status) => {
  if (!contentContainer.value || !tagString) return

  if(status === 'ADD'){
    const fragment = document.createDocumentFragment()
    const tempDiv = document.createElement('div')
    tempDiv.innerHTML = tagString

    // 将解析后的节点添加到文档片段
    while (tempDiv.firstChild) {
      fragment.appendChild(tempDiv.firstChild)
    }

    // 将整个文档片段一次性添加到容器中
    contentContainer.value.appendChild(fragment)
  } else if (status === 'UPDATE') {
    if (contentContainer.value.lastElementChild) {
      // 创建一个临时元素来解析 tagString
      const tempDiv = document.createElement('div')
      tempDiv.innerHTML = tagString

      // 如果解析后有内容,替换最后一个元素
      if (tempDiv.firstChild) {
        contentContainer.value.replaceChild(
          tempDiv.firstChild,
          contentContainer.value.lastElementChild
        )
      }
    } else {
      // 如果容器为空,则添加新元素
      const tempDiv = document.createElement('div')
      tempDiv.innerHTML = tagString

      const fragment = document.createDocumentFragment()
      while (tempDiv.firstChild) {
        fragment.appendChild(tempDiv.firstChild)
      }

      contentContainer.value.appendChild(fragment)
    }
  }
}

// props.content 为 HTML 字符串
watch(() => props.content, (newContent, oldContent) => {
  if (!newContent || newContent === oldContent) return

  try {
    const newTagStrings = parseTopLevelTagsToArray(newContent)
    if (newTagStrings.length === 0) return

    // 如果标签数量相同,更新最后一个标签
    if (tagList.value.length === newTagStrings.length && tagList.value.length > 0) {
      const lastDom = newTagStrings[newTagStrings.length - 1]
      renderTagsWithAppendChild(lastDom, 'UPDATE')
    }
    // 如果新标签数量更多,添加新标签
    else if (tagList.value.length < newTagStrings.length) {
      // 只添加新增的标签
      const newTags = newTagStrings.slice(tagList.value.length)
      for (const tag of newTags) {
        renderTagsWithAppendChild(tag, 'ADD')
      }
    }
    // 如果标签数量减少,可能是内容被替换,重新渲染全部
    else {
      if (contentContainer.value) {
        contentContainer.value.innerHTML = ''
        for (const tag of newTagStrings) {
          renderTagsWithAppendChild(tag, 'ADD')
        }
      }
    }
    tagList.value = newTagStrings
  } catch (error) {
    console.error('解析或渲染出错:', error)
  }
})

watch(()=>renderStatus, () => {
    // 渲染完成后,再把整个输出 inner 到文档中。防止渲染错误
    if(renderStatus === 'finished') contentContainer.value.innerHTML = props.conten
})

效果如下:可以看到渲染过的标签不会再重复渲染,但是这里只有一级标签。

mdast

最终的目标是做到精准更新。可以围绕 Vue3 的 h 函数 去做扩展。

整体思路是:

  1. 利用 mdast-util-from-markdownfromMarkdown 方法转换成 mdast
  2. 再把 mdast 通过 toHast 转换成 html ast
  3. 拿到 ast 之后判断节点类型,根据节点类型或者再细分 标签 tag 去做对应的渲染;

注意:前两种方案传进来的 props 都是 HTML 字符串。但是这个组件传进来的是 markdown 字符串

html 复制代码
<template>
  <div class="markdown-body">
    <component :is="renderedContent" />
  </div>
</template>

<script setup>
import { toHast } from "mdast-util-to-hast";
import { ref, h, watch } from "vue";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfm } from "micromark-extension-gfm";
import { gfmFromMarkdown } from "mdast-util-gfm";

// 注意:这里传进来的已经不是 HTML 字符串了,而是 markdown 字符串
const props = defineProps({
  markdown: {
    type: String,
    required: true,
  },
});

const renderedContent = ref(null);

// 将 hast 节点转换为 Vue h 函数
function hastToVue(node) {
  if (!node) return null;
  if (node.type === "text") return node.value;
  if (node.type === "element") {
    const { tagName, properties = {}, children = [] } = node;
    // 递归处理子节点
    const childNodes = children.map((child) => hastToVue(child)).filter(Boolean);
    return h(tagName, properties, childNodes);
  }
  // 处理根节点
  if (node.type === "root") {
    return h("div", {}, node.children?.map((child) => hastToVue(child)).filter(Boolean) || []);
  }
  // 处理其他类型节点
  console.warn(`未处理的 hast 节点类型: ${node.type}`, node);
  return null;
}

watch(
  () => props.markdown,
  (newVal) => {
    if (!newVal) {
      renderedContent.value = null;
      return;
    }
    try {
      const mdast = fromMarkdown(newVal, {
        extensions: [gfm()],
        mdastExtensions: [gfmFromMarkdown()],
      });
      const hast = toHast(mdast);
      const vueComponent = hastToVue(hast);
      renderedContent.value = vueComponent;
    } catch (error) {
      console.error("解析 markdown 时出错:", error);
      renderedContent.value = h("div", { style: "color: red;" }, `解析错误: ${error.message}`);
    }
  },
  { immediate: true }
);
</script>

最终效果:

可以看到即使是深层次的节点也不会重新渲染了。

结语

第一种方案在是最简单的处理;直接把 HTML 字符串填入即可。但是如果希望在输出时做 hover 操作的时候是满足不了的。因为 Dom 会一直重载;

第二种方案加了个 DomCache 去做缓存,做了一个比较粗颗粒度的刷新(当然可以往深的节点去做递归检查。但是对应的复杂度也会增加),满足了在渲染时去做 hover 等类似操作,但是不够好;

第三种方案依赖具体框架的渲染机制;vue 的话利用 h 函数,结合第三方包把 ast 打进去 h 函数去做精准渲染。

在 react 生态中。react-markdown 是会有这种渲染场景的

参考 react-markdown 里的 package.json; 里面也采用了 mdast-util-to-hast 这个包。所以沿着这个思路应该是可以处理的。

但是寻找 Vue 社区好像没有类似的插件。如有类似的 markdown 渲染插件。请务必告知!

相关推荐
粥里有勺糖3 分钟前
视野修炼-技术周刊第121期 | Rolldown-Vite
前端·javascript·github
帅夫帅夫4 分钟前
四道有意思的考题
前端·javascript·面试
前端工作日常5 分钟前
我理解的Vue样式穿透
vue.js
tonytony5 分钟前
useRequest如何避免Race condition
前端·react.js
白柚Y13 分钟前
小程序跳转H5或者其他小程序
前端·小程序
一袋米扛几楼9816 分钟前
【前端】macOS 的 Gatekeeper 安全机制阻止你加载 bcrypt_lib.node 文件 如何解决
前端·安全·macos
_CodePencil_32 分钟前
CSS专题之层叠上下文
前端·javascript·css·html·css3·html5
海天胜景41 分钟前
vue3 el-input type=“textarea“ 字体样式 及高度设置
javascript·vue.js·elementui
萌萌哒草头将军1 小时前
🚀🚀🚀这几个为 vue 设计的 vite 插件,你一定要知道!
前端·vue.js·vite
知识分享小能手1 小时前
Typescript学习教程,从入门到精通,TypeScript 配置管理与编译器详解(19)
前端·javascript·学习·typescript·前端框架·ecmascript·jquery