☰
OpenHarmony文本高亮:用字符串操作替代正则的稳定方案
2026/10/2 4:29:12 网站建设 项目流程

在 OpenHarmony 上做文本高亮,听起来是个小功能,实际一碰就知道坑不少。通常的解法是正则替换,或者往字符串里塞 HTML 标签,但这两条路我走下来都不太稳。后来我用纯字符串操作重写了一遍——用indexOf找位置、用substring切片段——才彻底把这块做扎实。

这个标记器本质上做三件事:给定一段原文和一组关键词,算出所有命中位置;把原文切成普通段和高亮段;交给 UI 分级渲染。难点不在最后一步,而在第二步——如何在保留原文大小写、处理多个重叠关键词的前提下,把切分逻辑写干净。下面我把整个过程拆开讲,代码是基于 ArkTS 的,OpenHarmony 工程里可以直接用。

1. 项目拆解与方案选型:为什么字符串操作更合适

1.1 搜索标注场景到底要什么

先说清楚这个模块要解决什么。它本质上是三个输入一个输出:输入一段原始文本、一个关键词集合、一组渲染样式,输出一段在 UI 上能区分"命中区域"和"非命中区域"的可视化文本。注意这里有个隐藏需求——原始文本不能变,高亮只是展示层的操作。比如原文是"OpenHarmony 支持多种分布式能力",搜索"harmony"时,要把"Harmony"这一个词标红,同时保留原文大小写,不能把原文改写成小写。

这个场景在 OpenHarmony 应用里很常见:搜索页面结果摘要、操作日志中的错误码标注、聊天记录过滤、电子书阅读器的关键词定位、设置项搜索匹配等。我自己遇到的最典型场景是结果摘要搜索:服务端返回一大段摘要,本地要把用户输入的关键词再标一遍,避免在请求时拼 HTML。这不只是因为前端要做二次过滤,很多时候是产品要求关键词是动态变化的,不能依赖服务端。

这类需求的特点有几个:命中词数量通常不多,个位数到十几个;原文长度中等,几十到几千字符;关键词一般来自用户输入,不可信;对响应速度有要求,需要在 UI 主线程毫秒级完成。围绕这些特点,方案选择就有了边界:不需要引入重型组件,不需要解析 HTML 或富文本协议,更不应该为了让一个简单的标注功能去引入复杂状态管理。

1.2 为什么不用正则表达式

做高亮,很多人的第一反应是正则:new RegExp('(' + keywords.join('|') + ')', 'gi'),然后用replace把命中内容替换成带标记的字符串。我最早也是这么写的,几分钟就出效果,但用着用着就会发现它的问题集中爆发。

