做后台管理系统的同学几乎都绕不开多级导航菜单这个需求。产品经理说菜单要支持三级,过了两周又说某些模块要加到四级、五级,如果类型定义是按固定层级写死的,改起来会非常痛苦。其实TypeScript的类型系统天然支持递归引用,只要让类型自己引用自己,就能定义出支持无限层级的菜单结构,无论数据嵌套多深都能获得完整的类型提示和校验。

一、用interface定义自引用的菜单项类型
递归类型的核心思路是:一个类型的某个属性,引用这个类型本身。以菜单项为例,每个菜单项有一个可选的children属性,它的类型是一个数组,数组元素又是这个菜单项类型本身。这样就形成了自引用的结构,理论上可以无限嵌套下去。
interface MenuItem {
/** 菜单唯一标识 */
id: string;
/** 菜单显示名称 */
title: string;
/** 路由地址,叶子节点才有 */
path?: string;
/** 图标名称,可选 */
icon?: string;
/** 子菜单,类型指向自身,实现无限嵌套 */
children?: MenuItem[];
}
用interface定义递归类型是最常见的方式,因为接口的成员声明本身就是惰性求值的,直接写children?: MenuItem[]不会有任何问题。如果换成type别名,写法几乎一样:type MenuItem = { id: string; title: string; children?: MenuItem[] },同样合法。二者的区别在于interface可以被重复声明合并,适合在项目里逐步扩展字段,而type配合联合类型和交叉类型更灵活。
有一点需要注意:递归类型必须至少有一个属性是可选的,或者在引用自身时经过数组、Promise等容器包装。如果写成children: MenuItem这种直接自引用且必填的形式,TypeScript会提示类型实例化过深,因为编译器无法确定这个无限结构何时终止。通过数组包装后,递归发生在元素层面,类型检查器可以正常处理。
二、强化类型安全:泛型、只读与字面量推导
基础的MenuItem能满足大部分场景,但在大型项目里还可以进一步收紧约束。比如给菜单数据加上只读修饰,防止运行时被意外修改;或者引入泛型,让不同系统在同一套菜单骨架上扩展自己的元数据字段。
interface BaseMenuItem<Meta = Record<string, unknown>> {
readonly id: string;
readonly title: string;
readonly path?: string;
readonly children?: ReadonlyArray<BaseMenuItem<Meta>>;
/** 业务扩展字段 */
readonly meta?: Meta;
}
/** 具体业务菜单:附带权限标识 */
interface MenuMeta {
permission: string;
hidden?: boolean;
}
type BizMenuItem = BaseMenuItem<MenuMeta>;
这里用了ReadonlyArray代替普通数组,配合每个字段前的readonly,整棵菜单树在类型层面就是不可变的。当某段代码试图执行menu.children.push(...)时,编译器会直接报错,这对菜单这种配置性质的数据非常有价值。泛型参数Meta默认是宽松的Record<string, unknown>,业务侧传入具体类型后,扩展字段也能获得完整的提示。
另一个实用技巧是配合as const和satisfies使用。静态菜单数据如果用as const声明,所有字符串都会被推导为字面量类型,路径拼写错误在编译期就能暴露;而satisfies可以在保留字面量推导的同时校验数据形状,比单纯的类型注解更精细。
const menus = [
{
id: 'system',
title: '系统管理',
children: [
{ id: 'user', title: '用户管理', path: '/system/user' },
{ id: 'role', title: '角色管理', path: '/system/role' },
],
},
] satisfies ReadonlyArray<BizMenuItem>;
这样写的好处是:menus中每个path都保留了字面量类型,后续做路由匹配时类型提示更精确;同时如果某个节点漏写了必填的id,或者children里塞了非法字段,编译器会立即标红,而不是等到运行时页面渲染出错才发现。
三、在组件中递归渲染与状态处理
定义好类型后,下一步是消费它。递归组件是渲染无限层级菜单的标准做法:组件接收一个MenuItem数组,渲染每个节点时,如果发现children存在且非空,就在内部再次渲染自身。以React为例,一个类型完备的递归菜单组件可以这样写。
import { useState } from 'react';
interface MenuItem {
id: string;
title: string;
path?: string;
children?: MenuItem[];
}
interface MenuListProps {
items: MenuItem[];
/** 当前选中的菜单id */
activeId: string;
/** 展开状态集合,由父组件统一管理 */
expandedIds: Set<string>;
onToggle: (id: string) => void;
onSelect: (item: MenuItem) => void;
/** 当前层级,用于缩进 */
depth?: number;
}
function MenuList({ items, activeId, expandedIds, onToggle, onSelect, depth = 0 }: MenuListProps) {
return (
<ul className="menu-list" style={{ paddingLeft: depth * 16 }}>
{items.map((item) => {
const hasChildren = (item.children?.length ?? 0) > 0;
const isOpen = expandedIds.has(item.id);
return (
<li key={item.id}>
{hasChildren ? (
<button onClick={() => onToggle(item.id)}>
{isOpen ? '收起' : '展开'} {item.title}
</button>
) : (
<span
className={item.id === activeId ? 'active' : ''}
onClick={() => onSelect(item)}
>
{item.title}
</span>
)}
{hasChildren && isOpen && (
<MenuList
items={item.children!}
activeId={activeId}
expandedIds={expandedIds}
onToggle={onToggle}
onSelect={onSelect}
depth={depth + 1}
/>
)}
</li>
);
})}
</ul>
);
}
这个组件的要点有三处。第一,展开状态expandedIds被提升到父组件,用Set<string>保存所有展开节点的id,这样任意层级的折叠互不干扰,也方便做默认展开全部、展开到某个层级这类批量操作。第二,判断是否有子菜单用的是item.children?.length ?? 0 > 0,先处理了children为undefined的情况,避免在空数组上也渲染展开按钮。第三,递归渲染时depth + 1逐层传递,缩进样式自然递增,不需要额外的全局变量。
在Vue中思路完全一致,只是写法换成在template里通过组件的name选项实现自引用,或者用defineAsyncComponent显式引入自身。类型层面同样用MenuItem[]约束props,这里不再赘述。
四、常见坑点与进阶技巧
实际落地时会遇到几个典型问题。最常见的是判空处理:由于children是可选的,直接item.children.map(...)在严格模式下会报可能为undefined的错误,除了可选链,也可以封装一个类型守卫函数function hasChildren(item: MenuItem): item is MenuItem & { children: MenuItem[] },谓词式守卫能让后续代码自动收窄类型,逻辑更清晰。
其次是面包屑路径的推导。根据当前选中的菜单id反查它在整棵树中的路径,同样需要递归遍历,返回值类型建议声明为MenuItem[] | null,找到时返回从根到叶子的完整链路:
function findMenuPath(menus: MenuItem[], targetId: string): MenuItem[] | null {
for (const item of menus) {
if (item.id === targetId) return [item];
if (item.children) {
const subPath = findMenuPath(item.children, targetId);
if (subPath) return [item, ...subPath];
}
}
return null;
}
还有一个容易被忽略的性能细节:深层菜单全量递归渲染时,如果数据有几百个节点,建议对子菜单做懒渲染,即只在展开状态下才渲染下一层的MenuList,上面示例中hasChildren && isOpen &&的写法已经隐含了这一点。另外要留意数据来源的循环引用问题,如果后端返回的数据里某个节点的children意外包含了自己的祖先节点,前端会栈溢出,生产环境最好在数据入口处做一次校验,遍历时记录已访问的id集合即可检测出环。
总结一下,无限层级菜单的类型方案核心就一句话:让children引用自身类型。再叠加readonly、泛型扩展、satisfies校验和类型守卫,就能得到一套从数据定义到组件渲染都类型完备的导航栏方案,无论产品后续把菜单加到多少层,代码都不需要跟着改。
TypeScript递归类型无限层级菜单导航栏组件类型修改时间:2026-09-06 03:14:46