Tree
简介
MTree 在 Nuxt UI Tree 之上做薄壳封装,透传其全部 props、事件与插槽,并补齐多项增强能力:搜索过滤与高亮、异步懒加载、工具栏(展开/折叠切换、三态全选)、复选框多选与父子策略(级联 / 互不关联)、键绑定(v-model:selectedKeys)以及命令式 API 与选中分类。树形数据的归一化、过滤、遍历等运算复用 @movk/core 的 Tree 工具方法。
用法
传入 items 渲染层级结构,节点 defaultExpanded 控制初始展开,v-model 绑定选中节点:
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: 'app',
icon: 'i-lucide-folder',
defaultExpanded: true,
children: [
{ label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' }
]
},
{
label: 'composables',
icon: 'i-lucide-folder',
children: [
{ label: 'useAuth.ts', icon: 'i-vscode-icons-file-type-typescript' },
{ label: 'useUser.ts', icon: 'i-vscode-icons-file-type-typescript' }
]
}
]
const selected = ref<TreeItem>()
</script>
<template>
<MTree v-model="selected" :items="items" />
</template>
defaultExpanded 默认展开
defaultExpanded 作用:以策略推导初始展开的父节点,缺省回退节点上的 defaultExpanded 标记。传 true 展开全部父级、传 number 仅展开 depth 小于该值的父级、传函数按节点与深度自定义:
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: 'app',
icon: 'i-lucide-folder',
children: [
{
label: 'composables',
icon: 'i-lucide-folder',
children: [
{ label: 'useAuth.ts', icon: 'i-vscode-icons-file-type-typescript' },
{ label: 'useUser.ts', icon: 'i-vscode-icons-file-type-typescript' }
]
},
{ label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' }
]
},
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' }
]
</script>
<template>
<MTree :items="items" :default-expanded="1" />
</template>
searchable 搜索过滤
searchable 作用:在顶部渲染搜索框,按关键字剪枝并保留命中节点的祖先链;highlight 默认开启,高亮命中文本,命中后自动展开。filter 可自定义匹配谓词,search 支持 v-model:search 双向绑定关键字:
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: 'app',
icon: 'i-lucide-folder',
children: [
{ label: 'useAuth.ts', icon: 'i-vscode-icons-file-type-typescript' },
{ label: 'useUser.ts', icon: 'i-vscode-icons-file-type-typescript' }
]
},
{
label: 'components',
icon: 'i-lucide-folder',
children: [
{ label: 'Card.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'Button.vue', icon: 'i-vscode-icons-file-type-vue' }
]
}
]
</script>
<template>
<MTree :items="items" searchable />
</template>
checkable 复选框级联
checkable 作用:在节点前渲染复选框,内部启用 multiple 与父子级联、子级半选冒泡,v-model 收集选中节点数组:
- 技术中心
- 前端组
- 后端组
- 产品中心
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: '技术中心',
defaultExpanded: true,
children: [
{ label: '前端组', children: [{ label: '组件库' }, { label: '可视化' }] },
{ label: '后端组' }
]
},
{ label: '产品中心', children: [{ label: '交互设计' }] }
]
const checked = ref<TreeItem[]>([])
</script>
<template>
<MTree v-model="checked" :items="items" checkable />
</template>
checkable 等价于 multiple + strategy(默认 cascade)的语法糖。仅需多选而不渲染复选框时改用 multiple。multiple 多选
multiple 作用:开启多选但不渲染复选框,点击节点累加选中,v-model 收集选中节点数组:
已选:无
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: '技术中心',
defaultExpanded: true,
children: [
{ label: '前端组', children: [{ label: '组件库' }, { label: '可视化' }] },
{ label: '后端组' }
]
},
{ label: '产品中心', children: [{ label: '交互设计' }] }
]
const selected = ref<TreeItem[]>([])
</script>
<template>
<div class="space-y-3">
<MTree v-model="selected" :items="items" multiple />
<p class="text-sm text-muted">
已选:{{ selected.map(node => node.label).join('、') || '无' }}
</p>
</div>
</template>
strategy 父子策略
strategy 作用:控制多选 / checkable 下的父子勾选关系。cascade(默认)父子级联且子级全选时回填父级,isolated 父子互不关联、半选不冒泡:
cascade(默认)
- 技术中心
- 前端组
- 后端组
isolated
- 技术中心
- 前端组
- 后端组
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: '技术中心',
defaultExpanded: true,
children: [
{ label: '前端组', children: [{ label: '组件库' }, { label: '可视化' }] },
{ label: '后端组', children: [{ label: '网关' }, { label: '存储' }] }
]
}
]
const cascade = ref<TreeItem[]>([])
const isolated = ref<TreeItem[]>([])
</script>
<template>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<p class="text-sm font-medium">
cascade(默认)
</p>
<MTree v-model="cascade" :items="items" checkable />
</div>
<div class="space-y-2">
<p class="text-sm font-medium">
isolated
</p>
<MTree v-model="isolated" :items="items" checkable strategy="isolated" />
</div>
</div>
</template>
selectedKeys 键绑定
selectedKeys 作用:以节点 key 数组双向绑定选中,适合从后端回显或与路由同步。v-model:selectedKeys 与 v-model 互通,键由 getKey / labelKey 派生:
- app
- app.vue
- nuxt.config.ts
selectedKeys:app.vue
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: 'app',
icon: 'i-lucide-folder',
defaultExpanded: true,
children: [
{ label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' }
]
}
]
const selectedKeys = ref<string[]>(['app.vue'])
</script>
<template>
<div class="space-y-3">
<MTree v-model:selected-keys="selectedKeys" :items="items" checkable />
<p class="text-sm text-muted">
selectedKeys:{{ selectedKeys.join('、') || '无' }}
</p>
</div>
</template>
toolbar 工具栏
toolbar 作用:渲染顶部工具栏,提供展开 / 折叠切换按钮;searchable 时内嵌可清除的搜索框,checkable 时附带三态全选复选框与选中计数:
- 技术中心
- 产品中心
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: '技术中心',
children: [
{ label: '前端组', children: [{ label: '组件库' }, { label: '可视化' }] },
{ label: '后端组', children: [{ label: '网关' }, { label: '存储' }] }
]
},
{ label: '产品中心', children: [{ label: '交互设计' }, { label: '用户研究' }] }
]
const checked = ref<TreeItem[]>([])
</script>
<template>
<MTree v-model="checked" :items="items" toolbar searchable checkable />
</template>
lazy 异步懒加载
lazy 作用:配合 loadChildren,展开未加载的父节点时拉取子节点并显示加载态;节点 isLeaf 标记为叶子,不渲染展开占位:
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{ label: '区域 A' },
{ label: '区域 B' },
{ label: '直辖节点', isLeaf: true }
]
let seq = 0
function loadChildren(node: TreeItem): Promise<TreeItem[]> {
return new Promise(resolve => setTimeout(() => {
seq += 1
resolve([
{ label: `${node.label} / 子节点 ${seq}-1` },
{ label: `${node.label} / 子节点 ${seq}-2`, isLeaf: true }
])
}, 800))
}
</script>
<template>
<MTree :items="items" lazy :load-children="loadChildren" />
</template>
childrenKey 字段映射
childrenKey 作用:将后端的子节点字段归一化为 children,labelKey 指定展示字段,无需预先改造数据结构:
<script setup lang="ts">
const items = [
{
name: '技术中心',
nodes: [
{ name: '前端组', nodes: [{ name: '组件库' }, { name: '可视化' }] },
{ name: '后端组' }
]
},
{ name: '产品中心', nodes: [{ name: '交互设计' }] }
]
</script>
<template>
<MTree :items="items" children-key="nodes" label-key="name" />
</template>
virtualize 虚拟滚动
virtualize 作用:透传 Nuxt UI Tree 的虚拟化能力,仅渲染可视区节点,适配大数据量树:
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = Array.from({ length: 60 }, (_, group) => ({
label: `分组 ${group + 1}`,
defaultExpanded: group === 0,
children: Array.from({ length: 30 }, (_, leaf) => ({ label: `节点 ${group + 1}-${leaf + 1}` }))
}))
</script>
<template>
<MTree :items="items" :virtualize="true" class="max-h-72 w-md" />
</template>
示例
自定义节点
通过透传的 item-trailing 等插槽自定义节点内容,未覆盖的插槽仍由 Nuxt UI Tree 默认渲染:
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: 'app',
icon: 'i-lucide-folder',
defaultExpanded: true,
children: [
{ label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' }
]
}
]
</script>
<template>
<MTree :items="items">
<template #item-trailing="{ item }">
<UBadge v-if="!item.children" label="file" color="neutral" variant="subtle" size="sm" />
</template>
</MTree>
</template>
自定义工具栏
toolbar-leading / toolbar-trailing 在默认工具栏首尾追加内容;需要完全接管时改用 #toolbar 插槽,其作用域暴露 toggleExpand、selectAll、clear、selectionSummary 等方法与状态:
- 技术中心
- 产品中心
<script setup lang="ts">
import type { TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: '技术中心',
children: [
{ label: '前端组', children: [{ label: '组件库' }, { label: '可视化' }] },
{ label: '后端组', children: [{ label: '网关' }, { label: '存储' }] }
]
},
{ label: '产品中心', children: [{ label: '交互设计' }, { label: '用户研究' }] }
]
const checked = ref<TreeItem[]>([])
</script>
<template>
<MTree v-model="checked" :items="items" toolbar searchable checkable>
<template #toolbar-leading>
<UBadge label="部门" color="neutral" variant="subtle" size="sm" />
</template>
<template #toolbar-trailing>
<UBadge :label="`${checked.length} 项`" color="primary" variant="subtle" size="sm" />
</template>
</MTree>
</template>
命令式控制
通过 useTemplateRef 拿到组件实例,调用 expandToDepth、collapseAll、selectAll、clearSelection 等方法控制树:
- 技术中心
- 产品中心
<script setup lang="ts">
import type { TreeExposed, TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: '技术中心',
children: [
{ label: '前端组', children: [{ label: '组件库' }, { label: '可视化' }] },
{ label: '后端组', children: [{ label: '网关' }, { label: '存储' }] }
]
},
{ label: '产品中心', children: [{ label: '交互设计' }, { label: '用户研究' }] }
]
const checked = ref<TreeItem[]>([])
const tree = useTemplateRef<TreeExposed>('tree')
</script>
<template>
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<UButton size="xs" label="展开到第 2 层" @click="tree?.expandToDepth(2)" />
<UButton size="xs" label="收起全部" color="neutral" variant="subtle" @click="tree?.collapseAll()" />
<UButton size="xs" label="全选" @click="tree?.selectAll()" />
<UButton size="xs" label="清空" color="neutral" variant="subtle" @click="tree?.clearSelection()" />
</div>
<MTree ref="tree" v-model="checked" :items="items" checkable />
</div>
</template>
选中结果分类
实例的 treeSelection 反应式回传选中分类:leaves(选中叶子)、parents(满选父级)、halfSelected(半选父级)、strictlyChecked(剔除随父级联的子节点):
- 技术中心
- 前端组
- 后端组
<script setup lang="ts">
import type { TreeExposed, TreeItem } from '@movk/nuxt'
const items: TreeItem[] = [
{
label: '技术中心',
defaultExpanded: true,
children: [
{ label: '前端组', children: [{ label: '组件库' }, { label: '可视化' }] },
{ label: '后端组', children: [{ label: '网关' }, { label: '存储' }] }
]
}
]
const checked = ref<TreeItem[]>([])
const tree = useTemplateRef<TreeExposed>('tree')
const labels = (nodes?: TreeItem[]) => (nodes ?? []).map(node => node.label).join('、') || '无'
</script>
<template>
<div class="space-y-3">
<MTree ref="tree" v-model="checked" :items="items" checkable />
<dl class="text-sm text-muted space-y-1">
<div>叶子(leaves):{{ labels(tree?.treeSelection.leaves) }}</div>
<div>满选父级(parents):{{ labels(tree?.treeSelection.parents) }}</div>
<div>半选父级(halfSelected):{{ labels(tree?.treeSelection.halfSelected) }}</div>
</dl>
</div>
</template>
API
Props
| Prop | Default | Type |
|---|---|---|
as | 'ul' | anyThe element or component this component should render as. |
items | T树数据源,按 childrenKey 解析层级 | |
childrenKey | 'children' | string取子节点数组的字段名,归一化为 UTree 的 children |
labelKey | 'label' | keyof Extract<NestedItem<T>, object> & string | DotPathKeys<Extract<NestedItem<T>, object>>展示字段名 |
getKey | (val: T[number]) => string自定义节点 key,缺省取 labelKey 字段值 | |
defaultExpanded | number | false | true | (node: T[number], depth: number): boolean初始展开策略,缺省回退节点上的 defaultExpanded 标记
| |
selectedKeys | string[]选中节点 key 列表,可用 v-model:selectedKeys 双向绑定 | |
multiple | M开启多选 | |
strategy | 'cascade' | "cascade" | "isolated"多选 / checkable 下的父子勾选策略
|
size | 'md' | "md" | "xs" | "sm" | "lg" | "xl"尺寸 |
color | 'primary' | "primary" | "secondary" | "info" | "success" | "warning" | "error" | "important" | "neutral"主色 |
filter | TreeFilter<T[number]>自定义匹配谓词,缺省按 labelKey 文本不区分大小写包含匹配 | |
loadChildren | TreeLoadChildren<T[number]>懒加载回调,展开未加载的父节点时调用 | |
checkable | M渲染复选框并启用多选(multiple + strategy,默认 cascade) | |
trailingIcon | appConfig.ui.icons.chevronDown | anyThe icon displayed on the right side of a parent node. |
expandedIcon | appConfig.ui.icons.folderOpen | anyThe icon displayed when a parent node is expanded. |
collapsedIcon | appConfig.ui.icons.folder | anyThe icon displayed when a parent node is collapsed. |
modelValue | M extends true ? T[number][] : T[number]The controlled value of the Tree. Can be bind as | |
defaultValue | M extends true ? T[number][] : T[number]The value of the Tree when initially rendered. Use when you do not need to control the state of the Tree. | |
virtualize | false | boolean | { overscan?: number; estimateSize?: number | ((index: number) => number); }Enable virtualization for large lists.
Note: when enabled, the tree structure is flattened like if |
onSelect | (e: SelectEvent<T[number]>, item: T[number]) => void | |
onToggle | (e: ToggleEvent<T[number]>, item: T[number]) => void | |
expanded | [] | string[]The controlled value of the expanded item. Can be binded with |
selectionBehavior | "replace" | "toggle"How multiple selection should behave in the collection. | |
search | '' | string |
propagateSelect | boolean选中父节点时级联选中子节点。cascade 策略下默认开启,显式 true 可在 isolated 下强制开启;关闭级联请用 strategy='isolated' | |
bubbleSelect | boolean子节点全部选中时回填父节点。cascade 策略下默认开启,显式 true 可在 isolated 下强制开启;关闭级联请用 strategy='isolated' | |
searchable | boolean开启顶部搜索过滤 | |
highlight | true | boolean高亮命中文本,仅在 searchable 时生效 |
lazy | boolean开启异步懒加载子节点 | |
toolbar | boolean开启顶部工具栏(展开/折叠,checkable 时附带全选/清空) | |
nested | true | booleanUse nested DOM structure (children inside parents) vs flattened structure (all items at same level).
When |
disabled | booleanWhen | |
ui | Record<string, ClassNameValue> & { root?: SlotClass; item?: SlotClass; listWithChildren?: SlotClass; itemWithChildren?: SlotClass; link?: SlotClass; linkLeadingIcon?: SlotClass; linkLabel?: SlotClass; linkTrailing?: SlotClass; linkTrailingIcon?: SlotClass; container?: SlotClass; toolbar?: SlotClass; toolbarButton?: SlotClass; search?: SlotClass; checkbox?: SlotClass; highlight?: SlotClass; loading?: SlotClass; loadingIcon?: SlotClass; empty?: SlotClass; } |
Emits
| Event | Type |
|---|---|
update:modelValue | [val: M extends true ? T[number][] : T[number]] |
update:expanded | [val: string[]] |
update:search | [value: string] |
change | [payload: { value: M extends true ? T[number][] : T[number]; keys: string[]; selection: TreeSelectionResult<T[number]>; }] |
update:selectedKeys | [value: string[]] |
update:modelValue、update:expanded 外,MTree 额外提供:update:search:搜索关键字变化时触发,支持v-model:search。update:selectedKeys:选中 key 列表变化时触发,支持v-model:selectedKeys。change:选中变化时触发,载荷为{ value, keys, selection },keys由getKey/labelKey派生,selection为选中结果分类。
Slots
| Slot | Type |
|---|---|
item-wrapper | { item: T[number]; index: number; level: number; expanded: boolean; selected: boolean; indeterminate: boolean; handleSelect: () => void; handleToggle: () => void; ui: { root: (props?: Record<string, any>) => string; item: (props?: Record<string, any>) => string; listWithChildren: (props?: Record<string, any>) => string; itemWithChildren: (props?: Record<string, any>) => string; link: (props?: Record<string, any>) => string; linkLeadingIcon: (props?: Record<string, any>) => string; linkLabel: (props?: Record<string, any>) => string; linkTrailing: (props?: Record<string, any>) => string; linkTrailingIcon: (props?: Record<string, any>) => string; }; } |
item | { item: T[number]; index: number; level: number; expanded: boolean; selected: boolean; indeterminate: boolean; handleSelect: () => void; handleToggle: () => void; ui: { root: (props?: Record<string, any>) => string; item: (props?: Record<string, any>) => string; listWithChildren: (props?: Record<string, any>) => string; itemWithChildren: (props?: Record<string, any>) => string; link: (props?: Record<string, any>) => string; linkLeadingIcon: (props?: Record<string, any>) => string; linkLabel: (props?: Record<string, any>) => string; linkTrailing: (props?: Record<string, any>) => string; linkTrailingIcon: (props?: Record<string, any>) => string; }; } |
item-leading | { item: T[number]; index: number; level: number; expanded: boolean; selected: boolean; indeterminate: boolean; handleSelect: () => void; handleToggle: () => void; ui: { root: (props?: Record<string, any>) => string; item: (props?: Record<string, any>) => string; listWithChildren: (props?: Record<string, any>) => string; itemWithChildren: (props?: Record<string, any>) => string; link: (props?: Record<string, any>) => string; linkLeadingIcon: (props?: Record<string, any>) => string; linkLabel: (props?: Record<string, any>) => string; linkTrailing: (props?: Record<string, any>) => string; linkTrailingIcon: (props?: Record<string, any>) => string; }; } |
item-label | { item: T[number]; index: number; level: number; expanded: boolean; selected: boolean; indeterminate: boolean; handleSelect: () => void; handleToggle: () => void; ui: { root: (props?: Record<string, any>) => string; item: (props?: Record<string, any>) => string; listWithChildren: (props?: Record<string, any>) => string; itemWithChildren: (props?: Record<string, any>) => string; link: (props?: Record<string, any>) => string; linkLeadingIcon: (props?: Record<string, any>) => string; linkLabel: (props?: Record<string, any>) => string; linkTrailing: (props?: Record<string, any>) => string; linkTrailingIcon: (props?: Record<string, any>) => string; }; } |
item-trailing | { item: T[number]; index: number; level: number; expanded: boolean; selected: boolean; indeterminate: boolean; handleSelect: () => void; handleToggle: () => void; ui: { root: (props?: Record<string, any>) => string; item: (props?: Record<string, any>) => string; listWithChildren: (props?: Record<string, any>) => string; itemWithChildren: (props?: Record<string, any>) => string; link: (props?: Record<string, any>) => string; linkLeadingIcon: (props?: Record<string, any>) => string; linkLabel: (props?: Record<string, any>) => string; linkTrailing: (props?: Record<string, any>) => string; linkTrailingIcon: (props?: Record<string, any>) => string; }; } |
toolbar | { expandAll: () => void; collapseAll: () => void; toggleExpand: () => void; allExpanded: boolean; selectAll: () => void; clear: () => void; search: string; selectionSummary: TreeSelectionSummary; } |
toolbar-leading | any默认工具栏起始处追加内容 |
toolbar-trailing | any默认工具栏末尾追加内容 |
empty | any |
loading | { node: T[number]; } |
Expose
您可以通过 useTemplateRef 访问该类型化组件实例。
| Name | Type |
|---|---|
expandAll() | void 展开全部可展开节点 |
collapseAll() | void 收起全部节点 |
expandToDepth(depth) | void 展开到指定层级, |
selectAll() | void 选中全部可选节点 |
clearSelection() | void 清空选中 |
treeSelection | TreeSelectionResult 当前选中结果分类( |
Theme
export default defineAppConfig({
ui: {
tree: {
slots: {
container: 'flex flex-col gap-2 min-h-0',
toolbar: 'flex items-center gap-1.5',
toolbarButton: 'shrink-0',
search: 'flex-1 min-w-0',
checkbox: 'shrink-0 me-1.5',
highlight: 'rounded-[2px] bg-primary/15 text-primary',
loading: 'flex items-center gap-1.5 text-sm text-muted',
loadingIcon: 'size-4 shrink-0 animate-spin',
empty: 'py-6 text-center text-sm text-muted'
}
}
}
})