需求很常见:AI 返回的 Markdown 里有大段代码,得高亮,还得在每个代码块右上角放个「复制」按钮。我用 markdown-it + highlight.js 搞定,踩了几个坑,记一下。
整体思路
三步走:
markdown-it把 Markdown 转 HTML,配置里挂上highlight.js做语法高亮。- 用一个
renderer.rules.fence重写,给每个代码块包一层带「复制」按钮的容器。 - 复制按钮用事件委托统一监听,别给每个按钮单独绑。
高亮配置
javascript
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 规则,在原始渲染结果外面套一层:
ini
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 既麻烦又容易内存泄漏。直接在容器上委托:
ini
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------现成大模型接口直接调,省了部署和运维。