blog/content/post/奇技淫巧/markdown渲染框架.md
2025-04-17 02:48:46 +08:00

330 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 支持Vue自定义组件渲染的Markdown渲染框架实现
description: NEU小站采用的支持Vue自定义组件渲染的Markdown渲染框架基于Vue 3实现
date: 2025-04-16
# image: helena-hertz-wWZzXlDpMog-unsplash.jpg
categories:
- 奇技淫巧
tags: [
"Markdown",
"Vue",
"前端",
"NEU小站"
]
lastmod: 2025-04-17T01:00:00+08:00
---
# 概述
NEU小站的文章页Markdown渲染框架是一个基于 Vue 3 的组件,能把 Markdown 格式的文本渲染HTML并且实现了增强功能——包括对特定自定义 Vue 组件(`<Attachment>``<CourseCard>`的渲染支持。此外支持的标准功能包括数学公式KaTeX、代码高亮highlight.js以及图表Mermaid并且能自动生成可交互的目录 (TOC)。
本文重点介绍对2种裸文本**自定义组件标签**`<CourseCard>``<Attachment>`)的渲染实现。
## 效果
### 裸文本(接口传参)
```markdown
> 标签:#可预览 #PDF #PPT #公路交通 #选修课 #通识选修
此处收录了《公路交通与驾驶技术》完整版PPT文件内文字可复制、查找
<CourseCard id="2" />
<Attachment id="72" type="pdf" coin="8" size="10.71" locked="0" filename="公路交通与汽车驾驶完整版PPT.pdf" />
```
### 渲染效果
[页面传送门](https://www.东北大学.com/article/68)
<img src="https://cdn.ember.ac.cn/images/bed/202504170134635.png" width="650">
# 技术方案
由于渲染框架采用的基本渲染器是基于 `markdown-it` 的,但是 `markdown-it` 无法支持渲染自定义的 Vue 组件,所以需要对 `markdown-it` 的渲染规则进行重写。NEU小站采用的方案是先让 `markdown-it` 解析 Markdown 文本,生成 Token 流,然后遍历 Token 流,对自定义组件标签进行特殊处理,生成占位符 div最后用 VNode 渲染这些占位符 div然后用HTML原生方法`replaceChild`替换掉原来的占位符 div。如下图所示
## pipeline 概览
```mermaid
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`
```js
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 (处理带引号和不带引号的情况。支持<CourseCard id="1" /> 和 <CourseCard id=1 /> 两种写法)
// 由于Attachment标签是服务器生成的不会被用户修改所以一定带引号
// CourseCard标签是用户手动插入的可能被用户修改
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` 函数生成占位符。
```js
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`规则:
```js
// 存储原始规则
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` 将原始标签替换为占位符。
```js
// 覆写 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步
1. **查找:** 使用 `this.$refs.articleContent.querySelectorAll('.attachment-wrapper / .course-card-wrapper')` 获取所有占位符元素。
2. **遍历:** 循环处理每一个找到的占位符 `wrapper`
3. **提取 Props:**`wrapper.dataset` 中读取之前存入的 `data-*` 属性值。
4. **创建 VNode:**`h(Attachment/CourseCard, { prop1: value1, ... })` 创建组件的虚拟节点,将提取的值作为 `props` 传入。注意这里要进行必要的类型转换(比如给 `coin``locked` 进行 `parseInt``Boolean` 转换等)。
5. **渲染组件:** 我们创建一个临时 `div` 容器 (`container`),调用 `render(vnode, container)` 将 VNode 渲染到这个临时容器中。此时 `container.firstChild` 就是真实渲染好的组件根 DOM 元素。
6. **替换 DOM:** 最后用HTML原生方法 `wrapper.parentNode.replaceChild(container.firstChild, wrapper)` 将 DOM 中的占位符 `wrapper` 替换为渲染好的组件 `container.firstChild`
```js
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` 钩子中调用:
```js
mounted() {
this.$nextTick(() => {
this.processAttachments();
this.processCourseCards();
});
}
```
这样一来,只要在组件中引入`Attachment``CourseCard`组件框架就支持在Markdown中渲染出对应的组件。
# 总结
我们采用了一种基于 `markdown-it` 规则重写、生成 HTML 占位符、再通过 `v-html` 渲染后手动查找并替换为 Vue 组件实例的策略,成功地在 Markdown 内容中嵌入了交互式 Vue 组件。这种方案巧妙地结合了 `markdown-it` 的扩展能力和 Vue 的组件系统,实现了灵活、个性化的 Markdown 渲染。