一、为什么你的项目分类总是像一锅乱炖?
还记得上次接手那个“简单”的项目分类需求吗?产品经理微笑着要求:“就做个能无限层级的树状分类,支持展开收起、选中高亮,最好还能动态加载数据哦~”
结果你写了300行代码,发现:
- 展开收起逻辑和点击事件纠缠不清
- 新增一个字段要修改5个地方
- 深层嵌套的数据让你头晕目眩
别慌,这不是你的问题!传统开发思维确实难以应对树状结构的复杂性。但Vue的组件化开发,特别是组合式API,就是为这种场景而生的!
二、Vue组件基础:先理解什么是“会繁殖”的组件
2.1 组件到底是什么鬼?
简单说,组件就是能自己复制自己的代码块。想象一下乐高积木:你设计了一个基础模块,然后可以用它搭建出整个宇宙。
<!-- 最简单的组件示例 -->
<template>
<div class="category-item">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
</template>
<script setup>
defineProps({
title: String,
description: String
})
</script>
但这只是个“死”组件,真正厉害的是...
2.2 组件的终极奥义:递归!
树状分类的核心就是组件自己调用自己,就像俄罗斯套娃一样:
<!-- CategoryNode.vue -->
<template>
<div class="node">
<div class="node-content" @click="toggle">
{{ node.name }}
</div>
<!-- 关键在这里:组件调用自身! -->
<div v-if="isExpanded" class="children">
<CategoryNode
v-for="child in node.children"
:key="child.id"
:node="child"
/>
</div>
</div>
</template>
看到没?CategoryNode组件内部又使用了CategoryNode,这就是递归组件的魔力!
三、组合式API:给你的组件装上涡轮增压
3.1 为什么选择组合API?
选项式API(Options API)就像在IKEA买东西:所有零件都给你分类放好,但你想改个设计得跑遍整个商场。
组合式API(Composition API)更像是乐高工作室:所有积木块任你挑选,想怎么组合就怎么组合。
3.2 为树状组件量身定制的组合函数
// useTree.js
import { ref, computed } from 'vue'
export function useTreeNode(node) {
const isExpanded = ref(false)
const isSelected = ref(false)
const toggle = () => {
isExpanded.value = !isExpanded.value
}
const select = () => {
isSelected.value = true
}
// 计算是否有子节点
const hasChildren = computed(() => {
return node.children && node.children.length > 0
})
return {
isExpanded,
isSelected,
toggle,
select,
hasChildren
}
}
这样就把树节点的状态逻辑完全抽离,可以在任何组件中复用!
四、实战:从零搭建会“生孩子”的树状分类
4.1 数据结构设计:打好地基
好的数据结构是成功的一半:
// 树节点数据结构
const treeData = [
{
id: 1,
name: '前端开发',
children: [
{
id: 2,
name: '框架',
children: [
{ id: 3, name: 'Vue' },
{ id: 4, name: 'React' }
]
},
{
id: 5,
name: '构建工具',
children: [
{ id: 6, name: 'Webpack' },
{ id: 7, name: 'Vite' }
]
}
]
}
]
4.2 核心组件实现:让树“长”起来
<!-- CategoryTree.vue -->
<template>
<div class="category-tree">
<CategoryNode
v-for="node in treeData"
:key="node.id"
:node="node"
:level="0"
/>
</div>
</template>
<script setup>
import CategoryNode from './CategoryNode.vue'
defineProps({
treeData: {
type: Array,
required: true
}
})
</script>
<!-- CategoryNode.vue -->
<template>
<div class="tree-node" :style="{ marginLeft: `${level * 20}px` }">
<!-- 节点内容 -->
<div
class="node-content"
:class="{
'is-selected': isSelected,
'has-children': hasChildren
}"
@click="handleClick"
>
<span
class="expand-icon"
v-if="hasChildren"
>
{{ isExpanded ? '📂' : '📁' }}
</span>
<span class="node-name">{{ node.name }}</span>
</div>
<!-- 递归渲染子节点 -->
<div v-if="isExpanded && hasChildren" class="children">
<CategoryNode
v-for="child in node.children"
:key="child.id"
:node="child"
:level="level + 1"
/>
</div>
</div>
</template>
<script setup>
import { useTreeNode } from '../composables/useTree'
const props = defineProps({
node: {
type: Object,
required: true
},
level: {
type: Number,
default: 0
}
})
const {
isExpanded,
isSelected,
toggle,
select,
hasChildren
} = useTreeNode(props.node)
const handleClick = () => {
if (hasChildren.value) {
toggle()
}
select()
}
</script>
<style scoped>
.tree-node {
text-align: left;
cursor: pointer;
}
.node-content {
padding: 8px 12px;
border-radius: 4px;
transition: all 0.2s;
}
.node-content:hover {
background-color: #f5f5f5;
}
.is-selected {
background-color: #e3f2fd;
color: #1976d2;
}
.expand-icon {
margin-right: 8px;
transition: transform 0.2s;
}
.children {
transition: all 0.3s ease;
}
</style>
4.3 添加高级功能:让树更智能
动态加载数据:
// 在useTree.js中添加
async function loadChildren(node) {
if (!node.children || node.children.length === 0) {
const newChildren = await fetchChildren(node.id)
node.children = newChildren
}
}
搜索过滤:
// 搜索功能
const searchQuery = ref('')
const filteredTree = computed(() => {
if (!searchQuery.value) return treeData
function filterNodes(nodes) {
return nodes.filter(node => {
const isMatch = node.name.includes(searchQuery.value)
if (node.children) {
node.children = filterNodes(node.children)
return isMatch || node.children.length > 0
}
return isMatch
})
}
return filterNodes([...treeData])
})
五、避坑指南:我踩过的坑你们就别踩了
5.1 递归组件的无限循环问题
错误示范:
// 这会导致栈溢出!
const node = {
...props.node,
children: props.node.children || []
}
正确做法:
// 确保有终止条件!
const hasChildren = computed(() => {
return props.node.children && props.node.children.length > 0
})
5.2 响应式数据更新陷阱
// 错误:直接修改props
props.node.children = newChildren
// 正确:使用emit或者重新赋值
const emit = defineEmits(['update:node'])
function updateNode(newNode) {
emit('update:node', newNode)
}
5.3 性能优化:成百上千节点也不卡
// 虚拟滚动:只渲染可见节点
const visibleNodes = computed(() => {
// 根据滚动位置计算可见节点
})
// 防抖搜索
const debouncedSearch = useDebounce(searchQuery, 300)
六、完整示例:复制粘贴就能用的树状菜单
下面是一个完整的、可以直接使用的示例:
<template>
<div class="tree-demo">
<div class="search-box">
<input
v-model="searchQuery"
placeholder="搜索分类..."
class="search-input"
/>
</div>
<CategoryTree :tree-data="filteredTree" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import CategoryTree from './components/CategoryTree.vue'
// 模拟数据
const treeData = ref([
{
id: 1,
name: '技术栈',
children: [
{
id: 2,
name: '前端',
children: [
{ id: 3, name: 'Vue.js' },
{ id: 4, name: 'React' },
{ id: 5, name: 'Angular' }
]
},
{
id: 6,
name: '后端',
children: [
{ id: 7, name: 'Node.js' },
{ id: 8, name: 'Java' },
{ id: 9, name: 'Python' }
]
}
]
},
{
id: 10,
name: '产品设计',
children: [
{ id: 11, name: 'UI设计' },
{ id: 12, name: 'UX设计' },
{ id: 13, name: '交互设计' }
]
}
])
const searchQuery = ref('')
const filteredTree = computed(() => {
// 实现搜索过滤逻辑
return treeData.value
})
</script>
<style>
.tree-demo {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 20px;
}
</style>
七、总结:从此告别“面条代码”
通过Vue组件和组合式API,我们实现了:
✅ 高度可复用:一个组件搞定无限层级
✅ 逻辑清晰:状态管理和UI渲染分离
✅ 易于维护:新增功能只需修改少量代码
✅ 性能优秀:虚拟滚动支持大数据量
记住,好的组件设计就像培养皿里的细胞——给它合适的养分(数据),它就能自己生长出完整的结构。现在就去试试这个会“繁殖”的树状组件吧,保证让你的项目分类从此井然有序!

被折叠的 条评论
为什么被折叠?



