思维导图节点的数据建模最容易被简化成 children 数组加一个 expanded 布尔值。早期确实能跑通,但一旦加入懒加载、异步拉取子节点、折叠状态持久化,类型层面就会出现很多无法区分的组合。比如展开的节点到底有没有加载完子节点?折叠的节点是否允许保留 children?这些问题不该留给运行时判断,而应该交给类型系统约束。下面从节点接口的基础设计开始,逐步构建支持展开折叠状态的 TypeScript 类型。

一、为什么 expanded 布尔值不够用
最直观的做法是在节点接口里放一个 expanded 布尔字段,再搭配一个可选的 children 数组。这样写起来简单,读起来也直观,但它把多个维度的状态混在了一起。下面这种定义在小型项目里经常出现:
interface MindMapNode {
id: string;
label: string;
expanded: boolean;
children?: MindMapNode[];
}
这段代码的问题在于,expanded 只描述用户是否点击了展开按钮,而 children 是否存在则取决于数据是否已经从服务端加载。两者组合之后会出现四种情况,但其中有两种会制造歧义:expanded=true 且 children=undefined 通常表示子节点还没加载,此时需要触发异步请求;expanded=false 且 children 存在则表示节点折叠但子节点数据仍然保留在内存中。仅靠一个布尔值,读取方无法直接判断是否需要请求接口、是否可以渲染子树,只能通过额外的环境判断,类型安全也随之下降。
更有风险的情况是节点处于加载中。此时既不是展开也不是折叠,UI 上需要展示旋转图标或占位符,但布尔值无法表达这个第三种状态。于是开发者往往会再加一个 loading 布尔字段,或者直接把 expanded 和 loading 混用,最终导致状态判断散落在组件各处。要根治这个问题,应当把节点的加载状态和展开折叠状态放到同一个可辨识联合中表达。
二、用可辨识联合区分加载状态与展开状态
可辨识联合的核心思路是给节点增加一个字面量类型字段,例如 state,用来区分节点当前处于未加载、加载中、已展开、已折叠中的哪一种。不同于平行布尔字段,这种设计让 TypeScript 在访问 children 之前必须先收窄 state,从而避免读取不存在的属性。
先定义一个基础节点结构,只包含所有节点都有的 id 和 label。然后让不同状态通过接口继承添加自己的专属字段。例如展开节点必须包含 children,折叠节点也可以保留 children,而加载中的节点则完全不需要该属性。示例代码如下:
interface BaseNode {
id: string;
label: string;
}
interface ExpandedNode extends BaseNode {
state: 'expanded';
children: MindMapNode[];
}
interface CollapsedNode extends BaseNode {
state: 'collapsed';
children: MindMapNode[];
}
interface UnloadedNode extends BaseNode {
state: 'unloaded';
}
interface LoadingNode extends BaseNode {
state: 'loading';
}
type MindMapNode = ExpandedNode | CollapsedNode | UnloadedNode | LoadingNode;
这种写法把展开折叠当作一个显式状态,而不是一个附着在其他字段上的布尔标记。展开节点和折叠节点都持有 children,这意味用户折叠一个已经加载过的节点时,子节点数据仍然保留,重新展开无需再次请求。如果业务希望折叠时释放子节点以节省内存,则可以在折叠操作里把节点转换为 unloaded 状态,完全由类型系统约束状态迁移。
读取节点状态时,TypeScript 会根据 state 的值自动收窄类型。例如判断 node.state === 'expanded' 之后,编译器就知道当前节点一定有 children 数组;判断 node.state === 'loading' 之后,则不会允许访问 children。这样能提前暴露很多潜在的空引用错误,也省去了大量手写非空断言。
三、递归类型让子节点同样遵守状态约束
思维导图是典型的树形结构,节点类型中 children 必须指向同一种节点类型。TypeScript 的类型别名允许通过数组间接引用自身,因此可以把 MindMapNode 作为递归类型使用。递归类型并不会降低可读性,反而保证了任意层级上的子节点都必须遵守相同的状态约束。
比如在渲染组件中递归渲染节点时,可以安全地根据 state 决定显示内容:展开节点渲染子树,折叠节点只渲染折叠标记,未加载节点渲染占位符并触发加载逻辑,加载中的节点显示转圈动画。每一层节点都经过同样的类型检查,避免了深层节点状态处理不一致的问题。
如果后续需要给节点增加更多元信息,例如节点深度、父节点引用、拖拽状态等,可以在 BaseNode 中添加可选字段,而不必改动每个联合成员。由于所有状态接口都继承自 BaseNode,公共字段只维护一份,后续扩展会更加轻松。递归类型配合继承能够保持节点结构清晰,也让接口在项目变大时依旧易于维护。
四、编写类型安全的切换与收集函数
定义好节点类型之后,常见的操作就是切换某个节点的展开折叠状态。由于状态已经集中在 state 字段中,切换逻辑可以写得很集中。实现不可变更新时,可以通过递归查找目标节点,找到后根据当前状态创建新的节点对象,其他节点保持原引用。下面是一个基于递归的切换函数:
function toggleNode(node: MindMapNode, targetId: string): MindMapNode {
if (node.id === targetId) {
if (node.state === 'expanded') {
return { ...node, state: 'collapsed' } as MindMapNode;
}
if (node.state === 'collapsed') {
return { ...node, state: 'expanded' } as MindMapNode;
}
return node;
}
if (node.state === 'expanded' || node.state === 'collapsed') {
return {
...node,
children: node.children.map((child) => toggleNode(child, targetId))
};
}
return node;
}
这个函数只处理目标节点的状态切换,不会影响其他兄弟节点。对于未加载或加载中的目标节点,直接返回原对象,因为它还不能进行展开折叠操作。递归过程中,展开节点和折叠节点的 children 都会被遍历,而 unloaded 和 loading 节点则不会进入递归逻辑,这也符合它们没有子节点数据的语义。
另一个常见需求是收集所有处于展开状态的节点,用于保存当前思维导图的视图状态。可以利用状态收窄判断当前节点是否展开,再递归处理子节点。示例函数如下:
function collectExpandedIds(node: MindMapNode, result: string[] = []): string[] {
if (node.state === 'expanded') {
result.push(node.id);
node.children.forEach((child) => collectExpandedIds(child, result));
} else if (node.state === 'collapsed') {
result.push(node.id);
}
return result;
}
这里的 result 数组通过引用累计节点 ID,遍历结束后即可得到展开节点的完整列表。得益于 state 字段,函数中不需要写任何关于 children 是否为 undefined 的防御性判断,代码更紧凑也更可靠。
五、反序列化环节用类型守卫保证数据合法
思维导图数据往往要保存到服务端,再在下次打开时恢复。从接口拿到的 JSON 通常是 any 类型,如果直接断言为 MindMapNode,类型系统不会帮你检查字段是否完整、状态是否合法。这时需要编写类型守卫,在运行时确认数据确实符合节点状态联合的定义。
类型守卫可以从最基础的字段开始检查,例如 id 和 label 是否都是字符串,state 是否是四个合法值之一。对于展开和折叠节点,还要确认 children 是数组,并递归验证每个子节点。下面是一个完整示例:
function isMindMapNode(value: unknown): value is MindMapNode {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as { id?: unknown; label?: unknown; state?: unknown; children?: unknown };
if (typeof candidate.id !== 'string' || typeof candidate.label !== 'string') {
return false;
}
const state = candidate.state;
if (state === 'unloaded' || state === 'loading') {
return true;
}
if (state === 'expanded' || state === 'collapsed') {
if (!Array.isArray(candidate.children)) {
return false;
}
return candidate.children.every((child) => isMindMapNode(child));
}
return false;
}
在从服务端加载数据时,先调用 isMindMapNode 验证,通过后再把数据交给渲染层。这样做不仅能防止脏数据进入组件,还能让后续的类型收窄更加可靠。若项目已经使用 zod、io-ts 等运行时校验库,也可以根据同一个联合类型生成校验器,但手写守卫对于中小项目已经足够。
把展开折叠状态建模为可辨识联合,并通过递归类型应用到整棵思维导图,解决的不只是类型标注问题,更是状态流转的约束问题。配合类型守卫和不可变更新函数,前端状态管理会少很多隐性的空值判断和错误分支,维护成本也会明显下降。
TypeScript思维导图节点展开折叠状态修改时间:2026-10-02 13:58:17