安全高亮需先清除旧mark再精准替换文本节点,用replaceChild避免DOM重绘,正则须转义元字符并加Unicode边界断言,取消时仅恢复textContent以保状态稳定。

mark 标签本身不带“取消高亮”逻辑,所有高亮都是 DOM 操作结果;动态高亮 + 取消,本质是「精准替换文本节点」+「可逆的标记状态管理」,不是简单地 innerHTML.replace()。
为什么直接 replace(/keyword/g, '…') 会出问题
常见错误现象:高亮后再次搜索新关键词,旧 mark 标签被当作文本内容二次匹配,导致嵌套(如 <mark>abc</mark> 被当成字符串再套一层 mark);表单输入框值丢失、按钮事件监听器消失、富文本中链接断裂。
根本原因是:innerHTML 是只读渲染快照,写入会强制重建子树,所有动态状态归零。
- 必须只操作
Node.TEXT_NODE类型节点,跳过元素、注释、script等 - 每次高亮前,先用
document.querySelectorAll('mark')清除已有mark,但要注意不能用outerHTML = innerHTML这种暴力还原——它会丢掉用户已输入的内容 - 中文关键词需加 Unicode 边界断言,否则搜“服务”会误匹配“微服务”里的“服务”二字
如何安全地高亮并支持取消
核心是把高亮动作拆成两步:「提取纯文本 → 插入 mark」,取消则是「遍历所有 mark → 用 replaceChild() 换回原始文本节点」。
立即学习“前端免费学习笔记(深入)”;
关键代码片段:
function highlightText(root, keyword) {
// 先清空已有 mark
root.querySelectorAll('mark').forEach(mark => {
const parent = mark.parentNode;
parent.replaceChild(document.createTextNode(mark.textContent), mark);
});
<p>// 再对文本节点做高亮
function walk(node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue;
const reg = new RegExp(
<code>(?<=[\s\u4e00-\u9fa5]|^)${keyword}(?=[\s\u4e00-\u9fa5]|$)</code>,
'gi'
);
if (reg.test(text)) {
const span = document.createElement('span');
span.innerHTML = text.replace(reg, '<mark>$&</mark>');
node.parentNode.replaceChild(span, node);
}
} else {
node.childNodes.forEach(walk);
}
}
walk(root);
}
- 用
replaceChild()而非innerHTML +=,避免重绘整个容器 - 正则里用
$&引用完整匹配项,比$1更稳妥(无需捕获组) - 取消高亮时,只恢复
mark.textContent,不碰父节点其他子元素(比如mark旁边可能有img或button)
关键词含正则元字符(如 . * + ?)怎么办
用户输入 foo.bar,直接当正则用会匹配任意字符,结果高亮错位甚至报错。
必须转义:
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[]\]/g, '\$&');
}
// 然后用 escapeRegExp(keyword) 构造正则
- 这个转义函数要放在高亮函数最外层,不是每次
replace时才调 - 大小写敏感与否由正则标志控制(
'gi'表示全局+忽略大小写),不要靠toLowerCase()处理文本——会破坏原始格式(如代码中的大小写) - 如果页面允许用户连续输入多个关键词,记得按长度降序排序后再处理,防“a”和“ab”嵌套冲突
真正难的不是高亮,是保持 DOM 状态稳定。哪怕只是给一个 textarea 加高亮,也要先 cloneRange 记录光标位置,高亮完再 restore —— 否则用户一输字,光标就跳到开头。这类细节没显式处理,功能上线后就会变成玄学 bug。



















