vue3 中 markdown 的渲染方案,并搭建简易个人blog

3 阅读7分钟

功能分析

markdown文件解析

通常项目中的md文件都放在docs目录下。如何在Vue3项目中借助Vite 构建时批量导入 docs 目录下所有 md 文件的原始内容。并传递给Markdown的组件进行渲染,要求:

  • 需要一个能渲染Markdown的Vue组件

  • Markdown内容来自md文件(如demo.md)

  • 可以在其他vue组件中引用markdownViewer组件展示md文件中的内容

可选方案

分析可用的npm包方案后,主要有以下几个选择:

  1. markdown-it - 最流行的Markdown解析器

  2. marked - 另一个流行的Markdown解析器

  3. vue-markdown-render - Vue专用的Markdown渲染组件

  4. @vueuse/integrations 配合 markdown-it

  5. vite-plugin-md - Vite插件,可以直接import markdown文件作为Vue组件

方案1:markdown-it(推荐)

这是最流行和成熟的Markdown解析器。

优点:

  • ⭐ 生态系统完善,插件丰富(代码高亮、表格、Emoji等)
  • 性能好,体积小(~50KB)
  • API简单,易于在Vue中使用
  • 支持扩展和自定义规则

实现方式:

npm install markdown-it
npm install highlight.js  # 如果需要代码高亮

使用示例:

import MarkdownIt from 'markdown-it'
import demoMd from '@/docs/demo.md?raw'  // Vite支持?raw导入

const md = new MarkdownIt()
const html = md.render(demoMd)


方案2:vite-plugin-md(最简单)

Vite插件,可以直接将Markdown文件作为Vue组件导入。

优点:

  • ⭐ 最简单,开箱即用
  • 可以直接 import Demo from './demo.md' 当作组件使用
  • 支持在Markdown中使用Vue组件
  • 与Vite深度集成

实现方式:

npm install vite-plugin-md

然后在 vite.config.js 中配置,就可以直接:

<script setup>
import DemoContent from '@/docs/demo.md'
</script>

<template>
  <DemoContent />
</template>


方案3:marked + DOMPurify

另一个流行的Markdown解析器,更轻量。

优点:

  • 轻量(~30KB)
  • 速度快
  • API简单

缺点:

  • 插件生态不如markdown-it丰富

方案4:vue-markdown-render 或 @kangc/v-md-editor

Vue专用的Markdown组件库。

优点:

  • 开箱即用的Vue组件
  • 自带样式和主题
  • @kangc/v-md-editor 还支持编辑功能

缺点:

  • 体积较大
  • 如果只需要渲染,可能过度设计

推荐方案:markdown-it + Vite的 ?raw 导入

理由:

  1. ✅ 灵活性高,可以完全控制渲染过程

  2. ✅ 您的demo.md包含代码块,可以配合 highlight.js 实现代码高亮

  3. ✅ 体积适中,性能好

  4. ✅ 可以自定义样式,与现有的左侧面板样式完美融合

  5. ✅ Vite原生支持 ?raw 导入文本文件,无需额外插件

实现步骤:

  1. 安装依赖:

    npm install markdown-it highlight.js
    
  2. 创建Markdown渲染组件 MarkdownViewer.vue

    <template>
      <div class="markdown-content" v-html="renderedHtml"></div>
    </template>
    
    <script setup>
    import { computed } from 'vue'
    import MarkdownIt from 'markdown-it'
    import hljs from 'highlight.js'
    import 'highlight.js/styles/github.css'  // 代码高亮主题
    
    const props = defineProps({
      markdown: String
    })
    
    const md = new MarkdownIt({
      html: true,
      linkify: true,
      typographer: true,
      highlight: function (str, lang) {
        if (lang && hljs.getLanguage(lang)) {
          try {
            return hljs.highlight(str, { language: lang }).value
          } catch (__) {}
        }
        return ''
      }
    })
    
    const renderedHtml = computed(() => {
      return md.render(props.markdown || '')
    })
    </script>
    
    <style scoped>
    /* 添加Markdown样式 */
    .markdown-content {
      font-size: 14px;
      line-height: 1.8;
      color: #333;
    }
    </style>
    
    
  3. 在 vue文件中使用:

<script setup>
import MarkdownViewer from '@/components/MarkdownViewer.vue'
import demoMd from '@/docs/demo.md?raw'
</script>

<template>
  <div class="left-panel">
    <div class="analysis-title">实现原理分析</div>
    <MarkdownViewer :markdown="demoMd" />
  </div>
</template>


总结对比

