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 渲染插件。请务必告知!

相关推荐
ze_juejin14 分钟前
Fetch API 详解
前端
用户669820611298222 分钟前
js今日理解 blob和arrayBuffer 二进制数据
前端·javascript
想想肿子会怎么做25 分钟前
Flutter 环境安装
前端·flutter
断竿散人25 分钟前
Node 版本管理工具全指南
前端·node.js
转转技术团队26 分钟前
「快递包裹」视角详解OSI七层模型
前端·面试
1024小神31 分钟前
Ant Design这个日期选择组件最大值最小值的坑
前端·javascript
卸任32 分钟前
Electron自制翻译工具:自动更新
前端·react.js·electron
安禅不必须山水34 分钟前
Express+Vercel+Github部署自己的Mock服务
前端
哈撒Ki36 分钟前
快速入门zod4
前端·node.js
用户游民1 小时前
Flutter 项目热更新方案对比与实现指南
前端