markdown 渲染自定义组件

现在在做ai问答,大模型流式输出markdown文本,一说到markdown文本大家想到的是不是v-html,我一直也用的是v-html,但是最近遇到一个需求,要求在markdown文本中文件引用部分鼠标悬浮要展示气泡卡片

传统的v-html是全量渲染,不支持渲染自定义组件,它只能渲染包含html标签的字符串,如果只是纯展示,可以用它,简单方便,但是如果需要事件跟踪,那就不适用了,下面我们来说一下markdown-it

markdown-it是一个功能强大、可扩展的 Markdown 解析器 ,用于将 Markdown 文本转换为 HTML,并支持丰富的插件系统,哈哈插件是这篇文章的主题哦

js 复制代码
import MarkdownIt from 'markdown-it';
/**
 * 渲染 Markdown 文本时为代码块添加复制功能
 */
const md: MarkdownIt = new MarkdownIt({
  html: true,
  highlight: function (str: string, lang: string): string {
    try {
      if (lang && hljs.getLanguage(lang)) {
        const languageName = lang.charAt(0).toUpperCase() + lang.slice(1);
        return '<pre class="code-block hljs relative">' +
          `<div class="code-title mb-3 mx-[-12px] sticky left-[-12px] px-3">${languageName}<span class="copy-icon cursor-pointer" data-code="${str.replace(/"/g, '&quot;')}">复制</span></div>` +
          '<code class="code-wrapper">' +
          hljs.highlight(str, { language: lang }).value +
          '</code></pre>';
      }
    } catch (e) {
      console.error("Highlighting error:", e);
    }

    // 如果没有语言或发生错误,返回普通的 HTML
    return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
  }
}).use(referencePlugin); // referencePlugin就是我们自定义的插件

/**
 * 自定义插件
 * 在 Markdown 文本中识别形如 [ID:123] 的标记。
 * 解析出数字 123。
 * 生成一个类型为 'reference' 的 token,携带这个数字。
 * 这个 token 后续可以被渲染成自定义的 HTML 或 Vue 组件。
 */
function referencePlugin(md: MarkdownIt) {
  // 自定义规则处理 [ID:x]
  md.inline.ruler.before('link', 'reference', (state, silent) => {
    const max = state.posMax;
    const start = state.pos;

    // 检查是否以 [ID: 开头
    if (state.src.charCodeAt(start) !== 0x5B/* [ */) return false;
    if (state.src.slice(start, start + 4) !== '[ID:') return false;

    const end = state.src.indexOf(']', start + 4);

    if (end === -1) return false;

    // 提取 ID 值
    const idText = state.src.slice(start + 4, end);

    const id = parseInt(idText, 10);
    if (isNaN(id)) return false;

    if (!silent) {
      // 创建 token
      const token = state.push('reference', '', 0);
      token.content = id.toString();
      token.attrSet('id', id.toString());
    }

    state.pos = end + 1;
    return true;
  });
}

然后把需要markdown渲染的文本封装成组件:

js 复制代码
  <!-- 机器人回答渲染markdown文本 -->
  <MarkdownRenderer
    v-if="message.output.answer"
    :markdownText="message.output.answer"
    :chunks="message?.chunks || []"
  />

下面是MarkdownRenderer组件的具体实现:

js 复制代码
  <template>
  <div>
    <template v-for="(node, index) in renderedNodes" :key="index">
      <span v-if="typeof node === 'string'">{{ node }}</span>
      <component v-else :is="node" />
    </template>
  </div>
</template>

<script setup lang="ts">
import { computed, h } from "vue";
import type { VNode } from "vue";
import ReferencePopover from "@/views/aiHome/components/referencePopover/Index.vue";
import { md } from "../../data";

const props = defineProps<{
  markdownText: string;
  chunks: any[];
}>();

const tokens = computed(() => md.parse(props.markdownText || "", {}));

/**
 * 递归渲染所有内联 token
 */
