功能分析
markdown文件解析
通常项目中的md文件都放在docs目录下。如何在Vue3项目中借助Vite 构建时批量导入 docs 目录下所有 md 文件的原始内容。并传递给Markdown的组件进行渲染,要求:
-
需要一个能渲染Markdown的Vue组件
-
Markdown内容来自md文件(如demo.md)
-
可以在其他vue组件中引用markdownViewer组件展示md文件中的内容
可选方案
分析可用的npm包方案后,主要有以下几个选择:
-
markdown-it - 最流行的Markdown解析器
-
marked - 另一个流行的Markdown解析器
-
vue-markdown-render - Vue专用的Markdown渲染组件
-
@vueuse/integrations 配合 markdown-it
-
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 导入
理由:
-
✅ 灵活性高,可以完全控制渲染过程
-
✅ 您的demo.md包含代码块,可以配合
highlight.js实现代码高亮 -
✅ 体积适中,性能好
-
✅ 可以自定义样式,与现有的左侧面板样式完美融合
-
✅ Vite原生支持
?raw导入文本文件,无需额外插件
实现步骤:
-
安装依赖:
npm install markdown-it highlight.js -
创建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> -
在
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 方案,既灵活又轻量,而且您可以完全控制样式和渲染逻辑。
代码实现
-
安装依赖:
markdown-it和highlight.js -
创建一个通用的
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
// 配置 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实现博客列表
根据markdown内容生成blog列表数据
在每个md文件头部添加YAML front matter元数据信息,包含 tag、date、detail 字段
---
tag: ['响应式布局']
date: 2026-02-22
detail: 在 "Mobile First" 喊了这么多年的今天,响应式布局(Responsive Web Design, RWD)早已不是一个新概念,而是前端开发的**基建技能**。
---
自动生成content.json
genDocContent.js 是一个 Node.js CLI 脚本,需要:
- 读取
src/docs/目录下所有.md文件 - 解析每个文件顶部的 YAML front matter(
---包裹的部分) - 提取文件名作为
title,以及tag、date、detail字段 - 自动分配
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展示博客详情页
代码实现
<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' })