不能直接 innerHTML.replace() 高亮,因为会破坏 DOM 结构、丢失事件监听器与表单值、导致非法 HTML 白屏;必须只操作文本节点,用 TreeWalker 定位并安全替换。

为什么不能直接 innerHTML.replace() 高亮
直接对容器的 innerHTML 做字符串替换,等于把整个 HTML 当纯文本处理——标签结构、事件监听器、表单值、动态插入节点全都会丢。搜 "a" 可能把 <span></span> 里的 a 也包进去;搜 "[" 会触发 Invalid regular expression;搜 "p" 甚至让 <p>abc</p> 变成 p>abc<mark>p</mark>>,DOM 非法直接白屏。
这不是“可能出问题”,是必然崩溃。尤其当页面含 <input value="已填写">、<textarea></textarea> 或 <script></script> 时,高亮后值清空、脚本执行、结构塌陷三连发。
- 表单控件内容彻底丢失
-
onclick等内联事件全部失效 - 用户手动选中的文本范围(
getSelection())指向的节点被 replaceChild 替换后,选区直接消失 - 正则未转义的关键词(如
"js+")导致匹配引擎回溯爆炸,页面卡死
只操作文本节点才是安全底线
必须绕过字符串拼接,用 DOM 原生能力定位纯文本内容。核心是:只遍历 Node.TEXT_NODE,跳过元素、注释、文档类型节点。
推荐用 document.createTreeWalker(el, NodeFilter.SHOW_TEXT),比递归 childNodes 更稳定,能自动过滤非文本节点,且兼容深层嵌套结构(比如 <p><strong>React</strong> Native</p> 这种跨标签的词)。
立即学习“前端免费学习笔记(深入)”;
- 对每个文本节点,用
textContent提取原始字符串(不解析 HTML) - 用
keyword.replace(/[.*+?^${}()|[]\]/g, '\$&')转义正则元字符 - 构造
new RegExp(escaped, 'gi')匹配,避免includes()无法支持大小写/全局等需求 - 匹配后用
document.createElement('mark')插入,再调用node.parentNode.replaceChild(markEl, node) - 高亮完成后立即调用
el.normalize()合并相邻文本节点,防止后续querySelectorAll('p')或children.length判断出错
中文词边界怎么防误匹配
搜 "服务",不希望它在 "服务器" 或 "微服务" 里被切开高亮——因为中文无空格,\b 失效。
简单有效的方案是用 Unicode 边界断言:/(?。其中 <code>\u4e00-\u9fa5 覆盖常用汉字,\s 匹配空白符,^/$ 处理开头结尾。这个正则不消耗字符,只做位置判断,不会破坏原文结构。
- 不要依赖分词库,多数文档级搜索不需要 NLP 级精度
- 避免用
split(' ')或match(/\S+/g)切词,中文根本没空格可切 - 如果用户明确要“子串匹配”(如搜 API 接口名),就不用加边界断言,按需开关
旧高亮清理和 DOM 节点爆炸风险
每次高亮都新建 <mark></mark> 节点,但旧的不清理,DOM 节点数会指数级增长。更麻烦的是:框架(Vue/React)、富文本编辑器(Quill)会主动 strip class 和 style,导致旧 <mark></mark> 残留或样式丢失,新旧标记叠加错乱。
清理逻辑比高亮还难写对。不能只靠 el.querySelectorAll('mark').forEach(m => m.replaceWith(m.textContent)) —— 它在 <mark></mark> 嵌套或父节点被框架接管时会失败。
- 高亮前先执行清理:用
TreeWalker找到所有<mark></mark>,逐个用replaceWith(textContent) - 若目标区域含 Vue/React 渲染内容,清理后需触发对应框架的重渲染(如 Vue 的
nextTick) - 动态加载内容(如分页、无限滚动)后,必须重新绑定高亮逻辑,不能只依赖初始 DOM
- 真正容易被忽略的是:高亮不是视觉装饰,而是 DOM 结构变更。后续所有基于
children.length、querySelector或事件委托的代码,都得重新验证是否还能正常工作



















