全局搜索最容易做成"看起来能搜"的功能:把几组数组拼在一起,把标题和正文连接成字符串,再用 indexOf() 判断是否包含关键词。数据只有几十条时,这段代码确实可以运行;但一旦用户输入化学式、同义词或跨类型关键词,结果顺序、空状态和目标跳转就会暴露问题。
本文基于 化学实验室 1.0.5 的真实 ArkTS 源码,分析 GlobalSearchPage.ets、Experiment.ets、KnowledgePoint.ets、ReadingArticle.ets 与 FormulaItem.ets。当前应用共有 13 个实验、6 个知识点、4 篇阅读和 15 条公式,总计 38 个本地内容项。页面已经能聚合四类数据,并使用复合键 ${type}_${id} 渲染稳定列表,知识点和阅读也有详情跳转。
本文唯一核验标记:CHEM-LAB-ONE12-GLOBAL-SEARCH-INDEX-20260730。
现有链路仍有四个明显断点。第一,空关键词时执行 allItems().slice(0, 12),由于实验最先加入,首页推荐只会显示前 12 个实验,知识点、阅读和公式完全没有露出。第二,每次输入都重新构造 38 个搜索对象。第三,所有命中只按源数组顺序展示,没有标题、正文和公式的相关度区别。第四,公式结果只跳到 FormulaPage,没有携带公式 ID,用户点击结果后仍要再次寻找。
更稳的设计是:全局搜索不是把四组数组拼起来过滤,而是把不同内容转换为同一检索契约 。业务模型仍是事实源,搜索索引只是只读投影;查询先归一化,再评分和排序;结果保留稳定的 type + id,导航层据此直达具体内容。

本文将完成以下工程拆解:
- 把实验、知识点、阅读和公式转换成统一
SearchDocument。 - 处理大小写、空白、化学上下标、箭头和常见别名。
- 建立标题、分类、正文与公式的分层相关度评分。
- 让空关键词推荐保持类型均衡,而不是被实验列表占满。
- 修复公式跳转丢失目标 ID,并统一四类导航契约。
- 补齐加载、推荐、结果、空结果和非法目标状态。
- 提供可复核的查询样例、测试矩阵与排障表。
一、先统计真实内容规模
当前搜索页的 allItems() 依次读取四类模型。
| 类型 | 模型函数 | 数量 | 主要可检索字段 |
|---|---|---|---|
| 实验 | getAllExperiments() |
13 | 名称、分类、描述、难度 |
| 知识点 | getAllKnowledgePoints() |
6 | 标题、分类、摘要、正文 |
| 阅读 | getReadingArticles() |
4 | 标题、分类、摘要、关键词 |
| 公式 | getAllFormulas() |
15 | 名称、方程式、说明、分类 |
现有统一结果类型如下:
ts
type SearchType = 'experiment' | 'knowledge' | 'reading' | 'formula'
interface SearchResultItem {
id: string
type: SearchType
typeName: string
title: string
subtitle: string
keywords: string
}
这个方向是正确的,因为页面不需要知道每个业务模型的全部字段。但 keywords 是提前拼好的单字符串,缺少字段来源、别名和权重,后续无法解释"为什么它排在前面"。