function renderInlineTokens(tokens: any[], idx = 0): (string | VNode)[] {
  const children: (string | VNode)[] = [];
  while (idx < tokens.length) {
    const token = tokens[idx];

    if (token.type.endsWith("_open")) {
      const tag = token.tag;
      const closeType = token.type.replace("_open", "_close");
      let level = 1;
      let j = idx + 1;
      while (j < tokens.length) {
        if (tokens[j].type === token.type) level++;
        else if (tokens[j].type === closeType) level--;
        if (level === 0) break;
        j++;
      }
      const innerChildren = renderInlineTokens(tokens.slice(idx + 1, j));
      children.push(h(tag, {}, innerChildren));
      idx = j + 1;
      continue;
    } else if (token.type === "text") {
      children.push(token.content);
    } else if (token.type === "reference") {
      const id = token.attrGet("id");
      // props.chunks
      if (id >= 0 && id < props.chunks.length) {
        const chunk = props.chunks[id];
        // h(componentOrTag, props?, children?)
        children.push(h(ReferencePopover, { chunk }));
      }
    } else if (token.type === "softbreak" || token.type === "hardbreak") {
      children.push(h("br"));
    } else {
      children.push(token.content || "");
    }
    idx++;
  }
  return children;
}

function renderBlockTokens(tokens: any[], idx = 0): (string | VNode)[] {
  const children: (string | VNode)[] = [];
  while (idx < tokens.length) {
    const token = tokens[idx];

    if (token.type.endsWith("_open")) {
      const tag = token.tag;
      const closeType = token.type.replace("_open", "_close");
      let level = 1;
      let j = idx + 1;
      while (j < tokens.length) {
        if (tokens[j].type === token.type) level++;
        else if (tokens[j].type === closeType) level--;
        if (level === 0) break;
        j++;
      }

      const innerChildren = renderBlockTokens(tokens.slice(idx + 1, j));
      children.push(h(tag, {}, innerChildren));
      idx = j + 1;
      continue;
    } else if (token.type === "inline" && token.children) {
      children.push(...renderInlineTokens(token.children));
      idx++;
    } else if (token.type === "text") {
      children.push(token.content);
      idx++;
    } else {
      idx++;
    }
  }
  return children;
}

const renderedNodes = computed(() => renderBlockTokens(tokens.value));
</script>

其中个人感觉有个不好的点,自定义插件只是"告诉 Markdown-it 如何识别新语法",真正的渲染逻辑仍然依赖我们写的 renderInlineTokens / renderBlockTokens;因此每次拿到新的 markdownText 都必须重新跑一遍 md.parse() 来生成完整的 token 流,否则插件识别出的新 token 不会被渲染。

稍微解释一下Markdown-it工作流程

1.解析阶段(parse)

md.parse(markdownText) -> 生成token数组,这一步会把纯文本拆成 paragraph_opentextreferencestrong_open ... 等 token

2.渲染阶段(render)

默认 md.render()会把token 转成html字符串,但是我们是自定义渲染插件,所以我们跳过了这步,自己用 h() 把 token 转成 VNode

为什么必须重新 parse

  • markdownText 变化时,文本内容、顺序、甚至插件规则都可能不同。
  • 旧的 token 数组里 没有 新插件识别出的 token,或者 token 属性已经失效。
  • 因此必须重新 md.parse() 生成全新的 token 流,才能保证自定义语法被正确识别并渲染。
    所以重新生成所有 token不是重复劳动,而是保证最新内容与最新插件规则匹配的必要步骤。
相关推荐
qq_424409198 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app
我在北京coding8 小时前
element el-table渲染二维对象数组
前端·javascript·vue.js
布兰妮甜8 小时前
Vue+ElementUI聊天室开发指南
前端·javascript·vue.js·elementui
SevgiliD8 小时前
el-button传入icon用法可能会出现的问题
前端·javascript·vue.js
我在北京coding8 小时前
Element-Plus-全局自动引入图标组件,无需每次import
前端·javascript·vue.js
鱼 空8 小时前
解决el-table右下角被挡住部分
javascript·vue.js·elementui
01传说9 小时前
vue3 配置安装 pnpm 报错 已解决
java·前端·vue.js·前端框架·npm·node.js
sunbyte11 小时前
50天50个小项目 (Vue3 + Tailwindcss V4) ✨ | DoubleVerticalSlider(双垂直滑块)
前端·javascript·css·vue.js·vue
拾光拾趣录12 小时前
虚拟DOM
前端·vue.js·dom
合作小小程序员小小店13 小时前
web网页,在线%食谱推荐系统%分析系统demo,基于vscode,uniapp,vue,java,jdk,springboot,mysql数据库
vue.js·spring boot·vscode·spring·uni-app