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插件解析 -
组件映射:在
toJsxRuntime的components参数中注册自定义标签到 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 原生配置需要写 series、xAxis、yAxis 等,但用户只想写 {"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;
}
原因二:组件映射引用不稳定
toJsxRuntime 的 components 参数每次渲染都是新对象。更严重的是,如果 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 三种碎片场景
| 碎片类型 | 示例 | 处理方式 |
|---|---|---|
| 不完整图片 | :
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 一样,通过 toJsxRuntime 的 components 映射将 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.vue 的 emit('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 │ ││ │
│ │ │ └───────────┘ ││ │
│ │ └─────────────────┘│ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
总结
这次插件化改造踩了五个主要坑:
- 不完整语法导致解析错乱 → 正则清理 + loading 占位
- ECharts 配置门槛高 → 简单模式自动补全
- 流式渲染闪烁 → CSS 排除 + 组件缓存 + Config 去重(三层修复)
-
自定义标签被
<p>包裹 → 块级 div 包裹 + 空行隔离 - VNode 树中事件无法冒泡 → provide/inject 跨层级传递
最深刻的教训是:流式渲染场景下,任何"引用不稳定"都会被放大。普通场景中组件重建一次可能无感,但流式场景下每秒更新数十次,组件反复销毁重建就变成了闪烁。核心策略是:能缓存就缓存,能比较就比较,能跳过就跳过。