330 lines
14 KiB
Markdown
330 lines
14 KiB
Markdown
---
|
||
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 渲染。
|
||
|
||
|
||
|
||
|