第一个坑是特殊字符转义。关键词是用户输入的,很可能包含[、(、*、$这类正则元字符。如果不转义,匹配结果会完全错乱。写一个escapeRegExp函数不难,但这意味着每次构造正则前都要遍历关键词做处理,漏一个就翻车。

第二个坑是正则回溯导致的性能不稳。动态拼接出来的正则,如果关键词组合得刁钻,在某些长文本上可能触发灾难性回溯,界面直接卡死。这种问题在本地调试时很难复现,一旦用户量上来就会变成偶发崩溃。

第三个坑是拿不到精准位置信息。正则的捕获组信息拿来做高亮替换还可以,但如果想区分多个关键词、想控制每个词的样式,或者想拿到命中区间去实现"前后文预览",就会很难受。

纯字符串操作方案把这三个问题都绕开了:用indexOf查找,时间复杂度基本可控,没有解析器的复杂度;主流程就是几个基础函数,程序员一眼能看明白,出问题也好排查。我个人观点是:能用显式逻辑解决的问题,就不要引入魔法。

2. 核心算法设计:定位、合并、切割三段式

2.1 定位:用 indexOf 把所有命中位置算出来

第一步,把"人在文本里找词"翻译成"程序在字符串里找词"。最基础的工具是String.prototype.indexOf(searchString, fromIndex),它从指定位置开始找,找不到返回 -1。为了支持大小写不敏感,我会先做一次小写化:把原文和所有关键词都转成小写,再在小写原文上做indexOf。这样做的关键是索引不会改变——toLowerCase()之后字符串长度不变(绝大多数情况下),所以小写字符串上的下标可以直接映射回原字符串,最后用原字符串substring取值,就能保留原文的大小写。

伪代码大概是:对每个关键词,从头开始循环indexOf,每找到一个就把[start, end)记下来,然后把起始位置推进到end,继续找下一个,直到返回 -1。这里稍微提醒一句:起始位置只能推进到end,不能end + 1。如果推进到end + 1,两个相邻或重叠的关键词命中会被漏掉。比如原文 "OpenHarmony",关键词 ["open"],命中区间是 [0,4);如果从 4 往后继续找,就找不到同一段里的第二个 "open" 了。还好对单个关键词循环的场景,推进到end已经够用。

所有关键词都查完后,会得到一个区间列表。这个列表在进入合并阶段之前不需要排序,因为下一步统一处理。

2.2 合并:重叠区间归一,避免重复高亮

多个关键词之间经常出现重叠。比如关键词列表是 ["OpenHarmony", "Harmony"],原文是 "OpenHarmony",那第一个关键词命中 [0,12),第二个关键词命中 [4,11)。如果不做处理,后面切分时 [4,11) 这一段会再次被切出来,导致同一个区域的文本被重复处理,UI 层也可能出现同一个 Span 被渲染两遍的问题。

我的做法很简单:先把区间按start升序排序,然后遍历,如果当前区间的start小于等于上一个区间的end,说明两者重叠或相邻,就把两者合并,end取较大值;否则就直接开启新区间。这个逻辑处理完,所有区间之间都有序且互不重叠,切分就非常干净。

这里有个细节值得讲:相邻区间要不要合并?比如关键词 ["abc", "def"],原文 "abcdef",第一个区间 [0,3),第二个区间 [3,6),按我的判断条件start <= last.end会合并成 [0,6)。这对于 UI 展示来说是合理的,两个关键词连在一起,视觉上就是一整块高亮,中间不会闪断。但如果你需要保留一条细缝,只要把条件改成start < last.end即可,不过要小心"相邻但不重叠"的边界处理。我基本不会去抠这个,合并后视觉更顺。

2.3 切割:用 substring 输出有序分段

区间合并完成后,就进入切分阶段。这一步的逻辑可以想象成拿着放大镜从左往右扫过原文:用一个cursor游标记录当前扫描位置;每遇到一个高亮区间,先把cursor到区间起点之间的内容切成"普通段",再把区间本身切成"高亮段",最后把游标移动到区间结束位置。扫描完所有区间后,如果游标还没到原文末尾,剩下的部分作为最后一个普通段。

每个段我用一个类来承载:

export class HighlightSegment { text: string; // 原始文本片段 isHighlight: boolean; // 是否为高亮段 }

最终返回一个HighlightSegment[],其中普通段和高亮段是交替出现的。为什么返回数组而不是直接返回带 HTML 的字符串?因为这样就把"切分"和"渲染"彻底解耦了。UI 层想用 Span 就用 Span,想输出 HTML 就给 HTML,想导出带 ANSI 颜色的终端文本也方便。这是我这套方案最想强调的设计:字符串操作只负责结构,不负责样式。

下面给一个完整的 ArkTS 工具函数,可以直接复制到工程里用:

export class HighlightSegment { text: string = ''; isHighlight: boolean = false; constructor(text: string, isHighlight: boolean) { this.text = text; this.isHighlight = isHighlight; } } export function markKeywords(source: string, keywords: string[]): HighlightSegment[] { const result: HighlightSegment[] = []; if (!source || !keywords || keywords.length === 0) { result.push(new HighlightSegment(source, false)); return result; } const lowerSource = source.toLowerCase(); const ranges: Array<{ start: number; end: number }> = []; // 去重和过滤空关键词 const seen = new Set<string>(); for (let i = 0; i < keywords.length; i++) { const kw = keywords[i]?.trim(); if (!kw) { continue; } const lowerKw = kw.toLowerCase(); if (seen.has(lowerKw)) { continue; } seen.add(lowerKw); let pos = 0; while (pos <= lowerSource.length) { const idx = lowerSource.indexOf(lowerKw, pos); if (idx === -1) { break; } ranges.push({ start: idx, end: idx + lowerKw.length }); pos = idx + lowerKw.length; } } if (ranges.length === 0) { result.push(new HighlightSegment(source, false)); return result; } // 排序并合并重叠区间 ranges.sort((a, b) => a.start - b.start); const merged: Array<{ start: number; end: number }> = []; for (const r of ranges) { if (merged.length === 0 || r.start > merged[merged.length - 1].end) { merged.push({ start: r.start, end: r.end }); } else { const last = merged[merged.length - 1]; last.end = Math.max(last.end, r.end); } } // 按区间切割 let cursor = 0; for (const m of merged) { if (m.start > cursor) { result.push(new HighlightSegment(source.substring(cursor, m.start), false)); } result.push(new HighlightSegment(source.substring(m.start, m.end), true)); cursor = m.end; } if (cursor < source.length) { result.push(new HighlightSegment(source.substring(cursor), false)); } return result; }

这段代码有几个可复用的小经验:去重用的是Set,避免同一个关键词重复参与匹配;空字符串关键词直接跳过,防死循环;substring的区间是左闭右开,end不用减一。pos推进在while里,理论上如果关键词是空串会无限循环,所以trim()之后的空判断非常关键。

3. 在 OpenHarmony 工程里落地:ArkUI 渲染全流程

3.1 接入 ArkTS 的组件与状态管理

工具函数写完之后,剩下的工作就是把它接到 UI 上。在 OpenHarmony 的 ArkUI 框架里,渲染多段不同样式文本最合适的组件是Text+Span。Text可以内嵌多个Span,每个Span有独立的fontColor、fontSize、fontWeight等属性,这天然就是我们需要的"分段渲染"能力。

具体接入方式:在页面里用@State定义两个变量,一个存原始文本,一个存切分结果:

@State private segments: HighlightSegment[] = [];

在onPageShow或者搜索回调里调用工具函数:

this.segments = markKeywords(this.resultText, this.searchKeywords);

然后渲染:

Text() { ForEach(this.segments, (seg: HighlightSegment) => { if (seg.isHighlight) { Span(seg.text) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor('#E84026') } else { Span(seg.text) .fontSize(16) .fontColor('#333333') } }, (seg: HighlightSegment, index: number) => index.toString()) }

这样写有个隐形好处:普通文本和高亮文本的基线、行高是统一的,因为它们在同一个Text容器里。我之前见过有人图省事,把高亮词拆成单独的Text组件再横向排列,结果出现行高不一致的情况,特别是当高亮词包含换行时槽位参差不齐。用Span从根上规避了这个问题。

3.2 如果非要输出 HTML:转义不能省

有的场景绕不开 HTML,比如你用的是RichText组件,或者要把高亮结果提交给 Web 端。这时候可以从同一个markKeywords函数出发,输出一个带<span>标签的字符串。但有个致命问题一定要处理:原始文本里的<、>、&必须先转义,否则会影响 HTML 结构。

我之前踩过一个特别典型的坑:搜索结果里有条日志,内容包含"[ERR] <build>: failed"这样的一行,搜索"ERROR"时RichText直接把这行当标签解析了,页面瞬间错乱。后来我强制做了 HTML 转义:把原文<换成&lt;、>换成&gt;、&换成&amp;。这里注意,高亮段和普通段都要转义,不能只转义普通段。转义完成后,再给高亮段包一层<span style="color:...">才安全。

function escapeHtml(input: string): string { return input .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;'); }

有同学会问,这不又用正则了吗?没错,replace内部确实有正则的影子,但这只是转义工具,不是匹配逻辑,不存在用户输入构造动态正则的风险。它处理的是固定替换模式,安全性没问题。当然你要洁癖到"零正则",也可以自己写循环替换,不过意义不大。

3.3 为什么不直接用 RichText 包打天下

既然有RichText,为什么我还强调用Span?我实测下来的体感是:RichText在不同 API 版本上对标签样式支持的完整度差异比较大。它能解析基础标签,但当你希望拿到精确的"命中段"来控制点击事件或自定义字体时,会发现它像一个黑盒,你没法在某个<span>上单独注册点击回调,也没法精确知道当前渲染到了哪一段。这不是RichText的设计目标,它更适合展示服务端下发的富文本,而不是做本地交互式标注。

反过来,Span方案的所有数据都是我们自己切出来的数组,每一段的text、isHighlight都是显式的。想做点击跳转?给高亮段包个onClick就行。想做默认色、高亮色切换?改一下fontColor的条件判断。这种"看得见摸得着"的感觉,在开发效率和排查问题时的体验是RichText给不了的。

4. 性能边界与隐藏陷阱

4.1 性能实测:十万级文本也能稳住

先说结论:在典型场景下,这套纯字符串操作方案的性能完全够用,甚至可以说游刃有余。

复杂度上,indexOf本身的时间复杂度跟待查找字符串的字长相关。我们循环查找每个关键词,相当于对每个关键词从头到尾扫描一次原文,最坏复杂度约O(n * m),其中n是原文长度,m是关键词总长度。我实际测试过一段 10 万字符的日志文本,配 20 个随机关键词,单次标注耗时在几毫秒到十几毫秒之间,肉眼完全感知不到。真正到上百万字符级别时,indexOf内部的优化(包括快速跳过)也能扛住,因为用户不太会在 UI 主线程直接渲染上百万字符的文本。

如果某天你真的遇到性能瓶颈——比如要做全文检索、上千个关键词,就要考虑升级数据结构。常见方案是用Trie或Aho-Corasick自动机,把多关键词匹配的复杂度降下来。但这是另一个话题了,绝大多数场景用基础indexOf就够。过早优化是折腾自己的常见原因,先把功能跑通,让 Profile 数据说话。

4.2 大小写与 Unicode 的隐藏裂坑

前面提到用toLowerCase()做大小写不敏感匹配。这里有一个比较偏的坑:toLowerCase()对绝大多数拉丁字符是"一对一"映射,长度不变,索引可以安全映射;但对个别 Unicode 字符,小写化后长度会变长。比如İ(U+0130,拉丁大写 I 带一点)在部分实现下toLowerCase()会变成i̇(两个字符)。一旦碰到这种字符,小写字符串的下标就和原字符串对不上了,高亮位置会整体错位。

实用建议是:如果你的产品主要处理中文、英文、数字,直接用toLowerCase()没问题;如果是多语言场景,更稳妥的方案是放弃"大小写不敏感"这个需求,或者在边缘情况做特殊处理。这不是我自己发明的结论,是 Unicode 大小写映射的客观行为。实际开发中,中英文场景占绝对大头,所以我在项目里保留了这个坑,但没为它做过度设计。

另一个容易被忽视的是换行符。原文里如果包含\n,indexOf查找时换行就是一个普通字符,不影响命中;Span 渲染时换行也正常生效。但如果你把结果输出成 HTML 再丢给 RichText,\n在 HTML 里会被压缩成空格,高亮段和非高亮段的换行位置可能全部变形。这是"为什么我更推荐 Span"的又一个理由。

4.3 多关键词的优先级与不同颜色

前面合并区间用的是"取大不取小",一视同仁地把所有关键词命中并成一个整体。如果产品需求是"第一个关键词用红色,第二个关键词用蓝色",那合并逻辑就不能偷懒了。我这里再给一个扩展思路:合并时候,重叠区间不是简单取大end,而是要根据关键词的优先级决定谁覆盖谁。最朴素的做法是给每个区间打上关键词标记,在合并时当start相同、重叠时,优先级高的关键词的区间"压过"优先级低的。

设计取舍上,我的建议是:先想清楚产品是否需要区分关键词,再决定是否上复杂度。大部分搜索场景只要"命中高亮"即可,区分颜色反而会让页面花里胡哨。如果一个需求确实要求区分,那也不要绕开区间重叠处理,直接在HighlightSegment里多加一个字段keywordIndex,在渲染时查表取颜色。扩展成本其实很小。

5. 常见问题与排查技巧实录

5.1 高亮标签被当作文本显示出来

症状:用 Span 渲染一切正常,但有人一不小心走了RichText路线,结果页面上直接显示出了<span>源代码。原因很简单:你给RichText传入的字符串里的<span>标签没有被作为 HTML 解析。排查方向:确认组件用的是RichText而不是Text——Text默认会把 HTML 标签当普通文本渲染,这是两者最容易混淆的地方。如果你一定要在Text里实现分段高亮,就用Span子组件,别指望它解析标签。

5.2 相邻关键词被合并成一个大高亮块

如果两个关键词本来不想连在一起,但因为"相邻合并"逻辑把它们连成了整块,看起来就像"吞掉了中间的空格或符号"。这种情况多半是start <= last.end中的等号导致的。把条件改成r.start < last.end即可,但注意这样会带来新的边界:区间 [0,3) 和 [3,6),也就是完全相邻但中间没有字符,按新逻辑不会合并,切分后是两个独立高亮段,中间没有任何间隙。如果你希望在视觉上拉开距离,可以考虑在普通段里强制拼一个空格,但我不推荐这种方式,因为会污染原文数据。

5.3 大小写匹配时高亮偏移

如果你发现高亮的是har而不是Harm,同时原文里刚好有特殊 Unicode 字符,先怀疑是不是toLowerCase改写了字符串长度。最简单的验证方法:打印一下lowerSource.length和source.length,如果长度不一致,基本就是这类边缘问题。中英文场景可以直接忽略,多语言场景建议先做字符级匹配或者弃用大小写不敏感。

5.4 关键词为空字符串导致卡死

这是最容易踩却最隐蔽的 bug:用户输入一个空关键词,或者关键词本身就是全角空格,indexOf('')会直接返回0,然后while循环永远推进不了,界面直接卡死。解决方案就是进入循环前做trim()和空值判断,我在示例代码里已经写上了。这个检查看着不起眼,实际上能避免一次严重的线上事故。建议不仅在工具函数里做,在 UI 层调用前也做一次,双保险。

5.5 高亮结果在列表复用中串色

在List或ForEach中渲染多个结果项时,如果把segments数组直接存在每个 item 对象里,并复用了组件状态,有可能出现上一页的高亮样式残留在下一页的情况。原因是@State数组的引用没有更新,或者keyGenerator生成重复 key 导致组件复用异常。我的经验是:给每个列表项的ForEach提供一个稳定唯一的 key,并且在搜索回调里保证segments是全新数组,而不是对旧数组的原地修改。markKeywords内部每次都新建result数组,天然满足这个要求。

6. 实操心得:这套方案的扩展玩法

6.1 从命中区间直接做摘要截取

搜索结果往往只显示前后若干字,传统做法是先截取再高亮,高亮容易切到一半。用我的方案,可以先对全文做markKeywords,得到所有命中区间,然后按区间前后展开一定长度截取拼接,这样高亮词一定完整出现在摘要里。这个功能只需要在合并后的merged数组上多做一步区间扩展和二次切割,代码量很小。

具体思路:假设摘要前后各展示 50 个字符,拿到merged区间后,对每个区间做start - 50到end + 50的截取,再把截取片段拼起来,中间用省略号隔开。注意截取时不能把半个代理对切坏,不过 OpenHarmony 上的字符串接口在这一步通常够用,中文场景尤其轻松。

6.2 从搜索框到日志诊断的复用

这个函数不只服务于搜索页。我在调试网络请求时,会把返回的关键字段(订单号、错误码、签名串)用这个函数高亮后输出到日志面板,肉眼定位问题快很多。这种内部调试功能不需要 UI 层介入,直接输出带 ANSI 转义码的字符串,终端里就能看到颜色。

另外,把关键词按空格拆成多个子词,分别高亮,再对每个子词做模糊匹配(比如去掉末尾助词、在关键词中间允许一个错字),在高亮层体现出"部分命中",这也能以字符串操作为基础叠加上去。文本高亮就这么点事,但用对方案之后,它的扩展空间其实比想象中大。

写在最后的一点个人体会:我见过太多项目为了一个"看起来不难"的功能,引入了正则、富文本解析、自绘 Canvas,最后维护成本远超收益。纯字符串操作之所以值得推荐,不是因为它有多炫技,而是它把复杂度摊平了——定位就是indexOf,切割就是substring,渲染就是Span,每一步都能讲清楚,出了问题都知道去哪里看。这个"可控感",是很多高级方案给不了你的。如果你正在 OpenHarmony 上做搜索页或标注功能,不妨先跑一遍这套逻辑,它大概率能帮你把第一版稳稳落地。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询