高亮显示功能的实现原理与前端优化实践

东方悦弘 交互 阅读 2,862
赞 13 收藏
二维码
手机扫码查看
反馈

高亮显示?别被花里胡哨的方案绕晕了

最近在做一个文档搜索功能,用户输入关键词后要高亮匹配内容。看似简单,但实际做起来发现坑不少。我试过好几种方案:纯 CSS、正则替换、第三方库(比如 mark.js)、还有用 contentEditable 的骚操作。折腾一圈下来,有些方案看着酷,实际用起来反手就给你一巴掌。今天就聊聊这些方案到底谁更靠谱,顺便说说我最后选了哪个。

高亮显示功能的实现原理与前端优化实践

纯 CSS 高亮?省省吧

一开始我想偷懒,直接用 ::-webkit-highlight 或者 background-clip: text 这类 CSS 技巧。结果现实很骨感——浏览器支持太差,而且只能高亮固定文本,没法动态响应用户输入。你总不能每次用户打一个字就改一次 CSS 吧?

更别说有些方案依赖 text-shadow 模拟背景色,遇到深色文字就翻车。我试过一次,在暗黑模式下高亮根本看不见,差点被产品经理骂死。所以结论很明确:纯 CSS 做动态高亮基本是死路一条,除非你的高亮内容是写死的、且只跑在可控环境(比如 Electron 内嵌页)。

正则替换:灵活但容易翻车

这是我自己最早用的方案。思路很简单:拿到关键词,用正则全局匹配,然后替换成带 <mark> 标签的 HTML 字符串。

function highlight(text, keyword) {
  if (!keyword) return text;
  const escaped = keyword.replace(/[.*+?^${}()|[]\]/g, '\$&');
  const regex = new RegExp((${escaped}), 'gi');
  return text.replace(regex, '<mark>$1</mark>');
}

看起来挺美,但问题一大堆:

  • HTML 注入风险:如果原文本包含 HTML(比如富文本),直接 replace 可能破坏结构。我有一次把 </div> 当普通文本高亮,结果页面布局炸了。
  • 大小写敏感处理麻烦:上面代码用了 gi,但实际业务中可能需要“智能大小写”——比如搜 “react” 要高亮 “React”,但又不能高亮 “reAct”。这时候正则就得写得非常复杂。
  • 性能问题:长文本 + 多关键词时,replace 很吃 CPU。我在一个 10k 字的文档里测过,连续输入卡到掉帧。

后来我加了一堆防御代码:先 escape 原始 HTML,再 parse 成 DOM,遍历 textNode 替换……代码越写越长,维护成本飙升。所以现在除非是超简单的场景(比如纯文本日志),否则我不会再碰裸正则方案。

mark.js:开箱即用但有点重

被正则折磨后,我转向了 mark.js。这库专门干高亮这事,API 简洁,还支持忽略标签、自定义 class、分词匹配等。

const instance = new Mark(document.querySelector('.content'));
instance.mark('关键词', {
  className: 'my-highlight',
  separateWordSearch: false,
  accuracy: 'exactly'
});

优点很明显:

  • 自动处理 HTML 结构,不会破坏原有标签
  • 支持异步高亮(大文档不卡主线程)
  • 配置项丰富,比如 diacritics 可以忽略音调符号

但缺点也扎眼:

  • 体积不小(gzip 后 5KB+),就为了个高亮功能引入整个库,有点亏
  • API 虽然多,但有些场景还是不够用。比如我想高亮时顺便统计命中次数,得自己额外遍历 DOM
  • 和 React/Vue 这种框架结合时有点别扭,因为它是直接操作 DOM 的

我在线上项目用过一阵子,后来因为 bundle size 压力大,又给砍掉了。不过如果是 jQuery 项目或者静态页,mark.js 绝对是省心之选。

我的最终选择:轻量级自研方案

折腾到最后,我决定自己写个微型高亮函数,只保留核心逻辑,砍掉所有花哨功能。核心思路是:递归遍历 DOM 文本节点,只对 textContent 做替换

function highlightElement(el, keyword) {
  if (!keyword) return;
  
  const walk = document.createTreeWalker(
    el,
    NodeFilter.SHOW_TEXT,
    null,
    false
  );
  
  const nodes = [];
  let node;
  while (node = walk.nextNode()) {
    if (node.textContent.trim()) {
      nodes.push(node);
    }
  }
  
  nodes.forEach(node => {
    const parent = node.parentNode;
    const fragment = document.createDocumentFragment();
    const text = node.textContent;
    const regex = new RegExp((${keyword}), 'gi');
    let match;
    let lastIndex = 0;
    
    while ((match = regex.exec(text)) !== null) {
      // 添加匹配前的文本
      if (match.index > lastIndex) {
        fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
      }
      // 添加高亮元素
      const mark = document.createElement('mark');
      mark.textContent = match[1];
      fragment.appendChild(mark);
      lastIndex = match.index + match[1].length;
    }
    
    // 添加剩余文本
    if (lastIndex < text.length) {
      fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
    }
    
    parent.replaceChild(fragment, node);
  });
}

这个方案的好处:

  • 安全:只处理文本节点,完全避开 HTML 注入问题
  • 轻量:代码不到 30 行,gzip 后几百字节
  • 可控:想加性能优化(比如防抖、分片处理)直接改几行就行

当然也有妥协:

  • 不支持复杂匹配(比如模糊搜索),但我的需求本来也不需要
  • 每次高亮要清掉旧的 <mark>,得自己写清理逻辑

但说实话,90% 的场景根本用不到 mark.js 那些高级功能。自己撸一个反而更清爽,还能根据项目定制。比如我现在加了个缓存机制,相同关键词不再重复高亮,性能提升明显。

选型建议:别追求完美,够用就好

总结一下我的选择逻辑:

  • 如果是 简单静态页,直接上 mark.js,省时省力
  • 如果是 React/Vue 项目,优先考虑用状态管理驱动高亮(比如把高亮文本拆成数组渲染),避免直接操作 DOM
  • 如果是 性能敏感的大文档,自研方案 + 分片处理最稳
  • 千万别碰纯 CSS 方案,除非你想给自己找罪受

其实高亮这功能,核心难点从来不是“怎么标红”,而是“怎么在复杂 DOM 里安全地插入标记”。一旦想通这点,方案选择就清晰多了。我现在的项目用自研方案跑了半年,没出过问题,bundle size 还小,真香。

以上是我踩坑后的总结,有更优的实现方式欢迎评论区交流。如果你也在做类似功能,不妨先问自己:我的场景真的需要 mark.js 吗?很多时候,简单点反而更可靠。

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

暂无评论