如何优雅的在AI应用中渲染流式Markdown数据

问题是如何出现的

在做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

其他

  1. 在react生态中有个react-markdown也是基于unifiedjs封装的,但是它更纯碎,更灵活。
  2. markdown-it也可以根据tokens转成vnode,欢迎大家提供示例。
相关推荐
空中湖2 小时前
免费批量Markdown转Word工具
word·markdown
漫游者Nova2 天前
PDF转Markdown/JSON软件MinerU最新1.3.12版整合包下载
pdf·json·markdown·mineru
余子桃4 天前
Python实现markdown文件转word
python·word·markdown
Eric.Lee20215 天前
vscode实时预览编辑markdown
ide·vscode·编辑器·markdown
charlee446 天前
解决Vditor加载Markdown网页很慢的问题(Vite+JS+Vditor)
javascript·markdown·cdn·vditor
漫游者Nova6 天前
微软markitdown PDF/WORD/HTML文档转Markdown格式软件整合包下载
pdf·html·word·markdown·ppt
charlee4410 天前
使用Vditor将Markdown文档渲染成网页(Vite+JS+Vditor)
前端·javascript·vite·markdown·vditor
shaziln10 天前
Android Studio 解决报错 not support JCEF 记录
android studio·markdown