在开发思维导图组件时,搜索高亮是一个典型的复合需求:用户输入关键词后,系统需要遍历所有节点找到匹配项,计算命中片段在节点文本中的起止位置,把匹配结果组织成分页数据,再交给虚拟滚动层按需渲染。如果类型定义不够严谨,很容易出现命中区间越界、分页游标错乱、可视区域计算偏差等运行时问题。本文将用TypeScript的泛型和工具类型,自顶向下地完成整套类型建模。

一、思维导图节点与搜索匹配结果的基础类型
首先定义思维导图节点的基本结构。思维导图是树形结构,节点之间存在父子关系,所以类型中需要包含id、parentId以及子节点列表。为了让搜索支持不同数据形态,我们把节点标签设计为泛型参数,这样既能容纳纯文本节点,也能容纳富文本节点。
interface MindNodeBase {
id: string;
parentId: string | null;
children: MindNodeBase[];
}
interface MindMapNode<T = string> extends MindNodeBase {
/** 节点展示数据,默认为纯文本 */
label: T;
/** 节点在画布中的布局信息,虚拟滚动会用到 */
layout: NodeLayout;
}
interface NodeLayout {
x: number;
y: number;
width: number;
height: number;
depth: number; // 节点层级,从0开始
}接下来是搜索匹配结果的核心类型。高亮渲染的关键在于命中区间:一段文本中关键词可能出现多次,所以匹配结果必须是一个区间数组,每个区间记录起止索引。这里建议遵循[start, end)的左闭右开约定,与String.prototype.slice的语义保持一致,能避免大量差一错误。
/** 左闭右开区间 [start, end) */
interface MatchRange {
start: number;
end: number;
}
/** 关键词在单个匹配结果上的命中描述 */
interface MatchHitInfo {
keyword: string;
ranges: MatchRange[];
/** 高亮片段的总数,等于 ranges.length */
totalHits: number;
}
interface SearchResultItem<T = string> {
/** 命中的节点 */
node: MindMapNode<T>;
/** 命中详情,可能同时在标题和备注中命中 */
hits: {
label: MatchHitInfo | null;
note: MatchHitInfo | null;
};
/** 匹配得分,用于排序 */
score: number;
}值得注意的细节是hits字段把label和note分开建模,并允许为null。这样做的好处是渲染层可以明确知道命中发生在哪个字段,而不是把所有区间混在一个数组里靠猜。score字段则为后续的排序策略(比如层级浅的节点优先)预留了空间。
二、匹配结果的分页类型设计
当匹配结果成百上千条时,一次性渲染下拉列表会带来明显的卡顿,分页是必然选择。分页类型要同时覆盖两种模式:传统的页码分页,以及搜索场景更常用的游标分页。用联合类型加判别字段(discriminated union)来建模,可以让TypeScript在编译期强制你先判断分页模式再访问对应字段。
type PaginationState =
| { mode: 'page'; pageIndex: number; pageSize: number; total: number }
| { mode: 'cursor'; cursor: string | null; nextCursor: string | null; hasMore: boolean };
interface PaginatedResults<T = string> {
items: SearchResultItem<T>[];
pagination: PaginationState;
/** 当前激活(被选中定位)的结果索引,用于键盘上下键切换 */
activeIndex: number;
}
/** 工具类型:根据分页模式提取对应字段 */
type PageMeta<P extends PaginationState> = P extends { mode: 'page' }
? { totalPages: number }
: { fetchMore: () => Promise<PaginatedResults> };判别联合的优势在消费端体现得最明显。当你写下if (pagination.mode === 'page')之后,TypeScript会自动收窄类型,直接访问pageIndex不会有任何报错;反之在cursor分支访问pageIndex则会直接编译失败。这种约束把一类低级的运行时错误彻底消灭在编辑器里。
分页还需要处理边界情况:搜索关键词变化时activeIndex必须重置为0,翻页时activeIndex要在当前页范围内循环。建议在类型层面就把状态机表达出来,例如用一个SearchPhase联合类型描述idle | searching | success | empty | error五种阶段,渲染层据此切换UI状态,避免用布尔值组合出非法状态。
三、虚拟滚动视口与可视区域计算的类型
虚拟滚动的本质是:给定总内容高度和视口高度,计算出当前应该渲染哪些节点。思维导图与普通列表不同,它是二维画布,横向和纵向都可能滚动,所以类型要同时支持两个方向的偏移量。另外节点高度不一(折叠状态、富文本节点高度不同),类型上必须允许非均匀行高。
interface Viewport {
width: number;
height: number;
scrollLeft: number;
scrollTop: number;
}
interface VirtualScrollState {
viewport: Viewport;
/** 实际渲染的节点索引范围 */
visibleRange: {
startIndex: number;
endIndex: number;
/** 上下缓冲区行数,防止快速滚动时白屏 */
overscan: number;
};
/** 总画布尺寸,撑开滚动容器用 */
contentSize: { width: number; height: number };
/** 可视区内的匹配结果,用于判断是否需要滚动定位 */
visibleMatchIds: Set<string>;
}
/** 计算可视范围的核心函数签名 */
type ComputeVisibleRange = (
nodes: MindMapNode[],
viewport: Viewport,
overscan: number
) => { startIndex: number; endIndex: number };这里有一个容易踩的坑:visibleMatchIds使用Set<string>而不是数组,因为可视区判定是高频操作(每次滚动都要执行),Set的has查找是O(1),而数组是O(n)。类型选择本身就是性能设计的一部分。
搜索定位与虚拟滚动还需要一个联动类型。当用户在结果列表中点击某一条时,需要让画布滚动到目标节点,这要求一个ScrollToMatchOptions类型,描述目标节点的布局信息、期望的对齐方式(居中或顶部对齐)以及是否需要动画。把这个类型定义清楚后,滚动函数的参数就不会随着需求迭代而变得混乱。
interface ScrollToMatchOptions {
targetId: string;
align: 'center' | 'start' | 'end';
smooth: boolean;
/** 回调:滚动完成后触发,用于设置高亮闪烁动画 */
onArrived?: (node: MindMapNode) => void;
}四、高亮渲染片段的类型推导
最后回到高亮本身。渲染层拿到MatchHitInfo后,需要把原始文本切分成普通片段和高亮片段交替的序列。这个片段序列的类型可以直接用类型推导自动生成,减少手写重复类型的负担。
interface HighlightSegment {
text: string;
isMatch: boolean;
}
type SegmentedLabel = HighlightSegment[];
function segmentLabel(label: string, ranges: MatchRange[]): SegmentedLabel {
const segments: SegmentedLabel = [];
let cursor = 0;
for (const { start, end } of ranges) {
if (start > cursor) {
segments.push({ text: label.slice(cursor, start), isMatch: false });
}
segments.push({ text: label.slice(start, end), isMatch: true });
cursor = end;
}
if (cursor < label.length) {
segments.push({ text: label.slice(cursor), isMatch: false });
}
return segments;
}这个函数与MatchRange的左闭右开约定严格配合:区间之间允许有间隙(普通文本),区间必须按升序排列且互不重叠。如果想更进一步,可以在开发环境加一个类型守护函数isValidRanges,校验区间合法性后再进入渲染流程,把脏数据挡在渲染层之外。
综合来看,这套类型体系的核心思路是:用泛型隔离数据形态,用判别联合约束状态组合,用工具类型收窄访问路径,用语义化的区间类型杜绝索引越界。类型定义完善之后,搜索高亮、分页和虚拟滚动三个模块之间的数据流转就有了明确的契约,后续无论是替换搜索引擎还是优化滚动性能,都不需要担心接口层面的回归问题。
TypeScript类型定义虚拟滚动思维导图搜索高亮修改时间:2026-08-31 12:13:03