自定义 markdown 解析规则并渲染 Vue 组件

前言

文本主要是介绍一种在 Vue 项目中自定义 markdown 解析规则并渲染 Vue 组件的方式,这里借助一个实际的业务场景来分析如何实现这个功能

需求分析

需求是基于 Vue 写一个 AI 前端对话网页,并且实现类似于腾讯元宝的联网搜索功能,具体效果可以参考下面的页面截图:

我们知道,AI 返回的内容是 markdown 格式的,因此图中那些标注引用来源的小圆点,实际上是由 markdown 解析器对某种自定义语法进行渲染后生成的。为了确认这一点,我们可以打开开发者工具,查看 AI 流式返回的原始内容到底是什么样子:

实际上,标注引用来源的小圆点,其本质是 markdown 解析器解析特定语法并进行渲染的结果。当解析器遇到 [citation:<num>] 格式的文字(<num> 为引用在搜索结果列表中的索引)时,便会将其渲染为对应的小圆点

当我们的鼠标悬停在小圆点上的时候,上方还会有一个悬浮的网页卡片,这个需求的实现方法在文章后面也会写到,先把基本的小圆点给渲染出来

项目准备

本文的示例代码的 Vue 版本是 vue3,采用的 markdown 解析器是 unified

为什么要用这个 markdown 解析器呢?因为这个解析器的插件系统允许深度定制解析流程,适合需要非标准语法扩展的场景

这里顺带附上本文示例代码中涉及到的所有和 markdown 解析器相关的依赖:

bash 复制代码
# 核心依赖
npm install unified
npm install remark-parse
npm install remark-rehype
npm install rehype-stringify

# 插件依赖
npm install remark-gfm
npm install remark-math
npm install rehype-katex
npm install rehype-raw

# 工具依赖
npm install hast
npm install unist-util-visit

# 类型依赖
npm install --save-dev @types/hast

基础 Markdown 渲染

先不考虑对于自定义语法的解析,先基于 unified 把最基本的 markdown 语法渲染给实现了

首先我们先创建一个 MarkdownRenderer 组件,文件结构如下:

markdown 复制代码
components/
└── MarkdownRender/
    ├── index.vue
    └── processor.ts

processor.ts 写的是 markdown 处理器的核心逻辑:

typescript 复制代码
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeStringify from 'rehype-stringify'

/**
 * 将 Markdown 内容处理为 HTML 字符串的异步函数
 *
 * @param content - 原始 Markdown 文本内容
 * @returns 处理后的 HTML 字符串
 */
export async function processMarkdown(content: string) {
  // 预处理数学公式标记:
  // 1. 将 \(公式\) 格式的行内数学公式转换为 $公式$ 格式,以便 remark-math 正确识别
  // 2. 将 \[公式\] 格式的块级数学公式转换为 $$公式$$ 格式,以便 remark-math 正确识别
  const processed = content
    .replace(/\\\(([^]*?)\\\)/g, (_, math) => `$${math}$`)
    .replace(/\\\[([^]*?)\\\]/g, (_, math) => `$$${math}$$`)

  // 创建统一处理器实例,并配置处理流水线
  const processor = unified()
    // 使用 remark-parse 将 Markdown 文本解析为 MDAST
    .use(remarkParse)
    // 使用 remark-gfm 添加对 GFM 扩展语法的支持
    .use(remarkGfm)
    // 使用 remark-math 识别和解析数学公式语法
    .use(remarkMath)
    // 使用 remark-rehype 将 MDAST 转换为 HAST (HTML AST)
    .use(remarkRehype)
    // 使用 rehype-raw 允许保留原始 HTML 标签
    .use(rehypeRaw)
    // 使用 rehype-katex 将数学公式渲染为美观的数学符号
    .use(rehypeKatex)
    // 添加 rehype-stringify 将 HAST 编译为 HTML 字符串
    .use(rehypeStringify)

  // 执行处理流程,将预处理后的内容转换为 HTML
  const file = await processor.process(processed)

  // 返回处理结果中的 HTML 字符串
  return file.value as string
}

