思维导图本质是一棵可以无限嵌套的树,节点上挂着标题、标签、备注等各种可搜索的文本信息。当节点数量达到几千甚至上万个时,如果每次搜索都遍历整棵树,用户每敲一个关键字就要全树扫描一次,卡顿感会非常明显。比较成熟的做法是为节点建立搜索索引,但索引一旦建立,就引出了新问题:节点被增删改之后,索引什么时候更新、怎么更新才不会出错。用TypeScript来写这套逻辑时,如果把索引的构建方式与更新策略用类型系统建模清楚,很多运行期才会暴露的bug在编译阶段就能被发现。本文围绕这个主题,展开讲讲类型设计上的具体做法。

一、先定义可索引的节点模型与索引条目类型
类型设计的第一步是把“什么数据可以被索引”说清楚。思维导图节点通常包含一个唯一标识、若干文本字段和子节点列表。我们先给出一个基础但够用的节点定义:
interface MindNode {
id: string;
title: string;
note?: string;
tags: string[];
children: MindNode[];
}
这个定义里最关键的是id字段,它是索引条目与节点之间的唯一关联键。索引本身不应该直接引用节点对象,而是引用节点的id,这样节点对象被替换或移动时索引不会持有过期的对象引用,只需要在真正需要取节点时再按id回查。索引条目的类型可以这样设计:
interface SearchIndexEntry {
nodeId: string;
// 关键字到节点的映射核心:分词结果,小写化便于不区分大小写匹配
tokens: ReadonlySet<string>;
// 该条目索引了哪些字段,便于按字段过滤搜索
fields: ReadonlyArray<'title' | 'note' | 'tags'>;
// 条目版本号,用于增量更新时判断新旧
version: number;
}
type NodeIndex = ReadonlyMap<string, SearchIndexEntry>;
这里有两个值得注意的类型选择。第一,ReadonlySet和ReadonlyMap明确告诉调用方“你不应该直接改索引内部结构”,任何修改都必须走专门的更新函数。第二,fields用字面量联合类型限制了可选值,将来如果节点新增了可搜索字段(比如高亮备注),只需要扩展这个联合类型,所有用到fields的地方都会被编译器逐一检查是否遗漏处理。
二、三种更新策略的类型化建模
索引怎么更新,直接决定了搜索体验和编辑性能的平衡。常见的策略有三种:全量重建、增量更新、脏标记延迟更新。它们的类型定义可以统一抽象为一个策略接口,再分别派生出具体实现。
全量重建策略
全量重建最简单粗暴:树一变,索引整个扔掉重算。它的类型定义只需要一个入口函数:
interface RebuildStrategy {
readonly kind: 'rebuild';
// 接收根节点数组,返回全新索引
build: (roots: readonly MindNode[]) => NodeIndex;
}
这种策略适合节点数在一千以内、编辑操作频繁但单次重建耗时可接受的场景。类型上没有任何中间状态,出错概率最低。缺点也明显:树大的时候每次编辑都要停顿一下重建,体验不好。可以在类型层面给它加一个约束,比如用一个泛型参数限制节点规模,超出阈值时编译器提醒改用增量策略,虽然只是软约束,但能在团队协作中起到提示作用。
增量更新策略
增量更新只在节点发生变化时更新对应的索引条目。它需要明确区分三种变更事件,用可辨识联合来建模最合适:
type NodeChangeEvent =
| { type: 'add'; node: MindNode; parentPath: string[] }
| { type: 'update'; nodeId: string; node: MindNode }
| { type: 'remove'; nodeId: string };
interface IncrementalStrategy {
readonly kind: 'incremental';
initialBuild: (roots: readonly MindNode[]) => NodeIndex;
applyEvent: (index: NodeIndex, event: NodeChangeEvent) => NodeIndex;
// 事件合并,把连续的小变更合成一次索引写入
coalesce: (events: readonly NodeChangeEvent[]) => readonly NodeChangeEvent[];
}
可辨识联合的好处在于,处理方在对event做switch收窄时,TypeScript会强制每个分支访问各自的合法字段。比如在add分支访问nodeId会直接报错,因为该分支只有node和parentPath。这种由编译器担保的事件类型边界,比手写if判断可靠得多。另外注意applyEvent返回的是新的NodeIndex而不是原地修改,这是为了配合不可变数据流,方便撤销重做和状态回放。
脏标记延迟更新策略
介于两者之间的是脏标记策略:编辑时只给受影响的节点id打标记,等用户真正触发搜索或空闲时才批量更新。它的类型需要引入一个脏集合:
interface DirtyTrackingStrategy {
readonly kind: 'dirty-tracking';
markDirty: (nodeId: string) => void;
isDirty: (nodeId: string) => boolean;
// 搜索前的强制刷新,保证结果一致
flush: (index: NodeIndex, lookupNode: (id: string) => MindNode | undefined) => NodeIndex;
}
lookupNode这个回调参数的设计意图是把策略与树的存储方式解耦。无论你的树存在内存里还是靠虚拟化组件延迟渲染,策略本身只关心“给我id,我还你节点”。这个函数签名里返回值带undefined也很重要,它强制调用方处理节点已被删除但脏标记残留的边界情况,避免运行时抛异常。
三、用联合策略与防御性收窄守住一致性
实际项目里往往不会只用一种策略,比如节点少于五百时全量重建,超过后切换到增量更新。这时可以定义一个联合策略类型,把前面的三种策略组合起来:
type IndexStrategy = RebuildStrategy | IncrementalStrategy | DirtyTrackingStrategy;
function handleEvent(strategy: IndexStrategy, event: NodeChangeEvent, index: NodeIndex): NodeIndex {
switch (strategy.kind) {
case 'rebuild':
// 全量策略忽略事件细节,直接重建即可
return strategy.build(currentRoots());
case 'incremental':
return strategy.applyEvent(index, event);
case 'dirty-tracking':
event.type !== 'remove' && strategy.markDirty(
event.type === 'add' ? event.node.id : event.nodeId
);
return index;
}
}
这段代码展示了kind字段作为判别式的完整用法:switch之后每个分支里,TypeScript都自动收窄到具体策略类型,访问不存在的属性会编译报错。最后再补充两个实践建议。一是给索引对外暴露的搜索接口加上严格的入参类型,比如搜索结果里除了节点id还应该带上命中的字段与高亮位置,方便前端渲染:
interface SearchResultItem {
nodeId: string;
matchedField: 'title' | 'note' | 'tags';
snippet: string;
score: number;
}
interface SearchService {
search: (query: string, options?: { fields?: readonly SearchIndexEntry['fields'][number][] }) => readonly SearchResultItem[];
}
二是所有公开的类型尽量标成readonly或Readonly变体,把可变操作收敛到少数几个内部函数里。这样一来,索引的构建与更新全部走类型声明的正规通道,任何试图绕过策略直接改索引的代码都会在编译期被拦下,思维导图在大量节点下的搜索体验也就有了稳定可靠的类型基础。
TypeScript类型定义思维导图节点搜索索引更新策略修改时间:2026-09-11 18:50:36