代码高亮实现的几种实用方案与踩坑总结
项目初期的技术选型
这个项目是个技术文档站,用户要能看代码、复制代码,还得看着舒服。一开始我也没想太多,直接上了 Prism.js,毕竟这玩意儿轻量,配置简单,highlight.js 也差不多,但我之前用过 Prism,顺手就来了。
需求其实也不复杂:支持常见语言(JavaScript、Python、Shell、HTML),能自动检测语言,有行号,能复制按钮,主题可切换。听起来很简单对吧?但实际做下来,坑比想象中多得多。
最大的坑:性能问题
本地跑得好好的,一到线上,页面稍微多几个代码块,滚动就开始卡了。尤其是移动端,iPhone 上一滑动,直接掉帧。开始我以为是 CSS 动画冲突,查了半天没发现啥问题。后来用 Performance 面板一录,好家伙,高亮过程占了主线程几百毫秒——原来 Prism 的 highlightAll() 是同步阻塞的。
我原本在 useEffect 里这么写的:
useEffect(() => {
if (typeof window !== 'undefined' && window.Prism) {
window.Prism.highlightAll();
}
}, []);
结果页面一渲染,所有代码块一起高亮,DOM 还没挂载完呢,Prism 就开始遍历,直接把 JS 线程堵死了。
解决办法?异步分批处理。我把高亮逻辑拆成微任务队列,一个一个来,避免阻塞 UI:
useEffect(() => {
const highlightCodeBlocks = async () => {
const blocks = Array.from(document.querySelectorAll('pre code'));
for (const block of blocks) {
await Promise.resolve();
if (!block.classList.contains('language-')) return;
window.Prism.highlightElement(block);
}
};
if (typeof window !== 'undefined' && window.Prism) {
// 延迟执行,确保 DOM 渲染完成
requestAnimationFrame(() => {
highlightCodeBlocks();
});
}
}, []);
这里注意我踩过好几次坑:一开始用了 setTimeout(fn, 0),但偶尔会漏掉动态加载的内容。后来改用 requestAnimationFrame + 微任务循环,才稳定下来。虽然整体高亮时间变长了,但用户体验顺滑多了,至少不会卡死。
动态内容的坑:MDX 和 SSR 不兼容
项目用了 Next.js,MDX 渲染文档。SSR 阶段当然没有 window,Prism 也不会运行。这本来正常,但问题出在 Hydration 阶段:客户端高亮后的 HTML 和服务端不一致,React 直接报警:
Warning: Text content did not match. Server: “const x = 1;” Client: “…”
因为 Prism 会给代码加一堆 <span>,而服务端压根没处理,两边 DOM 结构对不上。
我试过几种方案:
- 服务端用
prismjs同步渲染 —— 可以,但构建时间翻倍,而且格式容易错 - 完全禁用 SSR 对代码块的 hydration —— 用
suppressHydrationWarning,治标不治本 - 最后选择了延迟高亮 + 容错渲染
最终方案是在组件层面控制:
function CodeBlock({ children, className }) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return <pre><code>{children}</code></pre>;
}
return (
<pre>
<code className={className} dangerouslySetInnerHTML={{ __html: children }} />
</pre>
);
}
配合 MDX 的自定义组件传入,确保只有客户端才让 Prism 处理 innerHTML。虽然首屏闪一下,但至少不报错,用户也感知不强。
复制功能看似简单,实际一堆细节
加个复制按钮,我以为 10 分钟搞定。结果光是“点击反馈”就折腾了半天。
最初想法很朴素:点击 → 执行 navigator.clipboard.writeText → 提示“已复制”。但问题来了:
- 移动端 Safari 不支持 clipboard API?得降级用 document.execCommand
- 异步复制失败怎么提示?
- 连续点两次,提示要防抖
最后写了个封装函数:
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// 降级方案
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
document.body.removeChild(textarea);
return true;
} catch (fallbackErr) {
document.body.removeChild(textarea);
return false;
}
}
}
按钮交互用了一个小技巧:点击后图标换成 check,3 秒后自动恢复。用了个简单的状态管理:
const [copied, setCopied] = useState(false);
const handleCopy = (code) => {
copyToClipboard(code);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
};
样式上用了透明浮层覆盖 pre 区域,避免布局重排。这块其实还能优化,比如加个全局 Toast,但现在这样也能用,就没动。
主题切换的脏活
主题切换不是重点,但用户提了好几次。Prism 的主题是靠 CSS 文件切换的,我一开始是动态插入 link 标签,后来发现加载有延迟,代码块会先白一下。
最终改成了预加载所有主题 CSS,在 <head> 里都加上,然后通过 class 控制显隐:
<link rel="stylesheet" href="/themes/prism-one-light.css" media="none" id="theme-light" />
<link rel="stylesheet" href="/themes/prism-one-dark.css" media="none" id="theme-dark" />
function switchTheme(theme) {
const light = document.getElementById('theme-light');
const dark = document.getElementById('theme-dark');
if (theme === 'dark') {
dark.media = 'all';
light.media = 'none';
} else {
light.media = 'all';
dark.media = 'none';
}
}
虽然多加载了几 KB,但切换丝滑,值得。
回顾与反思
回头看看,这个功能开发花了将近一周,远超预期。核心问题不在技术本身,而是边界情况太多:SSR、移动端兼容、性能瓶颈、用户体验细节。
做得好的地方:
- 异步高亮解决了卡顿问题
- 复制功能兼容性覆盖全
- 主题切换无闪烁
还能优化的:
- 行号现在是 Prism 插件生成的,和代码结构耦合太紧,考虑换用 CSS 计数器
- 大段代码(>50 行)还是有点慢,或许可以懒加载可视区域内的代码块
- 没做语法错误提示,不过这也不是编辑器,暂时不急
还有一个小问题到现在没解决:某些特殊字符(比如模板字符串里的 ${})会被 Markdown 解析器提前转义,导致高亮错乱。目前靠写文档时手动加双大括号规避,比如 {{${var}}},丑是丑了点,但影响不大。
以上是我的项目经验,希望对你有帮助
这东西看起来只是个“高亮”,真做起来全是细节。如果你也在搞类似功能,建议早点考虑异步处理和 SSR 兼容问题,别像我一样等到上线前才发现卡顿。
以上是我踩坑后的总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类实战博客。

暂无评论