思维导图的搜索功能看似简单,输入一个关键词,命中的节点高亮显示,可一旦深入下去就会发现:节点标题往往需要分段渲染,匹配的区间需要精确定位,折叠的子树中命中了关键词时父节点要不要展示,这些都直接影响数据类型的设计。类型定义是这类功能的骨架,骨架没搭好,后面写渲染逻辑会处处是any。下面我们从头梳理这套高亮数据类型该怎么定义。

一、基础节点类型与高亮片段的建模
先定义思维导图节点本身。思维导图是典型的树形结构,用递归的interface就能描述清楚,节点包含唯一id、标题、子节点数组,再加上一些渲染所需的字段。
interface MindNode {
id: string;
title: string;
children: MindNode[];
collapsed?: boolean; // 是否折叠
note?: string; // 备注,也可能参与搜索
}
高亮的核心问题是:节点标题被关键词切分后,会产生若干文本片段,有的命中需要高亮,有的不命中正常显示。很多初学者会用一个boolean字段标记整个节点是否命中,这种做法在渲染时还得重新算一次字符串切割,等于把计算逻辑从数据层推到了视图层,容易写出重复代码。
更合理的做法是预先算好片段数组。每个片段要么是普通文本,要么是高亮片段,用可辨识联合最合适:
// 片段类型:普通文本 或 命中高亮
type HighlightSegment =
| { kind: 'text'; content: string }
| { kind: 'highlight'; content: string; range: [number, number] };
interface HighlightedNode {
nodeId: string;
segments: HighlightSegment[]; // 标题按命中切分后的片段
hitCount: number; // 命中次数,可用于排序
}
这里的kind字段就是判别依据,渲染时用switch或类型守卫就能在编译期保证不会漏处理某种分支。range记录的是片段在原始标题中的起止下标,虽然渲染时未必用得上,但对后续做跳转到下一个命中项这类功能是必须的。
二、搜索上下文与筛选状态的类型设计
光有片段还不够。搜索时的整体上下文状态,包括关键词本身、搜索结果集合、以及哪些节点因为子孙命中而需要保持可见,这些都要纳入类型。折叠状态下,如果某个被折叠的子树里有命中节点,通常的做法是让它的祖先链全部保持展开,否则用户搜到了却看不见,等于没搜。
可以定义一个搜索状态类型来承载这些信息:
interface SearchContext {
keyword: string;
results: Map<string, HighlightedNode>; // nodeId 到高亮数据的映射
visibleAncestors: Set<string>; // 因子孙命中而需保持可见的祖先
activeIndex: number; // 当前聚焦的命中项
status: 'idle' | 'searching' | 'done';
}
用Map而不是普通对象做结果容器,好处是nodeId可能是任意字符串(包括原型链上的属性名),Map不会有键名冲突的隐患,查找复杂度也是稳定的。visibleAncestors用Set同理,判断某个节点是否需要展开时一步就能查到。
status字段用字符串字面量联合而非枚举,这是现代TypeScript的常见取舍:字面量联合在编译后是零开销的,且配合判别式访问检查非常自然。如果团队规模大、状态会跨包复用,也可以考虑用const对象加派生类型的方式,效果等价且不需要导入运行时对象。
三、用类型守卫与工具函数保证数据安全
类型定义好之后,切分标题的核心函数也要有严谨的类型签名。切分逻辑输入标题和关键词,输出片段数组,注意关键词为空时要返回整段文本的单一片段:
function splitTitle(title: string, keyword: string): HighlightSegment[] {
if (!keyword) {
return [{ kind: 'text', content: title }];
}
const segments: HighlightSegment[] = [];
let last = 0;
const lower = title.toLowerCase();
const kw = keyword.toLowerCase();
let idx = lower.indexOf(kw);
while (idx !== -1) {
if (idx > last) {
segments.push({ kind: 'text', content: title.slice(last, idx) });
}
segments.push({
kind: 'highlight',
content: title.slice(idx, idx + kw.length),
range: [idx, idx + kw.length],
});
last = idx + kw.length;
idx = lower.indexOf(kw, last);
}
if (last < title.length) {
segments.push({ kind: 'text', content: title.slice(last) });
}
return segments;
}
再配一个类型守卫,渲染层拿到片段时可以精确收窄类型:
function isHighlight(s: HighlightSegment): s is Extract<HighlightSegment, { kind: 'highlight' > {
return s.kind === 'highlight';
}
// 使用示例:统计某节点高亮字符总数
function totalHighlightLength(segments: HighlightSegment[]): number {
return segments
.filter(isHighlight)
.reduce((sum, s) => sum + (s.range[1] - s.range[0]), 0);
}
有了is谓词守卫,filter之后TS能自动把数组类型收窄为高亮片段,不需要断言。递归遍历整棵树生成SearchContext时,建议同时维护一个父节点映射,这样标记visibleAncestors只需沿父链回溯,整体复杂度是命中节点数乘以树深,对几千个节点的导图完全够用。
四、结合框架渲染与性能上的几点建议
类型落到渲染层时,React中可以把HighlightedNode直接作为组件props,片段渲染用一个小的map即可。Vue项目同样适用,只不过把状态放进响应式对象时,建议用shallowRef包住整棵搜索结果,避免深层响应式带来的开销。
性能上有两点值得注意。一是搜索输入要做防抖,300毫秒左右比较合适,避免每个按键都触发全树遍历;二是关键词变化时优先复用已有的树结构引用,只重建SearchContext,不要为了搜索去复制整棵节点树,深拷贝大树的开销在低配设备上会明显卡顿。
另外,如果后续要做结果面板跳转,activeIndex配合results的插入顺序即可实现,把命中节点按深度优先顺序塞进一个数组,跳转时按索引取值并让视图滚动定位。这一套类型定义预留了range和hitCount字段,扩展时基本不用推翻重来,这也是前期把类型设计做扎实的价值所在。
TypeScript数据类型思维导图节点高亮修改时间:2026-09-07 00:58:46