做大模型应用的你们,真的懂markdown渲染吗?

140 阅读6分钟

实现的效果:

遇到过的问题:当我禁止获取缓存时,会发生下面的效果:页面图片抖动、闪动

核心设计原则

四个要点:Web安全、高可用、易拓展和性能

站得高才飞得远,怎么在众多markdown渲染库和清理恶意 HTML的库中技术选型呢?

清理恶意 HTML:选择 DOMPurify。

  • 高安全性:DOMPurify 专注于防止 XSS 攻击,能有效清理恶意 HTML。
  • 跨平台支持:同时支持浏览器和 Node.js,适合前后端一致的 Markdown 渲染需求。

markdown渲染库:选择markdownIt

  • 生态丰富,各种插件都有,拓展性极强。
  • start数最多...也说明认可度...

markdownIt基本使用

通过包管理工具安装:

npm install markdown-it --save

markdown-it 的核心流程是:创建解析实例 → 配置选项 → 解析 Markdown 文本 → 生成 HTML

基础示例

javascript

// 引入 markdown-it
import MarkdownIt from 'markdown-it';

// 创建实例
const md = new MarkdownIt();

// Markdown 文本
const markdownText = `
# 标题 1
## 标题 2

这是一段普通文本,包含 **加粗**、*斜体* 和 \`代码\`。

- 列表项 1
- 列表项 2

[链接](https://example.com)
`;

// 解析为 HTML
const html = md.render(markdownText);
console.log(html); 
// 输出:包含对应标签的 HTML 字符串(如 <h1>、<strong>、<ul> 等)
核心配置项

示例:启用 HTML 解析 / 自动换行 / 自动识别链接

const md = MarkdownIt({ 
  html: true,       // markdown格式里内嵌html标签,可以进行解析 (默认 false)
  breaks: true,     // 是否将换行符(\n)解析为 <br> 标签(默认 false)
  linkify: true     // 自动识别链接(如 www.example.com 转为 <a>)(默认 false)
});
插件拓展

示例:使用 emoji 插件

  1. 安装插件:
npm install markdown-it-emoji --save
  1. 使用插件:
import MarkdownIt from 'markdown-it';
import emoji from 'markdown-it-emoji';

const md = MarkdownIt().use(emoji); // 集成 emoji 插件

// 解析包含 emoji 的 Markdown
const html = md.render('Hello :smile:!'); 
// 输出:<p>Hello 😊!</p>

其他常用插件:

  • <font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">markdown-it-table-of-contents</font>:生成目录
  • <font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">markdown-it-task-lists</font>:支持任务列表(<font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">- [x] 完成</font>
  • <font style="color:rgb(0, 0, 0);background-color:rgb(249, 250, 251);">markdown-it-prism</font>:代码块语法高亮(基于 Prism)
自定义渲染规则

