搜索高亮这件事,我一开始也是用正则替换硬干的,直到某次在一个内容管理后台里给几百个关键词同时做标记,页面直接卡了好几秒,用户从搜索结果里复制出去的文本还带着一层层嵌套的<span>,整个DOM结构乱成一锅粥。后来换成了专门做标记的库mark.js,才真正把这块从"碰运气"变成了"可配置、可预期"的功能。这篇文章就完整拆一下 mark.js 的原理、核心API、性能优化方案,以及我在实际项目里踩过的各种边界坑。
1. 为什么给关键词上背景色是件容易翻车的小事
1.1 从一次DOM爆炸说起
很多前端做搜索高亮的第一反应是:拿到关键文本,用正则替换掉,再塞回去。听起来很简单,但真实项目里往往十分钟后就翻车了。我当时在做一个企业内部的资料检索页,需求是用户在搜索框输入关键词后,正文里所有匹配到的内容都要加上高亮底色。
最初我用的是innerHTML级别的直接替换,也就是把 document 里的 HTML 源码字符串拿过来,正则匹配一番,再用<span class="highlight">包住关键词,最后写回页面。跑起来之后的第一个大问题就是:原本绑定在按钮上的点击事件全部失效了。因为 innerHTML 重写会把整个子树的 DOM 全部销毁重建,之前依赖引用关系挂上去的事件监听一并清零。第二个更严重的问题是性能,一个几千字的文档还好,文档一旦上了几万字,那些原本存在于HTML里的脚本标签、隐藏元素、甚至注释节点,全部被正则扫了一遍,页面切换搜索词的时候肉眼可见地卡。
后来我研究了一下,发现问题的根源其实不是"高亮"这件事本身,而是我替换错了层级。搜索高亮应该发生在文本节点层面,而不是整个HTML字符串层面。正则替换法把HTML结构也当成了纯文本去处理,才会导致节点重建、事件丢失、样式错乱这一连串问题。
1.2 mark.js 解决的核心问题
mark.js 这个库,本质上就是一个专门处理"在DOM中标记关键词"的 JavaScript 工具。它做了三件事情:
- 遍历页面里所有文本节点,而不是直接碰 HTML。
- 用传入的关键词构造正则,在文本节点的字符内容上做匹配。
- 匹配命中后,将命中的字符片段从原文本节点中切割出来,用自定义的标签(默认是
<mark>)包裹,再插回原位置。
因为标记的过程是最小化DOM操作的,它不会碰 script、style、textarea、select 这类不适合内容替换的区域,所以不会像 innerHTML 替换那样把页面搞崩。
我在后来的若干个项目里基本都改用了这个思路。它适用的场景很明确:搜索页结果高亮、文档站点关键字定位、知识库检索命中提示、数据分析后台的筛选词标记,凡是"用户输入一个词,需要在渲染好的页面内容里找出来并涂色"的需求,用它都很顺手。官方源码不到两千行、无第三方依赖、支持浏览器和Node环境,接入成本低,出问题也容易排查,这些都是我反复推荐它的原因。
1.3 适合谁读这篇内容
这篇不是库文档的翻译,而是基于我实际用下来之后的经验总结。适合这几类人看:
- 正在做搜索功能,想让搜索结果关键词高亮但不知道从何下手的前端开发者。
- 已经接了 mark.js,但在大文档、iframe、动态渲染这些场景下出了各种诡异问题的开发。
- 想看怎么在框架里集成高亮功能,以及如何把高亮性能做稳的人。
我会把原理、配置、性能、边界问题一条条拆开,尽量避开那些只是表面能用但实际运行就会翻车的写法。
2. mark.js 的标记原理:从文本节点到<mark>标签的完整链路
2.1 为什么必须只遍历文本节点
要理解 mark.js 的可靠性,就得先理解 DOM 里面文本到底存放在哪。打开浏览器的开发者工具,检查任意一段带文字的元素,你会发现文字并不是直接挂在div上的字符串属性,而是以#text类型的节点挂在div.childNodes里的。用户看到的"文字内容"由若干个文本节点拼接而成,中间可能隔着<span>、<strong>、<a>等各种标签。
如果你用innerHTML正则替换,你实际上是在对 HTML 语法字符串做匹配,HTML 标签名、属性名、样式值在某种情况下也可能命中你的关键词,这非常危险。比如你搜 "span",正则把<span>标签本身给包了层高亮,页面结构可能直接错乱。mark.js 的做法是用 TreeWalker 遍历文档树,只挑出Node.TEXT_NODE类型的节点来处理,从源头避开了"匹配到标签"的问题。
实际操作中它还会默认排除 script、style、textarea、input、select 以及 contenteditable 元素,因为这些区域里的文本要么不能破坏、要么是用户输入区,不应该被搜索高亮强行改动。我接手过一些半路项目,它们用简单的替换方案做的搜索高亮,词一旦出现在textarea的默认值里,整个输入框内容都会被高亮标签污染,这个坑在 mark.js 里面默认是不会踩到的。
2.2 切割文本:正则命中的那一小片文字如何被单独拿出来
假设现在有一个<p>元素,它的子节点是文本内容"今天天气很好,适合出门散步",我们要高亮 "天气"。mark.js 会对这个文本节点执行一次正则匹配,拿到命中位置之后,把原本一个完整的文本节点拆成三个:
- 命中位置之前的文本,也就是
"今天"。 - 命中位置那一小段文本
"天气"。 - 命中位置之后的文本
"很好,适合出门散步"。
第二步关键的操作:把中间那个文本节点用document.createElement('mark')替换成一个<mark>元素,mark元素内部的文本值就是"天气"。于是这个<p>的子节点就从原本的[text]变成了[text, mark, text]。这样在整个文档树里,关键词原本所在的位置被一个高亮元素精确替代,其他文本内容保持原样,DOM 结构没有大面积动过,因此也不会破坏原来的事件绑定和 CSS 继承关系。
这个"三段切割"思路看着简单,但实现起来要处理很多细节:如果文本里同一个关键词出现了很多次,就要匹配一处切一处,直到整段文本扫完;如果关键词跨越了不同的文本节点(比如"天"在一个文本节点里,"气"在另一个相邻文本节点里),库会先扫描出连续文本块的上下文,再决定是否合并处理。这些细节如果不处理,就会出现"关键词明明在页面上,但高亮总是漏一半"的现象。
2.3 mark 元素的插入与空节点清理
当你把一段文本切成了三段,中间替换成了<mark>,还会留下一个潜在问题:原来的文本节点已经被切断成两个文本节点,它们分别位于 mark 元素的左右两侧,这两段文本如果恰好是空字符串(比如关键词正好在文本的开头或结尾),就需要被清理掉,否则 DOM 里会残留一堆空的#text节点。这些空节点看着无害,但会在后续再次执行高亮时产生多余的遍历开销,也会让某些针对childNodes做操作的功能出现不可预期的行为。
mark.js 在内部会做好空文本节点的移除,这也是它"用久了不容易出问题"的隐藏优势之一。我自己之前写简易版高亮时就吃过亏,关键词出现在段落开头时,段落前面总留着几个空的文本节点,后来调试了半天才发现是切割算法没处理边界情况。
理解了这个核心流程之后,你再去看 mark.js 的 API 和配置项,就不会觉得它们晦涩难懂了。所有配置项本质上都是在控制"如何遍历文本节点"和"如何构造正则",下面一节就展开讲配置。
3. 上手集成:基本 API 与高频配置项逐项解读
3.1 几十行代码跑通基础版
如果你的页面是通过<script>直接引入的 CDN,或者通过 npm 装包,基础用法都很一致。先给一个最简单的例子:
<p>今天天气很好,适合出门散步。</p> <script> var instance = new Mark(document.querySelector("body")); instance.mark("天气"); </script>这样跑完后,页面里的 "天气" 两个字会被<mark>标签包住,浏览器默认样式会给它一个黄底黑字的视觉效果。这就是 mark.js 最基础的使用方式,两行代码搞定。
如果关键词不止一个,mark方法也支持接收字符串数组:
instance.mark(["天气", "散步"]);内部会把这些关键词构造成一个正则,做一次遍历匹配。注意这里的优势:它是一次遍历匹配多个词,而不是每个词单独遍历一遍文档,所以在多关键词场景下性能比"foreach 全文档"要优秀得多,后面我会重点说性能问题。
对应地,清除高亮用unmark,它会把所有被 mark.js 包裹的<mark>元素释放掉,把里面的文本放回原来的文本节点位置,页面恢复原样:
instance.unmark();3.2 关键配置项:从匹配精度到忽略区域
mark()方法第二个参数可以传入一个配置对象,里面有一大堆选项。我在实际项目中用得比较多的有这么几个,先用表格列一下:
| 配置项 | 默认值 | 用途 |
|---|---|---|
caseSensitive | false | 是否区分大小写,默认不区分 |
separateWordSearch | true | 多关键词时是否拆分成单个词分别匹配 |
accuracy | "partially" | 匹配精度:部分匹配/完全匹配/互补匹配 |
diacritics | true | 是否处理带变音符号的字符 |
iframes | false | 是否在 iframe 内部文档中进行高亮 |
ignorePunctuation | [] | 忽略标点,比如搜"well-known"时可以把-忽略掉 |
wildcards | "disabled" | 是否支持通配符,比如"disabled"/"enabled"/"withSpaces" |
exclude | [] | 指定不做高亮的区域选择器 |
acrossElements | false | 是否跨元素匹配文本(关键词分散在不同标签中时) |
done | null | 全部标记完成后的回调 |
这里挑几个容易踩坑的细讲一下。
accuracy有三个值:"partially"、"exactly"、"complementary"。partially是只要字符串包含关键词就算命中,比如搜 "天气","好天气" 也会被高亮;exactly要求两边不能有字母数字或下划线等字符,避免把 "天气好" 里的 "天气" 单拎出来标;complementary则要求匹配片段左右边界必须是空格或标点,或者干脆是独立的 token,适合英文文档里做整词高亮。默认值partially对搜索场景其实不太严谨,你搜英文单词 "car",结果 "scar" 也被标了色,这通常不是用户想要的行为。做英文站的话我会建议把精度调到exactly或者complementary。
exclude是一个选择器数组,直接告诉 mark.js 哪些区域不要碰。比如:
instance.mark(keyword, { exclude: [".comment-list", ".sidebar"] });如果你在做博客站点的搜索结果预览,通常不希望把评论区的内容也高亮出来,exclude就能帮你快速隔离掉。
acrossElements这个选项值得单独说。默认情况下,如果关键词被切成两半分别塞在两个相邻标签里,比如<p>今天天<strong>气</strong>很好</p>,搜 "天气" 是匹配不上的,因为在 DOM 结构上它不是一个连续的文本节点。开启acrossElements: true之后,mark.js 会扫描相邻元素之间的文本内容并进行拼接匹配,但这个选项的消费会比默认模式大不少,不建议在超大文档上默认开启,等有实际需求再按需处理。
3.3 用户输入关键词时的转义与防呆处理
用 mark.js 做搜索功能时,关键词通常来自用户的输入框,这时候有一个我最早忽略的问题:正则转义。用户如果输入"a.b",你直接把它丢给 mark.js,它内部构造正则时.会被当成了任意字符通配符,高亮结果可能命中"aXb"、"a/b"这类原本并不匹配的内容。mark.js 内部虽然有一套处理流程,但为了稳妥,我一般会在把用户输入传到mark()方法之前自己先做一层转义:
function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }另外,用户输入前后可能带着空格,或者输了一个很长的句子,这些都需要自己做清洗和拆分。我通常会做一个简单的预处理:把连续空白压缩成单个空格,然后 trim 一下,再按空格拆成多个关键词传给 mark.js,配合separateWordSearch: true来高亮每个词。实测下来这样既符合用户预期——输入框里多个词时,词和词之间没有交集、各自高亮——也不会因为整个句子作为单个关键词匹配不到而不显示任何高亮。
还有一个细节:很多搜索场景希望用户切换关键词时先把上一次的高亮清掉。unmark()内部会遍历当前所有 mark.js 添加的标记元素,将它们还原为文本节点,但如果你的页面本身也有旧的渲染逻辑操作过这些节点,建议每次mark前果断调用一次unmark(true)。这个参数会顺便清掉挂载的缓存和事件,避免下次标记时残留数据。
4. 大文档高亮不再卡顿:异步标记与输入节流的实测方案
4.1 卡顿的根源在哪
mark.js 在原理上已经很克制了,它只做必要的最小 DOM 操作,但一旦文档规模变大,或者说关键词数量变多,仍然可能出现肉眼可感知的卡顿。核心原因主要有两块。
第一块是文档遍历成本。TreeWalker 要扫描整棵文档树,找到所有文本节点。如果一个页面有几 MB 的纯文本内容(比如长报告、小说阅读器、代码仓库界面),文本节点的数量可能上十万,遍历一遍就是几十毫秒起步。
第二块是正则匹配的瞬时开销。当你传了几百个关键词进去,库要把这些关键词构造一个很大的正则表达式,然后逐一对每个文本节点执行匹配。正则本身在匹配特别长的文本时会有回退问题,这一层叠加起来,同步执行就会阻塞主线程,页面表现为"点了搜索按钮后整个界面冻了半秒甚至几秒"。
4.2 用 async 实现分块标记
mark.js 原生提供了异步处理入口,配置项里有一个async: true,配合chunked行为可以把遍历和标记工作分片进行。核心配置大概长这样:
instance.mark(keyword, { async: true, done: function (totalMarks) { console.log("所有高亮完成,共标记了", totalMarks, "个位置"); } });开启async之后,库不会一次性处理完全部文本节点,而是分批处理,每处理一批会让出主线程,等其他优先级的任务(比如渲染、事件响应)先跑一跑,再继续下一批。用户能感觉到的是:点击搜索按钮之后,页面很快就有响应,高亮是"一块一块冒出来"的,而不是等很久之后一次性全部出现。
不过这里要提醒一点:async: true不是银弹。如果你的文档特别小,异步反而会增加额外的事件循环开销,白添一点延迟。我一般只在文档体量确认较大(比如超过几万字符)或者关键词数量超过几十个时,才开启异步模式。
这里给一个简单的字符量级估算法:先把页面主要文本内容innerText取出来,数一下长度,如果超过五万字符,就直接走异步高亮;低于这个量级,同步处理通常也不会有感知。
4.3 输入节流:别让每个字符都触发一次全量标记
很多搜索框是高亮和过滤一起做的,用户每敲一个字母,前端就执行一次 mark,这会产生大量重复遍历。你一定不想用户敲一个 "a",页面就要把全文档扫一遍。我最初没做节流,用户快速连续输入时,页面疯狂重新标记,不仅性能差,还容易出现闪烁。
推荐的方案是在输入框上做一个 200~300ms 的防抖:
let timer = null; searchInput.addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(function () { const keyword = searchInput.value.trim(); instance.unmark(); if (keyword) { instance.mark(keyword, { async: true, done: function () { updateResultCount(); } }); } }, 250); });300ms 左右是人能够感知到的"即时"边界,既能保证用户体验顺畅,又能把高频输入造成的重复标记降到可接受范围。如果文档非常大,也可以把防抖时间再提高到 400~500ms,代价是搜索反馈略显迟钝,需要做取舍。
这里还会有另一个细节:当用户输入从一个长关键词删除到只剩一个短词时,比如从 "搜索引擎优化" 删到 "搜索",如果旧的关键词正在异步标记过程中还没完成,新的 mark 又被触发,两个异步任务可能会互相覆盖。实践里我加了一个生成序号的方式:每次发起局部标记任务时自增一个 id,done回调里比较当前 id 是否还是最新,如果不是就丢弃这次结果,避免旧任务覆盖新结果。
4.4 MutationObserver 应对动态加载的列表内容
如果你的页面是 SPA 或者有大量异步渲染逻辑,比如搜索结果列表是接口返回后再渲染出来的,mark.js 初始执行的时候这些列表还不存在,高亮自然落空。我在一个前后端分离的后台管理页面里就遇到过:第一次 mark 正常运行了,但用户切换筛选条件后,表格重新渲染了一遍,之前的高亮全部消失,新出现的内容也没有高亮。
一种稳妥的处理方式是在搜索接口返回数据并渲染完成之后,再主动执行一次mark。但这要求你清楚掌握每个渲染时机,一旦业务代码里有人改动了组件更新逻辑,很容易漏掉某个分支。
另一种更省心的方案是用 MutationObserver 监听容器变化。观察某个容器,当子节点发生变化时,做一个轻量的回调来重新执行高亮。注意不能每次 mutation 都跑完整 mark,那会性能爆炸。我的做法是配合上面的 300ms 防抖,等 DOM 变化稳定后集中高亮一次。
const observer = new MutationObserver(function (mutations) { clearTimeout(timer); timer = setTimeout(function () { instance.unmark(); instance.mark(keyword, { async: true }); }, 300); }); observer.observe(document.getElementById("list-container"), { childList: true, subtree: true });这套在长列表、虚拟滚动和高频刷新场景里非常实用。个体量大的文档在初始加载完成后可能经历了数百次 DOM 变更,通过批量防抖,最终只做了几次有效高亮,整体稳定性高很多。
5. 边界场景与排查经验:iframe、动态渲染、嵌套标签与样式干扰
5.1 iframes 开关:开启前想清楚隔离边界
mark.js 默认不在 iframe 内做高亮,这是一个非常合理的保守默认。iframe 是独立的文档上下文,跨 iframe 的 DOM 遍历成本更高,而且很多 iframe 里是第三方内容(比如地图、广告、外部表单),这些内容通常不应该被你的搜索词影响。
但有些场景确实需要在 iframe 里做高亮,比如富文本编辑器里嵌了一个预览 iframe,或者文档站用 iframe 展示 Markdown 预览。这时候可以打开iframes: true。这个配置开启后,mark.js 会尝试遍历 iframe 内部 document,并在其中执行同样的文本节点遍历和标记流程。
用下来要注意的问题有两个。第一,跨域 iframe 会让脚本根本无法访问其内部 document,此时开了iframes: true也没用,这在控制台会报跨域错误,排查起来很容易困惑。第二,iframe 里的内容加载是异步的,mark.js 执行时 iframe 尚未加载完成的话,它的遍历就为空,高亮自然失败。这种情况下,你需要在 iframeonload之后再去触发 mark,或者监听 iframe 内容变化再补一次。
我一般建议在项目里避免直接舒服地开启iframes: true,而是先评估 iframe 是否有必要参与高亮。很多需求其实是伪需求,用户根本不怎么关注 iframe 里的内容有没有高亮,为这个增加跨域异常和异步时序问题的复杂度,不值得。
5.2 动态路由与组件切换导致的"高亮没跟上"
SPA 应用中,路由切换会重建页面局部区域,已经做好的高亮标记会被框架的 diff 渲染直接移除。这个问题不只在 mark.js 里有,任何直接操作 DOM 的第三方库在 React/Vue 环境下都可能踩到。核心矛盾是:框架希望 DOM 由虚拟节点完全接管,而第三方库直接改动真实 DOM,框架后续更新时可能覆盖这些改动。
几个应对思路:
- 在最底层的容器组件渲染完成后的
mounted生命周期里调用 mark,而不是在数据请求发起时就 mark。 - 如果组件内部有对容器区域的重新渲染,并且你知道某个节点会被替换,可以在该阶段之后重新 mark。
- 对于 Vue 3 或 React,避免把 mark.js 的调用放在高频响应式副作用里,尽量放在最终渲染完成的时机后再执行。
还有一点:如果页面用了虚拟滚动,只有可视区域内的 DOM 节点真实存在于文档树中,mark.js 只能遍历到这些节点,屏幕外面的节点天然不会被标记。这是正常的,不要以为是 bug。但搜索结果的对比数量如果依赖高亮统计,就会出现数字偏小的情况。我一般在虚拟列表场景下直接放弃 DOM 高亮统计,改为在数据层做匹配计数,UI 层只对可视行的可见文本做纯展示优化。
5.3<mark>标签样式被全局样式覆盖的问题
mark是 HTML 里早就存在的标签,含义是"标记文本",浏览器默认给它一个黄底黑字的样式。但如果项目的全局 CSS 里有类似:
mark { background: transparent; }或者某种重置样式把所有元素背景都清掉了,高亮效果就会直接消失或者淡得看不出来。这个排查起来很隐蔽。我第一次遇到时以为 mark.js 没生效,F12 一看节点确实被<mark>包了,但页面上一丁点颜色变化都没有,最后才定位到是全局样式冲突。
解决方式有两种:一是给 mark.js 生成的元素加自定义 class,然后专门写一个优先级足够的高亮样式。在配置里用className指定:
instance.mark(keyword, { className: "custom-highlight" });然后写:
mark.custom-highlight { background: #ffe58f; color: #333; padding: 0 2px; border-radius: 2px; }二是直接把自定义元素名改掉,比如设置element: "hl",让它生成<hl>而不是<mark>标签。这样能彻底规避全局 mark 样式表的冲突。但使用自定义元素名时要额外补一段样式,否则<hl>没有任何浏览器默认样式,高亮仍然看不见:
instance.mark(keyword, { element: "hl", className: "search-highlight" });hl.search-highlight { background: rgba(255, 213, 79, 0.9); color: inherit; }从兼容性角度讲,自定义元素在现代浏览器里完全没障碍,但在特别老旧的浏览器中可能存在未知元素默认 display: inline 的兼容差异。实际项目如果面向现代浏览器,问题不大。
5.4 高亮之后复制文本带出标签干扰
mark.js 生成的<mark>只要在页面里存在,用户做"全选复制"时,复制的文本就会带上这些 HTML 标签。如果粘贴到富文本编辑器里,就会出现大量<mark>包裹的内容,非常干扰阅读。这不是 mark.js 的问题,而是任何 DOM 内高亮的天然副作用。
如果你在意这个体验,有两个处理方向:
- 在复制按钮被触发时先
unmark,复制完成后再红标。适合"复制整段内容"这种明确按钮场景。 - 如果用户通过 Ctrl+A 全选复制,这个防不胜防,通常不处理。真正对粘贴格式敏感的富文本编辑器用户,一般用的是"粘贴为纯文本"快捷键,所以这个干扰在实际业务里影响有限。
我之前做一个文本批注类项目时,用户要求批注标记不能被误复制出去,最终方案就是:复制前统一 unmark,复制完成后统一 mark。用document.execCommand还是 Clipboard API 都必须在这个流程里处理,实测下来体验可接受,代价是复制时机对性能有些要求,要保证 unmark 和复制动作之间没有渲染间隙。
5.5 重复标记与嵌套:多次 mark 会不会越包越厚
如果你在同一个实例上连续多次调用mark而没有先unmark,mark.js 会在已有高亮的基础上继续叠加。比如第一次高亮了 "搜索",第二次高亮了 "搜索关键字",那么第二次执行时,"搜索" 二字已经被<mark>包住了,mark.js 会把关键词再包上一层新的标记元素,DOM 就出现了嵌套的 mark。
嵌套本身不会报错,但会有两个实际影响:一是样式层叠后颜色可能变深,视觉上出现不规则的深浅不一致;二是 unmark 一次只会抹掉一层标记,如果多层嵌套,可能需要多次 unmark 才能清干净,这容易让代码状态混乱。
因此我这里有一个很朴素但在多个项目里救过场的习惯:在调用 mark 之前,永远先调用一次unmark()。不管当前实例之前标记过什么,都先恢复原状再重新标记。这样代码的执行状态总是可预期的,也不会因为嵌套导致样式不可控。如果你担心 unmark 有额外开销,实测下来对一个几千字的页面,unmark 的成本几乎可以忽略;反而是嵌套高亮带来的后续排查成本要高得多。
6. 把 mark.js 改造成适合自己业务的形态
6.1 定制标记元素与回调:让高亮不只是变个色
高亮最常见的形态是变色,但实际业务里它能承载的信息远不止于此。比如在一个文档批注系统里,你可以把命中关键词的标记元素加上数据属性,标注它命中的是哪个词条、来自哪条规则,后续点击这个高亮元素就能调起对应的详情面板。
mark.js 里面有两个回调能用到:each和filter。each会在每个标记元素生成后调用一次,给你一个机会对元素做附加修改:
instance.mark(keyword, { each: function (element) { element.dataset.keyword = keyword; element.setAttribute("title", "点击查看详情"); element.addEventListener("click", function () { openKeywordDetail(keyword); }); } });filter则可以对已经生成的标记元素做二次判断,返回false就移除该标记。这个回调非常有用,比如你可以基于额外的业务规则做筛选,只保留特定上下文里的高亮:
instance.mark(keyword, { filter: function (textNode, foundTerm, counter) { if (foundTerm === "内部") { // 某些敏感词在合规区域不做高亮 return textNode.parentElement.closest(".skip-highlight") === null; } return true; } });6.2 自定义样式体系与暗色模式适配
mark.js 自带的默认高亮样式往往不够适配你项目的设计系统。最常见的坑是:你的站点在暗色模式下给mark默认样式设了一个非常亮的黄色,结果整个页面在暗色环境下像贴了大面积的荧光条。我一般会统一把高亮样式收敛为一个 CSS 变量体系:
:root { --highlight-bg: rgba(255, 213, 79, 0.8); --highlight-color: #333; } [data-theme="dark"] { --highlight-bg: rgba(255, 200, 50, 0.35); --highlight-color: #f0e6d2; } mark.custom-highlight { background: var(--highlight-bg); color: var(--highlight-color); border-radius: 2px; padding: 0 2px; }这样主题切换的时候,高亮颜色会自动跟随整套设计体系变化,不需要额外写一套 mark.js 专用样式分支。另外,考虑到高亮片段通常是用户最关注的信息,我建议额外增强一下可读性:
mark.custom-highlight::selection { background: #3399ff; color: #fff; }这样用户在页面里二次选中高亮文本时,选区颜色仍然清晰可辨,不会因为与高亮背景叠加而糊成一团。
6.3 扩展思路:同义词、拼音匹配、跨组件共享高亮状态
mark.js 还支持一个synonyms配置,可以把不同写法映射为同一个逻辑关键词。比如用户搜 "JS",你可能希望 "javascript" 也被高亮;搜 "互联网",希望 "因特网" 也亮一下:
instance.mark("Javascript", { synonyms: { "JS": "Javascript" } });这种同义词映射在处理中英文混排、缩写展开、行业黑话替换的场景下特别好用。我的一个项目里做了专业词库,用户搜简称时,文章中的全称也会被一起高亮出来,整体搜索命中率和用户信任感提升了不少。
拼音匹配则需要自己预处理。中文字符无法直接用拼音搜索原生匹配,常见做法是把正文文本和用户输入都转成拼音再比对,但 mark.js 并不具备这个能力,需要你在搜索触发前做额外的搜索链路处理,比如先把正文拼音索引做好,命中位置映射回原文,再调用 mark.js 标记原文字符串。这个方案复杂度会上升不少,但对某些特定垂直行业(比如古文检索、生僻字库)非常有用。
跨组件共享高亮状态则是另一个设计维度的考量。如果你有多个视图(比如列表视图、详情视图、阅读视图)同时展示同一批搜索词,你可以把当前关键词放进全局状态管理,不同视图在挂载时各自调用 mark.js 标记自己的 DOM。关键是让"当前关键词"成为一个唯一的真源,而不是每个视图各自保存一份。这能避免切换视图时出现"列表高亮了但详情页没高亮"的割裂状态。
7. 我用的过程中沉淀的几个实用技巧
最后聊几个零散但实际能省不少时间的点,算是我在各种项目里反复验证过的小细节。
第一个是高亮统计。很多时候产品需要展示"共命中 XX 处",mark.js 的done回调参数里就带着totalMarks,但这个数字统计的是 DOM 里标记元素的数量。如果同一关键词在同一个文本节点里出现三次,它会生成三个<mark>元素,计数是准确的。但如果关键词被跨元素匹配,一个关键词跨多个标签生成多个 mark,计数可能比语义上的"次数"多,做展示时心里有数。
第二个是在框架中执行时机。Vue 的nextTick、React 的useEffect都不能完全保证 DOM 的最终渲染形态,因为子组件可能还有异步渲染。我通常会在setTimeout(..., 0)或者配合组件树渲染完成的事件再执行 mark,能显著减少"明明调用了 mark 但效果没出来"的尴尬情况。如果页面结构特别复杂,甚至可以主动重试两三次。
第三个是搜索词和页面的编码一致性。这不是 mark.js 的锅,但我遇到过一个非常隐蔽的问题:后端返回的内容经过接口转义,把 存储成了真实空格,而用户输入的关键词里使用的是普通空格,两边视觉一样但字符不一致,导致高亮永远匹配不上。遇到"发际线后移"式的高亮失效问题,先检查数据源文本的字符编码形态,再检查烦人的零宽字符。
第四个是不要在一个容器里反复 new Mark 实例。建议页面核心容器只创建一次实例,后续通过unmark和mark复用,而不是每次都重新new Mark(container)。反复创建实例意味着每次都要重新遍历容器、重新绑定缓存,对性能没有任何好处,代码也不干净。
mark.js 是我目前见过在"搜索标记"这个细分场景下做得最省心的库,原理清晰、源码易读、可定制性也够。只要把它的工作边界搞清楚,把它放在数据渲染链路里正确的位置,就能避免大多数项目里遇到的高亮失灵、性能卡顿和样式冲突问题。希望这篇基于实际项目经验的拆解,能帮你把搜索高亮这个"小功能"做成一个稳健、不跳刺的模块。