在网页里做搜索结果展示或关键词提示时,我们经常需要把一段文本中的多个关键词同时高亮出来。看似只是用replace包一层标签,但当匹配词数量变多、词之间出现重叠或包含关系时,简单的多次替换会让原本的字符索引全部错位,最终页面要么漏掉某些词,要么出现嵌套的span导致样式崩掉。本文从底层索引变化讲起,给出两种稳定的多词高亮实现方案。

为什么逐词替换会破坏索引
字符串在JavaScript里是不可变类型,每一次调用replace其实都返回了一个新字符串。假设你有一段文本长度是100,先把“苹果”替换成“<span>苹果</span>”,此时字符串长度变成了100+13=113。接下来你再基于原逻辑去匹配“果冻”,原本在第50位出现的“果冻”在新字符串里已经不在第50位了,因为前面插入的标签把位置全顶过去了。
更麻烦的是重叠词。例如原文本是“JavaScript编程”,关键词既有“Java”又有“JavaScript”。如果你先高亮“Java”,再把“JavaScript”当独立词去原串找,就会发现问题:前者已经把“Java”包进标签,后者再用正则全局匹配时会匹配到标签内部的字符,造成标签嵌套甚至HTML结构破损。这类bug在测试短句时看不出来,一旦用户输入长文就频繁爆发。
方案一是合并区间后统一替换
思路是先把所有关键词的匹配区间找出来,存成[start, end]数组,然后对这些区间做排序和重叠合并,最后从后往前替换。因为从后往前改,前面还没处理的位置索引不会受后面插入标签的影响。
下面是一段可直接运行的示例代码,演示如何合并区间并安全高亮:
function highlight(text, words) {
let ranges = [];
words.forEach(function(w) {
if (!w) return;
let reg = new RegExp(w.replace(/[.*+?^${}()|[]\]/g, '\$&'), 'gi');
let match;
while ((match = reg.exec(text)) !== null) {
ranges.push([match.index, match.index + match[0].length]);
}
});
// 按起点排序
ranges.sort(function(a, b) { return a[0] - b[0]; });
// 合并重叠或相邻区间
let merged = [];
ranges.forEach(function(r) {
if (!merged.length || r[0] > merged[merged.length - 1][1]) {
merged.push(r.slice());
} else {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], r[1]);
}
});
// 从后往前拼接
let result = '';
let cursor = text.length;
for (let i = merged.length - 1; i >= 0; i--) {
let s = merged[i][0], e = merged[i][1];
result = text.slice(s, e) + '<span class="hl">' + result;
result = text.slice(e, cursor) + result;
cursor = s;
}
result = text.slice(0, cursor) + result;
return result;
}
这种写法的好处是只遍历文本有限次,且彻底规避了标签嵌套。缺点是在合并区间时如果词特别多,排序开销会随数量上升,但比起反复生成超大字符串仍然划算得多。
方案二是构建片段数组渲染
如果不想手动算索引,可以把文本拆成“普通片段”和“高亮片段”交替的数组,最后用map拼成HTML。这样做逻辑更直观,也方便以后扩展点击交互。
示例代码如下,核心是先标出所有区间,再切分原串:
function highlightByParts(text, words) {
let ranges = [];
words.forEach(function(w) {
let reg = new RegExp(w.replace(/[.*+?^${}()|[]\]/g, '\$&'), 'gi');
let m;
while ((m = reg.exec(text)) !== null) {
ranges.push([m.index, m.index + m[0].length]);
}
});
ranges.sort(function(a, b) { return a[0] - b[0]; });
let parts = [];
let last = 0;
ranges.forEach(function(r) {
if (r[0] < last) return; // 跳过重叠
parts.push(text.slice(last, r[0]));
parts.push('<span class="hl">' + text.slice(r[0], r[1]) + '</span>');
last = r[1];
});
parts.push(text.slice(last));
return parts.join('');
}
该方案在跳过重叠时采用了简单策略:后一个词如果起点在前一个结束之前就直接丢弃,保证不嵌套。若业务需要“长词优先”,可在排序前按词长倒序,这样长词先占区间,短词自然被跳过。两种方案都比直接链式replace稳妥,实际项目里建议封装成函数并加上转义,防止用户输入带尖括号的关键词破坏页面结构。
小结与避坑提醒
处理多词高亮时,永远不要信任“先替换一个再说”的直觉。只要字符串长度变了,后面所有基于原索引的操作都失效。统一收集区间、控制替换方向或改用片段拼接,是从原理上消灭索引错乱的办法。
另外注意,如果高亮文本来自用户输入,拼HTML前要对文本做HTML转义,否则像<script>这样的内容会被浏览器执行。高亮逻辑只负责加span,安全过滤交给专门的转义函数,职责分离后代码更好维护。
JavaScript文本高亮多词匹配修改时间:2026-08-04 03:45:30