问题是如何出现的
在做AI应用的时候,渲染后端小伙伴通过sse流式返回的markdown数据的时候,前端一般是通过v-html直接渲染到页面上,但是问题出现了:因为每次后端返回数据的时候,前端是通过一个变量接收拼接后,再全量渲染的,如果markdown只有纯文字的话问题不大,但是如果有图片或者echarts图、视频的话,那这些元素就会被重复渲染,导致页面会闪烁。
不能忍,坚决不能忍!!!
如何解决
解决方案:交给vue
不管你是使用markdown-it、marked、remark哪个markdown转换工具,最终的解决思路就是将markdown数据通过vue的h函数转成vnode,再进行渲染,这样vue在重复渲染的时候,会利用diff算法避免重复渲染。
思路有了,如何实现呢
在调研期间,也是走了不少弯路,下面说两种实现方案吧,条条大路通罗马
1、将markdown转成的html通过htmlparse2这个插件转成ast树,然后再递归将ast树的每一层的每一个节点转成vnode,示例:
javascript
<script setup>
import { ref, computed } from 'vue'
import MarkdownIt from 'markdown-it' // 用其他markdown渲染工具同理
import data from './README.md?raw' // ?raw表示将资源引入为字符串
import { ElButton } from 'element-plus'
import { parseDocument } from 'htmlparser2'
import DOMPurify from 'dompurify'
const md = new MarkdownIt()
let str = ref(''),
content = ref(),
index = ref(0)
// 模拟流式输出
function mockStream() {
let timer = setInterval(() => {
if (index.value >= data.length) {
clearInterval(timer)
return
}
str.value += data.substring(index.value, index.value + 1)
content.value = str.value
index.value++;
}, 100)
}
// html转ast
const renderedContent = computed(() => {
// Markdown模式添加安全过滤和样式类,并处理成dom ast
return parseDocument(DOMPurify.sanitize(
md.render(processedContent.value)
)).children
})
// html ast转vnode核心代码
const MarkdownNodeRenderer = defineComponent({
name: 'MarkdownNodeRenderer',
props: {
node: {
type: Object,
required: true,
},
},
setup(props) {
return () => {
const { node } = props;
if (node.type === 'text') {
return node.data
} else {
return h(
node.tagName,
{ ...node.attribs },
node.children.map((child, index) =>
h(MarkdownNodeRenderer, { node: child, key: index })
)
)
}
}
},
})
</script>
<template>
<div>
<ElButton type="primary" @click="mockStream">提问</ElButton>
<MarkdownNodeRenderer v-for="(node, index) in renderedContent" :key="index" :node="node" />
</div>
</template>
该方案简单直接,如果有自定义渲染的需求的话,可以在MarkdownNodeRenderer过程拦截node.type实现自定义渲染
2、unifiedjs生态(remark/rehype)(推荐)
unifiedjs生态是一个很强大的数据处理的插件系统,可以自行百度了解
当然,也可以按照第一种方法通过unifiedjs来处理数据并转成vnode,但是unifiedjs插件系统可以做到更多,大概处理流程如下:

画功有限,望理解
代码示例如下:
js
const processor = unified()
.use(remarkParse)
.use(remarkBreaks)
.use(remarkGfm, { singleTilde: false })
.use(remarkMath)
.use(remarkGemoji)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
const hastToVNode = (node) => {
if (!node) return null
switch (node.type) {
case 'root':
return h(
'div',
{
class: 'markdown-body'
},
node.children?.map((child) => hastToVNode(child))
)
case 'element':
// 通过父级节点判断代码块类型,优先处理pre标签
if (node.tagName === 'pre') {
const codeNode = node.children?.find((child) => child.tagName === 'code')
if (codeNode) {
// Mermaid
if (codeNode.properties?.className?.includes('language-mermaid')) {
return h(MermaidParser, { code: codeNode.children[0].value })
}
// Math
if (codeNode.properties?.className?.includes('language-math')) {
return h('div', {
class: 'math-block',
innerHTML: katex.renderToString(codeNode.children[0].value, {
displayMode: true,
throwOnError: false
})
})
}
// 其它代码块
const lang = codeNode.properties?.className?.[0]?.replace('language-', '') || ''
return h(CodeParser, { code: codeNode.children[0].value, lang })
}
}
// 行内代码
if (node.tagName === 'code' && !node.properties?.className) {
return h(
'code',
{},
node.children?.map((child) => hastToVNode(child))
)
}
// 行内公式
if (node.tagName === 'code' && node.properties?.className?.includes('math-inline')) {
return h('span', {
class: 'math-inline',
innerHTML: katex.renderToString(node.children[0].value, {
displayMode: false,
throwOnError: false
})
})
}
// 配置a标签的target属性
if (node.tagName === 'a') {
return h(
'a',
{
...node.properties,
target: '_blank',
rel: 'noopener noreferrer'
},
node.children?.map((child) => hastToVNode(child))
)
}
return h(
node.tagName,
node.properties,
node.children?.map((child) => hastToVNode(child))
)
case 'text':
return node.value.trim()
case 'comment':
return h('span', { class: 'comment' }, `<!-- ${node.value} -->`)
default:
// 对于未知类型的节点,如果有子节点则渲染子节点,否则返回 null
return node.children
? h(
'span',
{},
node.children.map((child) => hastToVNode(child))
)
: null
}
}
const VNodeTree = ref('')
watch(
() => props.content,
async (content) => {
const hast = await processor.run(processor.parse(content))
VNodeTree.value = hastToVNode(hast)
},
{ immediate: true }
)
使用
js
<template>
<component :is="VNodeTree" />
</template>
上面的示例中有部分自定义渲染成vue组件的逻辑,大家可以参考。unifiedjs还能做到更多,可以自行去探索。
总结
解决方案都是将markdown数据转成了vnode交给vue渲染,但是转vnode的步骤都不一样,大家可以自行做取舍,或者可以按照其他思路转成vnode
其他
- 在react生态中有个react-markdown也是基于unifiedjs封装的,但是它更纯碎,更灵活。
- markdown-it也可以根据tokens转成vnode,欢迎大家提供示例。