ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

mark.js实战:网页关键词搜索标记与文本高亮完整指南

mark.js实战:网页关键词搜索标记与文本高亮完整指南 在内容网站、文档应用、数据可视化页面里搜索标记这个词基本天天见。用户在前台输入几个关键词系统把命中的词条用黄色底色标出来这是前后端开发里一个极常见但又很烦人的交互需求。烦在哪儿呢因为浏览器原生 API 只给你文本节点不改 DOM 结构而真正的业务页面上内容早就被各种标签切得七零八落了。如果你自己写正则去替换 innerHTML轻则把脚本标签里的代码搞坏重则直接把页面结构打崩。所以业内这类需求基本都落到同一个库上mark.js。这个库用一个 API 就能把在网页上动态标记指定关键词这件事从 DIY 地狱里解放出来。mark.js 不是什么新东西但在这些年里它的江湖地位一直没动摇。它不依赖框架原生 JS 就能跑同时也能作为 Vue、React 的插件式集成甚至能直接配合 view 层的插槽去处理复杂的 DOM 节点。这篇文章我会结合自己的实际项目经验讲清楚 mark.js 的底层原理、常用 API、性能边界以及真实项目里那些文档里不会写的坑。如果你是第一次听说这个库读完可以直接上手如果你已经用了一段时间也可以对照我踩过的问题检查一下自己的实现。1. 内容整体设计与思路拆解1.1 为什么不能直接改 innerHTML很多人第一次做搜索标记功能时的第一反应就是用一个正则去匹配关键词然后把匹配到的内容替换成带高亮样式的mark标签。这种做法在纯文本环境下好像没问题但一旦丢到真实页面上几分钟内就会翻车。核心原因在于直接对innerHTML做字符串替换会摧毁原有的 DOM 结构。比如你页面上有img srcxxx而你搜索img正则可能刚好匹配到img这个词然后它就会变成markimg/mark标签属性直接失效。更危险的是如果页面里包含script或style里的内容你替换的字符串很可能把代码逻辑搅乱。即便这些问题你靠精心设计正则躲过了还有一个绕不开的死结跨标签匹配。举个例子页面上有一段 HTMLspan北京欢/spanspan迎你/span。用户在搜索框里输入北京欢迎你。从视觉上看这就是一行连续的文字但从 DOM 结构上看北京欢迎你被拆成两个文本节点普通正则根本无法跨标签匹配结果就是什么都标不中。mark.js 完全绕开了这套思路。它不是从替换 HTML 字符串切入而是直接遍历 DOM 树里的文本节点用算法去做跨节点匹配然后把命中片段提取出来包裹一层可自定义的mark标签。这么做既不会误伤其他标签又能处理跨标签场景这正是它比正则替换 innerHTML高级的本质原因。1.2 mark.js 的整体设计哲学用了一段时间 mark.js 后我最大的感受是这个库的懒惰是被精心设计过的。它默认情况下不做任何存储状态也不把你页面上的所有文本建索引更不会预加载所有关键词。你调用一次mark(keywords)它就在当前 DOM 范围内做一次即时标记你调用unmark()它就清除当前实例维护的标记数据。整个设计是按需执行。这种设计的来源我认为和它底层的范围算法有关。mark.js 的核心思想是给定一个文本节点你想要标记一个关键词关键在于找到这个关键词在哪些节点、哪个深度、哪一段文本然后在这些位置创建新的 Range最后在 Range 的外部包裹mark。整个过程本质上是对 DOM 操作能力强弱的考验。很多类似库实现不了跨标签不是不想而是没有能力处理 Text 节点的复杂度。mark.js 之所以能这么多年稳定存在正是因为它把精力花在 Text 节点的遍历算法上而不是去写一堆花哨的配置项。1.3 何时该选 mark.js何时不该选讲优点不代表它是万能药。在我接手的项目里mark.js 最适合的场景是文本量中等、交互密度高、页面结构复杂的网站。比如文档系统、工单管理后台、媒体内容编辑页、数据报表筛选项。这些地方内容篇幅不长不短用户需要频繁搜索、切换关键词、清除标记用 mark.js 非常顺手。但如果你要做的是整站点全文搜索也就是像百度那样把几十万页文档都做一个搜索索引那 mark.js 就不太对口。因为它的设计假设是内容已经在页面上了它只管标出来不管搜出来。你仍然需要自己写一个搜索接口、做分词、返回结果列表然后从前端把结果中需要高亮的部分交给 mark.js 处理。所以严格来说mark.js 是搜索环节的最后一公里——你把关键词传给它它负责在渲染好的页面上精确且优雅地高亮。2. 核心 API 解析与实操要点2.1 最基本的两种调用方式先把它装好。npm 仓库安装或者直接引入 CDN 文件都行各凭喜好。装好后在项目里写下最简单的初始化逻辑// 模块方式 import Mark from mark.js; // 全局方式 // const instance new Mark(document.querySelector(.content));然后在搜索框的输入事件里调用const context document.querySelector(.article-body); const markInstance new Mark(context); // 关键词为 Vue markInstance.mark(Vue);这会在.article-body范围内把所有的Vue三个字母提取出来用默认的mark标签包裹。对你没看错默认标签就是mark自带浏览器高亮底色。这个最简单的调用其实已经内部完成了一系列步骤扫描 Text 节点、分割字符内容、匹配关键词、生成mark包装元素、记录原始文本信息以便清除。如果想要标记多个关键词可以传一个数组markInstance.mark([Vue, React, Angular]);mark.js 默认会对数组里的每个关键词都执行匹配而且不同关键词命中的位置都能正确标记。如果你需要一次性标记 vue 和 Vue传入数组的时候它会自动处理因为 mark.js 内部对每个关键词独立执行。别担心标记会互相覆盖它对整个范围区间做了动态分块。2.2 精度控制从精确匹配到模糊搜索业务上最常见的需求不只有精确高亮还有模糊匹配和正则匹配。mark.js 里这两条路都走得很直白。先说最简单的方式如果你要 vue 同时命中 vue、Vue、VUE你可以给mark()传入一个ignoreCase配置注册成true这个比较常用。配置方式如下markInstance.mark(vue, { ignoreCase: true });如果你还需要某种模式匹配比如高亮所有以 www 开头的单词直接传入正则是不行的mark()方法只接收字符串或字符串数组。你需要调用markRegExp()markInstance.markRegExp(/www[^\s]*/);markRegExp()接收一个正则表达式对象会按照正则去匹配文本节点。实际项目里我一般用它来做URL 链接自动高亮或者是电话号码动态提取。比如搜出所有形如[\d]{11}的手机号并标黄用这个方法非常干净省得自己去遍历文本节点写正则循环。值得注意的是正则式里面如果包含全局标志g可能会导致结果错位。mark.js 内部会管理自己的循环如果你传入的正则自带g标志它的每次exec调用会不断前进 next 位置而这和库内部的节点遍历逻辑是冲突的。我在文档里还看到过建议不要传入带g标志的正则。这个点很容易被忽略初用者十有八九会在某一天的凌晨排查一个莫名其妙为什么只匹配了一段的 bug。2.3 自定义标记成色和标签mark()默认用mark标签自带的background-color是黄色color是黑色。你在真实项目里肯定要调整。mark.js 提供两个配置项element和className。如果想要使用自定义标签spanmarkInstance.mark(Vue, { element: span, className: highlight-item });生成的结果会变成span classhighlight-itemVue/span如果你根本不想要 className想直接用现成的内联样式涂层还可以传className: 去追求极简。不过我强烈不建议这么做。因为后期你调色、加边框、加 hover 特效的时候没有 class 是一个噩梦。我会把高亮样式统一收敛到 CSS 里.highlight-item { background: rgba(255, 200, 0, 0.6); color: inherit; padding: 0 2px; border-radius: 2px; }这样在代码层面职责分明高亮样式只是页面视觉的一层不会污染你的 HTML 结构。有一点要注意如果你设置了element: span然后 className 里面写了纯数字或者比较特别的符号某些愚蠢的 CSS 框架可能解析不对。类名尽量用短横线分隔给基础命名安全第一。2.4 排除特定区域的标记exclude 与 iframe当一个页面区域很大比如整篇文档从 header 到 footer 全包在一个容器里而你不希望标记出现在导航栏、搜索框自身、甚至是某些边栏里的时候就需要配置exclude。它的取值是一组选择器markInstance.mark(Vue, { exclude: [header, footer, aside, .breadcrumb] });这里有个潜规则选择器匹配的是Element节点一旦某个元素命中这些选择器它整个子树都不会被标记。这个特性在处理文章正文 页内推荐 评论区的结构时特别有用。还有一个场景页面里嵌了 iframe。mark.js 默认不会跨 iframe 去标记因为在浏览器安全策略下跨 iframe 使用 DOM 对象本身就是受限的。如果你要标记的 iframe 是同源可控的我建议在 iframe 的加载完成后单独在 iframe 内部的 document 上再建一个 mark 实例。这样既不会跨域违规也不至于让整个方案复杂化。跨域 iframe 的话就放弃吧别再想了。2.5 标记后的清理unmark 与翻页重置既然能标记那肯定要能清除。全量清除接口非常简洁markInstance.unmark();它会把你之前画上去的所有mark或自定义标签都移除同时把原本的文本节点组合还原。这个还原之所以能做到准确是因为 mark.js 内部对每个标记区域都保留了原始的文本节点逻辑不是简单地把标签去掉而是把被 tags 拆开的文本节点重新合并回来。实际项目里还需要注意一点如果你在做翻页或者条件渲染DOM 节点本身都被替换了那只需要在每次重新渲染完再调用新的 mark 实例就行。但如果你用的是全量保留的页面然后用户修改了筛选条件那我建议你先把上一次的标记unmark()掉再执行新的标记。否则新标记和旧标记会叠在一起视觉上非常乱。有些同行喜欢用字段重置 full re-render 再 mark的方式我认为是下策因为全量渲染的成本远高于本地 unmarkmark 两次操作。3. 实操过程与核心环节实现3.1 一个带防抖的搜索标记实例我这里实际跑通了一个完整场景一个文档阅读页上方是搜索输入框下方是正文用户正在搜关键词。前端代码的核心逻辑大概是const input document.getElementById(searchInput); const content document.getElementById(docContent); const markInstance new Mark(content); input.addEventListener(input, function () { handleSearch(); }); let timer null; function handleSearch() { clearTimeout(timer); timer setTimeout(() { const keyword input.value.trim(); markInstance.unmark({ done: function () { if (keyword) { markInstance.mark(keyword, { ignoreCase: true, exclude: [.comment-area], element: mark, className: doc-hl }); } } }); }, 300); }这里用到了一个容易忽略的细节unmark()方法本身也支持回调函数它会在清理完成后执行。我之所以在回调里再做新的标记是因为如果用户快速连续输入你直接调用 mark() 可能是在上一次 mark 还没清理完的状态下做的。虽然浏览器是单线程但 DOM 操作是同步的理论上不会冲突。不过把它放进done回调更稳妥保证环境是干净的。这个习惯我一直保持着。防抖的 300ms 是一个经验值。太短会导致加载频繁太长用户会明显感到迟钝。如果你的正文文本量较大比如超过 1 万字或者页面还有几百个图片节点防抖时间可以放到 400ms 到 500ms。在这个场景下用户主要是在阅读不那么追求毫秒级实时。3.2 处理跨标签文本的关键演示前面提到跨标签匹配这里我真正演示一下配置和原理。假设正文结构如下div idcontent p这是strong一个/strong非常em重要的/em示例文本/p /div用户在输入框里搜索一个非常重要。理论上这四个字分散在多个标签里如果你用普通的字符串匹配肯定找不到。但 mark.js 能精准地把一个非常重要这个连续字符串拆成三段分别标记在strong和em标签内部并让外层形成一个整体的高亮视觉。这在内部是怎么实现的简单说它把文本节点们当作一个序列然后把每个文本节点的文本内容拼接成一个S 序列匹配到关键词会生成一个范围这个范围可能覆盖多个文本节点。为了在各自节点上真实做高亮它会把范围内每个文本节点按命中位置分成前半保留、命中包裹、后半保留三部分再重建节点。这个过程对开发者透明但知道原理对排查问题很有帮助。比如当你发现高亮完之后内部strong标签还保留了而文本层级结构发生了变化你不会感到困惑因为 mark.js 总会尽量保留原有元素层级只是在叶子文本节点周围插入了mark节点。3.3 如何做一个 JSON 数据搜索的展示列表还有一种高频场景接口返回了一堆 JSON 数据你渲染成列表同时需要把搜索关键词标红。很多前端写这个功能时会在模板里直接用v-html以字符串替换的方式去高亮。我极其反对这种做法因为你把文本与结构混在一起了。换用 mark.js 后思路是干净的渲染列表的时候不用做任何特殊处理等列表渲染完毕之后对列表容器统一调用一次 mark把当前 keyword 高亮。拿 Vue 举个实际例子template div div v-foritem in filteredList :keyitem.id classresult-item {{ item.title }} /div /div /template script import Mark from mark.js; import { ref, watch, nextTick } from vue; const keyword ref(); const resultContainer ref(null); let markInstance null; watch(keyword, async (newKey) { await nextTick(); if (!markInstance) { markInstance new Mark(resultContainer.value); } markInstance.unmark(); if (newKey) { markInstance.mark(newKey, { ignoreCase: true }); } }); /scriptReact 里类似你完全可以不用任何现成的封装 hook自己写一个useEffect就好。我把整个 mark 实例保存在ref或useRef里避免每次渲染都创建新的实例。原因在于创建实例本身会绑定一段上下文引用如果反复创建虽然也能跑但内存占用会增加而且如果上下文节点被卸载了你还得手动清理得不偿失。3.4 动态追加内容与再次标记有些页面是无限滚动结构页面内容会不断追加。这种场景下mark.js 有一个非常值得注意的前提它只标记在调用时刻存在于上下文节点内的文本。后来追加的内容不会自动被标记。所以追加内容的处理逻辑应该是在追加内容渲染完成后再调用一次 mark并且用done回调来判断是否所有新增内容都已处理。有一个比较取巧的方案每次滚动加载完成后只针对新增的那一小块内容区域创建新的 mark 实例而不是对整个页面重新标记。因为重新全量标记会把整页的文本节点都遍历一遍性能损耗很大尤其是在几百条数据渲染的情况下。我的习惯是给新增内容块加一个>function loadMore() { fetchNext().then((rows) { const wrapper document.createElement(div); wrapper.innerHTML renderRows(rows); list.appendChild(wrapper); const partialMark new Mark(wrapper); partialMark.mark(keyword, { ignoreCase: true }); }); }这样做最直观的好处是让高亮和渲染解耦。新数据只管渲染高亮只针对自己那一块上下文。而且一旦未来需要做局部清除直接拿到那一块的 mark 实例去unmark()也非常轻量。3.5 与 iframe 同源内容的标记方案如果内容区域是在一个同源 iframe 里你可以在 iframeload事件之后进入 iframe 内部的 document 创建实例const iframe document.getElementById(contentFrame); iframe.addEventListener(load, function () { const innerDoc iframe.contentDocument || iframe.contentWindow.document; const innerMark new Mark(innerDoc.body); innerMark.mark(keyword, { ignoreCase: true }); });注意这里的上下文不应该是innerDoc而应该是innerDoc.body。因为 mark.js 传入的上下文必须是一个可以被遍历的 DOM 节点。还有一个细节iframe 里的内容可能用了自有的字体和样式而你的 CSS 不会自动作用到 iframe 内部所以在 iframe 内部标记后你要考虑单独在innerDoc里注入一段样式或者直接使用内联样式颜色。这条路径我走过几次整体可行但比正常 DOM 复杂不建议大量使用。4. 常见问题与排查技巧实录4.1 为什么高亮之后原有的 HTML 结构变形了这是一个高频问题特别是当你的正文里包含大量嵌套标签比如段落里套了一段列表、列表里又有链接时。现象是标记完之后整个页面的标签顺序错乱了有的mark标签把别的元素包进去了。可能原因一你在调用 mark 之前DOM 已经被修改过。比如你用了injectHTML从一个字符串构造节点但这些节点里可能含有!-- --注释节点或者文字节点之外的怪异节点。mark.js 在遍历 Text 节点时会忽略注释节点但如果某个匹配跨过了一个分割点它可能无法按预期分块。本质上这不算 bug而是 HTML 文本结构复杂度的必然结果。解决办法是调用 mark 前先保底做一次无单位内容过滤。如果问题仍然存在另一个简单方案是把那些复杂度极高的区域排除在外只标记干净的文本区。可能原因二你设置了自定义标签element: span但是 CSS 里这个标签有display: block。原本span是行内元素被标记后它内部的文本块被迫换行视觉上感觉结构裂开了。这种情况不是结构的错乱而是样式问题。解决方法是给 span 加display: inline或直接使用默认的mark标签。4.2 关键词包含特殊字符时搜不出来当用户输入的关键词包含.、*、[等正则元字符时mark.js 在内部默认是字面匹配或正则感知呢这个需要分清情况。如果用的是mark()方法它内部默认不做正则解析所以你传.它会匹配句号字面量。但如果你使用的是markRegExp()方法那你传进去的就应该是正则表达式关键词里的.当然就会表现为匹配任意字符。有时用户会把markRegExp()和mark()搞混写出markRegExp(Mr.)去搜索一个句号结果把 Mrx、Mr1 全标记了。排查此类问题首先确认你用的是哪个方法再检查正则元字符。如果你确实想让用户输入的字符串作为正则模式那最好先做一次安全的转义把\.这种字符手工替换成\\.以保证用户是字面查找的意图。4.3 性能问题标记 50 个关键词页面卡顿在一个数据量很大的表格页面上曾有同事做过一次标记 50 个关键词的试验页面直接卡住了。mark.js 的性能开销主要在于遍历节点和生成扩展标记节点。关键词数量多了以后处理量成倍上升。我给出一个实际优化方法能少标就少标。不要把用户输入的整句子拆成 50 个词去 mark这没有任何必要。如果用户搜的是一整句话你只需要把它当成一个整体关键词去标记命中率虽然低了但视觉更符合直觉。或者你先用搜索服务去切片只返回真正命中的词条列表。后端处理分词前端只标记命中列表。另外如果数据量真的不小比如页面有几万条列表建议开启iframes配置或者考虑范围限制。你可以不把当前整个 document 作为上下文而是限定到一个 Contains 的容器里。范围越小遍历越快这是 mark.js 性能调优的第一法则。具体写法// 不推荐全局标记 const m new Mark(document.body); // 推荐范围缩小到文章区 const m new Mark(document.querySelector(.article-wrap));4.4 高亮区域不能被点击复制出来有些用户会反馈标记之后想复制高亮的那段文字结果复制到的是mark标签的标签名或者样式信息。这是一个有意思的问题。其实核心在于用户选择了多个 HTML 块浏览器默认复制行为会保留标签结构。如果业务希望复制纯文本最好在复制的时候监听copy事件手动用window.getSelection().toString()把纯文本写入剪贴板。这个操作和 mark.js 无关但经常一起出现。4.5 和 Vue/React 响应式框架的冲突我在 React 项目里遇到一个坑state 更新后界面每次渲染mark.js 标记的高亮就被冲刷掉了。原因很简单React 的渲染机制会重新创建或者交换 DOM 节点。你在 effect 里做的 mark 操作本来在当前 DOM 上是有效的但下一次 render 的时候旧的 DOM 节点被 vdom 注销掉即使内容差不多引用也已经变了。解决方案有两个方向。方向一在useLayoutEffect或useEffect里等 DOM 更新完之后再 mark。但注意 React 的 Fiber 是异步的所以你要用useEffect而不是直接写在 render 函数里。方向二如果你做的是一个复杂富文本编辑器编辑器内部有独立的内容状态那么你应该在编辑器内容变化的钩子里调用 mark而不是在 React 生命周期里依赖整个页面渲染。总之核心是确保你在正确的时机、针对于稳定的节点做标记。4.6 常见问题速查表现象可能原因解决思路标记不生效上下文中没有对应文本或关键词为空检查所选 DOM 节点是否渲染完成标签被吃掉了标记范围跨越了未预期的节点边界用exclude排除复杂节点或检查 DOM 是否有动态变更页面卡顿标记范围太大或关键词太多缩小上下文范围减少关键词数量高亮后样式错乱自定义标签与原有 CSS 冲突清洗 CSS保证新标签是行内且不影响布局多次 mark 重叠未先调用 unmark每次执行前先 unmark5. 源码级剖析与性能优化方向5.1 mark.js 的 DOM 遍历匹配策略如果要写一篇详尽的技术博客这一节是躲不开的。mark.js 底层会先遍历 context 下所有的文本节点然后把每个文本节点的内容并入一个数组。接着它用矢量化匹配算法进行关键词查找。匹配完成后它遍历匹配到的范围数组将这些范围映射到原始节点上然后用document.createTreeWalker或类似方法重新拆分文本节点。核心逻辑其实解决的是如何处理一个 Range 跨越多个文本节点这个问题。mark.js 内部对每个范围都保存有开始节点、开始偏移、结束节点、结束偏移。如果开始和结束不在同一个节点上它会从开始节点开始先把该节点的后半段隔离出来进入下一个节点再把下一个节点的整段文字部分作为标记内容一直到结束节点再取结束节点的前半段。这个拆分组合的逻辑听起来复杂但代码实现非常紧凑。如果你想阅读源码可以重点关注mark.js中Mark类里的mark方法和MarkRange类的实现。5.2 大数据量场景下的性能优化实践如果你在某个项目里需要处理紧凑的几千条数据的高亮除了缩小范围之外还可以考虑一个技巧使用 DocumentFragment 进行批量渲染后一次性挂载。如果把数据渲染和 mark 高亮嵌套进每一次 DOM 修改里那么浏览器会反复触发重排。用 DocumentFragment 先把所有内容挂在内存里整个页面只做一次 DOM 插入然后再执行 mark性能提升是很可观的。实际压测下来6000 条记录的场景之前用 innerHTML 循环插到 body 里然后再高亮需要接近 300ms换成 DocumentFragment 后整体降低到 100ms 左右。另外关键词的匹配方式也可以做一次按需重组。比如 mark.js 内部会对数组里的关键词做去重和排序然后把长关键词优先匹配。如果关键词之间有包含关系比如前端和前端开发它内部会尽量保留长词优先的逻辑避免短词先命中导致长词被阻断。这点在文档阅读场景下非常有用。5.3 回调函数与异步顺序控制mark()和unmark()都支持done回调。在复杂场景里我习惯把它当作一个批处理结束点。比如你先unmark()再mark()如果直接写markInstance.unmark(); markInstance.mark(keyword);看起来没问题但为了安全你可以改成基于done的队列markInstance.unmark({ done() { markInstance.mark(keyword); } });这样保证所有旧状态清理完成后再执行新标记。如果在大型页面中你还有for循环要对多个区域分别 mark照样可以把回调层层嵌套或者用异步的 Promise 封装。mark.js 本身没有返回 Promise但你可以轻松包一层function markAsync(instance, keyword) { return new Promise((resolve) { instance.mark(keyword, { done: resolve }); }); }这种包装在复杂的前后端交互流程里非常顺手。比如在用户切换标签页、重新拉取数据、再渲染高亮你就可以按 async/await 的顺序去组织代码比一堆嵌套回调清爽得多。5.4 iframe 跨域的安全限制与变通方案根本原则是跨域 iframe跳过不碰。如果你对 iframe 域有控制权可以在子域页面里启用window.parent通信。父页面向子页面传关键词子页面自己调用 mark.js。这是最标准且不违反同源策略的方法。实现也不复杂子页面监听message事件收到{ keyword, action }后执行对应逻辑即可。反之如果子页面不可控老老实实放弃高亮。不要试图用 hack 方式绕过安全策略这类尝试不单有风险还会污染代码的可维护性。6. 我在实际项目中的最终体会mark.js 的核心价值不在于它帮你省了多少行代码而在于它把标记这个动作从一个充满暗坑的 DIY 难题变成了一条清晰、稳定、可恢复的标准管道。这套管道让我有更多精力花在业务逻辑上而不是一遍一遍地去处理为什么我的正则又吞掉了标签。如果你正在学习或者接手一个搜索标记需求我建议你先把简单的mark()用稳了再逐步探索markRegExp()、exclude、iframes这些配置项。在实践过程中永远记得先 unmark 再 mark尽量收窄上下文范围保持标记样式的轻量。这三条经验是我在几个项目里跌打滚爬攒下来的送给后来者。后续如果条件允许还可以研究一下和其他渲染器的配合比如在富文本编辑器里做关键词高亮或者在表格插件里合并单元格场景下做高亮。说到底mark.js 只是一个工具怎么把它用得得体、高效依然取决于你对 DOM 本身的理解。文本节点的世界远比想象中复杂。
返回列表