14 KiB
title | description | date | categories | tags | lastmod | |||||
---|---|---|---|---|---|---|---|---|---|---|
支持Vue自定义组件渲染的Markdown渲染框架实现 | NEU小站采用的支持Vue自定义组件渲染的Markdown渲染框架,基于Vue 3实现 | 2025-04-16 |
|
|
2025-04-17T01:00:00+08:00 |
概述
NEU小站的文章页Markdown渲染框架是一个基于 Vue 3 的组件,能把 Markdown 格式的文本渲染HTML,并且实现了增强功能——包括对特定自定义 Vue 组件(<Attachment>
和 <CourseCard>
)的渲染支持。此外,支持的标准功能包括数学公式(KaTeX)、代码高亮(highlight.js)以及图表(Mermaid)等,并且能自动生成可交互的目录 (TOC)。
本文重点介绍对2种裸文本自定义组件标签(<CourseCard>
和 <Attachment>
)的渲染实现。
效果
裸文本(接口传参)
> 标签:#可预览 #PDF #PPT #公路交通 #选修课 #通识选修
此处收录了《公路交通与驾驶技术》完整版PPT(文件内文字可复制、查找)。
<CourseCard id="2" />
<Attachment id="72" type="pdf" coin="8" size="10.71" locked="0" filename="公路交通与汽车驾驶完整版PPT.pdf" />
渲染效果

