本文主要探讨在 Vue 中,渲染 AI 聊天应用 Chatbot 消息的几种方式。
获取数据的方式是通过 SSE(Server-Sent Events)
拿到流式数据(这里的数据为 markdown
格式)进行渲染。
v-html 和 innerHTML
通过 markdown-it
或者 marked
等 markdown 渲染插件,将数据转成 HTML 字符串,直接通过 v-html
或者是 innerHTML
进行渲染。最终也是可以正常渲染出内容。
但是他是缺点十分明显
- 流式输出时会刷新整个 DOM 的内容,输出效率不高
- 无法满足在输出时做一些交互;如鼠标悬浮操作

DOMParser
流式输出是从上往下输出的。那可以利用这个特性。做一个比较粗颗粒度的缓存对比。基于上一种方式把 markdown 转换成 HTML 字符串传进来做处理。
- 先把整个 HTML 字符串进行
DOMParser.parseFromString
- 获取
doc.body.children
也就是一级标签进行缓存 - 如果解析出来的数组长度大于缓存数组的长度则为新增。否则为更新
- 根据 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
函数 去做扩展。
整体思路是:
- 利用
mdast-util-from-markdown
的fromMarkdown
方法转换成mdast
- 再把
mdast
通过toHast
转换成html ast
- 拿到
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 渲染插件。请务必告知!