Markdown 渲染如何穿插自定义组件

在 Vue 3 流式 Markdown 渲染器中实现插件化自定义组件------踩坑全记录

背景

v3-markdown-stream 是一个基于 Vue 3 的高性能 Markdown 流式渲染组件,核心特性是支持 LLM 场景下的增量输出渲染------内容一段一段地追加,页面实时更新,无闪屏、无卡顿。

随着 AI 对话场景的丰富,单纯渲染文本已经不够了。我们希望在 Markdown 流式输出中直接嵌入图表、自定义组件。比如 LLM 返回:

lua 复制代码
根据数据分析,本月销售情况如下:

[[echarts {"type":"bar","data":[10,20,30,40,50]}]]

从图表可以看出...

渲染器应该识别 [[echarts ...]] 语法,直接在 Markdown 中渲染出 ECharts 图表。

听起来简单,实际开发中踩了一堆坑。本文记录整个开发过程和解决方案。


一、插件系统设计

1.1 核心思路

插件系统的核心流程:

lua 复制代码
流式内容: [[echarts {"type":"bar","data":[10,20,30]}]]
    ↓ 正则匹配
转换后: <v3md-echarts data-config="..." data-key="..."></v3md-echarts>
    ↓ rehype-raw 解析 HTML
HAST 树中包含自定义标签节点
    ↓ toJsxRuntime 组件映射
渲染为 ECharts Vue 组件

关键设计决策:

  • 正则匹配 :用 [[插件名 JSON配置]] 语法,正则 \[\[echarts\s+([\s\S]*?)\]\] 匹配
  • HTML 标签桥接 :将匹配结果转换为自定义 HTML 标签,利用已有的 rehype-raw 插件解析
  • 组件映射 :在 toJsxRuntimecomponents 参数中注册自定义标签到 Vue 组件的映射

1.2 流式场景的"不完整语法"问题