自定义代码块(``` 包裹的内容)

例如:为代码块添加自定义类名和容器:

md.renderer.rules.fence = function (tokens, idx, options, env, self) {
  const token = tokens[idx];
  const code = token.content; // 代码内容
  const lang = token.info || 'plaintext'; // 代码语言(如 js、html)
  // 自定义渲染结构
  return `<div class="code-block lang-${lang}">
    <pre><code>${md.utils.escapeHtml(code)}</code></pre>
  </div>`;
};

DOMPurify基本使用

  1. 安装 DOMPurify:
npm install dompurify --save
  1. 渲染后净化 HTML:
import DOMPurify from 'dompurify';

const md = MarkdownIt({ html: true });
const dirtyHtml = md.render(markdownText);
const safeHtml = DOMPurify.sanitize(dirtyHtml); // 净化恶意代码

markdownIt和DOMPurify配合使用

  • 给一个很简单的示例吧,主要如何拓展和自定义后续的内容,可以进一步自己去封装
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
import { full as emoji } from 'markdown-it-emoji';

export default function useToMarkdownHtml(initialMarkdown) {
  let error = null;

  // 初始化 MarkdownIt 实例
  const md = MarkdownIt({
    html: false,
    breaks: true,
  }).use(emoji);

  // 默认的渲染方法
  const defaultRender = function (tokens, idx, options, env, self) {
    return self.renderToken(tokens, idx, options);
  };

  // 自定义表情符号渲染规则
  md.renderer.rules.emoji = function (tokens, idx, options, env, self) {
    return defaultRender(tokens, idx, options, env, self);
  };

  // 自定义链接渲染规则
  md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
    const token = tokens[idx];
    const hrefIndex = token.attrIndex('href');
    token.attrs.splice(hrefIndex, 1); // 删除 href 属性

    if (hrefIndex >= 0) {
      token.attrPush(['id', currentHref]); // 添加 id 属性
    }

    return defaultRender(tokens, idx, options, env, self);
  };


  // 获取渲染后的 HTML
  const getHtml = function () {
    try {
      let htmlContent = md.render(initialMarkdown);
      return DOMPurify.sanitize(htmlContent);
    } catch (err) {
      error = err;
      return useInitMarkdown(initialMarkdown);
    }
  };

  return {
    html: getHtml(),
    error,
  };
}

// Markdown 初始化处理
const MARKDOWN_CLEAN_PATTERNS = [
  [/\[([^\]]+)\]\(.*?\)/g, '$1'],   // 保留链接文字
  [/!\[.*?\]\(.*?\)/g, ''],         // 移除图片
  [/`/g, ''],                       // 移除代码标记
  [/\*/g, ''],                      // 移除星号
  [/#/g, ''],                       // 移除井号
  [/:[a-zA-Z0-9_]+:/g, '']         // 移除表情符号
];
export const useInitMarkdown = (markdown) => 
  MARKDOWN_CLEAN_PATTERNS.reduce(
    (acc, [pattern, replacement]) => acc.replace(pattern, replacement),
    markdown
  );
  • 关注几个细节:当遇上 a标签的时候,我们对其原本的 href属性是进行了剔除的,增加一个id属性给a标签上,那么跳转的时候就可以自定义跳转的方法了。
    jumpLink(e) {
      if (e.target.localName.toLowerCase() === 'a') {
        const url = e.target.id;
        // 自定义跳转方法
      }
    },
  • 实际上 html:false时,是可以不使用 DOMPurify.sanitize的,原因是markdownIt此时不会渲染markdown格式内容里面的标签的
  • 兜底策略:我们现在采用简单的去除标签来展示,当然你可以看业务要求选择别的更优雅的方式,实际上基本是不会触发这个 catch的,markdownIt解析的时候遇到非标准的也会解析为字符串,那么你就可以根据业务要求来自定义这个错误的捕获

一直在全量更新渲染,你知道吗?

就拿 vue3来进行举例吧,使用以上进行解析之后得到的是一个完整的 html结构,使用 v-html进行插入,然后 v-html实际上就是直接操作 DOM 的 innerHTML属性,将字符串解析为 HTML 并插入到目标元素中。

当渲染更新时,vue 在 diff的时候是无法顾及 v-html插入的内容的,也就是不会转为 vNode

当我禁止获取缓存时,会发生下面的效果:页面图片抖动、闪动

在使用缓存情况下,如果markdown只有纯文字和图片的话问题不大。

但是如果有echarts图、视频的话,那这些元素就会被重复渲染,以至于甚至会导致闪烁和卡顿。

局部更新具体方案

  • 既然 vue不会将其转为 vnode,那么我们进行手动转,流程如下
  • markdown -> html -> ast -> vnode

unifiedjs生态是一个很强大的数据处理的插件系统,可处理多种数据,使用示例如下:

// use-markdown-renderer.ts
import { ref, watch, type Ref, type VNode, h, computed } from 'vue';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import remarkGemoji from 'remark-gemoji';
import remarkRehype from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
import katex from 'katex';
import type { Root } from 'hast';
import type { Plugin } from 'unified';

export interface MarkdownRenderers {
  mermaid?: any;
  code?: any;
  image?: any;
  video?: any;
  math?: any;
  [key: string]: any;
}

export interface UseMarkdownRendererOptions {
  content: string;
  safeMode?: boolean;
  plugins?: Plugin[];
  renderers?: MarkdownRenderers;
}

