AI回答代码块高亮加一键复制

1 阅读2分钟

需求很常见:AI 返回的 Markdown 里有大段代码,得高亮,还得在每个代码块右上角放个「复制」按钮。我用 markdown-it + highlight.js 搞定,踩了几个坑,记一下。

整体思路

三步走:

  1. markdown-it 把 Markdown 转 HTML,配置里挂上 highlight.js 做语法高亮。
  2. 用一个 renderer.rules.fence 重写,给每个代码块包一层带「复制」按钮的容器。
  3. 复制按钮用事件委托统一监听,别给每个按钮单独绑。

高亮配置

import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';

const md = new MarkdownIt({
  highlight(code, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(code, { language: lang }).value;
      } catch (_) {}
    }
    return md.utils.escapeHtml(code); // 兜底转义,防 XSS
  },
});

那个兜底的 escapeHtml 别省。AI 有时会返回它自己都不认识的语言标记,或者干脆没标语言,这时如果不转义直接塞进 DOM,万一代码里有 <img onerror=...> 就出事了。

给代码块包按钮

重写 fence 规则,在原始渲染结果外面套一层:

const defaultFence = md.renderer.rules.fence;
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
  const raw = tokens[idx].content;
  const html = defaultFence(tokens, idx, options, env, self);
  // 把原始代码存进 data 属性,复制时直接取
  const encoded = encodeURIComponent(raw);
  return `
    <div class="code-block">
      <button class="copy-btn" data-code="${encoded}">复制</button>
      ${html}
    </div>`;
};

把原始代码 encodeURIComponent 后塞进 data-code,复制时拿这个,而不是去读高亮后那堆 <span>textContent。我一开始偷懒读 textContent,结果发现高亮 DOM 里有时会混进多余空白和不可见字符,复制出来的代码缩进对不上,干脆改回存原始串。

一键复制 + 事件委托

流式场景下代码块是一个个动态冒出来的,逐个 addEventListener 既麻烦又容易内存泄漏。直接在容器上委托:

container.addEventListener('click', async (e) => {
  const btn = e.target.closest('.copy-btn');
  if (!btn) return;
  const code = decodeURIComponent(btn.dataset.code);
  try {
    await navigator.clipboard.writeText(code);
    btn.textContent = '已复制';
    setTimeout(() => (btn.textContent = '复制'), 1500);
  } catch {
    btn.textContent = '复制失败';
  }
});

两个真实的坑

第一,navigator.clipboard 只在 HTTPS 或 localhost 下可用。内网 IP + HTTP 直接 undefined,本地联调时我对着控制台一脸懵,后来才想起来要么上 HTTPS,要么退回老掉牙的 document.execCommand('copy') 兜底。这个 API 现在虽然废弃了,但作为 HTTP 环境的备胎还真不能少。

第二,流式输出时代码块还没闭合就被渲染。模型刚吐到代码块一半,``` 还没来,markdown-it 会把后面所有内容都当成代码。我的处理是流式过程中检测未闭合的 ``` 数量是奇数,就临时补一个,等流结束再用完整文本重渲一次。有点 hack,但比让用户看半天「假代码块」强。

highlight.js 全量包挺大的,按需引入语言能省不少体积,但配置略繁,小项目我一般直接全量,懒。

收尾

前端这层渲染壳是我手搓的,对话背后的智能体是在一个能拖拽编排的平台上零代码搭的。模型 API 我也没自建,用的讯飞这种 MaaS——现成大模型接口直接调,省了部署和运维。