二、空关键词推荐被数据追加顺序控制
当前空查询逻辑是:
ts
if (q.length === 0) {
return this.allItems().slice(0, 12)
}
allItems() 先追加 13 个实验,再追加知识点、阅读和公式。因此前 12 项全部是实验。用户还没有输入时,"全局搜索"实际上展示的是"实验推荐",其他内容类型没有机会出现。
推荐结果应该显式定义类型配额:
ts
const DEFAULT_LIMIT: number = 12
const TYPE_QUOTA: Record<SearchType, number> = {
experiment: 3,
knowledge: 3,
reading: 3,
formula: 3
}
export function buildBalancedSuggestions(
documents: SearchDocument[]
): SearchDocument[] {
const result: SearchDocument[] = []
const types: SearchType[] = ['experiment', 'knowledge', 'reading', 'formula']
for (const type of types) {
result.push(...documents.filter(doc => doc.type === type).slice(0, TYPE_QUOTA[type]))
}
return result.slice(0, DEFAULT_LIMIT)
}
这是产品规则,不应由数组碰巧的先后顺序决定。
三、搜索文档要保留字段结构
统一索引不等于把所有内容压成一个字符串。建议保留标题、分类、摘要、正文、别名和目的地:
ts
export interface SearchDestination {
route: string
params: Record<string, string>
}
export interface SearchDocument {
key: string
id: string
type: SearchType
typeName: string
title: string
subtitle: string
category: string
body: string
aliases: string[]
destination: SearchDestination
}
key 使用 ${type}:${id},用于列表和去重;destination 由索引构建时产生,点击结果不再重新扫描业务数组;aliases 专门保存用户可能输入但正文没有出现的表达。
四、为实验构建搜索文档
实验结果需要保留 expId,目的地是实验模拟页:
ts
function indexExperiment(item: Experiment): SearchDocument {
return {
key: `experiment:${item.id}`,
id: item.id,
type: 'experiment',
typeName: '实验',
title: item.name,
subtitle: `${item.category} · ${item.description}`,
category: item.category,
body: `${item.description} ${item.level}`,
aliases: experimentAliases(item.id),
destination: {
route: 'views/experiment/ExperimentSimPage',
params: { expId: item.id, expName: item.name }
}
}
}
例如 acid_base 可以增加"中和""滴定""pH"等别名,oxygen_prep 可以增加"高锰酸钾""制氧"。别名应由内容维护者显式配置,避免搜索层猜测化学含义。
五、知识点详情只传 ID 更稳
现有知识点跳转把标题、分类、摘要和正文全部放入路由参数:
ts
params: {
id: item.id,
title: kp ? kp.title : item.title,
category: kp ? kp.category : '基础化学',
summary: kp ? kp.summary : item.subtitle,
content: kp ? kp.content : item.subtitle
}
这会复制内容,模型更新后,索引和详情参数可能不一致。更稳的契约只传稳定 ID,详情页从模型重新读取:
ts
function indexKnowledge(item: KnowledgePoint): SearchDocument {
return {
key: `knowledge:${item.id}`,
id: item.id,
type: 'knowledge',
typeName: '知识点',
title: item.title,
subtitle: `${item.category} · ${item.summary}`,
category: item.category,
body: `${item.summary} ${item.content}`,
aliases: [],
destination: {
route: 'views/learning/KnowledgeDetailPage',
params: { id: item.id }
}
}
}
详情页若找不到 ID,应显示内容已更新的错误状态,而不是使用搜索摘要拼成临时正文。
六、阅读索引需要包含 sections 正文
当前阅读搜索只使用标题、分类、摘要和 keywords,没有搜索各章节正文。用户输入"试管口不能朝向他人"时,真实内容存在于 sections.body,但不会命中。
ts
function indexReading(article: ReadingArticle): SearchDocument {
const sectionText = article.sections
.map((section: ReadingSection) => `${section.title} ${section.body}`)
.join(' ')
return {
key: `reading:${article.id}`,
id: article.id,
type: 'reading',
typeName: '阅读',
title: article.title,
subtitle: `${article.category} · ${article.minutes}分钟阅读`,
category: article.category,
body: `${article.summary} ${sectionText}`,
aliases: article.keywords.slice(),
destination: {
route: 'views/learning/ReadingDetailPage',
params: { id: article.id }
}
}
}
正文加入索引后,要通过权重保证标题命中仍排在正文命中之前。
七、公式结果必须携带公式 ID
现有公式结果点击后只执行:
ts
router.pushUrl({ url: 'views/learning/FormulaPage' })
用户搜索"理想气体方程",点击结果却只进入公式列表,没有定位目标。公式文档应携带 ID:
ts
function indexFormula(item: FormulaItem): SearchDocument {
return {
key: `formula:${item.id}`,
id: item.id,
type: 'formula',
typeName: '方程式',
title: item.name,
subtitle: `${item.formula} · ${item.description}`,
category: item.category,
body: `${item.formula} ${item.description}`,
aliases: formulaAliases(item),
destination: {
route: 'views/learning/FormulaPage',
params: { formulaId: item.id }
}
}
}
FormulaPage 读取 formulaId 后滚动或高亮对应项。找不到 ID 时回到完整列表,并给出轻量提示。
八、构建索引时只聚合一次
当前 filteredItems() 每次执行都调用 allItems(),ArkUI 重新构建时可能重复创建所有搜索对象。38 项规模影响不大,但结构上没有必要。
ts
export class LocalSearchIndex {
private readonly documents: SearchDocument[]
constructor() {
this.documents = [
...getAllExperiments().map(indexExperiment),
...getAllKnowledgePoints().map(indexKnowledge),
...getReadingArticles().map(indexReading),
...getAllFormulas().map(indexFormula)
]
}
all(): SearchDocument[] {
return this.documents.slice()
}
}
如果内容未来支持编辑或在线更新,再增加显式 rebuild();当前离线静态内容只需页面创建时构建一次。
九、查询归一化要适配化学文本
简单 trim().toLowerCase() 不能处理所有化学输入。用户可能输入 CO2,模型里是 CO₂;可能输入 ->,公式使用 →;也可能插入全角空格。
ts
const SUBSCRIPT_MAP: Record<string, string> = {
'₀': '0', '₁': '1', '₂': '2', '₃': '3', '₄': '4',
'₅': '5', '₆': '6', '₇': '7', '₈': '8', '₉': '9'
}
export function normalizeChemicalText(value: string): string {
let output = value.normalize('NFKC').toLowerCase()
Object.keys(SUBSCRIPT_MAP).forEach((key: string) => {
output = output.split(key).join(SUBSCRIPT_MAP[key])
})
return output
.replace(/→|⟶|=>/g, '->')
.replace(/\s+/g, ' ')
.trim()
}
索引文档在构建时生成归一化字段,查询时只归一化输入一次。
十、不要把字段直接无分隔拼接
现有关键词使用:
ts
keywords: `${item.name}${item.category}${item.description}${item.level}`
字段边界消失后,可能产生跨字段的偶然命中。更稳的文档索引保留各字段,并用空格连接归一化文本:
ts
export interface IndexedSearchDocument extends SearchDocument {
normalizedTitle: string
normalizedCategory: string
normalizedBody: string
normalizedAliases: string[]
}
function compileDocument(doc: SearchDocument): IndexedSearchDocument {
return {
...doc,
normalizedTitle: normalizeChemicalText(doc.title),
normalizedCategory: normalizeChemicalText(doc.category),
normalizedBody: normalizeChemicalText(doc.body),
normalizedAliases: doc.aliases.map(normalizeChemicalText)
}
}
字段结构为相关度评分提供了基础。
十一、相关度评分应能解释
"质量守恒"会同时命中知识点、阅读文章和公式。源数组顺序不等于相关度,可以使用简单、稳定的本地评分:
ts
export function scoreDocument(
doc: IndexedSearchDocument,
normalizedQuery: string
): number {
if (!normalizedQuery) return 0
let score = 0
if (doc.normalizedTitle === normalizedQuery) score += 120
else if (doc.normalizedTitle.startsWith(normalizedQuery)) score += 90
else if (doc.normalizedTitle.includes(normalizedQuery)) score += 70
if (doc.normalizedAliases.some(alias => alias === normalizedQuery)) score += 60
else if (doc.normalizedAliases.some(alias => alias.includes(normalizedQuery))) score += 40
if (doc.normalizedCategory.includes(normalizedQuery)) score += 25
if (doc.normalizedBody.includes(normalizedQuery)) score += 15
return score
}
这不是复杂搜索引擎,却能稳定保证标题精确命中优先于正文偶然命中。
十二、多词查询需要明确规则
用户可能输入"酸碱 反应"或"实验 安全"。可以把归一化查询按空格拆分,要求所有词至少命中一个字段:
ts
function queryTokens(value: string): string[] {
return normalizeChemicalText(value)
.split(' ')
.map(token => token.trim())
.filter(token => token.length > 0)
}
function scoreTokens(doc: IndexedSearchDocument, tokens: string[]): number {
let total = 0
for (const token of tokens) {
const score = scoreDocument(doc, token)
if (score === 0) return 0
total += score
}
return total
}
使用 AND 规则可以减少无关结果。若产品需要更宽松的 OR 搜索,应明确展示命中了哪些词,而不是混合两种语义。
十三、搜索结果需要稳定排序与去重
统一文档 key 已包含类型和 ID,可以在查询阶段去重:
ts
export interface SearchHit {
document: SearchDocument
score: number
}
function sortHits(hits: SearchHit[]): SearchHit[] {
return hits.slice().sort((left, right) => {
if (right.score !== left.score) return right.score - left.score
const titleCompare = left.document.title.localeCompare(right.document.title, 'zh-CN')
return titleCompare !== 0
? titleCompare
: left.document.key.localeCompare(right.document.key)
})
}
分数相同时按中文标题与稳定 key 排序,同一查询不会因数组实例变化而跳动。
十四、可以增加类型筛选,但不要复制查询逻辑
全局结果上方可以提供"全部、实验、知识点、阅读、公式"分段控件。类型筛选只作用于评分后的结果:
ts
export type SearchScope = 'all' | SearchType
function applyScope(hits: SearchHit[], scope: SearchScope): SearchHit[] {
if (scope === 'all') return hits
return hits.filter(hit => hit.document.type === scope)
}
不要为每种类型写一套搜索函数,否则标准化和评分规则会逐渐不一致。