处理器接收原始的 markdown 文本字符串,可以返回解析之后得到的 HTML 字符串。然后我们可以直接通过 v-html 指令将解析得到的 HTML 字符串绑定到一个容器元素里面:

vue 复制代码
<script setup lang="ts">
import { ref, watch, defineOptions } from 'vue'
import { processMarkdown } from './processor'

defineOptions({
  name: 'MarkdownRenderer',
})
const props = defineProps<{
  content: string
}>()

const htmlString = ref('')

// 监听 content 变化
watch(
  () => props.content,
  async (newContent) => {
    htmlString.value = await processMarkdown(newContent)
  },
  { immediate: true },
)
</script>

<template>
  <div class="markdown-container" v-html="htmlString"></div>
</template>

<style>
@import 'katex/dist/katex.min.css';
.markdown-container {
  line-height: 1.6;
}

/* 基础 Markdown 样式 */
.markdown-container h1 {
  font-size: 2em;
  margin: 0.67em 0;
}
.markdown-container h2 {
  font-size: 1.5em;
  margin: 0.75em 0;
}
.markdown-container p {
  margin: 1em 0;
}
.markdown-container pre {
  background-color: #f6f8fa;
  padding: 16px;
  border-radius: 6px;
  overflow: auto;
}
.markdown-container code {
  font-family: monospace;
  background-color: rgba(175, 184, 193, 0.2);
  padding: 0.2em 0.4em;
  border-radius: 6px;
}
.markdown-container blockquote {
  border-left: 4px solid #dfe2e5;
  color: #6a737d;
  padding: 0 1em;
  margin: 0 0 1em 0;
}
.markdown-container table {
  border-collapse: collapse;
  width: 100%;
}
.markdown-container th,
.markdown-container td {
  border: 1px solid #dfe2e5;
  padding: 6px 13px;
}
.markdown-container tr {
  background-color: #fff;
  border-top: 1px solid #c6cbd1;
}
.markdown-container tr:nth-child(2n) {
  background-color: #f6f8fa;
}
</style>

然后我们在 App.vue 里面写一下测试代码:

vue 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRenderer from '@/components/MarkdownRender/index.vue'

// 基本Markdown语法测试
const basicMarkdown = ref(`
# 标题1
## 标题2

这是一个段落,包含**粗体**和*斜体*文本。

- 无序列表项1
- 无序列表项2

1. 有序列表项1
2. 有序列表项2

> 这是一个引用块

\`行内代码\`
`)

// 数学公式测试
const mathMarkdown = `
# 数学公式测试

行内公式:$E = mc^2$

块级公式:
$$
\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
$$

转义括号公式:\\(a^2 + b^2 = c^2\\) 和 \\[x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\\]
`

// GFM扩展语法测试
const gfmMarkdown = `
# GFM扩展语法测试

## 表格

| 姓名 | 年龄 | 城市 |
| ---- | ---- | ---- |
| 张三 | 25   | 北京 |
| 李四 | 30   | 上海 |

## 删除线

~~这是删除的文本~~

## 任务列表

- [x] 已完成任务
- [ ] 未完成任务
`
</script>

<template>
  <div class="container">
    <!-- 测试基本Markdown语法 -->
    <MarkdownRenderer :content="basicMarkdown" />

    <!-- 测试数学公式 -->
    <MarkdownRenderer :content="mathMarkdown" />

    <!-- 测试GFM扩展语法 -->
    <MarkdownRenderer :content="gfmMarkdown" />
  </div>
</template>

<style scoped>
.container {
  padding: 20px;
}
</style>

测试效果没有问题,至此我们已经完成了基础 markdown 语法的解析和渲染

