CSS Custom Highlight:不改 DOM 也能做文本高亮
翻 MDN 的时候,我在 CSS.highlights 这个条目上停了很久。
这是个我之前没注意过的 API,规范里叫 CSS Custom Highlight。它承诺的事情很直接:不往 DOM 里插任何标签,就能给指定的文本范围加背景色、下划线这类样式。我们后台的站内搜索一直用最土的办法做高亮——把命中的词替换成 <mark>,在纯文本里没问题,可一旦内容里夹着代码块、链接、加粗文本,字符串替换就变得很危险,用户复制文字时还会把插进去的结构带出奇怪效果。所以看到这个 API,我第一反应是想试试,但心里也清楚,「规范里有」和「能上生产」是两回事,得先自己把边界摸一遍。
先说结论式的观望:Chrome 和 Edge 从 105 起就支持了,我在自己机器上验证没问题;但 Safari 到现在还不支持,看 caniuse 也没给出明确的落地版本。所以这篇是我读文档加动手验证的笔记,能用的场景我只敢先放在内部工具和可控环境里试,面向外部用户的页面还得老老实实带降级。
它到底解决了什么
传统高亮就是往内容里插标签:
1这是一段 <mark>需要高亮</mark> 的文字
静态内容这么写没毛病。但高亮范围如果是动态算出来的——搜索关键词、拼写检查、评论定位——就得不断改 DOM,插标签、清旧标签、合并被拆开的文本节点,一路都是坑。
Custom Highlight 换了个思路,把这件事拆成三步:
- 用 JavaScript 创建文本
Range - 把这个 Range 注册进浏览器的高亮集合
CSS.highlights - 用 CSS 的
::highlight()伪元素控制样式
文本节点本身一个字都不用动。对富文本编辑器、在线文档、代码阅读器来说,这点很关键,因为它们的 DOM 结构还同时承担着内容模型、选区、光标和事件处理,往里硬插节点等于在雷区里施工。
把 API 跑通
先来一段文本:
1<p id="content">前端开发需要关注性能、交互和可维护性。</p>
创建一个 Range,注册成高亮:
1const textNode = document.querySelector("#content").firstChild; 2 3const range = new Range(); 4range.setStart(textNode, 0); 5range.setEnd(textNode, 4); 6 7CSS.highlights.set("keyword", new Highlight(range));
再用 CSS 给这个名字的高亮上样式:
1::highlight(keyword) { 2 background: #ffe58f; 3 color: #111; 4}
这样「前端开发」这四个字就高亮了。我实际跑的时候印象最深的一点是:整个过程 DOM 里连一个多余节点都没有,#content 的 innerHTML 完全没变。选中、复制出来的还是原始那句话,不会夹带任何标签碎片。这正是 <mark> 方案给不了的。
这里有个命名的小细节要注意:CSS.highlights.set 的第一个参数是高亮名,::highlight() 里必须写一模一样的名字才能对上。名字写错、或者 CSS 里写成 ::highlight(keywords) 多了个 s,高亮就是不显示,控制台还不报错——排查时先核对这两处名字是否严格一致,能省不少冤枉时间。
搜索关键词高亮
真实项目里高亮范围通常来自搜索词,得先在文本里找到所有匹配位置,再给每个匹配建一个 Range:
1function highlightKeyword(element, keyword) { 2 if (!CSS.highlights || !keyword) { 3 return; 4 } 5 6 const textNode = element.firstChild; 7 8 if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { 9 return; 10 } 11 12 const text = textNode.textContent; 13 const ranges = []; 14 let startIndex = 0; 15 16 while (startIndex < text.length) { 17 const index = text.indexOf(keyword, startIndex); 18 19 if (index === -1) { 20 break; 21 } 22 23 const range = new Range(); 24 range.setStart(textNode, index); 25 range.setEnd(textNode, index + keyword.length); 26 ranges.push(range); 27 28 startIndex = index + keyword.length; 29 } 30 31 CSS.highlights.set("search-result", new Highlight(...ranges)); 32}
一个 Highlight 里可以塞任意多个 Range,所有命中的词共用同一套 ::highlight(search-result) 样式,这比给每个词单独插标签清爽多了。
但这段代码有个我验证时立刻撞上的限制:它只处理单个文本节点。真实文章里,一句话中间夹个 <strong>,关键词就可能横跨好几个文本节点,element.firstChild 只能拿到第一段。要正经支持多节点,得用 TreeWalker 把元素下所有文本节点遍历出来,逐个计算匹配范围:
1const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); 2const ranges = []; 3let node; 4 5while ((node = walker.nextNode())) { 6 const text = node.textContent; 7 let from = 0; 8 let hit; 9 while ((hit = text.indexOf(keyword, from)) !== -1) { 10 const range = new Range(); 11 range.setStart(node, hit); 12 range.setEnd(node, hit + keyword.length); 13 ranges.push(range); 14 from = hit + keyword.length; 15 } 16}
就算这样,关键词恰好被 <strong> 从中间劈开的情况还是覆盖不到。要彻底解决,得维护一套文本索引,把 DOM 文本节点和全局字符偏移映射起来,再把跨节点的匹配拆成多个 Range 拼回去。这个成本不低,所以别看 API 优雅就低估了搜索匹配本身的复杂度——API 省掉的是「插标签」,没省掉「找到底在哪儿」。
多个高亮层可以叠加
CSS.highlights 是个类 Map 的集合,可以同时注册多个命名高亮,它们能叠在同一段文本上。这在需要区分「当前命中项」和「其他命中项」时很好用——比如搜索结果里,所有匹配用浅黄底,用户正跳到的那一个用深橙底:
1CSS.highlights.set("search-all", new Highlight(...allRanges)); 2CSS.highlights.set("search-current", new Highlight(currentRange));
1::highlight(search-all) { background: #fff3bf; } 2::highlight(search-current) { background: #ffa94d; }
多个高亮重叠时,谁盖谁由 priority 决定。Highlight 实例有个 priority 属性,数值大的画在上面;默认都是 0,这时按注册顺序,后注册的在上:
1const current = new Highlight(currentRange); 2current.priority = 1; // 确保当前项盖过整体高亮 3CSS.highlights.set("search-current", current);
这套「一个 all 层加一个 current 层」的结构,比往 DOM 里插两种不同 class 的 <mark> 干净太多——切换当前项时,只要换掉 search-current 里那个 Range,不用去 DOM 里挪 class。
和原生 Selection 的区别
有人会想:浏览器不是本来就有选区高亮吗,用 window.getSelection() 加 Range 不也能高亮?两者看着像,但不是一回事。Selection 是用户选中文本那套,全页只有一个、样式基本不可控(就是系统的选区蓝底),而且会和用户真实的选择操作冲突——你程序化设一个 selection,用户一点别处就没了。
Custom Highlight 是独立的一层:它不占用 selection,用户照样能正常选中、复制文本,两套高亮互不干扰。样式也完全由 ::highlight() 自定义。所以做「搜索命中」这种需要长期停留、又不能妨碍用户操作的标注,Custom Highlight 才是对的工具,Selection 顶多适合「程序临时选中一段让用户看到」的一次性场景。
它适合哪些场景
摸下来,这类功能最契合 Custom Highlight:
- 页面内搜索关键词高亮
- 在线文档里定位并高亮某段内容
- 代码编辑器标记错误或警告区间
- 富文本批注、划词评论
- 拼写检查、语法提示
共同点是:高亮范围频繁变,但你并不想动原始 DOM。拿搜索举例,每输入一个字符都重新插一遍 <mark>,就要处理旧标签清理、文本节点合并、选区丢失一堆问题;换成 Custom Highlight,「内容结构」和「视觉标注」彻底分开了,改的只是注册进去的 Range,DOM 纹丝不动。
代码编辑器标错误区间是另一个很贴的例子。像 lint 波浪线、语法错误下划线,本质就是「在某段文本下画条线」,既不能改动源码文本节点、又要随光标和编辑频繁移动。以前 CodeMirror、Monaco 这类编辑器都是自己用绝对定位的覆盖层硬画,Custom Highlight 出来后,这类标注理论上能交给浏览器原生去画,省掉一整套坐标计算——当然前提还是那句,Safari 没支持之前,编辑器类库也只能把它当增强,不能当唯一方案。
需要提前想清楚的边界
第一位的还是兼容性。Chrome/Edge 能用,Safari 现在不支持,所以用之前必须探测:
1if ("highlights" in CSS && "Highlight" in window) { 2 // 走 Custom Highlight 3} else { 4 // 降级到 mark / span,或者只定位不高亮 5}
我给自己定的规矩是:只要页面会被外部用户在 Safari 里打开,就默认走降级分支,Custom Highlight 只作为「支持就更好」的增强,绝不当成唯一实现。内部后台工具全员 Chrome,才敢直接上。
降级分支我倾向做成「同一套匹配逻辑,两种渲染出口」,而不是两套完全不同的实现。前面用 TreeWalker 找到的那批匹配位置,支持 API 就注册成 Range,不支持就退回给命中处包 <mark>——匹配这一层复用,只有最后「怎么把高亮画出来」分叉:
1function applyHighlight(element, keyword) { 2 const supported = "highlights" in CSS && "Highlight" in window; 3 const matches = findMatches(element, keyword); // 两条路共用 4 5 if (supported) { 6 CSS.highlights.set("search-result", new Highlight(...toRanges(matches))); 7 } else { 8 wrapWithMark(element, matches); // 老办法兜底 9 } 10}
这样将来 Safari 补上支持,删掉 else 分支就行,匹配逻辑不用重写。渐进增强的关键就是让「能力探测」只影响最末端那一步,别让它把整条逻辑劈成两份各自维护。
第二,Range 的起止位置绑在具体文本节点上。内容一旦重新渲染,原来的文本节点可能就没了,高亮也得跟着重算。React/Vue 这类会频繁重建 DOM 的场景尤其要小心:虚拟 DOM diff 后复用的可能是新的文本节点,旧 Range 指向的节点已经被替换,高亮要么错位要么消失。稳妥的做法是把「重算高亮」这件事挂到内容真正变更的那个信号上,而不是指望 Range 自己跟着 DOM 走——它不会。
第三,::highlight() 能设的 CSS 属性有限。规范把它归到 highlight 伪元素那一类,只允许一小撮和文本着色相关的属性生效:color、background-color、text-decoration 系列、text-shadow 这些,还有 -webkit-text-stroke。margin、padding、border、transform 这类会影响布局的属性直接被忽略,写了也没用。所以它能做的就是「给文字换个颜色、加条线」,做不了「给命中词加个圆角小胶囊背景」这种带盒模型的效果——真要那种视觉,还是得回到 <mark> 加 CSS 的老路,权衡就又回到「值不值得动 DOM」上了。
还有个容易忽略的点是生命周期。高亮不是注册完就一劳永逸,搜索词变了、文章重渲染了、主题切换了,都得清掉旧的或重算:
1CSS.highlights.delete("search-result");
不清理的话,旧 Range 可能继续挂着,甚至指向已经被替换掉的文本节点。在框架里,最稳妥的是组件卸载时把对应高亮 delete 掉,避免跨页面残留。这一点和 IntersectionObserver、事件监听那些「注册了要记得注销」的资源是一个道理。
在 Vue 里我会把整套注册和清理收进一个 composable,卸载时自动收尾:
1export function useHighlight(elementRef, keywordRef) { 2 const NAME = "search-result"; 3 4 watchEffect(() => { 5 if (!("highlights" in CSS)) return; 6 const el = elementRef.value; 7 if (!el) return; 8 CSS.highlights.delete(NAME); // 每次重算前先清旧的 9 if (keywordRef.value) { 10 highlightKeyword(el, keywordRef.value); 11 } 12 }); 13 14 onBeforeUnmount(() => { 15 if ("highlights" in CSS) { 16 CSS.highlights.delete(NAME); 17 } 18 }); 19}
关键是 watchEffect 每次跑之前先 delete 一遍。搜索词从 ab 变成 abc,如果不先清,abc 的新 Range 会叠在 ab 的旧 Range 上,出现残影。React 里对应的就是 useEffect 的 cleanup 函数里 delete,思路一样:注册和清理必须成对,且更新时先清后建。
性能上它其实占优
<mark> 方案有个隐性代价:往文本里插标签会拆分文本节点、触发局部重排,命中很多时会明显卡。Custom Highlight 因为不动 DOM 结构,注册 Range 只是往一个集合里塞对象,浏览器在绘制阶段单独画高亮层,不引起 DOM 重排。命中几百上千处时,这个差距会拉得很开。
但也别以为它零成本。创建大量 Range、用 TreeWalker 全文遍历、字符串多次 indexOf,这些计算量还在。真正做大文档搜索时,我会给 highlightKeyword 加防抖,用户停止输入一小段时间再算,而不是每敲一个字符全文重扫一遍。高亮的绘制便宜了,匹配计算这块该省还是得省。
我现在会怎么选
做高亮,我会先问一句:内容结构能不能改。
纯静态内容用 <mark> 最省心,简单、语义也清楚,Safari 也照样支持。而且 <mark> 是有语义的元素,屏幕阅读器能识别,对可访问性友好;Custom Highlight 画的高亮目前对辅助技术基本是透明的,纯视觉层,这也是选型时要顺带考虑的一点——如果高亮承载的是「这段很重要」这类语义,<mark> 反而更合适。
文章、富文本、代码块、编辑器这类结构一复杂,往 DOM 里硬插标签就容易牵出选区、复制、事件、重渲染一连串问题,Custom Highlight 把结构和标注分开的价值这时才显出来。
但它现在还不是能无脑替换 <mark> 的方案。兼容性摆在那儿——Safari 还没跟上,我只在内部可控环境里放心用;对外的页面一律带降级,先保证「能搜到、能定位」,高亮只是锦上添花,不该为了它让页面在半数用户那里变脆。
我打算把它先在团队内部的一个文档阅读器里试起来:那是个只在 Chrome 环境用的内部工具,正好有「全文搜索并高亮命中」的需求,内容还带代码块和加粗,用 <mark> 改结构一直很别扭。这种可控、又确实吃到它长处的场景,是现在唯一敢上的地方。等哪天 Safari 补上支持、caniuse 上绿成一片,再谈把它铺到面向用户的产品里也不迟——在那之前,观望和降级都不丢人。