export function useMarkdownRenderer(options: UseMarkdownRendererOptions) {
  const vNodeTree = ref<VNode | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);

  // 创建 markdown 处理器
  const processor = computed(() => {
    let p = unified()
      .use(remarkParse)
      .use(remarkBreaks)
      .use(remarkGfm, { singleTilde: false })
      .use(remarkMath)
      .use(remarkGemoji)
      .use(remarkRehype, { allowDangerousHtml: !options.safeMode })
      .use(rehypeRaw);

    options.plugins?.forEach(plugin => p.use(plugin));
    return p;
  });

  // 完整转换逻辑
  const createVNodeTransformer = (renderers?: MarkdownRenderers) => {
    const handlePreElement = (node: any): VNode | null => {
      const codeNode = node.children?.find((child: any) => child.tagName === 'code');
      if (!codeNode) return h('pre', {}, node.children?.map((child: any) => hastToVNode(child)));

      const className = codeNode.properties?.className || [];
      const classArray = Array.isArray(className) ? className : [className];
      const classStr = classArray.join(' ');

      // Mermaid 图表
      if (classStr.includes('language-mermaid')) {
        const code = codeNode.children?.[0]?.value || '';
        return renderers?.mermaid 
          ? h(renderers.mermaid, { code })
          : h('div', { class: 'mermaid-error' }, 'Mermaid组件未注册');
      }

      // 数学公式块
      if (classStr.includes('language-math')) {
        const mathCode = codeNode.children?.[0]?.value || '';
        return renderers?.math
          ? h(renderers.math, { code: mathCode, displayMode: true })
          : h('div', {
              innerHTML: katex.renderToString(mathCode, {
                displayMode: true,
                throwOnError: false
              })
            });
      }

      // 普通代码块
      const lang = classArray.find(c => typeof c === 'string' && c.startsWith('language-'))?.replace('language-', '') || '';
      const code = codeNode.children?.[0]?.value || '';
      
      return renderers?.code
        ? h(renderers.code, { code, language: lang })
        : h('pre', {}, h('code', codeNode.properties, code));
    };

    const handleCodeElement = (node: any): VNode => {
      const className = node.properties?.className || [];
      const classArray = Array.isArray(className) ? className : [className];
      
      // 行内公式
      if (classArray.includes('math-inline')) {
        const mathCode = node.children?.[0]?.value || '';
        return renderers?.math
          ? h(renderers.math, { code: mathCode, displayMode: false })
          : h('span', {
              innerHTML: katex.renderToString(mathCode, {
                displayMode: false,
                throwOnError: false
              })
            });
      }

      return h('code', node.properties, node.children?.map((child: any) => hastToVNode(child)));
    };

    const hastToVNode = (node: any): VNode | string | (VNode | string)[] | null => {
      if (!node) return null;
      
      if (node.type === 'root') {
        return h(
          'div', 
          { class: 'markdown-body' },
          node.children?.map((child: any) => hastToVNode(child))
        );
      }
      
      if (node.type === 'element') {
        const { tagName, properties, children } = node;
        
        if (renderers?.[tagName]) {
          return h(renderers[tagName], { ...properties, node });
        }

        switch (tagName) {
          case 'pre': return handlePreElement(node);
          case 'code': return handleCodeElement(node);
          case 'a': return h('a', { ...properties, target: '_blank', rel: 'noopener noreferrer' }, children?.map(hastToVNode));
          case 'img': return renderers?.image ? h(renderers.image, properties) : h('img', properties);
          case 'video': return renderers?.video ? h(renderers.video, properties) : h('video', properties);
          default: return h(tagName, properties, children?.map(hastToVNode));
        }
      }
      
      if (node.type === 'text') return node.value;
      
      return null;
    };

    return hastToVNode;
  };

  // 处理 markdown 内容
  const processMarkdown = async (content: string) => {
    loading.value = true;
    error.value = null;

    try {
      const file = await processor.value.process(content);
      const hastTree = file.result as Root;
      const transformer = createVNodeTransformer(options.renderers);
      return transformer(hastTree);
    } catch (err) {
      error.value = (err as Error).message;
      return h('div', { class: 'render-error' }, 'Markdown 处理错误');
    } finally {
      loading.value = false;
    }
  };

  // 自动监听内容变化
  watch(
    () => [options.content, options.renderers, options.plugins],
    async ([newContent]) => {
      if (newContent) {
        vNodeTree.value = await processMarkdown(newContent) as VNode;
      }
    },
    { immediate: true }
  );

  return {
    vNodeTree,
    loading,
    error,
    refresh: () => processMarkdown(options.content)
  };
}

组件使用示例 (MarkdownRenderer.vue):

<template>
  <div class="markdown-renderer">
    <component :is="vNodeTree" v-if="vNodeTree" />
    <div v-if="loading" class="loading-indicator">
      <div class="spinner"></div>
      <span>渲染中...</span>
    </div>
    <div v-if="error" class="error-message">
      <span>⚠️ {{ error }}</span>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { useMarkdownRenderer } from './use-markdown-renderer';
import type { MarkdownRenderers } from './use-markdown-renderer';

const props = defineProps<{
  content: string;
  safeMode?: boolean;
  plugins?: any[];
  renderers?: MarkdownRenderers;
}>();

const { vNodeTree, loading, error } = useMarkdownRenderer({
  content: props.content,
  safeMode: props.safeMode,
  plugins: props.plugins,
  renderers: props.renderers
});
</script>