# 为 Markdown 代码块添加语言标签和复制功能
# 前言
在现代的 AI 聊天应用中,代码块的展示是一个重要的功能。用户不仅需要看到语法高亮的代码,还需要能够快速识别代码语言类型,并且能够一键复制代码。本文将详细介绍如何在 Vue 3 + markdown-it + highlight.js 的技术栈中,为代码块添加语言标签和复制功能。
# 技术栈
- Vue 3 - 前端框架
- markdown-it - Markdown 解析器
- highlight.js - 代码语法高亮库
- TypeScript - 类型支持
# 核心挑战
# 1. markdown-it 的渲染机制
markdown-it 在渲染代码块时会自动生成 <pre><code> 标签。如果我们在 highlight 函数中返回包含这些标签的 HTML,会导致标签重复和空行问题。
解决方案:重写 fence 规则,完全自定义代码块的渲染逻辑。
# 2. 代码块结构设计
我们需要在代码块上方添加一个头部,包含:
- 语言类型标签(左侧)
- 复制按钮(右侧)
# 实现方案
# 第一步:重写 fence 规则
markdown-it 的 fence 规则负责渲染代码块。我们需要完全重写这个规则,以便在代码块周围添加头部信息。
// 定义 fence 规则的类型 | |
type FenceRule = NonNullable<MarkdownIt['renderer']['rules']['fence']>; | |
type FenceRuleParams = Parameters<FenceRule>; | |
// 重写 fence 规则 | |
md.renderer.rules.fence = function ( | |
tokens: FenceRuleParams[0], | |
idx: FenceRuleParams[1], | |
_options: FenceRuleParams[2], | |
_env: FenceRuleParams[3], | |
_self: FenceRuleParams[4] | |
) { | |
const token = tokens[idx]; | |
if (!token) return ''; | |
// 提取语言和代码内容 | |
const info = token.info ? token.info.trim() : ''; | |
const langName = info ? info.split(/\s+/g)[0] : ''; | |
const code = token.content || ''; | |
//... 处理逻辑 | |
} |
# 第二步:语言检测和美化
我们需要:
- 检测代码的语言类型
- 将语言标识符转换为友好的显示名称(如
javascript→JavaScript)
// 语言名称映射 | |
const langMap: Record<string, string> = { | |
javascript: 'JavaScript', | |
typescript: 'TypeScript', | |
python: 'Python', | |
//... 更多语言 | |
}; | |
// 语言检测逻辑 | |
let highlightedCode = ''; | |
let detectedLang = langName || ''; | |
if (langName && hljs.getLanguage(langName)) { | |
// 使用指定语言进行高亮 | |
const result = hljs.highlight(code, { | |
language: langName, | |
ignoreIllegals: true | |
}); | |
highlightedCode = result.value; | |
detectedLang = langName; | |
} else { | |
// 自动检测语言 | |
const result = hljs.highlightAuto(code); | |
highlightedCode = result.value; | |
detectedLang = result.language || 'text'; | |
} | |
// 美化显示名称 | |
const displayLang = langMap[detectedLang.toLowerCase()] || detectedLang || 'Text'; |
# 第三步:生成代码块 HTML
我们生成包含头部和代码内容的完整 HTML 结构:
// 转义代码内容用于 data 属性 | |
const escapedCode = md.utils.escapeHtml(code); | |
// 生成唯一的 ID | |
const codeId = `code-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; | |
// 返回完整的代码块 HTML | |
return ` | |
<div class="code-block-wrapper"> | |
<div class="code-block-header"> | |
<span class="code-block-lang">${displayLang}</span> | |
<button | |
class="code-block-copy-btn" | |
data-code-content="${escapedCode.replace(/"/g, '"').replace(/'/g, ''')}" | |
title="复制代码" | |
type="button" | |
> | |
<svg class="copy-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> | |
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> | |
</svg> | |
<svg class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: none;"> | |
<polyline points="20 6 9 17 4 12"></polyline> | |
</svg> | |
<span class="copy-text">复制</span> | |
</button> | |
</div> | |
<pre class="hljs"><code>${highlightedCode}</code></pre> | |
</div> | |
`; |
# 第四步:实现复制功能
我们使用事件委托的方式处理复制按钮的点击事件:
const handleGlobalClick = async (e: MouseEvent) => { | |
// 使用 closest 寻找点击的目标按钮 | |
const btn = (e.target as HTMLElement).closest('.code-block-copy-btn') as HTMLElement; | |
if (!btn || btn.classList.contains('copied')) return; | |
const codeContent = btn.getAttribute('data-code-content'); | |
if (!codeContent) return; | |
try { | |
// 解码 HTML 实体 | |
const textarea = document.createElement('textarea'); | |
textarea.innerHTML = codeContent; | |
const decodedContent = textarea.value; | |
// 复制到剪贴板 | |
await navigator.clipboard.writeText(decodedContent); | |
// 更新按钮状态 | |
const copyIcon = btn.querySelector('.copy-icon') as HTMLElement; | |
const checkIcon = btn.querySelector('.check-icon') as HTMLElement; | |
const copyText = btn.querySelector('.copy-text') as HTMLElement; | |
btn.classList.add('copied'); | |
if (copyIcon) copyIcon.style.display = 'none'; | |
if (checkIcon) checkIcon.style.display = 'block'; | |
if (copyText) copyText.textContent = '已复制'; | |
// 2 秒后恢复 | |
setTimeout(() => { | |
btn.classList.remove('copied'); | |
if (copyIcon) copyIcon.style.display = 'block'; | |
if (checkIcon) checkIcon.style.display = 'none'; | |
if (copyText) copyText.textContent = '复制'; | |
}, 2000); | |
ElMessage.success('代码已复制'); | |
} catch (err) { | |
console.error('复制失败', err); | |
ElMessage.error('复制失败'); | |
} | |
}; |
为什么使用事件委托?
- 动态内容:代码块是通过 markdown-it 动态生成的,使用事件委托可以自动处理所有按钮
- 性能优化:只需要一个事件监听器,而不是为每个按钮单独绑定
- 简化管理:不需要在内容更新时重新绑定事件
# 第五步:样式设计
我们使用深色主题,并添加了丰富的交互效果:
/* 代码块包装器 */ | |
.code-block-wrapper { | |
margin: 1em 0; | |
border-radius: 8px; | |
overflow: hidden; | |
background-color: #0d1117; | |
border: 1px solid rgba(255, 255, 255, 0.1); | |
} | |
/* 代码块头部 */ | |
.code-block-header { | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
padding: 8px 12px; | |
background-color: rgba(255, 255, 255, 0.03); | |
border-bottom: 1px solid rgba(255, 255, 255, 0.1); | |
} | |
/* 语言标签 */ | |
.code-block-lang { | |
font-size: 0.75rem; | |
font-weight: 500; | |
color: rgba(255, 255, 255, 0.7); | |
text-transform: uppercase; | |
letter-spacing: 0.5px; | |
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace; | |
} | |
/* 复制按钮 */ | |
.code-block-copy-btn { | |
display: flex; | |
align-items: center; | |
gap: 6px; | |
padding: 6px 12px; | |
background-color: transparent; | |
border: 1px solid rgba(255, 255, 255, 0.2); | |
border-radius: 6px; | |
color: rgba(255, 255, 255, 0.7); | |
font-size: 0.75rem; | |
cursor: pointer; | |
transition: all 0.2s ease; | |
} | |
.code-block-copy-btn:hover { | |
background-color: rgba(255, 255, 255, 0.1); | |
border-color: rgba(255, 255, 255, 0.3); | |
color: rgba(255, 255, 255, 0.9); | |
} | |
/* 复制成功状态 */ | |
.code-block-copy-btn.copied { | |
background-color: rgba(34, 197, 94, 0.15); | |
border-color: rgba(34, 197, 94, 0.3); | |
color: #22c55e; | |
} |
# 关键技术点
# 1. 为什么重写 fence 而不是 highlight?
highlight函数只处理代码内容的高亮,返回的 HTML 会被 markdown-it 包裹在<pre><code>中fence规则完全控制代码块的渲染,可以返回包含头部和代码的完整 HTML 结构
# 2. HTML 实体转义
代码内容需要转义后存储在 data-code-content 属性中,防止 XSS 攻击:
const escapedCode = md.utils.escapeHtml(code); | |
// 替换引号,避免破坏 HTML 属性 | |
data-code-content="${escapedCode.replace(/"/g, '"').replace(/'/g, ''')}" |
# 3. 状态管理
每个复制按钮都有独立的状态管理:
- 使用
copiedclass 标记复制状态 - 使用独立的
setTimeout定时器,互不干扰 - 通过检查
copiedclass 防止重复点击
# 4. 类型安全
使用 TypeScript 的类型推断来确保类型安全:
type FenceRule = NonNullable<MarkdownIt['renderer']['rules']['fence']>; | |
type FenceRuleParams = Parameters<FenceRule>; |
这样可以从 markdown-it 的类型定义中自动推断出正确的参数类型。
# 完整代码结构
# markdown.ts(工具函数)
export function createMarkdownRenderer(): MarkdownIt { | |
const md = new MarkdownIt({ | |
html: true, | |
linkify: true, | |
typographer: true, | |
highlight: function (str: string, lang?: string): string { | |
// 只返回高亮后的代码内容 | |
// ... | |
}, | |
}); | |
// 重写 fence 规则 | |
md.renderer.rules.fence = function (...) { | |
// 生成包含头部和代码的完整 HTML | |
// ... | |
}; | |
return md; | |
} |
# ChatView.vue(使用)
<template>
<div @click="handleGlobalClick">
<div v-html="renderMarkdown(message.content)"></div>
</div>
</template>
<script setup lang="ts">
import { createMarkdownRenderer } from '@monorepo/utils';
const md = createMarkdownRenderer();
function renderMarkdown(text: string): string {
return md.render(text);
}
const handleGlobalClick = async (e: MouseEvent) => {
// 处理复制按钮点击
// ...
};
</script>
# 效果展示
最终实现的代码块包含:
- 语言标签:显示代码的语言类型(如 "PYTHON"、"JavaScript")
- 复制按钮:点击后复制代码到剪贴板
- 视觉反馈:复制成功后显示绿色对勾和 "已复制" 文字
- 语法高亮:使用 highlight.js 进行代码高亮
- 美观样式:深色主题,圆角边框,自定义滚动条
# 总结
通过重写 markdown-it 的 fence 规则,我们成功地:
- ✅ 为代码块添加了语言标签
- ✅ 实现了复制功能
- ✅ 保持了代码高亮功能
- ✅ 提供了良好的用户体验
- ✅ 确保了类型安全
这个实现方案具有以下优点:
- 可复用:提取到工具函数中,可以在任何地方使用
- 类型安全:使用 TypeScript 确保类型正确
- 性能优化:使用事件委托,减少事件监听器数量
- 用户体验:提供清晰的视觉反馈