十五、页面状态要区分推荐、结果和空结果
当前关键词为空时显示推荐,有关键词但无匹配时 List 直接空白。建议明确状态:
ts
export type SearchPageMode = 'suggestions' | 'results' | 'empty'
interface SearchViewState {
query: string
scope: SearchScope
mode: SearchPageMode
hits: SearchHit[]
total: number
}
无结果时应展示当前查询和可操作建议,例如"尝试输入 CO2、酸碱、质量守恒",并提供清空按钮。不要让用户面对完全空白页面。
十六、输入响应不必过度防抖
当前只有 38 条静态文档,纯内存评分开销很低,可以在 onChange 中同步查询。只有当正文索引显著增长,或加入拼音、分词后,才需要 100~200ms 防抖。
ts
private updateQuery(value: string): void {
this.keyword = value
this.refreshHits()
}
private refreshHits(): void {
const result = this.searchIndex.search(this.keyword, this.scope, 40)
this.viewState = toSearchViewState(this.keyword, this.scope, result)
}
关键优化是索引只构建一次,而不是盲目加入计时器增加异步复杂度。
十七、结果数量限制要向用户可解释
现有查询使用 .slice(0, 40),当前总数 38,不会截断。但内容扩展后,用户可能不知道还有结果。
ts
export interface SearchResponse {
hits: SearchHit[]
total: number
limited: boolean
}
function limitHits(hits: SearchHit[], limit: number): SearchResponse {
return {
hits: hits.slice(0, limit),
total: hits.length,
limited: hits.length > limit
}
}
页面可以显示"找到 63 项,当前展示前 40 项",并通过类型筛选缩小范围。
十八、导航层只处理 SearchDestination
当前 openResult() 按类型重新查询模型,导航逻辑与索引构建重复。统一目的地后,页面只调用导航器:
ts
export class SearchNavigator {
static open(document: SearchDocument): void {
const allowedRoutes: string[] = [
'views/experiment/ExperimentSimPage',
'views/learning/KnowledgeDetailPage',
'views/learning/ReadingDetailPage',
'views/learning/FormulaPage'
]
if (!allowedRoutes.includes(document.destination.route)) return
router.pushUrl({
url: document.destination.route,
params: document.destination.params
})
}
}
允许列表避免索引数据意外跳到未注册页面。所有目标仍来自本地代码,不接受用户输入的任意 route。
十九、详情页必须验证 ID
搜索命中只是"当时索引存在",详情页打开时仍要重新验证。以知识点为例:
ts
interface KnowledgeRouteParams {
id?: string
}
private loadKnowledge(): void {
const params = router.getParams() as KnowledgeRouteParams | undefined
const item = getKnowledgePointById(params?.id ?? '')
if (!item) {
this.phase = 'not_found'
return
}
this.item = item
this.phase = 'content'
}
公式页、阅读页和实验页都应采用同样的 ID 校验,避免旧索引或错误参数显示成默认内容。
二十、关键词高亮要避免直接拼 HTML
搜索结果可高亮标题中的命中词,但 ArkUI 中不应把用户输入拼进 HTML。可以计算文本片段,再用 Text 与 Span 渲染。
ts
interface HighlightPart {
text: string
matched: boolean
}
function splitHighlight(source: string, query: string): HighlightPart[] {
const normalized = normalizeChemicalText(query)
if (!normalized) return [{ text: source, matched: false }]
const index = normalizeChemicalText(source).indexOf(normalized)
if (index < 0) return [{ text: source, matched: false }]
return [
{ text: source.slice(0, index), matched: false },
{ text: source.slice(index, index + query.length), matched: true },
{ text: source.slice(index + query.length), matched: false }
].filter(part => part.text.length > 0)
}
对于 Unicode 归一化后长度变化的情况,可以只在原始标题直接包含查询时高亮,其余命中保持普通文本,避免错误切片。
二十一、验证查询覆盖四种内容
本地索引适合用固定数据做确定性测试:
ts
const index = new LocalSearchIndex()
const hits = index.search('质量守恒', 'all', 40)
console.info(hits.map(hit => `${hit.document.type}:${hit.document.id}`).join(','))
| 查询 | 预期首要类型 | 关键验证 |
|---|---|---|
质量守恒 |
知识点或公式 | 标题精确命中优先 |
CO2 |
公式、实验、阅读 | 能匹配 CO₂ |
酸碱 反应 |
实验、知识点 | 两个词都必须命中 |
试管口 |
阅读 | sections 正文参与索引 |
PV=nRT |
公式 | 空白与符号归一化 |
电解水 |
实验、知识点、公式 | 多类型结果稳定排序 |
| 空字符串 | 四类推荐 | 每类都有露出 |
| 不存在词 | 空结果 | 显示建议与清空动作 |
二十二、页面与多设备验证
搜索页使用 List 和 layoutWeight(1),基础布局适合长结果。还需要检查:
- 键盘弹出后结果列表是否仍可滚动。
- 小窗口下类型标签、标题和箭头是否互相挤压。
- 长公式是否允许两行显示,而不是全部截断。
- 大字体下搜索框占位和返回按钮是否完整。
- 深色模式下输入框背景、类型标签和正文对比度是否合格。
- 2in1 设备使用 Enter 时能否打开首个结果或收起键盘。
结果行应保持稳定最小高度,但不要用固定高度裁掉长公式。
二十三、常见故障与排查顺序
| 现象 | 优先检查 | 典型原因 | 修复方向 |
|---|---|---|---|
| 默认推荐全是实验 | 推荐配额 | 直接截取聚合数组前 12 项 | 按类型均衡取样 |
CO2 搜不到 CO₂ |
文本归一化 | 上下标字符不同 | 统一化学字符 |
| 正文命中排在标题前 | 评分规则 | 只做布尔过滤 | 按字段赋权 |
| 点击公式后找不到目标 | 路由参数 | 只跳公式列表 | 携带 formulaId 并定位 |
| 阅读正文搜不到 | 索引字段 | 未加入 sections | 聚合章节标题和正文 |
| 同一查询顺序跳动 | 排序后备规则 | 同分时没有稳定 key | 分数、标题、key 三级排序 |
| 无结果页面空白 | 页面模式 | List 为空但无 empty state | 明确空结果与建议 |
| 输入卡顿 | 索引构建 | 每次渲染重建所有文档 | 页面初始化时编译一次 |
排查顺序应是查询归一化、文档字段、单词评分、类型筛选、数量限制、目的地参数,最后才看列表样式。
二十四、建议的落地顺序
- 新建
SearchDocument与四个类型转换函数。 - 页面初始化时构建一次
LocalSearchIndex。 - 增加化学文本归一化和别名字段。
- 用字段权重替换简单
indexOf过滤。 - 空查询改为四种类型均衡推荐。
- 加入稳定排序、复合键去重和结果总数。
- 公式结果携带
formulaId,详情页按 ID 定位。 - 知识点跳转收敛为 ID,详情页重新读取事实源。
- 补齐推荐、结果、空结果和非法目标状态。
- 执行化学符号、多词查询、键盘与多设备验证。
先解决索引契约和导航身份,再增加高亮、历史搜索或拼音功能,改动会更可控。
二十五、源码事实与建议实现的边界
本地搜索的讨论很容易把"能通过统一索引实现"写成"当前页面已经具备"。下面把本轮打开源码后可确认的行为与本文建议分开,便于按文件逐项复核。
| 类别 | 当前源码可直接确认 | 本文建议但未声称已合入 |
|---|---|---|
| 内容规模 | 13 个实验、6 个知识点、4 篇阅读、15 条公式,共 38 项 | 为内容扩展增加显式索引重建机制 |
| 聚合时机 | filteredItems() 每次调用都会重新执行 allItems() |
页面初始化时构建一次 LocalSearchIndex |
| 空关键词 | 直接返回聚合数组前 12 项,因此当前全部来自实验 | 按四种类型设置明确推荐配额 |
| 匹配规则 | trim().toLowerCase() 后对拼接字符串执行 indexOf() |
化学字符归一化、多词规则和字段权重评分 |
| 阅读字段 | 当前只索引标题、分类、摘要和 keywords |
将 sections 的标题与正文加入只读索引 |
| 列表身份 | ForEach 使用 ${type}_${id} 作为键 |
继续使用稳定复合键并在查询层去重 |
| 结果数量 | 非空查询最多截取 40 项 | 同时返回总数和是否截断 |
| 实验跳转 | 携带 expId 与 expName 进入模拟页 |
详情页以 ID 重新读取事实源 |
| 知识点跳转 | 当前把 ID、标题、分类、摘要和正文都放入路由参数 | 收敛为只传 ID,并验证目标是否存在 |
| 阅读跳转 | 当前携带阅读 ID 进入详情页 | 保持 ID 契约并补齐非法目标状态 |
| 公式跳转 | 当前只打开 FormulaPage,没有携带公式 ID |
携带 formulaId 并定位或高亮目标 |
| 页面状态 | 无结果时列表为空,没有专门空状态 | 区分推荐、结果、空结果和非法目标 |
文中的 SearchDocument、相关度评分、类型配额、化学文本归一化和 SearchNavigator 都是基于当前断点提出的设计,不代表这些类型已经存在于工程,也没有声称完成 ArkTS 编译、性能测试或真机键盘验证。实现后仍需按第二十一、二十二节保存查询结果、跳转结果和多设备验证证据。
总结
一个可靠的本地全局搜索,不是把字符串连接得更长,而是把不同业务内容编译成统一、可解释的搜索文档。标题、分类、正文和别名保持字段边界,查询经过化学字符归一化,结果按相关度与稳定规则排序,点击时使用 type + id 直达真实内容。
完成这套结构后,38 条离线内容即使继续扩展,也不会因数组顺序导致推荐偏斜;CO2、CO₂、方程式箭头和多词查询能得到一致结果;知识点、阅读、公式和实验都能从搜索页进入正确目标。搜索索引只是业务模型的只读投影,内容更新、页面渲染和导航职责也因此保持清晰。
AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。