方案简单度灵活性体积推荐度
markdown-it + ?raw⭐⭐⭐⭐⭐⭐⭐⭐⭐~50KB⭐⭐⭐⭐⭐
vite-plugin-md⭐⭐⭐⭐⭐⭐⭐⭐中等⭐⭐⭐⭐
marked⭐⭐⭐⭐⭐⭐⭐⭐~30KB⭐⭐⭐
Vue组件库⭐⭐⭐⭐⭐⭐⭐较大⭐⭐

我建议使用 markdown-it 方案,既灵活又轻量,而且您可以完全控制样式和渲染逻辑。

代码实现

  1. 安装依赖:markdown-it 和 highlight.js

  2. 创建一个通用的 MarkdownViewer.vue 组件

<template>
  <div class="markdown-viewer" v-html="renderedHtml"></div>
</template>

<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import githubCssUrl from 'highlight.js/styles/github.css?url'
import githubDarkCssUrl from 'highlight.js/styles/github-dark.css?url'

let hljsStyleLink = null

const updateHljsTheme = () => {
  const isDark = document.documentElement.classList.contains('dark')
  if (!hljsStyleLink) {
    hljsStyleLink = document.createElement('link')
    hljsStyleLink.rel = 'stylesheet'
    document.head.appendChild(hljsStyleLink)
  }
  hljsStyleLink.href = isDark ? githubDarkCssUrl : githubCssUrl
}

let observer = null

onMounted(() => {
  updateHljsTheme()
  observer = new MutationObserver(updateHljsTheme)
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})

onUnmounted(() => {
  observer?.disconnect()
  hljsStyleLink?.remove()
  hljsStyleLink = null
})

const props = defineProps({
  markdown: {
    type: String,
    default: ''
  }
})

// HTML 转义函数
const escapeHtml = (str) => {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;')
}

// 配置 markdown-it
const md = new MarkdownIt({
  html: true,
  linkify: true,
  typographer: true,
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return (
          '<pre class="hljs"><code>' +
          hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
          '</code></pre>'
        )
      } catch (err) {
        console.error('Error highlighting code:', err)
      }
    }
    return '<pre class="hljs"><code>' + escapeHtml(str) + '</code></pre>'
  }
})

const renderedHtml = computed(() => {
  try {
    if (!props.markdown) {
      console.warn('No markdown content provided')
      return '<p>No content</p>'
    }
    const result = md.render(props.markdown)
    console.log('Markdown rendered successfully, output length:', result.length)
    return result
  } catch (error) {
    console.error('Error rendering markdown:', error)
    return '<p style="color: red;">Error rendering markdown: ' + error.message + '</p>'
  }
})
</script>

使用markdownView实现博客列表

截屏2026-02-25 16.02.34.png

根据markdown内容生成blog列表数据

在每个md文件头部添加YAML front matter元数据信息,包含 tagdatedetail 字段

---
tag: ['响应式布局']
date: 2026-02-22
detail:  "Mobile First" 喊了这么多年的今天,响应式布局(Responsive Web Design, RWD)早已不是一个新概念,而是前端开发的**基建技能**。
---

自动生成content.json

genDocContent.js 是一个 Node.js CLI 脚本,需要:

  1. 读取 src/docs/ 目录下所有 .md 文件
  2. 解析每个文件顶部的 YAML front matter(--- 包裹的部分)
  3. 提取文件名作为 title,以及 tagdatedetail 字段
  4. 自动分配 id,并将结果写入 src/config/content.json
import { readdirSync, readFileSync, writeFileSync } from 'fs'
import { resolve, basename, dirname } from 'path'
import { fileURLToPath } from 'url'

const __dirname = dirname(fileURLToPath(import.meta.url))
const DOCS_DIR = resolve(__dirname, '../docs')
const OUTPUT_FILE = resolve(__dirname, '../config/content.json')

/**
 * 解析文件顶部的 YAML front matter(--- 包裹的块)
 * 返回 { tag, date, detail } 及 front matter 之后的正文
 */