流式输出时,内容是逐步到达的。[[echarts {"type":"bar","data":[10,20 这样的内容在某一时刻是不完整的------JSON 没闭合、]] 没出现。

第一个坑:不完整的插件语法会导致后续所有 Markdown 解析错乱。

如果正则匹配不到完整的 [[...]],残留的 [ 会被 Markdown 解析器当作链接语法,导致后续内容渲染异常。

解决方案 :对不完整的插件语法进行清理,用正则 [[echarts\b[\s\S]*$ 匹配流末尾的不完整语法,将其替换为 loading 占位符(而非直接删除,后面会讲为什么)。

js 复制代码
for (const [, plugin] of pluginMap) {
  const incompleteRegex = new RegExp(
    `\\[\\[${escapeRegex(plugin.name)}\\b[\\s\\S]*$`,
    'g'
  );
  result = result.replace(incompleteRegex, () => {
    return `\n\n<div class="v3md-plugin-container"><v3md-loading></v3md-loading></div>\n\n`;
  });
}

二、ECharts 组件的集成

2.1 动态导入

ECharts 体积很大(压缩后约 1MB),不能让所有用户都加载。使用动态 import() 实现:

js 复制代码
const initChart = async () => {
  const echarts = await import('echarts');
  chartInstance.value = echarts.init(chartRef.value);
  chartInstance.value.setOption(getOption(props.config));
};

2.2 配置解析------简单模式 vs 完整模式

第二个坑:用户期望的配置方式和 ECharts 原生配置差距很大。

ECharts 原生配置需要写 seriesxAxisyAxis 等,但用户只想写 {"type":"bar","data":[10,20,30]}

解决方案:支持两种模式:

  • 简单模式type + data,自动补全坐标轴等配置
  • 完整模式 :直接传 ECharts 的 option,支持所有功能
js 复制代码
const getOption = (config) => {
  const { type, data, width, height, ...rest } = config;
  const option = { ...rest };

  if (type && !option.series) {
    option.series = [{ type, data: data || [] }];
  }

  if (!option.xAxis && !option.yAxis && type === 'bar') {
    option.xAxis = { type: 'category', data: data.map((_, i) => `${i + 1}`) };
    option.yAxis = { type: 'value' };
  }
  // ...
  return option;
};

三、闪烁问题------最大的坑

这是整个开发过程中最棘手的问题。流式追加内容时,ECharts 图表会不断闪烁(消失再出现),体验极差。

3.1 原因分析

经过深入排查,闪烁有三层原因

原因一:CSS 通配符动画
css 复制代码
* {
  animation: fade-in 0.6s ease-in-out;
}

这个通配符选择器让所有元素每次 DOM 更新都重新触发淡入动画。Vue 虽然复用了 DOM 节点,但 CSS 动画会在元素属性变化时重新触发。

修复:排除插件容器及其子元素:

css 复制代码
*:not(.v3md-plugin-container):not(.v3md-plugin-container *) {
  animation: fade-in 0.6s ease-in-out;
}
原因二:组件映射引用不稳定

toJsxRuntimecomponents 参数每次渲染都是新对象。更严重的是,如果 getComponentMappings() 每次返回新的组件定义,Vue 会认为是不同的组件,直接销毁重建。

js 复制代码
// ❌ 每次调用都创建新的 defineComponent
function getComponentMappings() {
  const mappings = {};
  for (const [, plugin] of pluginMap) {
    mappings[plugin.tagName] = createPluginWrapper(plugin); // 每次都是新组件!
  }
  return mappings;
}

修复:缓存组件映射:

js 复制代码
let cachedMappings = null;

function getComponentMappings() {
  if (cachedMappings) return cachedMappings;
  cachedMappings = {};
  for (const [, plugin] of pluginMap) {
    cachedMappings[plugin.tagName] = createPluginWrapper(plugin);
  }
  return cachedMappings;
}
原因三:Config 对象引用每次都是新的

这是最隐蔽的问题。流式追加内容时,props.node 引用每次都变(因为 HAST 树重建),即使 data-config 字符串完全相同,watch 也会触发,生成新的 config 对象。ECharts 组件的 deep: true watch 检测到"新"对象,就调用 setOption 重绘。

js 复制代码
// ❌ 即使 config 内容相同,对象引用不同就会触发
watch(() => props.config, (newConfig) => {
  chartInstance.value.setOption(getOption(newConfig));
}, { deep: true });

修复:在两层都做字符串比较去重:

层一------Plugin Wrapper :比较原始 data-config 字符串,相同则不更新 configRef

js 复制代码
let lastRawConfig = '';

watch(() => props.node, (node) => {
  const rawConfig = node.properties?.['data-config'] || '';
  if (rawConfig === lastRawConfig) return;  // 字符串相同,跳过
  lastRawConfig = rawConfig;
  configRef.value = JSON.parse(decodeURIComponent(rawConfig));
});

层二------ECharts 组件 :比较 JSON 序列化结果,相同则跳过 setOption

js 复制代码
let lastConfigJson = '';

const updateChart = (newConfig) => {
  const newJson = JSON.stringify(newConfig);
  if (newJson === lastConfigJson) return;  // 内容相同,跳过
  lastConfigJson = newJson;
  chartInstance.value.setOption(getOption(newConfig));
};

3.2 闪烁修复总结

层级 问题 修复
CSS * 通配符动画影响插件容器 :not() 排除
组件映射 每次返回新组件定义 缓存 cachedMappings
Config 传递 node 引用变化触发不必要的更新 字符串比较去重
ECharts 更新 deep: true watch 过于敏感 JSON 序列化比较去重

四、流式碎片的 Loading 状态

4.1 从"删除"到"Loading"

最初处理流式碎片的方式是直接删除:不完整的图片删掉、不完整的数学公式删掉、不完整的插件语法删掉。

问题:用户看到内容突然消失又出现,体验很差。比如图片 URL 传到一半被删掉,传完整后又突然出现,视觉上就是"闪一下"。

改进:将碎片内容替换为 loading 动画,内容完整后自动替换为实际渲染结果。

4.2 三种碎片场景

碎片类型 示例 处理方式
不完整图片 ![alt](http://incom 替换为 <v3md-loading>
未闭合公式 $$ x^2 + 删除未闭合部分 + 替换为 <v3md-loading>
不完整插件 [[echarts {"type": 替换为 <v3md-loading>

4.3 Loading 组件的虚拟 DOM 实现

Loading 动画使用 three-body 旋转点动画,需要用虚拟 DOM 实现(因为整个渲染管线都是虚拟 DOM):

js 复制代码
const V3mdLoading = defineComponent({
  name: 'V3mdLoading',
  setup() {
    return () =>
      h('div', { class: 'v3md-loading' }, [
        h('div', { class: 'three-body' }, [
          h('div', { class: 'three-body__dot' }),
          h('div', { class: 'three-body__dot' }),
          h('div', { class: 'three-body__dot' }),
        ]),
      ]);
  },
});

第四个坑:<v3md-loading> 标签被 Markdown 解析器包裹在 <p> 标签内。

自定义标签在 Markdown 中默认被当作行内 HTML,被 <p> 包裹。流式追加时 <p> 的结构变化导致 VNode 树不稳定。

修复 :在替换时前后加空行,并用 <div class="v3md-plugin-container"> 包裹,确保被解析为块级元素:

js 复制代码
return `\n\n<div class="v3md-plugin-container"><v3md-loading></v3md-loading></div>\n\n`;

五、插件默认内置

5.1 用户体验优化

最初的设计要求用户手动引入和配置:

vue 复制代码
<script setup>
import { createPluginRegistry } from 'v3-markdown-stream'
import { echartsPlugin } from './echarts-plugin.js'
const registry = createPluginRegistry([echartsPlugin])
</script>

<template>
  <MarkdownRender :pluginRegistry="registry" />
</template>

这对用户来说太繁琐了。ECharts 是最常用的图表库,应该开箱即用。

修复 :在 createPluginRegistry 中默认包含 echarts 插件:

js 复制代码
import { echartsPlugin } from './echarts-plugin.js';
const DEFAULT_PLUGINS = [echartsPlugin];

export function createPluginRegistry(plugins = []) {
  const allPlugins = [...DEFAULT_PLUGINS, ...plugins];
  // ...
}

markdownRender.vue 中自动创建默认 registry:

js 复制代码
const defaultRegistry = createPluginRegistry();

模板中 fallback:

vue 复制代码
<VueMarkdownStreamRender :pluginRegistry="pluginRegistry || defaultRegistry" />

现在用户只需:

vue 复制代码
<MarkdownRender :markInfo="content" />

ECharts 图表就能直接渲染。


六、ref 标签点击事件

Markdown 中使用 <ref>[3]</ref> 标注引用,点击时需要将引用编号上报给父组件。

6.1 组件映射

和 ECharts 一样,通过 toJsxRuntimecomponents 映射将 ref 标签映射到 Vue 组件:

js 复制代码
const baseComponents = {
  table: TableCode,
  pre: PreCode,
  ref: RefTag,
};

6.2 事件传递------provide/inject 模式

第五个坑:toJsxRuntime 生成的 VNode 树中,组件无法直接 emit 事件到上层。

因为 RefTag 组件不是 markdownRender.vue 的直接子组件,中间隔了 markdown-parse.js 和 VNode 树多层嵌套,emit 事件无法冒泡。

解决方案 :使用 provide/inject 跨层级传递事件回调:

js 复制代码
// markdown-parse.js - provide
provide(REF_CLICK_KEY, (numbers) => {
  if (props.onRefClick) {
    props.onRefClick(numbers);
  }
});

// ref-tag.js - inject
const onRefClick = inject(REF_CLICK_KEY, null);

// 点击时调用
onClick: (e) => {
  if (onRefClick && numbers.length > 0) {
    onRefClick(numbers);
  }
}

最终通过 markdownRender.vueemit('refClick', numbers) 暴露给父组件。

6.3 正则提取引用编号

js 复制代码
const extractRefNumbers = (node) => {
  const text = getTextContent(node);  // 递归提取所有文本子节点
  const match = text.match(/\[(\d+(?:\s*,\s*\d+)*)\]/);
  if (match) {
    return match[1].split(/\s*,\s*/).map(Number);
  }
  return [];
};

支持 [3][1,2,3][1, 2, 3] 等格式。


七、整体架构

yaml 复制代码
┌─────────────────────────────────────────────────────┐
│                    MarkdownRender                     │
│  props: markInfo, themeColor, pluginRegistry         │
│  emit: refClick                                      │
│  ┌─────────────────────────────────────────────────┐ │
│  │            markdown-parse.js                     │ │
│  │  ┌───────────┐  ┌──────────┐  ┌──────────────┐ │ │
│  │  │ stripBroken│→ │ transform │→ │   unified    │ │ │
│  │  │  Images    │  │ Markdown  │  │  processor   │ │ │
│  │  │ (loading)  │  │ (plugins) │  │  (HAST)      │ │ │
│  │  └───────────┘  └──────────┘  └──────┬───────┘ │ │
│  │                                       │         │ │
│  │                              ┌────────▼────────┐│ │
│  │                              │  toJsxRuntime   ││ │
│  │                              │  components:    ││ │
│  │                              │  ┌───────────┐  ││ │
│  │                              │  │ table     │  ││ │
│  │                              │  │ pre       │  ││ │
│  │                              │  │ ref       │  ││ │
│  │                              │  │ v3md-*    │  ││ │
│  │                              │  │ loading   │  ││ │
│  │                              │  └───────────┘  ││ │
│  │                              └─────────────────┘│ │
│  └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘

总结

这次插件化改造踩了五个主要坑:

  1. 不完整语法导致解析错乱 → 正则清理 + loading 占位
  2. ECharts 配置门槛高 → 简单模式自动补全
  3. 流式渲染闪烁 → CSS 排除 + 组件缓存 + Config 去重(三层修复)
  4. 自定义标签被 <p> 包裹 → 块级 div 包裹 + 空行隔离
  5. VNode 树中事件无法冒泡 → provide/inject 跨层级传递

最深刻的教训是:流式渲染场景下,任何"引用不稳定"都会被放大 。普通场景中组件重建一次可能无感,但流式场景下每秒更新数十次,组件反复销毁重建就变成了闪烁。核心策略是:能缓存就缓存,能比较就比较,能跳过就跳过

相关推荐
橙子家6 分钟前
浏览器缓存之【结构化数据库与缓存】: IndexedDB、Cache storage 和 Storage buckets
前端
user205855615181312 分钟前
X6 中边悬浮置顶,规避 `mouseleave` 事件丢失问题
前端
李明卫杭州13 分钟前
CSS aspect-ratio 属性完全指南
前端
Pedantic2 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘2 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆3 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师4 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆4 小时前
VSCode自动格式化三要素
前端
爱勇宝4 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员