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

4,487 阅读4分钟

实现的效果:

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

核心设计原则

四个要点: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

Vue3 现成的组件方案:github.com/ttLeslie/vu…