但如果我们要实现「需求分析」中提到的引用小圆点和悬浮网页卡片的需求,思路肯定是去自定义一套解析规则,并且最好能实现直接渲染一个 Vue 组件,这样的话我们可以自由定制渲染内容和交互逻辑

至于怎么实现渲染 Vue 组件,这里先卖个关子,先来简单了解一下 unified 的工作流程

unified 工作流程简述

假设现在有如下这样一段 markdown 字符串:

markdown 复制代码
# 标题

这是一个**粗体**文本。

行内公式:$E = mc^2$

以我们之前编写的那个 markdown 解析器为例,讲解一下它是怎么解析上面这段 markdown 字符串的

文本字符串转 MDAST

remarkParse 插件会把 markdown 字符串文本解析为抽象语法树:

json 复制代码
{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "标题"
        }
      ]
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "这是一个"
        },
        {
          "type": "strong",
          "children": [
            {
              "type": "text",
              "value": "粗体"
            }
          ]
        },
        {
          "type": "text",
          "value": "文本。"
        }
      ]
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "行内公式:"
        },
        {
          "type": "inlineMath",
          "value": "E = mc^2"
        }
      ]
    }
  ]
}

功能增强

完成了基础的转换之后,还会继续使用我们注册的一些别的插件对 markdown 解析结果再进行增强:

  1. GFM 扩展 (remarkGfm):增强对表格等语法的支持
  2. 数学公式处理 (remarkMath):专门处理数学公式节点
  3. 转换为 HAST (remarkRehype):将 MDAST 转换为 HTML 抽象语法树

转换后的 HAST 结构类似于:

json 复制代码
{
  "type": "root",
  "children": [
    {
      "type": "element",
      "tagName": "h1",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "标题"
        }
      ]
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "这是一个"
        },
        {
          "type": "element",
          "tagName": "strong",
          "properties": {},
          "children": [
            {
              "type": "text",
              "value": "粗体"
            }
          ]
        },
        {
          "type": "text",
          "value": "文本。"
        }
      ]
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "行内公式:"
        },
        {
          "type": "element",
          "tagName": "span",
          "properties": {
            "class": "math-inline"
          },
          "children": [
            {
              "type": "text",
              "value": "E = mc^2"
            }
          ]
        }
      ]
    }
  ]
}

对比之前第一步解析得到的 MDAST,可以很明显地看出有如下区别:

  1. HAST 的节点类型是基于 HTML 标签来定义的;MDAST 的节点类型是基于 markdown 语法来定义的(比如 heading/paragraph
  2. HAST 具有 tagName 属性,因为最终 HAST 会转化为 HTML 字符串,需要指定每个节点的标签名是什么才能进行转换;而 MDAST 没有这个属性,其节点字段定义基于 markdown 语法
  3. HAST 具有 properties 字段,存储一些 HTML 节点的属性,比如类名、自定义属性;而 MDAST 则没有属性字段

到这一步,其实思路就已经浮现出来了,细心的你肯定可以发现,这个所谓的 HAST 和 Vue 的虚拟 DOM 有一定的相似之处,其本质都是用对象去模拟真实的 DOM 节点

那我们是不是可以不把 HAST 转化为 HTML 字符串,而是建立一个 HASTVNODE 之间的映射,然后直接把映射得到的 VNODE 交给 Vue 框架自己去渲染,这样的话,我们就可以在映射的过程中,把自定义的 HAST 节点映射为 Vue 的组件来进行渲染了

原始 HTML 和数学公式处理

  1. 原始 HTML 支持 (rehypeRaw):允许保留原始 HTML 标签
  2. 数学公式渲染 (rehypeKatex):用 KaTeX 将数学公式渲染为美观的数学符号

编译为 HTML 字符串

使用 rehypeStringify 将 HAST 编译为最终的 HTML 字符串:

html 复制代码
<h1>标题</h1>

<p>
  这是一个
  <strong>粗体</strong>
  文本。
</p>

<p>
  行内公式:
  <span class="math-inline">E = mc^2</span>
</p>

建立 HAST-VNODE 映射

获取 HAST

根据之前的思路,我们需要先去获得字符串解析得到的 HAST,要获得 HAST 得先解析得到前置产物 MDAST,我们可以通过处理器实例的 parse 方法获得 MDAST,然后再 run 方法将 MDAST 转化为 HAST:

typescript 复制代码
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'

// 获取HAST的函数
export async function processMarkdown(content: string) {
  const processed = content
    .replace(/\\\(([^]*?)\\\)/g, (_, math) => `$${math}$`)
    .replace(/\\\[([^]*?)\\\]/g, (_, math) => `$$${math}$$`)

  console.log(processed)

  // 创建处理器实例
  const processor = unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkMath)
    .use(remarkRehype)
    .use(rehypeRaw)
    .use(rehypeKatex)

  // 先解析为 MDAST
  const mdast = processor.parse(processed)
  // 再运行转换
  const hast = await processor.run(mdast)
  console.log(JSON.stringify(hast, null, 2))

  return hast
}

