如何优雅的在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,欢迎大家提供示例。
相关推荐
secondyoung13 小时前
Markdown转换为Word:Pandoc模板使用指南
开发语言·经验分享·笔记·c#·编辑器·word·markdown
Source.Liu2 天前
【mdBook】6 在持续集成中运行 mdbook
markdown
Source.Liu3 天前
【mdBook】5.5 mdBook 特色功能
markdown
Source.Liu5 天前
【mdBook】7.1 预处理器
markdown
Source.Liu5 天前
【mdBook】5.2.3 渲染器配置详解
markdown
Source.Liu7 天前
【mdBook】5.2 配置
markdown
Source.Liu7 天前
【mdBook】1 安装
笔记·rust·markdown
qq7422349848 天前
免费版Markdown 编辑器:Typora
大模型·编辑器·markdown
Georgewu8 天前
【鸿蒙开源技术共建】用@luvi/lv-markdown-in在HarmonyOS上打造高性能Markdown编辑体验
harmonyos·markdown
Source.Liu9 天前
mdBook 开源笔记
笔记·rust·markdown