代码高亮实现的几种实用方案与踩坑总结

Designer°可馨 组件 阅读 2,437
赞 24 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这个项目是个技术文档站,用户要能看代码、复制代码,还得看着舒服。一开始我也没想太多,直接上了 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 兼容问题,别像我一样等到上线前才发现卡顿。

以上是我踩坑后的总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类实战博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论