概述
NEU小站的文章页Markdown渲染框架是一个基于 Vue 3 的组件,能把 Markdown 格式的文本渲染HTML,并且实现了增强功能——包括对特定自定义 Vue 组件(<Attachment>
和 <CourseCard>
)的渲染支持。此外,支持的标准功能包括数学公式(KaTeX)、代码高亮(highlight.js)以及图表(Mermaid)等,并且能自动生成可交互的目录 (TOC)。
本文重点介绍对2种裸文本自定义组件标签(<CourseCard>
和 <Attachment>
)的渲染实现。
效果
裸文本(接口传参)
1> 标签:#可预览 #PDF #PPT #公路交通 #选修课 #通识选修
2
3此处收录了《公路交通与驾驶技术》完整版PPT(文件内文字可复制、查找)。
4<CourseCard id="2" />
5
6<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
。
1const createAttachmentPlaceholder = (tagString) => {
2 // 基本检查确保是 Attachment 标签
3 if (!tagString || !tagString.trim().startsWith('<Attachment')) {
4 return null;
5 }
6
7 // 提取属性
8 const id = tagString.match(/id="([^"]*)"/)?.[1];
9 const size = tagString.match(/size="([^"]*)"/)?.[1];
10 const type = tagString.match(/type="([^"]*)"/)?.[1];
11 const filename = tagString.match(/filename="([^"]*)"/)?.[1];
12 const coin = tagString.match(/coin="([^"]*)"/)?.[1];
13 const locked = tagString.match(/locked="([^"]*)"/)?.[1];
14
15 // 检查必要属性
16 if (id && size && type) {
17 return `<div class="attachment-wrapper"
18 data-id="${id}"
19 data-size="${size}"
20 data-type="${type}"
21 ${coin ? `data-coin="${coin}"` : ''}
22 ${locked ? `data-locked="${locked}"` : ''}
23 ${filename ? `data-filename="${filename}"` : ''}
24 ></div>`;
25 }
26
27 return null; // 必要属性缺失
28};
29
30const createCourseCardPlaceholder = (tagString) => {
31 // 基本检查确保是 CourseCard 标签
32 if (!tagString || !tagString.trim().startsWith('<CourseCard')) {
33 return null;
34 }
35
36 // 提取 ID (处理带引号和不带引号的情况。支持<CourseCard id="1" /> 和 <CourseCard id=1 /> 两种写法)
37 // 由于Attachment标签是服务器生成的,不会被用户修改,所以一定带引号
38 // CourseCard标签是用户手动插入的,可能被用户修改
39 const idWithQuotes = tagString.match(/id="([^"]*)"/);
40 const idWithoutQuotes = tagString.match(/id=([^\s^\/^>]*)/);
41 const id = idWithQuotes ? idWithQuotes[1] : (idWithoutQuotes ? idWithoutQuotes[1] : null);
42
43 if (id) {
44 return `<div class="course-card-wrapper" data-course-id="${id}"></div>`;
45 }
46
47 return null; // ID 缺失
48};
然后我们需要在两种情形下调用上面的方法:独立HTML块、内联TEXT。
独立HTML块
我们用generatePlaceholder
方法来处理被 markdown-it
识别为独立 html_block
或 html_inline
Token 的情况。
它会首先检查 content
(即 Token 的内容) 是否以 <Attachment
或 <CourseCard
开头。如果是,就尝试匹配完整的标签字符串,并调用相应的 create...Placeholder
函数生成占位符。
1const generatePlaceholder = (content) => {
2 const trimmedContent = content.trim();
3 // Attachment 标签
4 if (trimmedContent.startsWith('<Attachment')) {
5 const tagMatch = trimmedContent.match(/<Attachment\s+.*?\/>/);
6 if (tagMatch) {
7 // 调用函数
8 const placeholder = createAttachmentPlaceholder(tagMatch[0]);
9 if (placeholder) return placeholder;
10 }
11 }
12 // CourseCard 标签
13 if (trimmedContent.startsWith('<CourseCard')) {
14 const tagMatch = trimmedContent.match(/<CourseCard\s+.*?\/>/);
15 if (tagMatch) {
16 // 调用函数
17 const placeholder = createCourseCardPlaceholder(tagMatch[0]);
18 if (placeholder) return placeholder;
19 }
20 }
21 return null; // 不是自定义标签或创建失败
22};
然后覆写markdown-it
的html-block
和html-inline
规则:
1// 存储原始规则
2 const defaultRenderText = md.renderer.rules.text || function(tokens, idx, options, env, self) {
3 return self.renderToken(tokens, idx, options, env, self);
4};
5const defaultRenderHtmlInline = md.renderer.rules.html_inline || function(tokens, idx, options, env, self) {
6 return self.renderToken(tokens, idx, options, env, self);
7};
8const defaultRenderHtmlBlock = md.renderer.rules.html_block || function(tokens, idx, options, env, self) {
9 return self.renderToken(tokens, idx, options, env, self);
10};
11
12// 覆写 html_inline 规则
13md.renderer.rules.html_inline = (tokens, idx, options, env, self) => {
14 const content = tokens[idx].content;
15 const placeholder = generatePlaceholder(content);
16 if (placeholder) {
17 return placeholder;
18 }
19 return defaultRenderHtmlInline(tokens, idx, options, env, self);
20};
21
22// 覆写 html_block 规则
23md.renderer.rules.html_block = (tokens, idx, options, env, self) => {
24 const content = tokens[idx].content;
25 const placeholder = generatePlaceholder(content);
26 if (placeholder) {
27 return placeholder;
28 }
29 return defaultRenderHtmlBlock(tokens, idx, options, env, self);
30};
内联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
将原始标签替换为占位符。
1// 覆写 text 规则(处理文本中的标签,现在用特定创建器)
2md.renderer.rules.text = (tokens, idx, options, env, self) => {
3 const token = tokens[idx];
4 let content = token.content;
5 let replaced = false;
6
7 // 处理 Attachment 标签
8 const attachmentRegex = /<Attachment\s+.*?\/?>/g; // 使斜杠可选
9 content = content.replace(attachmentRegex, (match) => {
10 // 调用复用函数
11 const placeholder = createAttachmentPlaceholder(match);
12 if (placeholder) {
13 replaced = true;
14 return placeholder;
15 }
16 return match; // 如果占位符创建失败,返回原始匹配
17 });
18
19 // 处理 CourseCard 标签
20 const courseCardRegex = /<CourseCard\s+.*?\/?>/g; // 使斜杠可选
21 content = content.replace(courseCardRegex, (match) => {
22 // 调用复用函数
23 const placeholder = createCourseCardPlaceholder(match);
24 if (placeholder) {
25 replaced = true;
26 return placeholder;
27 }
28 return match; // 如果占位符创建失败,返回原始匹配
29 });
30
31 return replaced ? content : defaultRenderText(tokens, idx, options, env, self);
32};
注意,很重要的一点:这里如果发生了替换,我们要直接返回修改后的 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
传入。注意这里要进行必要的类型转换(比如给coin
和locked
进行parseInt
和Boolean
转换等)。 - 渲染组件: 我们创建一个临时
div
容器 (container
),调用render(vnode, container)
将 VNode 渲染到这个临时容器中。此时container.firstChild
就是真实渲染好的组件根 DOM 元素。 - 替换 DOM: 最后用HTML原生方法
wrapper.parentNode.replaceChild(container.firstChild, wrapper)
将 DOM 中的占位符wrapper
替换为渲染好的组件container.firstChild
。
1processAttachments() {
2 const attachmentWrappers = this.$refs.articleContent.querySelectorAll('.attachment-wrapper');
3 if (attachmentWrappers.length === 0) return;
4
5 attachmentWrappers.forEach(wrapper => {
6 const id = wrapper.dataset.id;
7 const size = wrapper.dataset.size;
8 const type = wrapper.dataset.type;
9 const filename = wrapper.dataset.filename;
10 const coin = wrapper.dataset.coin;
11 const locked = wrapper.dataset.locked;
12 // 创建一个临时容器
13 const container = document.createElement('div');
14
15 // 使用Vue 3的方式创建和渲染组件
16 const vnode = h(Attachment, {
17 id,
18 size,
19 type,
20 coin: coin ? parseInt(coin) : null,
21 locked: locked === '1',
22 filename: filename
23 });
24
25 render(vnode, container);
26
27 // 替换原始占位符
28 wrapper.parentNode.replaceChild(container.firstChild, wrapper);
29 });
30},
31processCourseCards() {
32 if (!this.$refs.articleContent) return;
33
34 const courseCardWrappers = this.$refs.articleContent.querySelectorAll('.course-card-wrapper');
35 if (courseCardWrappers.length === 0) return;
36
37 courseCardWrappers.forEach(wrapper => {
38 const courseId = wrapper.dataset.courseId;
39 if (!courseId) return;
40
41 const container = document.createElement('div');
42
43 const vnode = h(CourseCard, { courseId });
44
45 render(vnode, container);
46
47 wrapper.parentNode.replaceChild(container.firstChild, wrapper);
48 });
49},
然后在 mounted
钩子中调用:
1mounted() {
2 this.$nextTick(() => {
3 this.processAttachments();
4 this.processCourseCards();
5 });
6}
这样一来,只要在组件中引入Attachment
和CourseCard
组件,框架就支持在Markdown中渲染出对应的组件。
总结
我们采用了一种基于 markdown-it
规则重写、生成 HTML 占位符、再通过 v-html
渲染后手动查找并替换为 Vue 组件实例的策略,成功地在 Markdown 内容中嵌入了交互式 Vue 组件。这种方案巧妙地结合了 markdown-it
的扩展能力和 Vue 的组件系统,实现了灵活、个性化的 Markdown 渲染。