高亮显示功能的实现原理与前端优化实践
高亮显示?别被花里胡哨的方案绕晕了
最近在做一个文档搜索功能,用户输入关键词后要高亮匹配内容。看似简单,但实际做起来发现坑不少。我试过好几种方案:纯 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 吗?很多时候,简单点反而更可靠。

暂无评论