--- 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 组件(`` 和 ``)的渲染支持。此外,支持的标准功能包括数学公式(KaTeX)、代码高亮(highlight.js)以及图表(Mermaid)等,并且能自动生成可交互的目录 (TOC)。 本文重点介绍对2种裸文本**自定义组件标签**(`` 和 ``)的渲染实现。 ## 效果 ### 裸文本(接口传参) ```markdown > 标签:#可预览 #PDF #PPT #公路交通 #选修课 #通识选修 此处收录了《公路交通与驾驶技术》完整版PPT(文件内文字可复制、查找)。 ``` ### 渲染效果 [页面传送门](https://www.东北大学.com/article/68) # 技术方案 由于渲染框架采用的基本渲染器是基于 `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`,分别处理附件和课程卡片。 * **输入:** 一个预期是完整自定义组件标签(如 ``)的字符串。 * **逻辑:** 使用正则表达式从输入字符串中提取必要的属性(`id`, `size`, `type`, `filename`, `coin`, `locked` 是Attachment的属性; `id` 是CourseCard的属性)。 * **输出:** 如果成功提取必要属性,返回一个包含 `data-*` 属性的 `
` 占位符 HTML 字符串;否则返回 `null`。 ```js const createAttachmentPlaceholder = (tagString) => { // 基本检查确保是 Attachment 标签 if (!tagString || !tagString.trim().startsWith('
`; } return null; // 必要属性缺失 }; const createCourseCardPlaceholder = (tagString) => { // 基本检查确保是 CourseCard 标签 if (!tagString || !tagString.trim().startsWith(']*)/); const id = idWithQuotes ? idWithQuotes[1] : (idWithoutQuotes ? idWithoutQuotes[1] : null); if (id) { return `
`; } return null; // ID 缺失 }; ``` 然后我们需要在两种情形下调用上面的方法:独立HTML块、内联TEXT。 ### 独立HTML块 我们用`generatePlaceholder`方法来处理被 `markdown-it` 识别为独立 `html_block` 或 `html_inline` Token 的情况。 它会首先检查 `content` (即 Token 的内容) 是否以 ` { const trimmedContent = content.trim(); // Attachment 标签 if (trimmedContent.startsWith('/); if (tagMatch) { // 调用函数 const placeholder = createAttachmentPlaceholder(tagMatch[0]); if (placeholder) return placeholder; } } // CourseCard 标签 if (trimmedContent.startsWith('/); 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`,`markdown-it` 会识别为`text` Token,而我们不能直接把整个`text` Token 传递给`create...Placeholder`函数,因为不是一个完整的自定义组件标签字符串。 因此,我们需要先找到``或``的完整标签字符串,然后再调用`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 = //g; // 使斜杠可选 content = content.replace(attachmentRegex, (match) => { // 调用复用函数 const placeholder = createAttachmentPlaceholder(match); if (placeholder) { replaced = true; return placeholder; } return match; // 如果占位符创建失败,返回原始匹配 }); // 处理 CourseCard 标签 const courseCardRegex = //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` 传入。注意这里要进行必要的类型转换(如 `parseInt` 变成 `coin`,`locked` 变成布尔值)。 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 渲染。