在前端开发中,Markdown渲染是非常常见的需求,无论是文档展示、博客系统还是内容管理平台,都需要一个可靠的Markdown解析组件。市面上的Markdown解析库(如marked、markdown-it)虽然功能强大,但往往体积较大,且需要额外配置代码高亮、自定义样式等。今天我将分享一个纯手写实现的、基于Vue3 + TypeScript的轻量级Markdown渲染组件,它无需任何外部依赖,支持完整的Markdown语法和丰富的自定义配置。
组件特性
这个Markdown组件完全基于原生JavaScript实现解析逻辑,兼顾了功能完整性和轻量性,核心特性如下:
- ✅ 无外部依赖:纯原生实现Markdown解析和代码高亮,无需引入marked、highlight.js等库
- ✅ 完整语法支持:支持标题、列表、表格、引用、代码块、行内代码、链接、图片、脚注、定义列表、任务列表等所有常用Markdown语法
- ✅ 代码增强功能:代码高亮(支持多语言)、行号显示、一键复制代码、多套代码主题
- ✅ 多主题支持:内置default/github/dark/vue/minimal 5种内容主题,default/monokai/github/dracula/one-dark/solarized 6种代码主题
- ✅ 灵活的内容来源:支持直接传入内容、远程URL加载、插槽传入三种方式
- ✅ 实用功能:自动链接识别、转义字符处理、嵌套引用/列表、表格对齐、加载/错误状态提示
- ✅ TypeScript支持:完整的类型定义,类型安全的Props和事件
- ✅ 可定制化:通过CSS变量和深度选择器轻松定制样式
快速上手
1. 组件引入
将代码保存为 LucasMarkdown.vue 文件,在你的Vue3 + TS项目中直接引入使用:
2. 基础使用示例
最简化的使用方式,通过content属性传入Markdown内容:
<template>
<div class="demo">
<h3>基础Markdown渲染</h3>
<LucasMarkdown :content="markdownContent" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import LucasMarkdown from './LucasMarkdown.vue'
const markdownContent = ref(`
# Hello Markdown
这是一个**Vue3 + TS**实现的Markdown渲染组件:
- 支持无序列表
- 支持有序列表
1. TypeScript类型安全
2. 无外部依赖
3. 代码高亮
\`\`\`typescript
// TypeScript示例代码
const sayHello = (name: string): string => {
return \`Hello, \${name}!\`;
};
console.log(sayHello('Markdown'));
\`\`\`
> 引用块示例:Markdown is awesome!
| 表格示例 | 对齐方式 | 数值 |
| :--- | :---: | ---: |
| 左对齐 | 居中 | 右对齐 |
| Vue3 | TS | 666 |
`)
</script>
<style scoped>
.demo {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
</style>
3. 高级使用示例
自定义主题、加载远程Markdown文件、监听事件:
<template>
<div class="demo">
<h3>高级Markdown配置</h3>
<LucasMarkdown
src="/docs/guide.md"
theme="github"
code-theme="dracula"
:showLineNumbers="true"
:copyable="true"
@load="handleLoad"
@error="handleError"
@copy="handleCopy"
/>
<!-- 也可以通过插槽传入内容 -->
<LucasMarkdown theme="dark" code-theme="monokai">
# 插槽方式传入内容
这是通过默认插槽传入的Markdown内容,支持所有语法。
- [x] 任务列表已完成
- [ ] 任务列表未完成
</LucasMarkdown>
</div>
</template>
<script setup lang="ts">
import LucasMarkdown from './LucasMarkdown.vue'
// 远程加载成功
const handleLoad = (content: string) => {
console.log('Markdown加载完成:', content.length, '字符')
}
// 加载失败
const handleError = (error: Error) => {
console.error('Markdown加载失败:', error.message)
}
// 代码复制成功
const handleCopy = (code: string) => {
console.log('代码已复制:', code.substring(0, 50) + '...')
}
</script>
完整API列表
1. Props(组件属性)
| 属性名 | 类型 | 可选 | 默认值 | 说明 |
|---|---|---|---|---|
| content | string | 是 | - | 要渲染的Markdown内容(优先级高于插槽) |
| src | string | 是 | - | 远程Markdown文件URL(优先级最高) |
| theme | MarkdownTheme | 是 | 'default' | 内容主题,可选值:default/github/dark/vue/minimal |
| codeTheme | CodeTheme | 是 | 'default' | 代码主题,可选值:default/monokai/github/dracula/one-dark/solarized |
| showLineNumbers | boolean | 是 | true | 是否显示代码块行号 |
| copyable | boolean | 是 | true | 是否显示代码复制按钮 |
| sanitize | boolean | 是 | true | 是否开启HTML转义(防止XSS) |
2. Events(组件事件)
| 事件名 | 回调参数 | 说明 |
|---|---|---|
| load | (content: string) | 远程Markdown文件加载成功时触发 |
| error | (error: Error) | 远程Markdown文件加载失败时触发 |
| copy | (code: string) | 代码复制成功时触发(返回复制的代码内容) |
3. Exposed Methods(暴露的方法)
可通过模板引用(ref)调用:
<template>
<LucasMarkdown ref="mdRef" />
<button @click="reloadMarkdown">重新加载</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import LucasMarkdown from './LucasMarkdown.vue'
const mdRef = ref<InstanceType<typeof LucasMarkdown>>()
const reloadMarkdown = () => {
// 重新渲染内容
mdRef.value?.render()
// 或加载远程文件
mdRef.value?.loadFromUrl('/docs/updated.md')
}
</script>
| 方法名 | 参数 | 说明 |
|---|---|---|
| render | - | 重新渲染Markdown内容(适用于内容更新后) |
| loadFromUrl | (url: string) | 加载指定URL的Markdown文件 |
4. 支持的Markdown语法
| 语法类型 | 示例 | 说明 |
|---|---|---|
| 标题 | # 一级标题 / ## 二级标题 / 标题\n=== |
支持ATX风格(#)和Setext风格(===/---) |
| 粗体/斜体 | **粗体** / *斜体* / ***粗斜体*** |
支持*和_两种符号 |
| 删除线/高亮 | ~~删除线~~ / ==高亮== |
扩展语法,兼容主流Markdown编辑器 |
| 列表 | - 无序列表 / 1. 有序列表 / - [x] 任务列表 |
支持嵌套列表和任务列表(带复选框) |
| 链接/图片 | [链接文本](url "标题") /  |
支持行内式和参考式链接/图片 |
| 代码块 | ```ts\n代码内容\n``` |
支持指定语言、行号、复制功能 |
| 行内代码 | `const a = 1` |
行内代码高亮显示 |
| 引用块 | > 一级引用\n>> 嵌套引用 |
支持多层嵌套引用 |
| 表格 | | 表头 | 表头 |\n| --- | :---: | |
支持左/中/右对齐,转义竖线 |
| 水平线 | --- / *** / ___ |
至少3个符号,支持空格分隔 |
| 脚注 | 文本[^1]\n[^1]: 脚注内容 |
自动生成脚注索引和跳转链接 |
| 定义列表 | 术语\n: 定义内容 |
支持多个定义项 |
| 上标/下标 | ^上标^ / ~下标~ |
扩展语法,需在脚注后解析 |
| 自动链接 | <https://example.com> / <email@example.com> |
自动识别URL和邮箱并生成链接 |
样式定制
组件所有样式均使用lucas-markdown作为根类名,且开启了scoped样式,你可以通过Vue的深度选择器(:deep())覆盖默认样式:
示例1:自定义代码块样式
<style scoped>
:deep(.lucas-markdown) {
--code-bg: #f8f9fa;
--code-text: #2d3748;
--code-border: #e2e8f0;
}
/* 自定义代码块背景和字体 */
:deep(.code-block) {
border-radius: 12px !important;
font-family: 'JetBrains Mono', monospace !important;
}
/* 自定义行号样式 */
:deep(.line-numbers) {
color: #94a3b8 !important;
border-right-color: #e2e8f0 !important;
}
/* 自定义复制按钮 */
:deep(.copy-btn) {
background: #3b82f6 !important;
color: white !important;
border: none !important;
}
</style>
示例2:自定义主题颜色
<style scoped>
/* 自定义Vue主题的链接颜色 */
.theme-vue :deep(a) {
color: #3b82f6 !important;
}
/* 自定义Dark主题的背景色 */
.theme-dark {
background: #121212 !important;
color: #e5e7eb !important;
}
/* 自定义表格样式 */
:deep(.md-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.md-table th) {
background: #f1f5f9;
font-weight: 700;
}
</style>
核心实现思路(拓展)
如果你想理解组件的核心实现逻辑,以下是关键技术点:
- 解析策略:采用“保护-解析-恢复”的解析流程,先将代码块等特殊内容用占位符保护,避免被其他规则误解析
- 语法解析:通过正则表达式逐类解析Markdown语法,按优先级处理(如先处理代码块,再处理行内代码,最后处理段落)
- 代码高亮:基于Token分词的方式,识别关键字、字符串、数字、注释等,生成带样式类名的HTML
- 主题系统:通过CSS类名切换不同主题,分离内容主题和代码主题,便于独立定制
- 交互增强:实现代码复制、行号显示、加载状态等交互功能,提升用户体验
总结
这个Vue3+TS Markdown渲染组件具备了生产环境所需的核心功能,核心亮点总结:
- 轻量无依赖:纯原生实现,无需引入额外库,打包体积小,加载速度快;
- 功能完整:支持几乎所有常用Markdown语法,包含代码高亮、复制、行号等实用功能;
- 高度可定制:多套内置主题,支持深度样式定制,适配不同设计风格;
- 类型安全:基于TypeScript开发,提供完整的类型定义,开发体验更佳;
- 灵活易用:支持多种内容来源,API设计简洁直观,接入成本低。
你可以直接将这个组件集成到你的Vue3项目中,也可以根据业务需求扩展更多功能(比如添加TOC目录、锚点跳转、Mermaid图表支持等)。希望这个组件能帮助你高效实现Markdown渲染功能!
源码
<script setup lang="ts">
import { ref, watch, onMounted, useSlots } from 'vue'
// 主题类型
export type MarkdownTheme = 'default' | 'github' | 'dark' | 'vue' | 'minimal'
// 代码高亮主题
export type CodeTheme = 'default' | 'monokai' | 'github' | 'dracula' | 'one-dark' | 'solarized'
// Props
export interface MarkdownProps {
content?: string
src?: string
theme?: MarkdownTheme
codeTheme?: CodeTheme
showLineNumbers?: boolean
copyable?: boolean
sanitize?: boolean
}
const props = withDefaults(defineProps<MarkdownProps>(), {
theme: 'default',
codeTheme: 'default',
showLineNumbers: true,
copyable: true,
sanitize: true
})
const emit = defineEmits<{
'load': [content: string]
'error': [error: Error]
'copy': [code: string]
}>()
const slots = useSlots()
// 状态
const loading = ref(false)
const error = ref<string | null>(null)
const rawContent = ref('')
const renderedHtml = ref('')
// 获取插槽内容
const getSlotContent = (): string => {
const slot = slots.default?.()
if (!slot || slot.length === 0) return ''
const extractText = (vnodes: any[]): string => {
return vnodes.map(vnode => {
if (typeof vnode === 'string') {
return vnode
}
if (typeof vnode.children === 'string') {
return vnode.children
}
if (Array.isArray(vnode.children)) {
return extractText(vnode.children)
}
return ''
}).join('')
}
return extractText(slot)
}
// 从URL加载内容
const loadFromUrl = async (url: string) => {
loading.value = true
error.value = null
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const text = await response.text()
rawContent.value = text
emit('load', text)
} catch (e) {
const err = e as Error
error.value = err.message
emit('error', err)
} finally {
loading.value = false
}
}
// HTML转义
const escapeHtml = (text: string): string => {
const map: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}
return text.replace(/[&<>"']/g, m => map[m] || m)
}
// 代码语法高亮 - 基于 token 的方式
const highlightCode = (code: string, lang: string): string => {
// 关键字映射
const keywords: Record<string, string[]> = {
javascript: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'extends', 'import', 'export', 'from', 'default', 'async', 'await', 'try', 'catch', 'throw', 'new', 'this', 'super', 'typeof', 'instanceof', 'true', 'false', 'null', 'undefined'],
typescript: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class', 'extends', 'import', 'export', 'from', 'default', 'async', 'await', 'try', 'catch', 'throw', 'new', 'this', 'super', 'typeof', 'instanceof', 'true', 'false', 'null', 'undefined', 'interface', 'type', 'enum', 'implements', 'private', 'public', 'protected', 'readonly', 'as', 'is'],
python: ['def', 'class', 'if', 'elif', 'else', 'for', 'while', 'return', 'import', 'from', 'as', 'try', 'except', 'finally', 'raise', 'with', 'lambda', 'yield', 'True', 'False', 'None', 'and', 'or', 'not', 'in', 'is', 'pass', 'break', 'continue', 'global', 'nonlocal', 'async', 'await'],
java: ['public', 'private', 'protected', 'class', 'interface', 'extends', 'implements', 'static', 'final', 'void', 'int', 'long', 'double', 'float', 'boolean', 'char', 'byte', 'short', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue', 'return', 'try', 'catch', 'finally', 'throw', 'throws', 'new', 'this', 'super', 'null', 'true', 'false', 'import', 'package'],
css: ['import', 'media', 'keyframes', 'font-face', 'supports', 'important'],
sql: ['SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'DROP', 'ALTER', 'TABLE', 'INDEX', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'AND', 'OR', 'NOT', 'NULL', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET', 'AS', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MAX', 'MIN'],
vue: ['template', 'script', 'style', 'setup', 'ref', 'reactive', 'computed', 'watch', 'onMounted', 'defineProps', 'defineEmits']
}
const langKeywords = new Set(keywords[lang] || keywords['javascript'] || [])
const tokens: Array<{ type: string; value: string }> = []
let i = 0
while (i < code.length) {
// 单行注释
if (code.slice(i, i + 2) === '//') {
const end = code.indexOf('\n', i)
const value = end === -1 ? code.slice(i) : code.slice(i, end)
tokens.push({ type: 'comment', value })
i += value.length
continue
}
// 多行注释
if (code.slice(i, i + 2) === '/*') {
const end = code.indexOf('*/', i + 2)
const value = end === -1 ? code.slice(i) : code.slice(i, end + 2)
tokens.push({ type: 'comment', value })
i += value.length
continue
}
// 字符串 (单引号、双引号、反引号)
if (code[i] === '"' || code[i] === "'" || code[i] === '`') {
const quote = code[i]
let j = i + 1
while (j < code.length) {
if (code[j] === '\\') {
j += 2
} else if (code[j] === quote) {
j++
break
} else {
j++
}
}
tokens.push({ type: 'string', value: code.slice(i, j) })
i = j
continue
}
// 数字
if (/\d/.test(code[i] || '')) {
let j = i
while (j < code.length && /[\d.]/.test(code[j] || '')) j++
tokens.push({ type: 'number', value: code.slice(i, j) })
i = j
continue
}
// 标识符或关键字
if (/[a-zA-Z_]/.test(code[i] || '')) {
let j = i
while (j < code.length && /\w/.test(code[j] || '')) j++
const value = code.slice(i, j)
// 检查是否是函数调用
let k = j
while (k < code.length && /\s/.test(code[k] || '')) k++
if (code[k] === '(') {
tokens.push({ type: 'function', value })
} else if (langKeywords.has(value)) {
tokens.push({ type: 'keyword', value })
} else {
tokens.push({ type: 'text', value })
}
i = j
continue
}
// 其他字符
tokens.push({ type: 'text', value: code[i] || '' })
i++
}
// 生成 HTML
return tokens.map(token => {
const escaped = escapeHtml(token.value)
switch (token.type) {
case 'keyword': return '<span class="hl-keyword">' + escaped + '</span>'
case 'string': return '<span class="hl-string">' + escaped + '</span>'
case 'number': return '<span class="hl-number">' + escaped + '</span>'
case 'comment': return '<span class="hl-comment">' + escaped + '</span>'
case 'function': return '<span class="hl-function">' + escaped + '</span>'
default: return escaped
}
}).join('')
}
// 解析Markdown
const parseMarkdown = (md: string): string => {
// 规范化输入:统一换行符,移除多余空行
let html = md
.replace(/\r\n/g, '\n') // 统一换行符
.replace(/\n{3,}/g, '\n\n') // 最多保留一个空行
.trim()
// 用占位符保护代码块,防止被后续规则破坏
const codeBlocks: string[] = []
// 代码块 ```
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
const trimmedCode = code.trim()
// 基于原始代码计算行数
const originalLines = trimmedCode.split('\n')
const lineCount = originalLines.length
const highlighted = highlightCode(trimmedCode, lang || 'text')
const lineNumbers = props.showLineNumbers
? '<div class="line-numbers">' + Array.from({ length: lineCount }, (_, i) => '<span>' + (i + 1) + '</span>').join('') + '</div>'
: ''
// 使用 base64 编码存储代码,避免引号等特殊字符破坏 HTML 属性
const encodedCode = btoa(encodeURIComponent(trimmedCode))
const copyBtn = props.copyable
? '<button class="copy-btn" data-code="' + encodedCode + '">复制</button>'
: ''
const blockHtml = '<div class="code-block" data-lang="' + (lang || 'text') + '">' +
'<div class="code-header"><span class="code-lang">' + (lang || 'text') + '</span>' + copyBtn + '</div>' +
'<pre>' + lineNumbers + '<code class="language-' + (lang || 'text') + '">' + highlighted + '</code></pre>' +
'</div>'
// 用占位符替换,防止被后续规则破坏(使用HTML注释格式避免被Markdown规则处理)
const idx = codeBlocks.length
codeBlocks.push(blockHtml)
return '<codeblock data-id="' + idx + '"></codeblock>'
})
// 转义字符处理 - 用占位符保护转义的特殊字符(必须在行内代码之前)
const escapeChars: string[] = []
html = html.replace(/\\([\\`*_{}[\]()#+\-.!~^|<>])/g, (_, char) => {
const idx = escapeChars.length
escapeChars.push(char)
return '\x00ESC' + idx + '\x00'
})
// 行内代码 `code` - 现在不会匹配到代码块内的内容了
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
// 水平线 - 必须在 Setext 标题和粗体/斜体之前处理
// 匹配独立一行的 ---、***、___ (至少3个,可以有空格分隔)
html = html.replace(/^[ \t]*([-*_])[ \t]*\1[ \t]*\1[ \t]*(?:\1[ \t]*)*$/gm, '<hr />')
// Setext 风格标题 (===== 和 -----)
// 匹配:非空行 + 换行 + 3个以上的=或-
// 注意:水平线已经被处理,所以这里不会误匹配
html = html.replace(/^(.+)\n={3,}\s*$/gm, (_, title) => '<h1>' + title.trim() + '</h1>')
html = html.replace(/^(.+)\n-{3,}\s*$/gm, (_, title) => '<h2>' + title.trim() + '</h2>')
// ATX 风格标题 (# ## ### 等)
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
// 粗体和斜体
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>')
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
html = html.replace(/_(.+?)_/g, '<em>$1</em>')
// 删除线
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>')
// 高亮文本 ==text==
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>')
// 自动链接 <url> 和 <email>
html = html.replace(/<(https?:\/\/[^>]+)>/g, '<a href="$1" target="_blank" rel="noopener">$1</a>')
html = html.replace(/<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/g, '<a href="mailto:$1">$1</a>')
// 脚注定义 - 先提取所有脚注定义(必须在上标处理之前)
// 格式: [^id]: 脚注内容
const footnoteDefs: Record<string, string> = {}
const footnoteOrder: string[] = []
// 提取脚注定义(单行格式)
html = html.replace(/^\[\^([^\]]+)\]:\s*(.+)$/gm, (_, id, content) => {
footnoteDefs[id] = content.trim()
return '' // 移除定义行
})
// 脚注引用 [^id] -> 上标链接(必须在上标处理之前)
html = html.replace(/\[\^([^\]]+)\]/g, (match, id) => {
if (footnoteDefs[id] !== undefined) {
if (!footnoteOrder.includes(id)) {
footnoteOrder.push(id)
}
const num = footnoteOrder.indexOf(id) + 1
return '<sup class="footnote-ref"><a href="#fn-' + id + '" id="fnref-' + id + '">[' + num + ']</a></sup>'
}
return match // 未找到定义,保持原样
})
// 上标 ^text^ (注意:必须在脚注之后处理)
html = html.replace(/\^([^^]+)\^/g, '<sup>$1</sup>')
// 下标 ~text~ (注意:在删除线之后处理)
html = html.replace(/~([^~]+)~/g, '<sub>$1</sub>')
// 参考式链接和图片定义 - 先提取所有定义
// 格式: [id]: url "title" 或 [id]: url 'title' 或 [id]: url (title)
const refDefs: Record<string, { url: string; title: string }> = {}
html = html.replace(/^\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]+)["')])?$/gm, (_, id, url, title) => {
refDefs[id.toLowerCase()] = { url, title: title || '' }
return '' // 移除定义行
})
// 参考式图片 ![alt][id] 或 ![alt][](使用 alt 作为 id)
html = html.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (_, alt, id) => {
const refId = (id || alt).toLowerCase()
const ref = refDefs[refId]
if (ref) {
const titleAttr = ref.title ? ' title="' + ref.title + '"' : ''
return '<img src="' + ref.url + '" alt="' + alt + '"' + titleAttr + ' class="md-image" />'
}
return _ // 未找到定义,保持原样
})
// 参考式链接 [text][id] 或 [text][](使用 text 作为 id)
html = html.replace(/\[([^\]]+)\]\[([^\]]*)\]/g, (_, text, id) => {
const refId = (id || text).toLowerCase()
const ref = refDefs[refId]
if (ref) {
const titleAttr = ref.title ? ' title="' + ref.title + '"' : ''
return '<a href="' + ref.url + '"' + titleAttr + ' target="_blank" rel="noopener">' + text + '</a>'
}
return _ // 未找到定义,保持原样
})
// 行内链接和图片
html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, alt, url, title) => {
const titleAttr = title ? ' title="' + title + '"' : ''
return '<img src="' + url + '" alt="' + alt + '"' + titleAttr + ' class="md-image" />'
})
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, text, url, title) => {
const titleAttr = title ? ' title="' + title + '"' : ''
return '<a href="' + url + '"' + titleAttr + ' target="_blank" rel="noopener">' + text + '</a>'
})
// 引用块 - 支持嵌套引用
// 匹配所有以 > 开头的连续行(包括只有 > 的空引用行)
const blockquoteRegex = /^(>+.*(?:\n>+.*)*)/gm
html = html.replace(blockquoteRegex, (block) => {
const lines = block.split('\n')
let result = ''
let prevLevel = 0
for (const line of lines) {
// 计算 > 的数量(嵌套层级)
const match = line.match(/^(>+)\s*(.*)$/)
if (!match) continue
const level = (match[1] || '').length
const content = (match[2] || '').trim()
// 关闭多余的层级
while (prevLevel > level) {
result += '</blockquote>'
prevLevel--
}
// 打开新的层级
while (prevLevel < level) {
result += '<blockquote>'
prevLevel++
}
// 添加内容(跳过空内容)
if (content) {
result += '<p>' + content + '</p>'
}
}
// 关闭所有剩余的层级
while (prevLevel > 0) {
result += '</blockquote>'
prevLevel--
}
return result
})
// 表格处理 - 必须在列表处理之前,否则带缩进的表格会被列表规则捕获
// 解析表格行,处理单元格内包含转义 | 的情况
const parseTableRow = (row: string): string[] => {
const cells: string[] = []
let current = ''
let i = 0
// 去掉首尾的 |
const content = row.replace(/^\||\|$/g, '')
while (i < content.length) {
// 检查是否是转义的 | (如 \|)
if (content[i] === '\\' && content[i + 1] === '|') {
current += '|'
i += 2
} else if (content[i] === '`') {
// 处理行内代码,代码内的 | 不分割
const codeEnd = content.indexOf('`', i + 1)
if (codeEnd !== -1) {
current += content.slice(i, codeEnd + 1)
i = codeEnd + 1
} else {
current += content[i]
i++
}
} else if (content[i] === '|') {
cells.push(current.trim())
current = ''
i++
} else {
current += content[i]
i++
}
}
if (current) {
cells.push(current.trim())
}
return cells
}
// 解析对齐方式: :--- 左对齐, :---: 居中, ---: 右对齐
const parseAlignment = (cell: string): string => {
const trimmed = cell.trim()
const left = trimmed.startsWith(':')
const right = trimmed.endsWith(':')
if (left && right) return 'center'
if (right) return 'right'
return 'left' // 默认左对齐
}
// 处理完整的表格块 - 支持带缩进的表格和最后一行没有换行符的情况
const tableRegex = /^([ \t]*\|.+\|(?:\n|$))+/gm
html = html.replace(tableRegex, (tableBlock) => {
const lines = tableBlock.trim().split('\n')
let headerRow = ''
const bodyRows: string[] = []
let alignments: string[] = []
let headerParsed = false
let separatorFound = false
for (const line of lines) {
// 去掉行首缩进
const trimmedLine = line.replace(/^[ \t]+/, '')
const cells = parseTableRow(trimmedLine)
// 检查是否是分隔行 (如 |---|---| 或 |:---:|---:|)
if (!separatorFound && cells.every((c: string) => /^:?-+:?$/.test(c))) {
// 解析对齐方式
alignments = cells.map(parseAlignment)
separatorFound = true
continue
}
if (!headerParsed && !separatorFound) {
// 表头行
headerRow = '<tr>' + cells.map((c: string, i: number) => {
const align = alignments[i] || 'left'
return '<th style="text-align: ' + align + '">' + c + '</th>'
}).join('') + '</tr>'
headerParsed = true
} else if (separatorFound) {
// 应用对齐方式到表头(如果还没应用)
if (headerParsed && headerRow.indexOf('text-align') === -1) {
const headerCells = parseTableRow((lines[0] || '').replace(/^[ \t]+/, ''))
headerRow = '<tr>' + headerCells.map((c: string, i: number) => {
const align = alignments[i] || 'left'
return '<th style="text-align: ' + align + '">' + c + '</th>'
}).join('') + '</tr>'
}
// 数据行
bodyRows.push('<tr>' + cells.map((c: string, i: number) => {
const align = alignments[i] || 'left'
return '<td style="text-align: ' + align + '">' + c + '</td>'
}).join('') + '</tr>')
}
}
return '<table class="md-table"><thead>' + headerRow + '</thead><tbody>' + bodyRows.join('') + '</tbody></table>'
})
// 定义列表 - 术语行后跟 ": 定义" 行
// 匹配:术语行 + 一个或多个定义行(以 : 开头)
const defListRegex = /^([^\n:]+)\n((?::[ \t]+[^\n]+(?:\n|$))+)/gm
html = html.replace(defListRegex, (_, term, defs) => {
const termHtml = '<dt>' + term.trim() + '</dt>'
const defLines = defs.trim().split('\n')
const defsHtml = defLines.map((line: string) => {
const content = line.replace(/^:[ \t]+/, '').trim()
return '<dd>' + content + '</dd>'
}).join('')
return '<dl>' + termHtml + defsHtml + '</dl>'
})
// 合并连续的定义列表
html = html.replace(/<\/dl>\n*<dl>/g, '')
// 列表处理 - 支持有序/无序列表的混合嵌套和任务列表
// 匹配列表块:无序 (- * +) 或有序 (1. 2. 等)
const listBlockRegex = /^([ \t]*(?:[-*+]|\d+\.)[ \t]+.+(?:\n(?:[ \t]*(?:[-*+]|\d+\.)[ \t]+.+|[ \t]+.+))*)/gm
html = html.replace(listBlockRegex, (block) => {
const lines = block.split('\n')
let result = ''
const stack: { indent: number; type: string }[] = []
for (const line of lines) {
// 匹配无序列表项:可选缩进 + [-*+] + 内容
const ulMatch = line.match(/^([ \t]*)([-*+])[ \t]+(.*)$/)
// 匹配有序列表项:可选缩进 + 数字. + 内容
const olMatch = line.match(/^([ \t]*)(\d+)\.\s+(.*)$/)
const match = ulMatch || olMatch
if (!match) continue
const indent = (match[1] || '').length
const listType = ulMatch ? 'ul' : 'ol'
let content = match[3] || ''
// 检查是否是任务列表
let isTask = false
let isChecked = false
const taskMatch = content.match(/^\[(x|\s?)\]\s*(.*)$/i)
if (taskMatch) {
isTask = true
isChecked = (taskMatch[1] || '').toLowerCase() === 'x'
content = taskMatch[2] || ''
}
// 关闭更深或同级但类型不同的列表
while (stack.length > 0) {
const top = stack[stack.length - 1]
if (!top) break
if (top.indent > indent) {
// 关闭更深层级
stack.pop()
result += '</li></' + top.type + '>'
} else if (top.indent === indent && top.type !== listType) {
// 同级但类型不同,关闭当前列表
stack.pop()
result += '</li></' + top.type + '>'
} else {
break
}
}
// 检查是否需要开启新列表
const topStack = stack[stack.length - 1]
if (stack.length === 0 || indent > (topStack?.indent ?? -1)) {
// 新的缩进层级,开启新列表
result += '<' + listType + '>'
stack.push({ indent, type: listType })
} else if (topStack && topStack.indent === indent) {
// 同级同类型,关闭上一个 li
result += '</li>'
}
// 生成 li
if (isTask) {
const checkedAttr = isChecked ? ' checked' : ''
const checkedClass = isChecked ? ' checked' : ''
result += '<li class="task-item' + checkedClass + '"><input type="checkbox"' + checkedAttr + ' disabled />' + content
} else {
result += '<li>' + content
}
}
// 关闭所有剩余的标签
while (stack.length > 0) {
const top = stack.pop()
if (top) {
result += '</li></' + top.type + '>'
}
}
return result
})
// 段落 - 只匹配非空且不以HTML标签开头的行
html = html.replace(/^(?!<[a-z]|<codeblock|\s*$)(.+)$/gm, '<p>$1</p>')
// 恢复代码块占位符
for (let i = 0; i < codeBlocks.length; i++) {
html = html.replace('<codeblock data-id="' + i + '"></codeblock>', codeBlocks[i] || '')
}
// 清理多余空行和空段落
html = html.replace(/<p>\s*<\/p>/g, '')
html = html.replace(/<blockquote>\s*<\/blockquote>/g, '')
// 移除连续的换行符
html = html.replace(/\n{2,}/g, '\n')
// 移除 hr 前后的空白换行
html = html.replace(/\n*(<hr\s*\/?>\n*)+/g, '<hr />')
// 移除块级元素之间的空白文本节点
html = html.replace(/(<\/(?:h[1-6]|p|div|pre|blockquote|ul|ol|table)>|<hr\s*\/?>)\s*\n*\s*(<(?:h[1-6]|p|div|pre|blockquote|ul|ol|table|hr|codeblock))/g, '$1$2')
// 清理开头和结尾的空白
html = html.trim()
// 添加脚注列表(如果有脚注)
if (footnoteOrder.length > 0) {
let footnoteHtml = '<hr class="footnote-sep" /><section class="footnotes"><ol>'
for (let i = 0; i < footnoteOrder.length; i++) {
const id = footnoteOrder[i]
const content = footnoteDefs[id || ''] || ''
footnoteHtml += '<li id="fn-' + id + '">' + content + ' <a href="#fnref-' + id + '" class="footnote-backref">↩</a></li>'
}
footnoteHtml += '</ol></section>'
html += footnoteHtml
}
// 恢复转义字符占位符
for (let i = 0; i < escapeChars.length; i++) {
html = html.replace('\x00ESC' + i + '\x00', escapeHtml(escapeChars[i] || ''))
}
return html
}
// 渲染内容
const render = () => {
let content = ''
if (props.content) {
content = props.content
} else if (rawContent.value) {
content = rawContent.value
} else {
content = getSlotContent()
}
if (content) {
renderedHtml.value = parseMarkdown(content)
}
}
// 复制代码
const handleCopy = async (e: Event) => {
const target = e.target as HTMLElement
if (target.classList.contains('copy-btn')) {
const encodedCode = target.dataset.code || ''
try {
// 解码 base64 编码的代码
const code = decodeURIComponent(atob(encodedCode))
await navigator.clipboard.writeText(code)
target.textContent = '已复制!'
emit('copy', code)
setTimeout(() => {
target.textContent = '复制'
}, 2000)
} catch {
target.textContent = '复制失败'
}
}
}
// 监听变化
watch(() => props.content, render, { immediate: true })
watch(() => props.src, (url) => {
if (url) loadFromUrl(url)
}, { immediate: true })
// 监听插槽变化
onMounted(() => {
if (!props.content && !props.src) {
render()
}
})
// 暴露方法
defineExpose({
render,
loadFromUrl
})
</script>
<template>
<div
class="lucas-markdown"
:class="[`theme-${theme}`, `code-theme-${codeTheme}`]"
@click="handleCopy"
>
<!-- 加载状态 -->
<div v-if="loading" class="md-loading">
<span class="loading-spinner"></span>
<span>加载中...</span>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="md-error">
<span>❌</span>
<span>{{ error }}</span>
</div>
<!-- 渲染内容 -->
<div v-else class="md-content" v-html="renderedHtml"></div>
</div>
</template>
<style scoped>
.lucas-markdown {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.7;
color: #333;
word-wrap: break-word;
}
/* 加载和错误状态 */
.md-loading, .md-error {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
color: #666;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e0e0e0;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.md-error {
color: #ff4d4f;
background: #fff2f0;
border-radius: 4px;
}
/* 内容样式 */
.md-content :deep(h1),
.md-content :deep(h2),
.md-content :deep(h3),
.md-content :deep(h4),
.md-content :deep(h5),
.md-content :deep(h6) {
margin: 24px 0 16px;
font-weight: 600;
line-height: 1.25;
}
.md-content :deep(h1) { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
.md-content :deep(h2) { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
.md-content :deep(h3) { font-size: 1.25em; }
.md-content :deep(h4) { font-size: 1em; }
.md-content :deep(h5) { font-size: 0.875em; }
.md-content :deep(h6) { font-size: 0.85em; color: #666; }
.md-content :deep(p) {
margin: 0 0 16px;
}
.md-content :deep(a) {
color: #1890ff;
text-decoration: none;
}
.md-content :deep(a:hover) {
text-decoration: underline;
}
.md-content :deep(strong) {
font-weight: 600;
}
.md-content :deep(blockquote) {
margin: 16px 0;
padding: 8px 16px;
border-left: 4px solid #dfe2e5;
background: #f6f8fa;
color: #666;
}
.md-content :deep(blockquote p) {
margin: 4px 0;
}
.md-content :deep(blockquote blockquote) {
margin: 8px 0;
border-left-color: #c0c4c8;
background: #eef0f2;
}
.md-content :deep(blockquote blockquote blockquote) {
border-left-color: #a0a4a8;
background: #e6e8ea;
}
.md-content :deep(hr) {
height: 1px;
margin: 24px 0;
background: #e1e4e8;
border: none;
}
/* 脚注 */
.md-content :deep(.footnote-ref) {
font-size: 0.75em;
vertical-align: super;
line-height: 0;
}
.md-content :deep(.footnote-ref a) {
color: #1890ff;
text-decoration: none;
}
.md-content :deep(.footnote-sep) {
margin-top: 40px;
}
.md-content :deep(.footnotes) {
font-size: 0.9em;
color: #666;
}
.md-content :deep(.footnotes ol) {
padding-left: 1.5em;
}
.md-content :deep(.footnotes li) {
margin: 8px 0;
}
.md-content :deep(.footnote-backref) {
color: #1890ff;
text-decoration: none;
margin-left: 4px;
}
.md-content :deep(ul),
.md-content :deep(ol) {
margin: 0 0 16px;
padding-left: 2em;
}
.md-content :deep(li) {
margin: 4px 0;
}
.md-content :deep(.task-item) {
list-style: none;
margin-left: -1.2em;
}
.md-content :deep(.task-item input) {
margin-right: 6px;
vertical-align: middle;
}
.md-content :deep(.task-item.checked) {
color: #888;
text-decoration: line-through;
}
.md-content :deep(ul ul),
.md-content :deep(ol ol),
.md-content :deep(ul ol),
.md-content :deep(ol ul) {
margin: 4px 0;
}
.md-content :deep(.md-image) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.md-content :deep(.md-table) {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.md-content :deep(.md-table td),
.md-content :deep(.md-table th) {
padding: 8px 12px;
border: 1px solid #dfe2e5;
}
.md-content :deep(.md-table tr:nth-child(2n)) {
background: #f6f8fa;
}
/* 定义列表 */
.md-content :deep(dl) {
margin: 16px 0;
}
.md-content :deep(dt) {
font-weight: 600;
margin-top: 12px;
}
.md-content :deep(dt:first-child) {
margin-top: 0;
}
.md-content :deep(dd) {
margin: 4px 0 4px 24px;
padding-left: 12px;
border-left: 3px solid #dfe2e5;
color: #666;
}
/* 行内代码 */
.md-content :deep(.inline-code) {
padding: 2px 6px;
margin: 0 2px;
font-size: 0.9em;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
background: #f6f8fa;
border-radius: 4px;
color: #e83e8c;
}
/* 代码块 */
.md-content :deep(.code-block) {
margin: 16px 0;
border-radius: 8px;
overflow: hidden;
background: #1e1e1e;
}
.md-content :deep(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #2d2d2d;
border-bottom: 1px solid #404040;
}
.md-content :deep(.code-lang) {
font-size: 12px;
color: #888;
text-transform: uppercase;
}
.md-content :deep(.copy-btn) {
padding: 4px 12px;
font-size: 12px;
color: #888;
background: transparent;
border: 1px solid #555;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.md-content :deep(.copy-btn:hover) {
color: #fff;
border-color: #888;
}
.md-content :deep(pre) {
margin: 0;
padding: 16px;
overflow-x: auto;
display: flex;
align-items: flex-start;
}
.md-content :deep(.line-numbers) {
flex-shrink: 0;
padding-right: 16px;
margin-right: 16px;
border-right: 1px solid #404040;
color: #666;
font-size: 14px;
font-family: 'SFMono-Regular', Consolas, monospace;
line-height: 1.6;
user-select: none;
text-align: right;
}
.md-content :deep(.line-numbers span) {
display: block;
}
.md-content :deep(code) {
flex: 1;
font-size: 14px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
line-height: 1.6;
color: #d4d4d4;
white-space: pre;
overflow-x: auto;
}
/* 语法高亮 */
.md-content :deep(.hl-keyword) { color: #569cd6; }
.md-content :deep(.hl-string) { color: #ce9178; }
.md-content :deep(.hl-number) { color: #b5cea8; }
.md-content :deep(.hl-comment) { color: #6a9955; font-style: italic; }
.md-content :deep(.hl-function) { color: #dcdcaa; }
/* ========== 主题变体 ========== */
/* GitHub 主题 */
.theme-github {
color: #24292e;
}
.theme-github .md-content :deep(.code-block) {
background: #f6f8fa;
border: 1px solid #e1e4e8;
}
.theme-github .md-content :deep(.code-header) {
background: #fafbfc;
border-bottom-color: #e1e4e8;
}
.theme-github .md-content :deep(code) {
color: #24292e;
}
.theme-github .md-content :deep(.hl-keyword) { color: #d73a49; }
.theme-github .md-content :deep(.hl-string) { color: #032f62; }
.theme-github .md-content :deep(.hl-number) { color: #005cc5; }
.theme-github .md-content :deep(.hl-comment) { color: #6a737d; }
.theme-github .md-content :deep(.hl-function) { color: #6f42c1; }
/* Dark 主题 */
.theme-dark {
background: #1a1a1a;
color: #e0e0e0;
}
.theme-dark .md-content :deep(h1),
.theme-dark .md-content :deep(h2) {
border-bottom-color: #333;
}
.theme-dark .md-content :deep(blockquote) {
background: #2a2a2a;
border-left-color: #555;
}
.theme-dark .md-content :deep(.inline-code) {
background: #2a2a2a;
color: #f08d49;
}
.theme-dark .md-content :deep(.md-table td),
.theme-dark .md-content :deep(.md-table th) {
border-color: #333;
}
.theme-dark .md-content :deep(.md-table tr:nth-child(2n)) {
background: #2a2a2a;
}
/* Vue 主题 */
.theme-vue {
color: #2c3e50;
}
.theme-vue .md-content :deep(a) {
color: #42b883;
}
.theme-vue .md-content :deep(h1),
.theme-vue .md-content :deep(h2),
.theme-vue .md-content :deep(h3) {
color: #35495e;
}
.theme-vue .md-content :deep(blockquote) {
border-left-color: #42b883;
background: #f3faf6;
}
.theme-vue .md-content :deep(.inline-code) {
color: #42b883;
background: #f3faf6;
}
/* Minimal 主题 */
.theme-minimal {
max-width: 700px;
margin: 0 auto;
}
.theme-minimal .md-content :deep(h1),
.theme-minimal .md-content :deep(h2) {
border-bottom: none;
}
.theme-minimal .md-content :deep(blockquote) {
background: transparent;
font-style: italic;
}
/* ========== 代码主题变体 ========== */
/* Monokai */
.code-theme-monokai .md-content :deep(.code-block) { background: #272822; }
.code-theme-monokai .md-content :deep(.code-header) { background: #1e1f1c; }
.code-theme-monokai .md-content :deep(code) { color: #f8f8f2; }
.code-theme-monokai .md-content :deep(.hl-keyword) { color: #f92672; }
.code-theme-monokai .md-content :deep(.hl-string) { color: #e6db74; }
.code-theme-monokai .md-content :deep(.hl-number) { color: #ae81ff; }
.code-theme-monokai .md-content :deep(.hl-comment) { color: #75715e; }
.code-theme-monokai .md-content :deep(.hl-function) { color: #a6e22e; }
/* Dracula */
.code-theme-dracula .md-content :deep(.code-block) { background: #282a36; }
.code-theme-dracula .md-content :deep(.code-header) { background: #21222c; }
.code-theme-dracula .md-content :deep(code) { color: #f8f8f2; }
.code-theme-dracula .md-content :deep(.hl-keyword) { color: #ff79c6; }
.code-theme-dracula .md-content :deep(.hl-string) { color: #f1fa8c; }
.code-theme-dracula .md-content :deep(.hl-number) { color: #bd93f9; }
.code-theme-dracula .md-content :deep(.hl-comment) { color: #6272a4; }
.code-theme-dracula .md-content :deep(.hl-function) { color: #50fa7b; }
/* One Dark */
.code-theme-one-dark .md-content :deep(.code-block) { background: #282c34; }
.code-theme-one-dark .md-content :deep(.code-header) { background: #21252b; }
.code-theme-one-dark .md-content :deep(code) { color: #abb2bf; }
.code-theme-one-dark .md-content :deep(.hl-keyword) { color: #c678dd; }
.code-theme-one-dark .md-content :deep(.hl-string) { color: #98c379; }
.code-theme-one-dark .md-content :deep(.hl-number) { color: #d19a66; }
.code-theme-one-dark .md-content :deep(.hl-comment) { color: #5c6370; }
.code-theme-one-dark .md-content :deep(.hl-function) { color: #61afef; }
/* Solarized */
.code-theme-solarized .md-content :deep(.code-block) { background: #002b36; }
.code-theme-solarized .md-content :deep(.code-header) { background: #073642; }
.code-theme-solarized .md-content :deep(code) { color: #839496; }
.code-theme-solarized .md-content :deep(.hl-keyword) { color: #859900; }
.code-theme-solarized .md-content :deep(.hl-string) { color: #2aa198; }
.code-theme-solarized .md-content :deep(.hl-number) { color: #d33682; }
.code-theme-solarized .md-content :deep(.hl-comment) { color: #586e75; }
.code-theme-solarized .md-content :deep(.hl-function) { color: #268bd2; }
</style>



李枭龙11 个月前
AI生成文章:请以上所有知识进行深入分析,确定主要知识点,为每个知识点撰写详细说明并附上具有代表性且带有清晰注释的代码示例,接着根据内容拟定一个准确反映文档核心的标题,最后严格按照 Markdown 格式进行排版,确保文档规范美观,以满足初学者学习使用的需求。
李枭龙1 年前
X Lucas