function parseFrontMatter(content) {
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
  if (!match) return { meta: {}, body: content }

  const [, frontRaw, body] = match
  const meta = {}

  for (const line of frontRaw.split(/\r?\n/)) {
    const colonIdx = line.indexOf(':')
    if (colonIdx === -1) continue

    const key = line.slice(0, colonIdx).trim()
    const rawVal = line.slice(colonIdx + 1).trim()

    if (key === 'tag') {
      // 支持 ["a","b"] / ['a','b'] 两种风格,统一用正则提取所有引号内的字符串
      const items = []
      const re = /["']([^"']+)["']/g
      let m
      while ((m = re.exec(rawVal)) !== null) {
        items.push(m[1])
      }
      meta.tag = items
    } else if (key === 'date') {
      // 去掉可能存在的引号,如 '2025-02-16'
      meta.date = rawVal.replace(/^['"]|['"]$/g, '')
    } else {
      // detail 等其他字段:仅去掉首尾普通引号(保留反引号内容)
      meta[key] = rawVal.replace(/^['"]|['"]$/g, '')
    }
  }

  return { meta, body }
}

/**
 * 从正文中提取第一段有效文字作为 summary(去除 markdown 语法符号,截取前 120 字符)
 */
function extractSummary(body) {
  const lines = body.split(/\r?\n/)
  for (const line of lines) {
    const stripped = line
      .replace(/^#+\s*/, '')       // 去掉标题 #
      .replace(/`[^`]*`/g, '')     // 去掉行内代码
      .replace(/\*\*([^*]+)\*\*/g, '$1') // 去掉加粗
      .replace(/\*([^*]+)\*/g, '$1')     // 去掉斜体
      .trim()
    if (stripped.length > 10) {
      return stripped.slice(0, 120) + (stripped.length > 120 ? '…' : '')
    }
  }
  return ''
}

function run() {
  const files = readdirSync(DOCS_DIR).filter((f) => f.endsWith('.md'))

  if (files.length === 0) {
    console.warn('⚠️  docs 目录下没有找到任何 .md 文件')
    process.exit(0)
  }

  const result = files.map((filename, index) => {
    const filePath = resolve(DOCS_DIR, filename)
    const raw = readFileSync(filePath, 'utf-8')
    const { meta, body } = parseFrontMatter(raw)

    const title = basename(filename, '.md')
    const summary = meta.detail || extractSummary(body)

    return {
      id: index + 1,
      title,
      date: meta.date || '',
      tag: meta.tag || [],
      summary,
      filename
    }
  })

  writeFileSync(OUTPUT_FILE, JSON.stringify(result, null, 2), 'utf-8')
  console.log(`✅ 已生成 ${result.length} 条记录 → ${OUTPUT_FILE}`)
  result.forEach((item) => console.log(`   [${item.id}] ${item.title}  (${item.date})`))
}

run()

封装列表页

<script setup>
import { ref } from 'vue'
import content from '@/config/content.json'

// Mock 数据
const blogPosts = ref(content)
</script>

<template>
  <div class="space-y-6">
    <div
      v-for="post in blogPosts"
      :key="post.id"
      class="container max-w-4xl px-10 py-6 mx-auto rounded-lg shadow-sm dark:bg-slate-900"
    >
      <div class="flex items-center justify-between">
        <span class="text-sm dark:text-gray-600">{{ post.date }}</span>
        <a
          rel="noopener noreferrer"
          href="#"
          class="px-2 py-1 font-bold rounded dark:bg-violet-600 dark:text-gray-50"
          >{{ post.tag.join(', ') }}</a
        >
      </div>
      <div class="mt-3">
        <router-link
          :to="{ name: 'blog-article', params: { id: post.id } }"
          class="text-2xl font-bold hover:underline"
        >
          {{ post.title }}
        </router-link>
        <p class="mt-2">
          {{ post.summary }}
        </p>
      </div>
      <div class="flex items-center justify-between mt-4">
        <router-link
          :to="{ name: 'blog-article', params: { id: post.id } }"
          class="hover:underline dark:text-violet-600"
        >
          Read more
        </router-link>
      </div>
    </div>
  </div>
</template>

使用markdownView展示博客详情页

截屏2026-02-25 16.07.49.png

代码实现

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import content from '@/config/content.json'
import MarkdownViewer from '@/components/MarkdownViewer.vue'

const route = useRoute()
const router = useRouter()

const blogPost = ref(null)
const markdownContent = ref('')
const loading = ref(true)
const error = ref(null)

// Vite 构建时批量导入 docs 目录下所有 md 文件的原始内容
const docModules = import.meta.glob('../../docs/*.md', { query: '?raw', import: 'default' })

// 上一篇 / 下一篇
const currentIndex = computed(() => content.findIndex((p) => p.id === parseInt(route.params.id)))
const prevPost = computed(() => (currentIndex.value > 0 ? content[currentIndex.value - 1] : null))
const nextPost = computed(() =>
  currentIndex.value < content.length - 1 ? content[currentIndex.value + 1] : null
)

// 去除 YAML front matter(--- 包裹的块)
function stripFrontMatter(raw) {
  return raw.replace(/^---[\s\S]*?---\r?\n?/, '').trimStart()
}

async function loadArticle(id) {
  loading.value = true
  error.value = null
  markdownContent.value = ''

  const post = content.find((p) => p.id === parseInt(id))
  blogPost.value = post

  if (!post) {
    error.value = '文章不存在'
    loading.value = false
    return
  }

  const matchKey = Object.keys(docModules).find((key) => key.endsWith('/' + post.filename))

  if (!matchKey) {
    error.value = `找不到文件:${post.filename}`
    loading.value = false
    return
  }

  try {
    const raw = await docModules[matchKey]()
    markdownContent.value = stripFrontMatter(raw)
  } catch (e) {
    error.value = '加载文章失败:' + e.message
  } finally {
    loading.value = false
  }
}

// 路由 id 变化时重新加载(上一篇/下一篇切换场景)
watch(
  () => route.params.id,
  (id) => {
    loadArticle(id)
    window.scrollTo({ top: 0, behavior: 'smooth' })
  }
)

onMounted(() => loadArticle(route.params.id))
</script>

<template>
  <div class="max-w-3xl mx-auto px-6 py-10">
    <!-- 返回按钮 -->
    <button
      class="flex items-center gap-1 mb-8 text-sm text-violet-600 hover:text-violet-800 dark:text-violet-400 dark:hover:text-violet-200 transition-colors"
      @click="router.back()"
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        class="w-4 h-4"
        viewBox="0 0 20 20"
        fill="currentColor"
      >
        <path
          fill-rule="evenodd"
          d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
          clip-rule="evenodd"
        />
      </svg>
      返回列表
    </button>

    <!-- 加载中 -->
    <div v-if="loading" class="flex justify-center py-20">
      <div
        class="w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full animate-spin"
      ></div>
    </div>

    <!-- 错误提示 -->
    <div v-else-if="error" class="text-center py-20 text-gray-500 dark:text-gray-400">
      <p class="text-lg">{{ error }}</p>
    </div>

    <!-- 文章内容 -->
    <template v-else-if="blogPost">
      <!-- 文章头部 -->
      <header class="mb-8 pb-6 border-b border-gray-200 dark:border-gray-700">
        <div class="flex flex-wrap gap-2 mb-3">
          <span
            v-for="tag in blogPost.tag"
            :key="tag"
            class="px-2 py-0.5 text-xs font-semibold rounded bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300"
          >
            {{ tag }}
          </span>
        </div>
        <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-3">
          {{ blogPost.title }}
        </h1>
        <time class="text-sm text-gray-500 dark:text-gray-400">{{ blogPost.date }}</time>
      </header>

      <!-- Markdown 正文 -->
      <div class="prose prose-slate dark:prose-dark max-w-none prose-headings:scroll-mt-20">
        <MarkdownViewer :markdown="markdownContent" />
      </div>

      <!-- 上一篇 / 下一篇 -->
      <nav class="mt-12 pt-6 border-t border-gray-200 dark:border-gray-700 grid grid-cols-2 gap-4">
        <router-link
          v-if="prevPost"
          :to="{ name: 'blog-article', params: { id: prevPost.id } }"
          class="group flex flex-col gap-1 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-violet-400 dark:hover:border-violet-500 transition-colors"
        >
          <span
            class="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500 group-hover:text-violet-500 transition-colors"
          >
            <svg
              xmlns="http://www.w3.org/2000/svg"
              class="w-3.5 h-3.5"
              viewBox="0 0 20 20"
              fill="currentColor"
            >
              <path
                fill-rule="evenodd"
                d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
                clip-rule="evenodd"
              />
            </svg>
            Previous
          </span>
          <span
            class="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-violet-600 dark:group-hover:text-violet-400 line-clamp-2 transition-colors"
          >
            {{ prevPost.title }}
          </span>
        </router-link>
        <div v-else></div>

        <router-link
          v-if="nextPost"
          :to="{ name: 'blog-article', params: { id: nextPost.id } }"
          class="group flex flex-col items-end gap-1 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-violet-400 dark:hover:border-violet-500 transition-colors"
        >
          <span
            class="flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500 group-hover:text-violet-500 transition-colors"
          >
            Next
            <svg
              xmlns="http://www.w3.org/2000/svg"
              class="w-3.5 h-3.5"
              viewBox="0 0 20 20"
              fill="currentColor"
            >
              <path
                fill-rule="evenodd"
                d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
                clip-rule="evenodd"
              />
            </svg>
          </span>
          <span
            class="text-sm font-medium text-gray-700 dark:text-gray-300 group-hover:text-violet-600 dark:group-hover:text-violet-400 text-right line-clamp-2 transition-colors"
          >
            {{ nextPost.title }}
          </span>
        </router-link>
        <div v-else></div>
      </nav>
    </template>
  </div>
</template>

代码片段

// Vite 构建时批量导入 docs 目录下所有 md 文件的原始内容
const docModules = import.meta.glob('../../docs/*.md', { query: '?raw', import: 'default' })