技术方案
由于渲染框架采用的基本渲染器是基于 markdown-it
的,但是 markdown-it
无法支持渲染自定义的 Vue 组件,所以需要对 markdown-it
的渲染规则进行重写。NEU小站采用的方案是,先让 markdown-it
解析 Markdown 文本,生成 Token 流,然后遍历 Token 流,对自定义组件标签进行特殊处理,生成占位符 div,最后用 VNode 渲染这些占位符 div,然后用HTML原生方法replaceChild
替换掉原来的占位符 div。如下图所示:
pipeline 概览
graph TD
A["输入 Markdown 字符串"] --> B["markdown-it 解析器"];
B --> C["生成 Token 流"];
C --> D["遍历 Token"];
subgraph "renderMarkdown"
D -- "html_block / html_inline (自定义标签)" --> E["generatePlaceholder 函数"];
E -- "识别标签类型" --> F["调用 create...Placeholder"];
F --> G["生成占位符 Div (含 data-*)"];
D -- "text Token" --> H["重写的 text 规则"];
H -- "Regex 查找内联自定义标签" --> I["调用 create...Placeholder"];
I --> G;
H -- "未匹配/普通文本" --> J["默认 text 渲染"];
J --> K["HTML 片段"];
D -- "fence (Mermaid)" --> L["自定义 fence 规则: 生成 Mermaid 容器 Div"];
L --> K;
D -- "其他 Token (标题, 列表等)" --> M["默认渲染规则"];
M --> K;
end
G --> N["HTML 字符串 (含占位符)"];
K --> N;
N --> O["Vue 使用 v-html 渲染"];
O --> P["DOM 中包含占位符 Div"];
P --> Q["mounted - nextTick"];
subgraph "Vue 组件挂载"
Q --> R["querySelectorAll 查找占位符"];
R --> S["遍历占位符"];
S --> T["读取 data-* 属性"];
T --> U["h(Component, props) 创建 VNode"];
U --> V["render(VNode, tempContainer) 渲染组件"];
V --> W["DOM: replaceChild(真实组件, 占位符)"];
end
W --> X["最终渲染: HTML + 交互式 Vue 组件"];
renderMarkdown 实现
renderMarkdown
方法是 Markdown 处理的核心,它在支持其他 markdown 原生功能的基础上,增加了对自定义组件标签的渲染支持。我们分成下面几个部分:
自定义组件占位符生成
我们编写了两个方法createAttachmentPlaceholder
和createCourseCardPlaceholder
,分别处理附件和课程卡片。
- 输入: 一个预期是完整自定义组件标签(如
<Attachment .../>
)的字符串。 - 逻辑: 使用正则表达式从输入字符串中提取必要的属性(
id
,size
,type
,filename
,coin
,locked
是Attachment的属性;id
是CourseCard的属性)。 - 输出: 如果成功提取必要属性,返回一个包含
data-*
属性的<div>
占位符 HTML 字符串;否则返回null
。
const createAttachmentPlaceholder = (tagString) => {
// 基本检查确保是 Attachment 标签
if (!tagString || !tagString.trim().startsWith('<Attachment')) {
return null;
}
// 提取属性
const id = tagString.match(/id="([^"]*)"/)?.[1];
const size = tagString.match(/size="([^"]*)"/)?.[1];
const type = tagString.match(/type="([^"]*)"/)?.[1];
const filename = tagString.match(/filename="([^"]*)"/)?.[1];
const coin = tagString.match(/coin="([^"]*)"/)?.[1];
const locked = tagString.match(/locked="([^"]*)"/)?.[1];
// 检查必要属性
if (id && size && type) {
return `<div class="attachment-wrapper"
data-id="${id}"
data-size="${size}"
data-type="${type}"
${coin ? `data-coin="${coin}"` : ''}
${locked ? `data-locked="${locked}"` : ''}
${filename ? `data-filename="${filename}"` : ''}
></div>`;
}
return null; // 必要属性缺失
};
const createCourseCardPlaceholder = (tagString) => {
// 基本检查确保是 CourseCard 标签
if (!tagString || !tagString.trim().startsWith('<CourseCard')) {
return null;
}
// 提取 ID (处理带引号和不带引号的情况)
const idWithQuotes = tagString.match(/id="([^"]*)"/);
const idWithoutQuotes = tagString.match(/id=([^\s^\/^>]*)/);
const id = idWithQuotes ? idWithQuotes[1] : (idWithoutQuotes ? idWithoutQuotes[1] : null);
if (id) {
return `<div class="course-card-wrapper" data-course-id="${id}"></div>`;
}
return null; // ID 缺失
};
然后我们需要在两种情形下调用上面的方法:独立HTML块、内联TEXT。
独立HTML块
我们用generatePlaceholder
方法来处理被 markdown-it
识别为独立 html_block
或 html_inline
Token 的情况。
它会首先检查 content
(即 Token 的内容) 是否以 <Attachment
或 <CourseCard
开头。如果是,就尝试匹配完整的标签字符串,并调用相应的 create...Placeholder
函数生成占位符。
const generatePlaceholder = (content) => {
const trimmedContent = content.trim();
// Attachment 标签
if (trimmedContent.startsWith('<Attachment')) {
const tagMatch = trimmedContent.match(/<Attachment\s+.*?\/>/);
if (tagMatch) {
// 调用函数
const placeholder = createAttachmentPlaceholder(tagMatch[0]);
if (placeholder) return placeholder;
}
}
// CourseCard 标签
if (trimmedContent.startsWith('<CourseCard')) {
const tagMatch = trimmedContent.match(/<CourseCard\s+.*?\/>/);
if (tagMatch) {
// 调用函数
const placeholder = createCourseCardPlaceholder(tagMatch[0]);
if (placeholder) return placeholder;
}
}
return null; // 不是自定义标签或创建失败
};
然后覆写markdown-it
的html-block
和html-inline
规则:
// 存储原始规则
const defaultRenderText = md.renderer.rules.text || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options, env, self);
};
const defaultRenderHtmlInline = md.renderer.rules.html_inline || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options, env, self);
};
const defaultRenderHtmlBlock = md.renderer.rules.html_block || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options, env, self);
};
// 覆写 html_inline 规则
md.renderer.rules.html_inline = (tokens, idx, options, env, self) => {
const content = tokens[idx].content;
const placeholder = generatePlaceholder(content);
if (placeholder) {
return placeholder;
}
return defaultRenderHtmlInline(tokens, idx, options, env, self);
};
// 覆写 html_block 规则
md.renderer.rules.html_block = (tokens, idx, options, env, self) => {
const content = tokens[idx].content;
const placeholder = generatePlaceholder(content);
if (placeholder) {
return placeholder;
}
return defaultRenderHtmlBlock(tokens, idx, options, env, self);
};
内联TEXT
内联的情况相比独立HTML块要复杂一些,比如abc<Attachment .../>
,markdown-it
会识别为text
Token,而我们不能直接把整个text
Token 传递给create...Placeholder
函数,因为不是一个完整的自定义组件标签字符串。
因此,我们需要先找到<Attachment .../>
或<CourseCard .../>
的完整标签字符串,然后再调用create...Placeholder
生成占位符。大致步骤:
- 获取
text
Token 的内容content
。 - 使用正则表达式(
attachmentRegex
,courseCardRegex
)在content
字符串中查找所有匹配的自定义标签。 - 对于每一个匹配到的标签字符串 (
match
),调用相应的create...Placeholder
函数。 - 如果
create...Placeholder
成功返回占位符,则使用String.prototype.replace
将原始标签替换为占位符。
// 覆写 text 规则(处理文本中的标签,现在用特定创建器)
md.renderer.rules.text = (tokens, idx, options, env, self) => {
const token = tokens[idx];
let content = token.content;
let replaced = false;
// 处理 Attachment 标签
const attachmentRegex = /<Attachment\s+.*?\/?>/g; // 使斜杠可选
content = content.replace(attachmentRegex, (match) => {
// 调用复用函数
const placeholder = createAttachmentPlaceholder(match);
if (placeholder) {
replaced = true;
return placeholder;
}
return match; // 如果占位符创建失败,返回原始匹配
});
// 处理 CourseCard 标签
const courseCardRegex = /<CourseCard\s+.*?\/?>/g; // 使斜杠可选
content = content.replace(courseCardRegex, (match) => {
// 调用复用函数
const placeholder = createCourseCardPlaceholder(match);
if (placeholder) {
replaced = true;
return placeholder;
}
return match; // 如果占位符创建失败,返回原始匹配
});
return replaced ? content : defaultRenderText(tokens, idx, options, env, self);
};
注意,很重要的一点:这里如果发生了替换,我们要直接返回修改后的 content
字符串(包含 HTML 占位符),而不是调用默认的 text
渲染器,以防止占位符被转义。
完成三个逻辑后,就可以调用 md.render(this.content)
生成最终的 HTML 字符串,赋值给 this.renderContent
,供 v-html
使用。
Vue 组件挂载实现
我们编写了processAttachments
/ processCourseCards
两个方法,负责将 DOM 中的占位符替换为功能齐全的 Vue 组件。这两个方法在 mounted
钩子的 nextTick
中调用,因为此时 DOM 已经更新,可以安全地进行替换。逻辑总共有6步:
- 查找: 使用
this.$refs.articleContent.querySelectorAll('.attachment-wrapper / .course-card-wrapper')
获取所有占位符元素。 - 遍历: 循环处理每一个找到的占位符
wrapper
。 - 提取 Props: 从
wrapper.dataset
中读取之前存入的data-*
属性值。 - 创建 VNode: 用
h(Attachment/CourseCard, { prop1: value1, ... })
创建组件的虚拟节点,将提取的值作为props
传入。注意这里要进行必要的类型转换(如parseInt
变成coin
,locked
变成布尔值)。 - 渲染组件: 我们创建一个临时
div
容器 (container
),调用render(vnode, container)
将 VNode 渲染到这个临时容器中。此时container.firstChild
就是真实渲染好的组件根 DOM 元素。 - 替换 DOM: 最后用HTML原生方法
wrapper.parentNode.replaceChild(container.firstChild, wrapper)
将 DOM 中的占位符wrapper
替换为渲染好的组件container.firstChild
。
processAttachments() {
const attachmentWrappers = this.$refs.articleContent.querySelectorAll('.attachment-wrapper');
if (attachmentWrappers.length === 0) return;
attachmentWrappers.forEach(wrapper => {
const id = wrapper.dataset.id;
const size = wrapper.dataset.size;
const type = wrapper.dataset.type;
const filename = wrapper.dataset.filename;
const coin = wrapper.dataset.coin;
const locked = wrapper.dataset.locked;
// 创建一个临时容器
const container = document.createElement('div');
// 使用Vue 3的方式创建和渲染组件
const vnode = h(Attachment, {
id,
size,
type,
coin: coin ? parseInt(coin) : null,
locked: locked === '1',
filename: filename
});
render(vnode, container);
// 替换原始占位符
wrapper.parentNode.replaceChild(container.firstChild, wrapper);
});
},
processCourseCards() {
if (!this.$refs.articleContent) return;
const courseCardWrappers = this.$refs.articleContent.querySelectorAll('.course-card-wrapper');
if (courseCardWrappers.length === 0) return;
courseCardWrappers.forEach(wrapper => {
const courseId = wrapper.dataset.courseId;
if (!courseId) return;
const container = document.createElement('div');
const vnode = h(CourseCard, { courseId });
render(vnode, container);
wrapper.parentNode.replaceChild(container.firstChild, wrapper);
});
},
然后在 mounted
钩子中调用:
mounted() {
this.$nextTick(() => {
this.processAttachments();
this.processCourseCards();
});
}
这样一来,只要在组件中引入Attachment
和CourseCard
组件,框架就支持在Markdown中渲染出对应的组件。
总结
我们采用了一种基于 markdown-it
规则重写、生成 HTML 占位符、再通过 v-html
渲染后手动查找并替换为 Vue 组件实例的策略,成功地在 Markdown 内容中嵌入了交互式 Vue 组件。这种方案巧妙地结合了 markdown-it
的扩展能力和 Vue 的组件系统,实现了灵活、个性化的 Markdown 渲染。