# 为 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 || '';
  
  //... 处理逻辑
}

# 第二步:语言检测和美化

我们需要:

  1. 检测代码的语言类型
  2. 将语言标识符转换为友好的显示名称(如 javascriptJavaScript
// 语言名称映射
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, '&quot;').replace(/'/g, '&#39;')}"
        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('复制失败');
  }
};

为什么使用事件委托?

  1. 动态内容:代码块是通过 markdown-it 动态生成的,使用事件委托可以自动处理所有按钮
  2. 性能优化:只需要一个事件监听器,而不是为每个按钮单独绑定
  3. 简化管理:不需要在内容更新时重新绑定事件

# 第五步:样式设计

我们使用深色主题,并添加了丰富的交互效果:

/* 代码块包装器 */
.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, '&quot;').replace(/'/g, '&#39;')}"

# 3. 状态管理

每个复制按钮都有独立的状态管理:

  • 使用 copied class 标记复制状态
  • 使用独立的 setTimeout 定时器,互不干扰
  • 通过检查 copied class 防止重复点击

# 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>

# 效果展示

最终实现的代码块包含:

  1. 语言标签:显示代码的语言类型(如 "PYTHON"、"JavaScript")
  2. 复制按钮:点击后复制代码到剪贴板
  3. 视觉反馈:复制成功后显示绿色对勾和 "已复制" 文字
  4. 语法高亮:使用 highlight.js 进行代码高亮
  5. 美观样式:深色主题,圆角边框,自定义滚动条

# 总结

通过重写 markdown-it 的 fence 规则,我们成功地:

  1. ✅ 为代码块添加了语言标签
  2. ✅ 实现了复制功能
  3. ✅ 保持了代码高亮功能
  4. ✅ 提供了良好的用户体验
  5. ✅ 确保了类型安全

这个实现方案具有以下优点:

  • 可复用:提取到工具函数中,可以在任何地方使用
  • 类型安全:使用 TypeScript 确保类型正确
  • 性能优化:使用事件委托,减少事件监听器数量
  • 用户体验:提供清晰的视觉反馈