阅读视图

发现新文章,点击刷新页面。

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

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

背景

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

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

根据数据分析,本月销售情况如下:

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

从图表可以看出...

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

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


一、插件系统设计

1.1 核心思路

插件系统的核心流程:

流式内容: [[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 占位符(而非直接删除,后面会讲为什么)。

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() 实现:

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,支持所有功能
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 通配符动画
* {
  animation: fade-in 0.6s ease-in-out;
}

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

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

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

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

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

修复:缓存组件映射:

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 重绘。

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

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

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

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

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):

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"> 包裹,确保被解析为块级元素:

return `\n\n<div class="v3md-plugin-container"><v3md-loading></v3md-loading></div>\n\n`;

五、插件默认内置

5.1 用户体验优化

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

<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 插件:

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

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

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

const defaultRegistry = createPluginRegistry();

模板中 fallback:

<VueMarkdownStreamRender :pluginRegistry="pluginRegistry || defaultRegistry" />

现在用户只需:

<MarkdownRender :markInfo="content" />

ECharts 图表就能直接渲染。


六、ref 标签点击事件

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

6.1 组件映射

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

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 跨层级传递事件回调:

// 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 正则提取引用编号

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] 等格式。


七、整体架构

┌─────────────────────────────────────────────────────┐
                    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 跨层级传递

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

❌