Vue 递归组件实战:手写一个文件/文件夹树形组件
用 Vue 递归组件实现可展开折叠的文件树,掌握「组件调用自己」的写法与状态提升思路。
一、写在前面
文件树、目录树是 IDE、网盘、项目管理里常见的 UI。实现要点就两条:树形数据 + 递归渲染。本文用 Vue 单文件组件,从数据结构到递归实现、展开状态管理,完整走一遍,方便直接用在项目里或当作递归组件练手。
你将收获:
- 树形数据的约定与设计
- Vue 递归组件的写法(组件内使用自身)
- 展开/折叠状态用「受控」方式由父组件管理
- 一份可直接复用的
FileFolderTree组件实现
二、效果与交互
- 文件夹:左侧三角可展开/折叠,带 📁 图标,点击整行切换展开状态
- 文件:叶子节点,带 📄 图标,点击整行触发「选中文件」
- 缩进:随层级加深,子级向右缩进,层级感清晰
数据示例:根目录 → src / public → 其下若干文件,和常见项目结构一致。
三、数据结构设计
树中每个节点统一为「文件夹」或「文件」,用 type 区分,文件夹才有 children。
约定:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id |
number / string | ✅ | 唯一标识,用于展开状态、key |
name |
string | ✅ | 显示名称 |
type |
'folder' | 'file' |
✅ | 节点类型 |
children |
array | 仅 folder | 子节点列表,文件没有 |
示例:
javascript
{
id: 1,
name: '项目根目录',
type: 'folder',
children: [
{
id: 2,
name: 'src',
type: 'folder',
children: [
{ id: 4, name: 'main.js', type: 'file' },
{ id: 5, name: 'App.vue', type: 'file' }
]
},
{
id: 3,
name: 'public',
type: 'folder',
children: [
{ id: 6, name: 'index.html', type: 'file' }
]
}
]
}
这样一层层嵌套,天然适合递归渲染。
四、核心思路:递归组件 + 受控展开
4.1 为什么用递归组件?
树是「节点 + 子节点」的重复结构,每一层结构相同,只是数据不同。所以:
- 同一套模板 :当前层渲染
list,遇到文件夹再渲染其children - 同一套逻辑:展开/折叠、选中都复用
- 实现方式:在组件内部再使用自己 ,即递归组件(如
<FileFolderTree :list="item.children" />)
4.2 展开状态为什么要「受控」?
- 每一层都只负责「当前这一层」的展示,但展开/折叠会影响整棵树。
- 若每层自己维护
expandedIds,子组件和父组件状态会脱节,难以同步。 - 因此采用受控 :由父组件(或页面)维护一个
expandedIds: number[],所有层都只读这个数组并通过@toggle上报点击,由父组件统一增删 id。这样任意层级展开/折叠,整棵树状态一致。
五、组件接口设计
Props:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
list |
Array | 见下 | 当前层节点列表 |
depth |
Number | 0 | 当前层级深度,用于缩进(如 paddingLeft = depth * 16 + 8) |
expandedIds |
Array | [] | 当前已展开的文件夹 id 集合(受控) |
Events:
| 事件名 | 参数 | 说明 |
|---|---|---|
toggle |
id | 点击文件夹行,切换该 id 的展开/折叠 |
select |
item | 点击文件行,选中该文件节点 |
子层递归时,把 expandedIds 原样传下去,并把 toggle 继续向上冒泡(@toggle="$emit('toggle', $event)"),这样顶层统一处理即可。
六、模板:分层 + 递归
vue
<template>
<ul class="file-tree">
<li v-for="item in list" :key="item.id" class="tree-node">
<!-- 文件夹:可展开/折叠 -->
<template v-if="item.type === 'folder'">
<div
class="node-row folder"
:style="{ paddingLeft: depth * 16 + 8 + 'px' }"
@click="toggle(item.id)"
>
<span class="expand-icon">{{ isExpanded(item.id) ? '▼' : '▶' }}</span>
<span class="icon folder-icon">📁</span>
<span class="name">{{ item.name }}</span>
</div>
<FileFolderTree
v-show="isExpanded(item.id)"
v-if="item.children?.length"
:list="item.children"
:depth="depth + 1"
:expanded-ids="expandedIds"
@toggle="$emit('toggle', $event)"
/>
</template>
<!-- 文件:叶子节点 -->
<template v-else>
<div
class="node-row file"
:style="{ paddingLeft: depth * 16 + 8 + 'px' }"
@click="$emit('select', item)"
>
<span class="expand-icon placeholder"> </span>
<span class="icon file-icon">📄</span>
<span class="name">{{ item.name }}</span>
</div>
</template>
</li>
</ul>
</template>
要点:
- v-if / v-show :子树用
v-if="item.children?.length"避免空 children 仍渲染一层;用v-show="isExpanded(item.id)"控制显隐,保留 DOM 便于动画或后续扩展。 - 递归 :
<FileFolderTree>传入item.children、depth + 1、同一份expandedIds,并@toggle="$emit('toggle', $event)"把事件冒泡到顶层。 - 缩进 :
paddingLeft: depth * 16 + 8,随层级增加缩进。 - 占位 :文件没有三角,用
expand-icon placeholder占位,保证与文件夹对齐。
七、逻辑与样式要点
Script:
javascript
export default {
name: 'FileFolderTree', // 递归组件必须写 name,以便在模板中引用自己
props: {
list: { type: Array, default: () => defaultList },
depth: { type: Number, default: 0 },
expandedIds: { type: Array, default: () => [] }
},
emits: ['toggle', 'select'],
methods: {
isExpanded(id) {
return this.expandedIds.includes(id)
},
toggle(id) {
this.$emit('toggle', id)
}
}
}
- name: 'FileFolderTree' :递归组件在模板里写
<FileFolderTree>时需要能解析到自己,所以 name 必写。 - isExpanded :只读
expandedIds,不修改,由父组件通过toggle事件更新。
样式(缩略):
- 列表去圆点、去默认 padding/margin。
.node-row:flex 横向排布,hover 背景,圆角,手型。- 文件夹名称可加粗;展开图标固定宽度;文件/文件夹图标用不同颜色区分(如文件夹橙、文件蓝)。
八、在页面里使用(受控用法)
父组件持有「树数据」和「展开 id 列表」,把展开逻辑放在父组件:
vue
<template>
<div id="app">
<FileFolderTree
:list="treeData"
:expanded-ids="expandedIds"
@toggle="onToggle"
@select="onSelectFile"
/>
</div>
</template>
<script>
import FileFolderTree from './components/FileFolderTree.vue'
export default {
name: 'App',
components: { FileFolderTree },
data() {
return {
expandedIds: [1], // 默认展开根节点 id=1
treeData: [ /* 上面那棵树的根节点 */ ]
}
},
methods: {
onToggle(id) {
const idx = this.expandedIds.indexOf(id)
if (idx === -1) {
this.expandedIds.push(id)
} else {
this.expandedIds.splice(idx, 1)
}
},
onSelectFile(item) {
console.log('选中文件:', item.name, item)
}
}
}
</script>
这样就是标准的受控组件用法:数据与展开状态都在父组件,树只负责展示和发事件。
九、小结
- 树形 UI :用「同一组件 + 递归」渲染每一层,数据结构用
type+children即可。 - 递归组件 :在
.vue里写name,模板中直接用<FileFolderTree>传子层list和depth。 - 展开状态 :用
expandedIds+toggle做成受控,父组件统一维护,子层只读并冒泡事件。 - 扩展方向:可在此基础上加懒加载、右键菜单、拖拽排序、多选等,数据结构和递归框架可以不变。
如果你正在实现项目里的目录树或类似结构,希望这篇能帮你把「递归组件」和「受控展开」串起来,直接套用或改一改就能用。有问题欢迎在评论区交流。
关键词:Vue3 / Vue2、递归组件、树形结构、受控组件、文件树