ARTICLE DETAIL

资讯详情

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

纯字符串操作实现文本关键词高亮:从扫描到Span渲染的全流程解析

纯字符串操作实现文本关键词高亮:从扫描到Span渲染的全流程解析 做社区App的搜索结果页时我遇到了一个看似简单、实际坑不少的需求把用户输入的搜索关键词在结果文本里标成醒目的颜色。第一反应是上正则一行replace换标签或者直接用 RichText 组件渲染 HTML。结果试了一圈发现OpenHarmony 这边的富文本方案远没有想象中顺手正则的转义和性能问题也让人头疼。最后我干脆绕开所有花活只用字符串操作函数indexOf、slice、toLowerCase 这些从头写了一个文本高亮关键词标记器效果反而最稳。这个标记器的核心思路很简单扫描原文找出所有关键词出现的位置合并重叠区间再按区间切片用 Text Span 逐段渲染。它不依赖任何三方库不依赖正则也不用 HTML 解析器纯 ArkTS 就能落地。如果你正在做 OpenHarmony 应用涉及搜索高亮、敏感词标注、笔记标签这类功能或者单纯想看看怎么用最朴素的字符串操作组合出“智能”效果这篇文章值得花几分钟读完。1. 项目整体设计与思路拆解1.1 这个标记器解决什么问题文本高亮听起来是小事实际场景里面到处都用得上。搜索结果页要把命中的关键词标红聊天界面要把敏感词标出来提醒用户阅读器里用户划线的词语需要突出显示编辑器里甚至要做简单的语法着色。这些功能的底层逻辑是一致的给你一段原文和一个关键词列表把原文中被命中的部分标记出来其余部分保持原样。我最初的版本是给搜索结果做高亮关键词通常只有一个。后来产品经理加需求说聊天消息里出现的多个敏感词要一起标出来而且不区分大小写。这一下就从“找一次”变成了“找多次”还得处理多个关键词相互重叠、重复命中的情况。如果只针对单一关键词硬编码代码会越写越丑所以我干脆把逻辑收敛成一个独立的标记器模块输入原文和关键词数组输出可渲染的分段结果UI 层只负责把分段结果映射成 Span。这个设计本质上是在做“内容与展示分离”标记器只产出数据结构不关心最终渲染成红色还是绿色、加粗还是下划线。后续想调整样式或者从 Text 组件换成 RichEditor业务代码完全不用动。1.2 技术选型为什么放弃正则和 RichText做关键词高亮常见方案有三条路。第一条是正则表达式用new RegExp(keyword, g)配合replace给命中文本加特殊标记第二条是走富文本组件OpenHarmony 里有 RichText 可以解析简单的 HTML 字符串第三条就是本文用的 Text Span 字符串扫描。我最终选了第三条原因很实际。正则的问题在于转义和性能。关键词是用户输入的可能包含.、*、(、)这些正则元字符每次都得先做一层转义。转义本身不难但转义完了正则表达式的可读性就崩了。而且正则回溯在极端情况下会出现性能抖动在低端设备上尤其明显。更麻烦的是正则无法直接给出“位置信息”你虽然能用匹配结果拿到 index但多关键词多次 replace 时位置容易乱。RichText 组件的问题在于控制粒度。它接受 HTML 字符串但样式是通过标签控制的关键词和普通文本混在一起样式切换、点击事件绑定都不够灵活。在 ArkUI 的声明式体系里Text组件内部的Span子组件才是更原生、更可控的方式。每个 Span 可以独立设置颜色、字号、字重还能绑定点击事件渲染性能和原生文本一样好。三种方案对比下来选择就很清晰了方案实现成本灵活度性能坑点正则替换低几行代码中但转义麻烦中极端情况回溯元字符转义、多关键词位置错乱RichText 组件中低样式依赖标签中需解析 HTML事件粒度粗、样式能力受限Text Span 字符串扫描偏高核心算法要写高完全可控可控纯线性扫描需处理区间合并、边界条件一句话总结如果只是 Demo 演示正则最快如果是正规功能Text Span 字符串扫描最稳。这个选择的本质是用一点点算法复杂度换取长期的维护确定性和渲染灵活性。1.3 整体架构与数据流标记器的架构分四层。第一层是输入预处理对关键词数组做去重、去空、trim避免脏数据干扰后续算法。第二层是区间扫描对每一个关键词在原文中找到所有出现的起止位置保存成MatchRange结构。第三层是区间合并把所有关键词产生的区间按起始位置排序再合并交叠的部分得到一个不重叠的区间列表。第四层是切片生成根据合并后的区间把原文切成若干段每段标记是否为关键词最终输出一个HighlightSegment数组。数据流是单向的原始文本 关键词列表 → 预处理 → 扫描 → 合并 → 切片 → 渲染。每一层只做一件事方便单独测试。比如合并逻辑出错可以单独喂一组区间进去验证切片结果不对单独打印每一段的文本和标记位就行了。这种清晰的分层结构比一大坨for循环嵌套到底的写法好排查得多。2. 核心字符串操作细节与算法解析2.1 用 indexOf 扫描关键词位置字符串扫描的起点是indexOf。这个函数在 ArkTS 里和 JavaScript 行为一致返回子串第一次出现的位置索引找不到就返回 -1。我们可以利用它的第二个参数fromIndex实现连续扫描第一轮从索引 0 开始找找到后把查找起点挪到命中位置的末尾再继续找下一处直到找不到为止。这里有个容易踩的坑很多人的第一反应是循环里用indexOf找到之后把fromIndex设成index 1。这样做会漏掉邻接的匹配。假设原文是“aaa”关键词是“aa”第一次命中[0, 2)如果只把起点加 1第二次查找会从索引 2 开始结果就漏掉了[1, 3)。正确的做法是从index keyword.length继续扫也就是沿着字符串向前移动不做重叠匹配。非重叠扫描在实际业务里更合理。关键词本身是一个个独立的词命中的位置重叠太多反而会让高亮区域糊成一片。设计成非重叠之后每个关键词的匹配次数也就被限制在文本长度除以关键词长度以内扫描的复杂度严格可控。扫描部分的代码逻辑大概是这样的private static findKeywordRanges( searchSource: string, searchKeyword: string ): MatchRange[] { const ranges: MatchRange[] []; const keywordLength searchKeyword.length; let cursor 0; while (true) { const index searchSource.indexOf(searchKeyword, cursor); if (index -1) { break; } ranges.push({ start: index, end: index keywordLength }); cursor index keywordLength; } return ranges; }每次命中都会得到一个半开区间[start, end)start 是关键词开头的位置end 是结尾位置的后一位。这个区间语义在后续切片时非常顺手因为slice(start, end)拿到的正好是关键词本身。2.2 区间排序与合并单关键词扫描之后你会得到一堆分散的区间。多个关键词扫描完毕问题就来了区间之间可能交叉、嵌套、或是相邻。比如原文是“OpenHarmony 开源操作系统”关键词列表里有“OpenHarmony”和“系统”两个区间一个在开头一个在结尾互不影响但如果关键词列表里同时有“OpenHarmony”和“Open”第二个关键词命中的区域完全落在第一个区间内部不合并的话同一个文本片段会被重复切分渲染出来的 Span 会异常。合并算法其实不复杂先把所有区间按 start 升序排列start 相同时按 end 升序然后顺序遍历维护一个当前区间如果下一个区间的 start 小于等于当前区间的 end就说明两者交叠或相邻把当前区间的 end 扩展成两者中较大的 end否则说明当前区间已经独立收进结果开始处理下一个。这个合并逻辑要处理的一个重要细节是排序必须先行。不排序直接两两比较会出现前面区间和后面区间交叠但后面区间的 start 反而小于前面区间的 start这时候合并结果就会错。排序后遍历时当前区间的 start 永远是递增的合并条件就简化为只比较 end。private static mergeRanges(ranges: MatchRange[]): MatchRange[] { if (ranges.length 0) { return []; } const sorted ranges.slice().sort((a, b) { if (a.start ! b.start) { return a.start - b.start; } return a.end - b.end; }); const merged: MatchRange[] []; let currentRange sorted[0]; for (let i 1; i sorted.length; i) { const nextRange sorted[i]; if (nextRange.start currentRange.end) { currentRange { start: currentRange.start, end: Math.max(currentRange.end, nextRange.end) }; } else { merged.push(currentRange); currentRange nextRange; } } merged.push(currentRange); return merged; }注意slice()必须先拷贝一份数组再排序不能直接sort原始数组。因为sort是原地排序会破坏调用方持有的数组顺序后续如果还要基于原顺序做分析就会得到错误结果。2.3 大小写不敏感与中英文混合场景搜索场景里“OpenHarmony”和“openharmony”通常被视为同一个词。所以扫描时不区分大小写是刚需。实现思路很朴素把原文和关键词都通过toLowerCase()转成小写在小写文本上进行indexOf扫描得到位置信息后回原文本按位置切片展示。这样做的效果是匹配逻辑用的是小写文本展示内容仍然来自原文大小写形态完全保留。例如原文是“OpenHarmony is great”关键词“openharmony”扫描时会命中索引 0 到 13切片时从原文拿到的还是“OpenHarmony”展示出来就是原样的大写开头。中文没有大小写概念这个方案对中文完全无副作用反而因为统一走小写逻辑不用为中文写特殊分支。唯一需要注意的是toLowerCase()对某些特殊 Unicode 字符会改变字符串长度。最经典的例子是德语ß转大写后会变成SS长度从 1 变成 2。如果文本里混入这种字符小写文本里的位置和原文的位置就对不上了切片会把错误的字符切进去。绝大多数中文 App 场景不会遇到这个问题但如果你做的是国际化版本就要多留一个心眼。一个更稳妥的长远方案是逐字符归一化匹配或者用双数组双向映射记录位置变化。但在当前的项目阶段我选择直接使用toLowerCase加文档注释把限制写清楚而不是过度设计。2.4 按最终区间切片原文区间合并结束后就到了真正产出分段结果的环节。这一步的逻辑是从头开始遍历合并后的区间数组维护一个当前游标current。对每个区间[start, end)先看current到start之间有没有原文片段有就标记为普通文本然后切片start到end的文本标记为关键词最后把current移到end。循环结束之后再检查current是否已经走到原文末尾没走完就把剩下部分标记为普通文本。这个算法可以处理一个边缘情况多个区间之间有大量普通文本切出来的普通片段可能非常长。比如一段 800 字的文章只命中了两个词普通文本片段就有几百字。这没关系Span 本身就是整段渲染的长普通文本拆成一个大 Span 和拆成多个小 Span视觉上没区别但大 Span 的渲染效率和内存占用更优。切片时我推荐用slice而不是substring。slice和substring在非负索引的情况下效果一致但slice支持负数索引语义与数组切片一致代码读起来更自然。日常写字符串处理把slice作为默认切片函数能少记一套差别。3. 完整实战项目代码与运行效果3.1 核心类 HighlightMarker把所有逻辑收敛成一个静态工具类大概是下面这样。它对外只暴露一个入口方法buildSegments入参是原文、关键词列表、是否忽略大小写出参是分段数组。// HighlightMarker.ets export interface HighlightSegment { content: string; isKeyword: boolean; } interface MatchRange { start: number; end: number; } export class HighlightMarker { static buildSegments( source: string, keywords: string[], caseInsensitive: boolean true ): HighlightSegment[] { const segments: HighlightSegment[] []; if (!source || !keywords || keywords.length 0) { segments.push({ content: source, isKeyword: false }); return segments; } // 1. 清理关键词trim、去空、去重 const cleanedKeywords: string[] []; for (const item of keywords) { const keyword item.trim(); if (keyword.length 0 cleanedKeywords.indexOf(keyword) -1) { cleanedKeywords.push(keyword); } } if (cleanedKeywords.length 0) { segments.push({ content: source, isKeyword: false }); return segments; } // 2. 统一小写降低重复转换开销 const lowerSource caseInsensitive ? source.toLowerCase() : source; const allRanges: MatchRange[] []; for (const keyword of cleanedKeywords) { const lowerKeyword caseInsensitive ? keyword.toLowerCase() : keyword; const searchSource caseInsensitive ? lowerSource : source; const ranges HighlightMarker.findKeywordRanges(searchSource, lowerKeyword); for (const range of ranges) { allRanges.push(range); } } // 3. 合并重叠区间 const mergedRanges HighlightMarker.mergeRanges(allRanges); // 4. 切片生成片段 let current 0; for (const range of mergedRanges) { if (range.start current) { segments.push({ content: source.slice(current, range.start), isKeyword: false }); } segments.push({ content: source.slice(range.start, range.end), isKeyword: true }); current range.end; } if (current source.length) { segments.push({ content: source.slice(current), isKeyword: false }); } return segments; } private static findKeywordRanges( searchSource: string, searchKeyword: string ): MatchRange[] { const ranges: MatchRange[] []; const keywordLength searchKeyword.length; let cursor 0; while (true) { const index searchSource.indexOf(searchKeyword, cursor); if (index -1) { break; } ranges.push({ start: index, end: index keywordLength }); cursor index keywordLength; } return ranges; } private static mergeRanges(ranges: MatchRange[]): MatchRange[] { if (ranges.length 0) { return []; } const sorted ranges.slice().sort((a, b) { if (a.start ! b.start) { return a.start - b.start; } return a.end - b.end; }); const merged: MatchRange[] []; let currentRange sorted[0]; for (let i 1; i sorted.length; i) { const nextRange sorted[i]; if (nextRange.start currentRange.end) { currentRange { start: currentRange.start, end: Math.max(currentRange.end, nextRange.end) }; } else { merged.push(currentRange); currentRange nextRange; } } merged.push(currentRange); return merged; } }这一段代码里有一个细节值得专门说buildSegments开头对空参数的处理会把原文原封不动地包成一个isKeyword: false的片段。这个兜底非常关键因为在 UI 层调用时你无法保证外部永远传一个合法的关键词数组如果返回空数组ForEach 就什么都渲染不出来页面会空白。3.2 在 ArkUI 页面中渲染高亮 Span有了分段数组UI 层就很简单了。在Text组件内部用ForEach遍历分段根据isKeyword决定用高亮样式还是普通样式。高亮样式我一般用主题色加中等字重普通样式用默认文本色两者形成明显的视觉层级。import { HighlightMarker, HighlightSegment } from ./HighlightMarker; Entry Component struct HighlightDemoPage { private content: string OpenHarmony是一个开源项目OpenHarmony 支持一次开发、多端部署。 开发者可以通过 ArkTS 快速构建应用OpenHarmony 的生态正在快速发展。; private keywords: string[] [OpenHarmony, 开源, ArkTS]; private segments: HighlightSegment[] []; aboutToAppear(): void { this.segments HighlightMarker.buildSegments(this.content, this.keywords, true); } build() { Column({ space: 16 }) { Text() { ForEach(this.segments, (segment: HighlightSegment) { if (segment.isKeyword) { Span(segment.content) .fontColor(#E84026) .fontWeight(FontWeight.Medium) } else { Span(segment.content) .fontColor(#182431) } }, (segment: HighlightSegment) segment.content _ segment.isKeyword) } .fontSize(16) .lineHeight(26) .width(100%) Text(识别到 ${this.segments.filter((item: HighlightSegment) item.isKeyword).length} 个高亮片段) .fontSize(14) .fontColor(#666666) } .padding(16) .width(100%) .alignItems(HorizontalAlign.Start) .backgroundColor(#F1F3F5) } }这里有两点必须注意。第一ForEach的第三个参数是键值生成函数必须保证返回字符串而且最好唯一。我直接用segment.content _ segment.isKeyword做键但在极端情况下如果原文里恰好有两段内容一样且isKeyword也一样键就会重复。更稳妥的做法是在HighlightSegment接口里加一个自增序号字段用序号做键彻底避免重复。第二Text组件内只允许放Span和ForEach千万不要混入其他组件否则编译直接报错。实际跑下来高亮效果非常干净关键词部分显示主题色其余部分保持普通颜色整段文本的换行和间距与原生Text完全一致没有任何富文本渲染的白屏或字体跳动问题。3.3 扩展给高亮关键词绑定点击事件高亮不只是为了看很多时候是为了点。比如搜索结果里点击高亮的命中词可以跳转到详情页聊天敏感词场景点击高亮词可以弹出提示说明。Span组件天然支持onClick我们只需要在渲染isKeyword片段的Span上挂事件即可。由于ForEach的闭包里能拿到当前segment所以绑事件非常直接if (segment.isKeyword) { Span(segment.content) .fontColor(#E84026) .fontWeight(FontWeight.Medium) .onClick(() { this.onKeywordClick(segment.content); }) }这里有个潜在的闭包陷阱如果ForEach的迭代变量被后续复用闭包里捕获的segment可能不是当前项。好在 ArkUI 的ForEach闭包每次迭代都会生成独立上下文实测下来绑定结果是正确的。但为了保险起见也可以在事件回调里通过传入的content再次匹配原始关键词列表拿到完整的业务数据对象。3.4 性能优化建议这个实现是纯线性扫描单关键词扫描一次原文时间复杂度是O(n)多个关键词就是O(n * m)m 是关键词数量。实测下来在 10000 字符的文本里扫描 10 个关键词耗时在个位数毫秒完全够日常业务使用。但有几个优化点可以提前埋好关键词预处理缓存如果同一批关键词要反复高亮不同文本可以把去重、小写转换这些结果缓存起来避免每次重复计算。限制高亮片段数量在极端文本里一个关键词可能出现几百次产生几百个 Span。渲染上千个 Span 会导致帧率下降。可以在buildSegments里加一个最大命中数参数超过后只保留前 N 个命中或者对后续命中做降级处理。避免频繁调用 buildSegments如果文本编辑框里每次输入都触发高亮建议做 debounce 处理用户停止输入 300 毫秒后再重新计算。提示ForEach 渲染大量 Span 时键值生成函数非常重要。如果键值不稳定组件会反复创建和销毁反而比不优化更卡。另外一个容易被忽略的点是buildSegments每次返回的数组都是新对象如果在build方法里直接调用会在每次组件刷新时重新计算。对于静态内容影响不大但如果是滚动列表里的项建议在aboutToAppear或者列表数据源里预先计算好渲染时直接使用。4. 常见问题与排查技巧实录实现过程中我踩了不少坑也帮同事排查过类似的代码问题整理成速查表相当有必要。现象原因解决方案页面直接白屏没有任何文本关键词数组为空导致分段结果为空空关键词时包一个isKeyword: false的完整原文片段关键词没被高亮但普通文本渲染正常关键词包含首尾空格匹配失败扫描前对关键词做trim()同一个词高亮了两遍且中间文本也被高亮重复关键词未去重区间未合并关键词去重 合并重叠区间高亮位置整体错位越到后面越偏特殊 Unicode 字符大小写转换导致长度变化对可疑文本用length校验或限制必须用原始大小写滚动列表滑动卡顿每个 Item 重新计算分段数组且 Span 数量过多预计算分段结果限制最大命中数这几个问题里我实际遇到最多的是前两个单独展开说一下。4.1 空关键词导致死循环这是字符串扫描最容易出的事故。indexOf(, cursor)对任意cursor都会返回cursor本身因为空字符串在任何位置都能匹配。如果你忘记了过滤空关键词扫描循环就会陷入死循环找到的位置永远是cursorcursor更新后还是原来的位置while永远跳不出来。虽说不至于把设备搞死机但页面会彻底卡住非常吓人。解决方式是在预处理阶段就彻底干掉空字符串。我用了trim()加length 0双重判断顺手把全空格字符串也过滤了。这个过滤必须在扫描之前做否则等于没过滤。4.2 重复关键词造成零碎片段假设关键词列表是[Harmony, Harmony]两遍扫描会产生两组完全相同的区间。如果区间不合并切片阶段会把同一个位置切成两遍产生一个关键词片段、一个普通片段、又一个关键词片段展示效果看似正常但多了一堆无意义的空片段还会干扰点击事件的绑定逻辑。解决方案是去重和合并一起做。去重解决的是“完全一样”的问题合并解决的是“部分重叠”的问题。我见过只去重不合并的代码遇到[OpenHarmony, Open]这种关键词组合时依然会出现区间嵌套所以这两步一个都不能少。4.3 ForEach 渲染空 Span 的问题即使算法正确也可能出现空字符串入数组的情况。比如区间start end时切片得到的content是空字符串Span 渲染空字符串通常不会报错但会在部分设备上产生一个多余换行或空白占位导致行高异常。最保险的做法是在buildSegments的最后一步给每个片段加一个content.length 0的判断空片段直接跳过不 push 进结果数组。这个防御性代码成本极低能省掉很多后续排查时间。4.4 大小写转换引发长度偏移前面提过toLowerCase()可能改变字符串长度。如果只是中文和普通英文完全碰不到这个问题。但如果你做的是面向海外的应用或者用户文本由输入法自动生成了一些特殊字符就要小心了。定位方法也简单在buildSegments里临时打印source.length和lowerSource.length如果发现两者不一致说明文本里有长度会变的字符此时就需要走逐字符匹配路线或者至少对这部分文本降级为不区分大小写的方案。4.5 长文本下的卡顿与排查方法拿一万字的文章做高亮生成几百个 Span 在部分中低端设备上确实会掉帧。排查性能问题时我一般先在buildSegments前后打点用console.time记录扫描和切片耗时console.time(highlight-marker); const segments HighlightMarker.buildSegments(this.content, this.keywords, true); console.timeEnd(highlight-marker);如果耗时在 10 毫秒以内瓶颈就不在算法而在渲染层。此时优先看 ForEach 的键值生成函数以及 Span 的数量。如果耗时超过 50 毫秒多半是关键词数量太多比如上百个词做敏感词库扫描这时需要引入更高效的多模式匹配算法比如 Aho-Corasick 自动机但这就超出纯字符串操作的范畴了。我个人在实际开发里的体会是Mark II 版本的“关键词 颜色 点击事件”模型已经覆盖了搜索、标注、敏感词三类常见的业务场景。如果后续要支持“每个关键词不同颜色”的精细控制只需在HighlightSegment里加一个可选的keywordIndex字段渲染时根据索引去颜色表里取色即可核心扫描和合并算法一行都不用改。这种通过数据字段承载展示差异的设计比写一堆分支条件灵活得多。
返回列表