rehypeStringify 这个插件可以删掉了,我们后面不需要转化为 HTML 字符串,只需要 HAST

然后这里我们用 processMarkdown 函数去获取之前例子中 markdown 文本的 HAST 结构,看看打印出了什么东西:

json 复制代码
{
  "type": "root",
  "children": [
    {
      "type": "element",
      "tagName": "h1",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "标题"
        }
      ]
    },
    {
      "type": "text",
      "value": "\n" // 换行符
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "这是一个"
        },
        {
          "type": "element",
          "tagName": "strong",
          "properties": {},
          "children": [
            {
              "type": "text",
              "value": "粗体"
            }
          ]
        },
        {
          "type": "text",
          "value": "文本。"
        }
      ]
    },
    {
      "type": "text",
      "value": "\n" // 换行符
    },
    {
      "type": "element",
      "tagName": "p",
      "properties": {},
      "children": [
        {
          "type": "text",
          "value": "行内公式:"
        },
        {
          "type": "element",
          "tagName": "span",
          "properties": {
            "className": ["katex"] // KaTeX公式样式
          },
          "children": [
            // 以下是KaTeX公式的HTML渲染结果
            {
              "type": "element",
              "tagName": "span",
              "properties": {
                "className": ["katex-html"],
                "ariaHidden": "true"
              },
              "children": [
                {
                  "type": "element",
                  "tagName": "span",
                  "properties": {
                    "className": ["base"]
                  },
                  "children": [
                    {
                      "type": "text",
                      "value": "E = mc²" // 最终渲染的公式文本
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

这个省略版的结果再一次验证了之前的思路,HAST 的结果完全符合预期

HAST 到 VNODE 的映射

接下来就只要实现从 HAST 节点到 VNODE 的映射就行了

对于大部分 markdown 解析得到的结果都可以用 HAST 的 element 节点和 text 节点来表示(当然,节点类型不止这么点),这里我们做映射主要考虑的节点类型有:

  • element:表示一个 HTML 元素节点
  • text:表示一个文本节点
  • root:表示一个根节点

HAST 节点的 tagName 属性就是 VNODE 的 tag 属性,而 HAST 节点的 properties 属性就是 VNODE 的 props 属性,所以我们只要把 HAST 节点的 tagNameproperties 属性分别赋给 VNODE 的 tagprops 属性就行了

怎么去创建 VNODE 自然不用多说,直接用 h 函数就行了。接下来我们来完成这个映射:

vue 复制代码
<script setup lang="ts">
import { h, ref, watch } from 'vue'
import { processMarkdown } from './processor'

const props = defineProps<{
  content: string
}>()

const rootNode = ref<any>(null)

const astToVnode = (ast: any) => {
  if (ast.type === 'text') {
    return ast.value
  }

  if (ast.type === 'element') {
    return h(ast.tagName, ast.properties, ast.children?.map(astToVnode) || [])
  }

  return null
}

watch(
  () => props.content,
  async (newContent) => {
    const ast = await processMarkdown(newContent)
    // 直接创建包含所有子节点的根 div
    rootNode.value = h(
      'div',
      { class: 'markdown-container' },
      ast.children?.map(astToVnode) || [],
    )
  },
  { immediate: true },
)
</script>

<template>
  <component :is="rootNode" />
</template>

<!-- 这里不能加 scoped -->
<style>
/* 省略样式 */
</style>

仍然复用之前 App.vue 的测试代码,发现渲染得到的结果是完全一样的!

阶段性总结

到这里为止,我们已经验证了之前的思路完全可行。相比于直接将 HTML 字符串插入到 DOM 中,使用 VNODE 来渲染有如下优点:

  1. 安全性
  • v-html:存在 XSS 风险,直接插入原始 HTML
  • VNode 渲染:可通过转换过程过滤危险内容,更安全
  1. Vue 集成度
  • v-html:脱离 Vue 响应式系统,无法使用组件
  • VNode 渲染:完全集成在 Vue 中,可插入自定义组件
  1. 自定义能力
  • v-html:只能渲染固定 HTML,难以扩展
  • VNode 渲染:可在转换过程中处理自定义语法和组件
  1. 性能
  • v-html:每次全量更新,无法细粒度控制
  • VNode 渲染:可实现更精细的 diff 和更新策略

实现小圆点的渲染

经过之前的一系列分析,实现小圆点渲染的方案已经呼之欲出了:

  1. 首先,通过某种方式去遍历解析得到的 MDAST 树,查找所有的文本节点

  2. 然后,使用正则表达式对文本进行匹配,检查一下文本中是否存在符合如 [citation:<num>] 这种格式的文本

  3. 如果匹配到了,那么不要将其作为文本节点输出,而是转化为一个我们自定义的 citations 类型节点,并把匹配到的数字作为节点属性存入到 citations 节点中

  4. HAST 中也会存在我们自定义的这个节点,而后我们可以在 HAST 到 VNODE 的映射中,将这个节点直接用 h 函数转化为一个 Vue 组件,最终 Vue 会把这个组件渲染成小圆点

编写自定义插件

首先我们去实现步骤 1、2、3。unist-util-visit 这个库提供了一个 visit 工具函数,它可以让我们很方便地去遍历解析得到的 MDAST 树,并且在遍历的过程中对节点进行增删改,影响最终解析输出的结果

这里直接附上笔者写的一个自定义插件,用于解析小圆点语法:

typescript 复制代码
import { visit } from 'unist-util-visit'

/**
 * 自定义 remark 插件来处理 citation 标记
 */
export const remarkCitation = () => {
  return (tree: any) => {
    visit(tree, 'text', (node: any, index: number | undefined, parent: any) => {
      const citationRegex = /\[\s*citation\s*:\s*(\d+(?:\s*[,,]\s*\d+)*)\s*\]/g
      const matches = [...node.value.matchAll(citationRegex)]

      if (matches.length === 0) return

      const newChildren = []
      let lastIndex = 0

      matches.forEach((match) => {
        const [fullMatch, numsString] = match
        const startIndex = match.index!

        // 添加匹配前的文本
        if (startIndex > lastIndex) {
          newChildren.push({
            type: 'text',
            value: node.value.slice(lastIndex, startIndex),
          })
        }

        // 处理数字部分,支持中英文逗号和空格
        const nums = numsString
          .split(/[,,]\s*/)
          .map((num: string) => num.trim())

        // 添加citations节点
        newChildren.push({
          type: 'citations',
          data: {
            hName: 'citations',
            hProperties: {
              dataNums: nums.join(','),
            },
          },
          children: [{ type: 'text', value: nums.join(',') }],
        })

        lastIndex = startIndex + fullMatch.length
      })

      // 添加剩余文本
      if (lastIndex < node.value.length) {
        newChildren.push({
          type: 'text',
          value: node.value.slice(lastIndex),
        })
      }

      // 替换原节点
      parent.children.splice(index, 1, ...newChildren)
    })
  }
}

export default remarkCitation

这个插件可以实现如下的转化效果:

  • 转化前的原始 markdown 文本
markdown 复制代码
这是内容[citation:1,2]这是后续内容
  • 转化前的 MDAST 树
json 复制代码
{
  "type": "paragraph",
  "children": [
    {
      "type": "text",
      "value": "这是内容[citation:1,2]这是后续内容"
    }
  ]
}
  • 转化后的 MDAST 树
json 复制代码
[
  { "type": "text", "value": "这是内容" },
  {
    "type": "citations",
    "data": {
      "hName": "citations",
      "hProperties": { "dataNums": "1,2" }
    },
    "children": [{ "type": "text", "value": "1,2" }]
  },
  { "type": "text", "value": "这是后续内容" }
]

插件写好之后,直接导入 processor.ts 中,并且注册插件即可让自定义解析逻辑生效

扩展 HAST-VNODE 映射

现在 HAST 树中已经存在了自定义的 citations 节点,接下来我们需要将这个自定义节点映射成 Vue 组件,才能在渲染出我们想要的小圆点的效果

首先我们完成小圆点组件 CitationList.vue 的编写:

vue 复制代码
<script setup lang="ts">
interface Props {
  nums: string // 以逗号分隔的数字字符串,如 "1,2,3"
}

const props = defineProps<Props>()
</script>

<template>
  <span class="citation-list">
    <span v-for="num in props.nums.split(',')" :key="num" class="citation">
      {{ num }}
    </span>
  </span>
</template>

<style scoped>
/* 样式省略 */
</style>

然后,在 markdown 渲染组件中,将 citations 节点映射成 CitationList 组件:

vue 复制代码
<script setup lang="ts">
import { h, ref, watch } from 'vue'
import { processMarkdown } from './processor'
import CitationList from './CitationList.vue'

const props = defineProps<{
  content: string
}>()

const rootNode = ref<any>(null)

const astToVnode = (ast: any) => {
  if (ast.type === 'text') {
    return ast.value
  }

  if (ast.type === 'element') {
    // 处理自定义的 citations 节点
    if (ast.tagName === 'citations') {
      console.log(ast)
      return h(CitationList, {
        nums: ast.properties?.dataNums || '',
      })
    }

    // 处理普通的 HTML 节点
    return h(ast.tagName, ast.properties, ast.children?.map(astToVnode) || [])
  }

  return null
}

watch(
  () => props.content,
  async (newContent) => {
    const ast = await processMarkdown(newContent)
    // 直接创建包含所有子节点的根 div
    rootNode.value = h(
      'div',
      { class: 'markdown-container' },
      ast.children?.map(astToVnode) || [],
    )
  },
  { immediate: true },
)
</script>

<template>
  <component :is="rootNode" />
</template>

<!-- 这里不能加 scoped -->
<style>
/* 样式省略 */
</style>

修改 App.vue 中的测试代码,加上测试文本文本:

markdown 复制代码
这是内容[citation:1]这是后续内容

测试效果如下图:

可以看到,小圆点非常完美地渲染出来了!这个小圆点就是一个正常 Vue 组件,具备所有 Vue 组件的特性,比如数据绑定、事件处理、生命周期钩子等等

拓展------实现悬浮卡片的思路

这个悬浮卡片其实就是类似于各大组件库中的 Tooltip 组件,我们只需要扩展一下 CitationList 组件,当鼠标悬浮在小圆点上的时候就显示 Tooltip 组件即可

现在最关键的问题是,悬浮卡片上展示的内容是哪里来的?

大模型的联网搜索,其实本质就是先根据用户的问题调用搜索引擎接口,然后把搜索结果在上下文中带给模型

我们可以为模型添加一个 prompt,让模型根据联网搜索的结果来生成回复,并且如果有引用联网搜索的内容的话,那么就要以 [citation:<num>] 这种格式来标注出引用的网页的编号,而搜索引擎接口返回的是一个对象数组,大致如下:

json 复制代码
[
  {
    "name": "2024年华科专业按分排名-专业填报-高考资讯网",
    "url": "http://www.gkzxw.com/major/1567890.html",
    "snippet": "录取分数线 2024年华中科技大学在湖北省物理类考生中的录取分数线为635分~658分。 以上信息根据最近的数据更新整理而来,具体排名可能会根据每年的实际情况有所变动。 以上内容仅供参考,部分文章是来自自",
    "siteName": "高考资讯网",
    "siteIcon": "https://th.bochaai.com/favicon?domain_url=http://www.gkzxw.com/major/1567890.html",
    "time": "y年M月d日"
  },
  {
    "name": "华科录取专业线-高校招生问答平台",
    "url": "http://www.zsask.com/university/2134567.html",
    "snippet": "问华科录取专业线 2024-12-01 15:30:22 156次 问题描述: 华科录取专业线希望能解答下璀璨的莫过于事业 原来就是每天吃饭的筷子,每天睡觉的那张床 2024-12-01 15:30:",
    "siteName": "高校招生问答平台",
    "siteIcon": "https://th.bochaai.com/favicon?domain_url=http://www.zsask.com/university/2134567.html",
    "time": "y年M月d日"
  }
]

联网搜索的结果会带给前端,小圆点里的数字其实就是上面这个数组的索引(引用了第几个搜索结果)

我们可以将上面这个搜索结果传入 MarkdownRenderer 中,再透传到 CitationList 组件中,这样就可以根据索引获取对应的搜索结果,并展示在悬浮卡片中了

具体实现代码在这里省略,感兴趣的笔者可以自行尝试实现

总结

本文详细介绍了如何在 Vue3 项目中通过自定义 markdown 解析规则来实现特殊语法(如 [citation:<num>])的解析,并将其渲染为 Vue 组件

基于 HAST-VNODE 映射的原理,我们可以自定义各种特殊语法,并且渲染成复杂的 Vue 组件,诸如 echarts 图标、mermaid 流程图等

新人第一次创作,如果本文有任何错误,欢迎各位大佬在评论区批评指正🌹

相关推荐
karshey3 小时前
【vue】NoticeBar:滚动通知栏组件手动实现(内容、速度、循环间隔可配置)
前端·javascript·vue.js
Cherry Zack4 小时前
Vue Router 路由管理完全指南:从入门到精通前言
前端·javascript·vue.js
慧一居士5 小时前
vue.config.js 文件功能介绍,使用说明,对应完整示例演示
前端·vue.js
_23336 小时前
vue3二次封装element-plus表格,slot透传,动态slot。
前端·vue.js
浪裡遊7 小时前
MUI组件库与主题系统全面指南
开发语言·前端·javascript·vue.js·react.js·前端框架·node.js
梵得儿SHI9 小时前
Vue 开发环境搭建全指南:从工具准备到项目启动
前端·javascript·vue.js·node.js·pnpm·vue开发环境·nvm版本管理
peachSoda710 小时前
自定义配置小程序tabbar逻辑思路
javascript·vue.js·微信小程序·小程序
fruge11 小时前
Vue 3 完全指南:响应式原理、组合式 API 与实战优化
javascript·vue.js·ecmascript
paopaokaka_luck11 小时前
基于SpringBoot+Vue的数码交流管理系统(AI问答、协同过滤算法、websocket实时聊天、Echarts图形化分析)
vue.js·人工智能·spring boot·websocket·echarts