用MarkdownAST来渲染Vue组件
理论上运行时框架和编译时框架都能用这种方法
传统派渲染Markdown
现在大模型火热,各种chat模式五花八门的,而chat模式下大模型输出内容时,常常需要动态渲染 Markdown。而这些由模型输出的 Markdown 内容往往类型丰富、结构复杂,甚至包含复杂且多样化的交互需求——比如最基础的代码块,不仅要实现高亮显示,还得在 <code> 区域上方展示语言名称,并附带一个“复制”按钮。
传统的做法是通过 marked、markdown-it 这些库解析 Markdown 字符串,然后生成 HTML 字符串,再用 v-html 渲染。这种方式简单直接,但是有很多问题:
安全性差:原始 HTML 可能包含恶意脚本,极易引发 XSS 攻击,还需额外引入
DOMPurify等库进行净化;不支持组件嵌入:无法在 Markdown 中插入自定义 Vue 组件或执行业务逻辑;
交互能力受限:实现“复制代码”、“折叠段落”、“实时预览”等复杂交互行为的成本很高。
而 使用 Markdown AST(抽象语法树)来渲染组件,就可以突破这些限制,实现更灵活、安全、强大的渲染能力。
AST的能力
之前写过的关于AST的介绍→Complier&AST-前端工具链开发基础 | NoelOrin's Blog
简单来说,AST(抽象语法树)赋予我们对每个语法节点进行精确控制和定制处理的能力。 正因为代码被解析成了结构化的树形数据,这给我们带来了极大的灵活性,例如:
自定义 Markdown 语法:识别
[!TIP]并渲染为<Alert type="tip" />;智能代码转换:在 Babel 中将
??空值合并运算符降级为兼容写法;静态分析与检查:ESLint 通过 AST 检测未使用的变量或潜在 bug;
Markdown AST
传统方法的渲染流程是Markdown -> 转译HTML -> DOM
而使用AST的渲染流程Markdown -> 构建AST -> 生成VNodes (虚拟节点) -> DOM
构建AST
为了实现构建Markdown AST,我们需要一些工具。
其实 markdown-it也可以
npm install unified remark-parse
首先来个小demo演示一下。
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm';
const parser = unified()
.use(remarkParse)
.use(remarkGfm) // GFM语法插件 有这个才能用完整的markdown语法
.use(remarkParse)
const ast = parser.parse("# 任意Markdown")
得到的Markdown AST结构如下↓ 可以看到,转化为ast的markdown抽象语法树没有丢失原来markdown文本的信息,我们得到了信息完整且结构完整的树,这样便于我们去进一步操作。
{
"type": "root",
"children": [
{
"type": "heading",
"depth": 1,
"children": [
{ "type": "text", "value": "任意Markdown" }
]
}
]
}
AST 解析器
先写一个AST解析器,将AST节点转换为VNode。
const renderAst2Html = (node:any) => {
if (!node) return null;
switch (node.type) {
// ========== 容器类 ==========
case 'root':
return h('div', { class: 'md-root' }, node.children.map(renderAst));
case 'paragraph':
return h('p', {}, node.children.map(renderAst));
// ========== 代码块 ==========
case 'code':
return h(CodeBlock, {
lang: node.lang || '',
value: node.value,
meta: node.meta || ''
});
// 可继续扩展...
}
};
封装 Vue 动态组件
<template>
<component :is="VNodeTree" />
</template>
<script setup>
import { computed, shallowRef, watchEffect } from 'vue'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm';
import CodeBlock from "~/components/CodeBlock.vue"; // 自己封装的代码块组件
import { h } from 'vue'; // 用render函数生成Vnode虚拟DOM
const props = defineProps({
markdown: {
type: String,
default: ''
}
})
const ast = shallowRef(null)
const parser = unified()
.use(remarkParse)
.use(remarkGfm) // Gfm语法插件 有这个才能用完整的markdown语法
.use(remarkParse)
watchEffect(() => {
ast.value = parser.parse(props.markdown)
})
// 生成将抽象语法树转化为虚拟DOM树 并替换部分标签节点为Vue组件
const renderAst2Html = (node:any) => {
.......
};
const VNodeTree = computed(() => renderAst(ast.value))
</script>
这样就可以使用动态组件了渲染出来Markdown了
总结
切换至 AST 方案后,Markdown 渲染从“字符串渲染”升级为“结构化组件渲染”:
性能更优:依托 Vue 原生 Diff 机制,无需
v-html;高度灵活:可将原生元素轻松为自定义 Vue 组件。
架构清晰:解析、渲染、组件逻辑分离,代码更易维护;
安全可靠:避免 XSS 风险,支持类型校验。
开发体验:依靠虚拟DOM渲染组件,避免了侵入式的DOM操作,心智负担低。
附 Markdown所有节点类型名
节点类型 (node.type) | 描述 | 常见属性 | 对应 Markdown 示例 |
|---|---|---|---|
root | 文档根节点 | children | 整个文档 |
paragraph | 段落 | children | 普通文本行 |
heading | 标题 | depth (1-6), children | # 标题 |
text | 纯文本 | value | 任意文字内容 |
emphasis | 斜体(* 或 _) | children | *斜体* |
strong | 加粗(** 或 __) | children | **加粗** |
delete | 删除线(需 GFM) | children | ~~删除线~~ |
inlineCode | 行内代码 | value, lang? | `code` |
code | 代码块 | value, lang, meta | js\nconsole.log() |
blockquote | 引用块 | children | > 引用 |
list | 列表(有序/无序) | ordered, start, spread, children | - item 或 1. item |
listItem | 列表项 | checked (任务列表), children, spread | - [x] 完成 |
thematicBreak | 分割线 | — | ---、***、___ |
link | 链接 | url, title?, children | [text](url) |
image | 图片 | url, title?, alt |  |
html | 原生 HTML(若启用) | value | <div>raw html</div> |
break | 换行(硬换行) | — | 行尾两个空格 + 回车 |
table | 表格(需 GFM) | align, children(含 tableRow) | 表格语法 |
tableRow | 表格行 | children(tableCell) | ` |
tableCell | 表格单元格 | children | 单元格内容 |
definition | 链接/图片定义(引用式) | identifier, url, title? | [id]: url "title" |
footnoteDefinition | 脚注定义(需插件) | identifier, children | [^1]: 注释 |
footnoteReference | 脚注引用 | identifier | [^1] |
math | 数学公式(需 remark-math) | value, meta? | $E=mc^2$ |
inlineMath | 行内数学公式 | value | $$a^2$$ |