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

14 KiB
Raw Permalink Blame History

title description date categories tags lastmod
支持Vue自定义组件渲染的Markdown渲染框架实现 NEU小站采用的支持Vue自定义组件渲染的Markdown渲染框架基于Vue 3实现 2025-04-16
奇技淫巧
Markdown
Vue
前端
NEU小站
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 原生功能的基础上,增加了对自定义组件标签的渲染支持。我们分成下面几个部分:

自定义组件占位符生成

我们编写了两个方法createAttachmentPlaceholdercreateCourseCardPlaceholder,分别处理附件和课程卡片。

  • 输入: 一个预期是完整自定义组件标签(如 <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 (处理带引号和不带引号的情况。支持<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_blockhtml_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-ithtml-blockhtml-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步

  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 传入。注意这里要进行必要的类型转换(比如给 coinlocked 进行 parseIntBoolean 转换等)。
  5. 渲染组件: 我们创建一个临时 div 容器 (container),调用 render(vnode, container) 将 VNode 渲染到这个临时容器中。此时 container.firstChild 就是真实渲染好的组件根 DOM 元素。
  6. 替换 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();
    });
}

这样一来,只要在组件中引入AttachmentCourseCard组件框架就支持在Markdown中渲染出对应的组件。

总结

我们采用了一种基于 markdown-it 规则重写、生成 HTML 占位符、再通过 v-html 渲染后手动查找并替换为 Vue 组件实例的策略,成功地在 Markdown 内容中嵌入了交互式 Vue 组件。这种方案巧妙地结合了 markdown-it 的扩展能力和 Vue 的组件系统,实现了灵活、个性化的 Markdown 渲染。