Vue 递归组件实战:手写一个文件/文件夹树形组件

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>

要点:

  1. v-if / v-show :子树用 v-if="item.children?.length" 避免空 children 仍渲染一层;用 v-show="isExpanded(item.id)" 控制显隐,保留 DOM 便于动画或后续扩展。
  2. 递归<FileFolderTree> 传入 item.childrendepth + 1、同一份 expandedIds,并 @toggle="$emit('toggle', $event)" 把事件冒泡到顶层。
  3. 缩进paddingLeft: depth * 16 + 8,随层级增加缩进。
  4. 占位 :文件没有三角,用 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> 传子层 listdepth
  • 展开状态 :用 expandedIds + toggle 做成受控,父组件统一维护,子层只读并冒泡事件。
  • 扩展方向:可在此基础上加懒加载、右键菜单、拖拽排序、多选等,数据结构和递归框架可以不变。

如果你正在实现项目里的目录树或类似结构,希望这篇能帮你把「递归组件」和「受控展开」串起来,直接套用或改一改就能用。有问题欢迎在评论区交流。


关键词:Vue3 / Vue2、递归组件、树形结构、受控组件、文件树

相关推荐
前端Hardy2 小时前
前端如何防止用户重复提交表单?4 种可靠方案(附防坑指南)
前端·javascript·面试
前端Hardy2 小时前
用户真的关掉页面了吗?前端精准检测页面卸载的 4 种方法(附避坑指南)
前端·javascript·面试
yangyanping201082 小时前
Vue入门到精通七之关键字const
前端·javascript·vue.js
lxh01132 小时前
重复的DNA序列
开发语言·javascript·ecmascript
李剑一2 小时前
Cesium 实现园区水景!3 种水面效果,Water 材质 5 分钟搞定
前端·vue.js·cesium
kgduu3 小时前
js之错误处理
开发语言·前端·javascript
德莱厄斯3 小时前
Milkup 技术内幕:一个 Typora 风格的即时渲染 Markdown 编辑器是怎样炼成的
前端·javascript·markdown
我命由我123453 小时前
Element Plus - Cascader 观察记录(基本使用、动态加载、动态加载下的异常环境)
开发语言·前端·javascript·vue.js·typescript·html5·js
qq_570398573 小时前
vue总结
前端·javascript·vue.js