概述
前端开发中,树形结构组件是企业管理系统、文件管理器、组织架构等场景中不可或缺的UI组件。这是一个基于Vue2开发的功能丰富的树形组件,它不仅支持传统的嵌套数据结构,还能处理扁平化数据,满足各种复杂业务需求。 这个树形组件提供了单选、多选、复选框、筛选、展开折叠等丰富功能,同时保持了出色的用户体验和性能表现。无论你是需要简单的目录树,还是复杂的企业组织架构,这个组件都能完美胜任。
效果
核心特性
🎯 丰富的数据结构支持
- 嵌套结构:传统的树形嵌套数据格式
- 扁平结构:通过
parentId关联的扁平数据,轻松处理数据库查询结果
🎨 灵活的模式
- 单选模式:只能选择一个节点
- 多选模式:支持选择多个节点
- 严格模式:父子节点选择状态独立,不相互影响
- 节点插槽:节点内容支持自定义插槽
🔍 筛选功能
- 实时关键词筛选
- 智能的筛选结果展示
⚡ 用户体验
- 响应式设计,完美适配移动端
- 平滑的动画过渡效果
- 可配置的点击展开/折叠行为
- 只读模式支持
🛠 API方法
- 展开/折叠所有节点
- 获取/设置选中节点
- 获取/设置展开节点
安装与使用
基本使用
<template>
<div>
<Tree
:data="treeData"
mode="multiple"
:show-checkbox="true"
:default-expand-all="true"
@node-click="handleNodeClick"
@check-change="handleCheckChange"
/>
</div>
</template>
<script>
import Tree from './components/newTree.vue'
export default {
components: {
Tree
},
data() {
return {
treeData: [
{
id: 1,
name: '技术部',
children: [
{ id: 2, name: '前端组' },
{ id: 3, name: '后端组' }
]
}
]
}
},
methods: {
handleNodeClick(node) {
console.log('点击节点:', node)
},
handleCheckChange(checked, node, checkedKeys, checkedNodes) {
console.log('选中状态变化:', checkedKeys)
}
}
}
</script>
Props 配置
树形组件提供了丰富的配置选项,让你能够灵活定制组件行为:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| dataSource | Array | [] | 树形数据,支持嵌套和扁平结构 |
| mode | String | 'single' | 选择模式:'single' 或 'multiple' |
| showCheckbox | Boolean | true | 是否显示复选框 |
| filterable | Boolean | true | 是否开启筛选 |
| defaultExpandAll | Boolean | false | 是否默认展开所有节点 |
| expandOnClickNode | Boolean | false | 点击节点时是否展开/折叠 |
| isView | Boolean | false | 是否为只读模式 |
| filterable | Boolean | false | 是否开启筛选功能 |
| checkStrictly | Boolean | false | 是否开启严格模式(父子不关联) |
| dataStructure | String | 'nested' | 数据结构类型:'nested' 或 'flat' |
| rootValue | [String, Number, null] | null | 扁平结构的根节点值 |
| nodeStructure | Object | {children: 'children',label: 'name',id: 'id',disabled: 'disabled',parentId: 'parentId',} | 树节点映射对象 |
数据结构配置
通过 props 配置项,你可以自定义数据字段的映射关系:
props: {
children: 'children', // 子节点字段名
label: 'name', // 显示文本字段名
id: 'id', // 节点唯一标识字段名
disabled: 'disabled', // 禁用状态字段名
parentId: 'parentId' // 扁平结构的父节点字段名
}
事件说明
组件提供了完整的事件系统,让你能够轻松响应用户操作:
| 事件名 | 参数 | 说明 |
|---|---|---|
| node-click | node | 节点点击事件 |
| node-expand | node | 节点展开事件 |
| node-collapse | node | 节点折叠事件 |
| check-change | checked, node, checkedKeys, checkedNodes | 复选框状态变化 |
| change | checkedKeys, checkedNodes | 选中状态变化 |
| select | node, checkedKeys, checkedNodes | 节点选择事件 |
数据格式
嵌套结构数据
const nestedData = [
{
id: 1,
name: '技术部',
children: [
{
id: 2,
name: '前端组',
children: [
{ id: 5, name: '组件开发组' },
{ id: 6, name: 'UI交互组' }
]
},
{
id: 3,
name: '后端组',
children: [
{ id: 8, name: '赵六' },
{ id: 9, name: '钱七' }
]
}
]
}
]
扁平结构数据
const flatData = [
{ id: 1, name: '一级节点 1', parentId: null },
{ id: 2, name: '二级节点 1-1', parentId: 1 },
{ id: 3, name: '三级节点 1-1-1', parentId: 2 },
{ id: 4, name: '三级节点 1-1-2', parentId: 2, disabled: true },
{ id: 5, name: '一级节点 2', parentId: null }
]
核心功能讲解
1. 严格模式 vs 非严格模式
非严格模式(默认):父子节点选中状态关联
- 选中父节点时,自动选中所有子节点
- 取消选中父节点时,自动取消选中所有子节点
- 子节点状态变化会影响父节点的选中状态 严格模式:父子节点选中状态独立
- 每个节点的选中状态完全独立
- 父子节点之间没有关联关系
2. 筛选功能
开启筛选功能后,用户可以实时搜索节点:
<Tree
:data="treeData"
:filterable="true"
filter-placeholder="搜索部门或人员"
/>
筛选功能支持:
- 实时关键词匹配
- 自动展开匹配节点的父级
3. 只读模式
在只读模式下,组件会显示所有节点为展开状态,但禁止用户进行任何交互:
<Tree
:data="treeData"
:is-view="true"
:default-expand-all="true"
/>
进阶用法
- 通过ref引用,你可以在父组件中调用树形组件的方法:
// 展开所有节点
this.$refs.tree.expandAll()
// 折叠所有节点
this.$refs.tree.collapseAll()
// 设置特定节点展开
this.$refs.tree.setExpandedKeys([1, 2, 3])
自定义节点内容
- 插槽
<!-- 自定义树节点示例 -->
<template #node-content="node">
<slot name="item" :item="node"> </slot>
</template>
源码
1. index.vue
<template>
<div class="myTreeView" style="height: 100%; position: relative">
<!-- 加载动画 -->
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
</div>
<Tree
ref="tree"
:data="dataSource"
:mode="mode"
:selected-keys.sync="selectKeys"
:default-expand-all="defaultExpandAll"
:expand-on-click-node="expandOnClickNode"
:is-view="isView"
:filterable="filterable"
:show-checkbox="showCheckbox"
:filter-placeholder="filterPlaceholder"
:props="nodeStructure"
:check-strictly="checkStrictly"
:data-structure="dataStructure"
:root-value="rootValue"
@node-click="handleNodeClick"
@node-expand="handleNodeExpand"
@node-collapse="handleNodeCollapse"
@check-change="handleCheckChange"
@change="handleChange"
>
<!-- 添加具名作用域插槽 -->
<template #node-content="node">
<slot name="item" :item="node"> </slot>
</template>
</Tree>
</div>
</template>
<script>
import Tree from './ChildComponents/newTree.vue';
export default {
name: 'ha-tree-view',
components: {
Tree,
},
props: {
dataSource: {
type: Array,
required: true,
default: () => [
{
id: 1,
name: '总经办',
parentId: 0,
},
{
id: 2,
name: '办公室',
parentId: 0,
},
{
id: 3,
name: '财务部',
parentId: 0,
},
{
id: 5,
name: '运营中心',
parentId: 0,
},
{
id: 6,
name: '信息中心',
parentId: 0,
},
{
id: 61,
name: '前端开发组',
parentId: 6,
},
{
id: 611,
name: '张三',
parentId: 61,
},
{
id: 612,
name: '李四',
parentId: 61,
},
{
id: 62,
name: '后端开发组',
parentId: 6,
},
{
id: 621,
name: '王五',
parentId: 62,
},
{
id: 622,
name: '周奇奇',
parentId: 62,
},
{
id: 7,
name: '行政人事部',
parentId: 0,
},
{
id: 52,
name: '华北业务部',
parentId: 5,
},
{
id: 51,
name: '华中业务部',
parentId: 5,
},
{
id: 53,
name: '华南业务部',
parentId: 5,
},
{
id: 54,
name: '西北业务部',
parentId: 5,
},
],
},
// 多选还是单选
mode: {
type: String,
default: 'single',
validator: (value) => ['single', 'multiple'].includes(value),
},
// 选中的值
values: {
type: Array,
default: () => [],
},
// 是否展开所有节点(查看模式下此属性为true)
defaultExpandAll: {
type: Boolean,
default: false,
},
//点击节点内容是否触发展开和折叠节点
expandOnClickNode: {
type: Boolean,
default: false,
},
// 是否为查看模式
isView: {
type: Boolean,
default: false,
},
// 是否开启筛选功能
filterable: {
type: Boolean,
default: true,
},
// 筛选框提示语
filterPlaceholder: {
type: String,
default: '请输入关键词筛选',
},
// 是否展示复选框
showCheckbox: {
type: Boolean,
default: true,
},
// 树节点属性
nodeStructure: {
type: Object,
default: () => ({
children: 'children',
label: 'name',
id: 'id',
disabled: 'disabled',
parentId: 'parentId',
}),
},
//严格模式
checkStrictly: {
type: Boolean,
default: true,
},
// 数据结构类型,支持 'nested'(嵌套)和 'flat'(扁平)
dataStructure: {
type: String,
default: 'flat',
validator: (value) => ['nested', 'flat'].includes(value),
},
// 根节点值
rootValue: {
type: [String, Number, null],
default: null,
},
},
computed: {
// 计算属性判断是否正在加载
isLoading() {
// 如果 dataSource 不存在或者为空数组,则认为仍在加载
return !this.dataSource || this.dataSource.length === 0;
},
},
mounted() {
// 在组件挂载完成后,初始化树结构
// this.fetchData();
},
data() {
return {
// dataSource: [],
selectKeys: [],
};
},
watch: {
selectKeys: {
handler(newVal) {
console.log('selectKeysChange', newVal);
if (newVal !== undefined && JSON.stringify(newVal) !== JSON.stringify(this.values)) {
this.$emit('update:values', newVal);
}
},
deep: true
},
},
methods: {
setCheckedKeys(ids) {
const checkedKeys = Array.isArray(ids) ? ids : ids ? [ids] : [];
if (this.$refs.tree) {
this.$refs.tree.setCheckedKeys(checkedKeys);
}
},
async fetchData() {
try {
const response = await fetch('http://localhost:3000/api/treeFlat');
this.dataSource = await response.json();
} catch (error) {
console.error('Failed to fetch tree data:', error);
}
},
// 暴露给外部使用方法
getCheckedNodes() {
console.log('getCheckedNodes', this.$refs.tree.getSelectedNodes());
return this.$refs.tree.getSelectedNodes();
},
getCheckedKeys() {
console.log('getCheckedKeys', this.$refs.tree.getCheckedKeys());
return this.$refs.tree.getCheckedKeys();
},
getHalfCheckedKeys() {
console.log('getHalfCheckedKeys', this.$refs.tree.getHalfCheckedKeys());
return this.$refs.tree.getCheckedKeys();
},
//传出外部使用方法
handleNodeClick(node) {
console.log('handleNodeClick', node);
this.$emit('node-click', JSON.stringify(node), node[this.nodeStructure.id]);
},
handleNodeExpand(node) {
console.log('handleNodeExpand', node);
this.$emit('node-expand', node);
},
handleNodeCollapse(node) {
console.log('handleNodeCollapse', node);
this.$emit('node-collapse', node);
},
handleCheckChange(node, keys, nodes, checked) {
console.log('handleCheckChange', node, keys, nodes, checked);
this.$emit('check-change', node, keys, nodes, checked);
},
handleChange(node) {
console.log('handleChange', node);
this.$emit('change', node);
},
},
};
</script>
<style scoped>
.myTreeView {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loading-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
2. newTree.vue - 树形组件核心
- 数据处理和转换逻辑
- 状态管理(选中、展开、筛选)
- 事件处理和API方法实现
- 扁平结构与嵌套结构的转换
<template>
<div class="tree-container">
<!-- 搜索框(如果开启筛选) -->
<div v-if="filterable" class="tree-filter">
<input v-model="filterText" type="text" :placeholder="filterPlaceholder" class="tree-filter__input" @input="handleFilter" />
<span v-if="filterText" class="tree-filter__clear" @click="clearFilter"> × </span>
</div>
<!-- 树节点 -->
<div class="tree-board" style="height: calc(100% - 50px); overflow-y: auto">
<tree-node
v-for="node in formattedData"
:key="getNodeId(node)"
:node="node"
:level="0"
:mode="mode"
:show-checkbox="computedShowCheckbox"
:selected-ids="selectedIds"
:indeterminate-ids="indeterminateIds"
:expanded-keys="expandedKeys"
:props="props"
:node-content-slot="$scopedSlots['node-content']"
:expand-on-click-node="expandOnClickNode"
:filter-text="filterText"
@toggle="handleNodeToggle"
@select="handleNodeSelect"
@node-click="handleNodeClick"
>
</tree-node>
<!-- 空状态 -->
<div v-if="filterable && filterText && formattedData.length === 0" class="tree-empty">暂无匹配数据</div>
</div>
</div>
</template>
<script>
import TreeNode from './newTreeNode.vue';
export default {
name: 'Tree',
components: {
TreeNode,
},
props: {
data: {
type: Array,
required: true,
},
mode: {
type: String,
default: 'single',
validator: (value) => ['single', 'multiple'].includes(value),
},
showCheckbox: {
type: Boolean,
default: true,
},
selectedKeys: {
type: Array,
default: () => [],
},
defaultExpandedKeys: {
type: Array,
default: () => [],
},
defaultExpandAll: {
type: Boolean,
default: false,
},
expandOnClickNode: {
type: Boolean,
default: false,
},
isView: {
type: Boolean,
default: false,
},
filterable: {
type: Boolean,
default: false,
},
filterPlaceholder: {
type: String,
default: '请输入关键词筛选',
},
props: {
type: Object,
default: () => ({
children: 'children',
label: 'label',
id: 'id',
disabled: 'disabled',
parentId: 'parentId',
}),
},
checkStrictly: {
type: Boolean,
default: false,
},
dataStructure: {
type: String,
default: 'nested',
validator: (value) => ['nested', 'flat'].includes(value),
},
rootValue: {
type: [String, Number, null],
default: null,
},
},
data() {
return {
selectedIds: Array.isArray(this.selectedKeys) ? [...this.selectedKeys] : [],
indeterminateIds: [],
expandedKeys: Array.isArray(this.defaultExpandedKeys) ? [...this.defaultExpandedKeys] : [],
filterText: '',
internalData: [],
// 映射表
nodeMap: new Map(), // 节点ID -> 节点对象
parentMap: new Map(), // 节点ID -> 父节点对象
childrenMap: new Map(), // 节点ID -> 子节点数组
};
},
computed: {
formattedData() {
if (!Array.isArray(this.internalData)) {
return [];
}
if (this.filterable) {
if (!this.filterText) {
return this.internalData;
} else {
const filter = (nodes) => {
const result = [];
nodes.forEach((node) => {
if (!node || !node[this.props.label]) return;
if (node[this.props.label].includes(this.filterText)) {
result.push({ ...node });
} else if (node.children && node.children.length > 0) {
const filteredChildren = filter(node.children);
if (filteredChildren.length > 0) {
result.push({
...node,
children: filteredChildren,
});
}
}
});
return result;
};
return filter(this.internalData);
}
} else {
return this.internalData;
}
},
computedShowCheckbox() {
return this.isView ? false : this.showCheckbox;
},
},
watch: {
data: {
handler(newData) {
console.log('newTree-Data', newData);
this.initData(newData);
},
immediate: true,
deep: true,
},
selectedKeys: {
handler(newVal, oldVal) {
console.log('newTree-watch-selectedKeys', newVal, oldVal);
if (newVal !== undefined && JSON.stringify(newVal) !== JSON.stringify(this.selectedIds)) {
if (this.mode === 'multiple' && !this.checkStrictly) {
const { selectedIds, indeterminateIds } = this.calculateCheckedState(newVal);
this.selectedIds = [...selectedIds];
this.indeterminateIds = [...indeterminateIds];
} else {
this.selectedIds = Array.isArray(newVal) ? [...newVal] : [];
this.indeterminateIds = [];
}
this.$nextTick(() => {
this.$forceUpdate();
});
}
},
immediate: true,
deep: true,
},
selectedIds: {
handler(newVal, oldVal) {
console.log('newTree-watch-selectedIds', newVal, oldVal);
if (newVal !== undefined && JSON.stringify(newVal) !== JSON.stringify(this.selectedKeys)) {
this.$emit('update:selectedKeys', [...newVal]);
}
},
deep: true,
},
defaultExpandAll: {
handler(newVal) {
if (newVal && this.internalData.length > 0) {
this.expandAll();
}
},
immediate: true,
},
isView: {
handler(newVal) {
if (newVal && this.internalData.length > 0) {
this.expandAll();
}
},
immediate: true,
},
},
mounted() {},
methods: {
// 初始化数据
initData(data) {
this.filterText = '';
if (!data || !Array.isArray(data)) {
this.internalData = [];
return;
}
if (this.dataStructure === 'flat') {
this.internalData = this.flatToNested(data);
} else {
this.internalData = this.deepClone(data);
}
// 构建映射表
this.buildNodeMaps(this.internalData);
if (this.isView) {
this.expandAll();
} else {
if (this.defaultExpandAll) {
this.expandAll();
}
if (this.defaultExpandedKeys && this.defaultExpandedKeys.length > 0) {
this.expandedKeys = [...this.defaultExpandedKeys];
}
}
},
// 构建节点映射表
buildNodeMaps(nodes, parent = null) {
if (!nodes || !Array.isArray(nodes)) return;
nodes.forEach((node) => {
const nodeId = this.getNodeId(node);
// 存储节点映射
this.nodeMap.set(nodeId, node);
// 存储父子关系
if (parent) {
this.parentMap.set(nodeId, parent);
}
// 存储子节点关系
const children = node[this.props.children];
if (children && children.length > 0) {
this.childrenMap.set(nodeId, children);
this.buildNodeMaps(children, node);
} else {
this.childrenMap.set(nodeId, []);
}
});
},
// 使用映射表查找节点
findNodeInFullData(nodes, targetNodeId) {
return this.nodeMap.get(targetNodeId) || null;
},
// 使用映射表查找父节点
findParent(nodes, childNode) {
const childNodeId = this.getNodeId(childNode);
return this.parentMap.get(childNodeId) || null;
},
// 获取节点的所有子节点
getNodeChildren(node) {
const nodeId = this.getNodeId(node);
return this.childrenMap.get(nodeId) || [];
},
// 已知选中节点-计算选中状态
calculateCheckedState(keys) {
const newSelectedIds = [];
const newIndeterminateIds = [];
keys.forEach((key) => {
const node = this.findNodeInFullData(this.internalData, key);
if (node) {
this.selectNodeAndChildrenForCalc(node, newSelectedIds, newIndeterminateIds);
}
});
this.updateAllParentStatesForCalc(newSelectedIds, newIndeterminateIds);
return {
selectedIds: [...newSelectedIds],
indeterminateIds: [...newIndeterminateIds],
};
},
// 已知选中节点-选中节点及其所有子节点
selectNodeAndChildrenForCalc(node, targetSelectedIds, targetIndeterminateIds) {
const nodeId = this.getNodeId(node);
if (!targetSelectedIds.includes(nodeId)) {
targetSelectedIds.push(nodeId);
}
const indeterminateIndex = targetIndeterminateIds.indexOf(nodeId);
if (indeterminateIndex > -1) {
targetIndeterminateIds.splice(indeterminateIndex, 1);
}
const children = this.getNodeChildren(node);
if (children && children.length > 0) {
children.forEach((child) => {
this.selectNodeAndChildrenForCalc(child, targetSelectedIds, targetIndeterminateIds);
});
}
},
// 已知选中节点-更新所有父节点状态
updateAllParentStatesForCalc(targetSelectedIds, targetIndeterminateIds) {
const updateParentsForNode = (node) => {
let parent = this.findParent(this.internalData, node);
while (parent) {
this.updateNodeStateForCalc(parent, targetSelectedIds, targetIndeterminateIds);
parent = this.findParent(this.internalData, parent);
}
};
const traverseAndUpdate = (nodes) => {
nodes.forEach((node) => {
const children = node[this.props.children];
if (!children || children.length === 0) {
updateParentsForNode(node);
} else {
traverseAndUpdate(children);
}
});
};
traverseAndUpdate(this.internalData);
},
// 已知选中节点-更新单个节点状态
updateNodeStateForCalc(node, targetSelectedIds, targetIndeterminateIds) {
const nodeId = this.getNodeId(node);
const children = this.getNodeChildren(node);
if (!children || children.length === 0) return;
let selectedCount = 0;
let indeterminateCount = 0;
children.forEach((child) => {
const childId = this.getNodeId(child);
if (targetSelectedIds.includes(childId)) {
selectedCount++;
} else if (targetIndeterminateIds.includes(childId)) {
indeterminateCount++;
}
});
const currentIndex = targetSelectedIds.indexOf(nodeId);
const indeterminateIndex = targetIndeterminateIds.indexOf(nodeId);
if (selectedCount === children.length) {
if (currentIndex === -1) {
targetSelectedIds.push(nodeId);
}
if (indeterminateIndex > -1) {
targetIndeterminateIds.splice(indeterminateIndex, 1);
}
} else if (selectedCount > 0 || indeterminateCount > 0) {
if (currentIndex > -1) {
targetSelectedIds.splice(currentIndex, 1);
}
if (indeterminateIndex === -1) {
targetIndeterminateIds.push(nodeId);
}
} else {
if (currentIndex > -1) {
targetSelectedIds.splice(currentIndex, 1);
}
if (indeterminateIndex > -1) {
targetIndeterminateIds.splice(indeterminateIndex, 1);
}
}
},
// 扁平结构转换为嵌套结构
flatToNested(flatData) {
if (!flatData || !Array.isArray(flatData)) return [];
const nodeMap = new Map();
const rootNodes = [];
flatData.forEach((item) => {
const node = { ...item };
nodeMap.set(this.getNodeId(node), node);
});
flatData.forEach((item) => {
const node = nodeMap.get(this.getNodeId(item));
const parentId = node[this.props.parentId];
if (parentId === this.rootValue) {
rootNodes.push(node);
} else if (parentId === null || parentId === undefined) {
rootNodes.push(node);
} else {
const parentNode = nodeMap.get(parentId);
if (parentNode) {
if (!parentNode[this.props.children]) {
parentNode[this.props.children] = [];
}
parentNode[this.props.children].push(node);
} else {
rootNodes.push(node);
}
}
});
return rootNodes;
},
// 获取所有节点ID
getAllNodeIds(nodes) {
let ids = [];
if (!nodes || !Array.isArray(nodes)) return ids;
nodes.forEach((node) => {
ids.push(this.getNodeId(node));
const children = node[this.props.children];
if (children && children.length > 0) {
ids.push(...this.getAllNodeIds(children));
}
});
return ids;
},
// 获取节点ID
getNodeId(node) {
return node[this.props.id];
},
// 计算筛选后的节点数量
countFilteredNodes(nodes) {
if (!Array.isArray(nodes) || !this.filterText) return 0;
let count = 0;
const traverse = (nodeList) => {
nodeList.forEach((node) => {
const label = node[this.props.label];
if (label && label.toLowerCase().includes(this.filterText.toLowerCase())) {
count++;
}
const children = node[this.props.children];
if (children && children.length > 0) {
traverse(children);
}
});
};
traverse(nodes);
return count;
},
// 处理筛选输入
handleFilter() {
// 筛选逻辑已经在 computed 中处理
},
// 清除筛选
clearFilter() {
this.filterText = '';
},
// 处理节点切换
handleNodeToggle(node, expanded) {
const nodeId = this.getNodeId(node);
if (expanded) {
if (!this.expandedKeys.includes(nodeId)) {
this.expandedKeys.push(nodeId);
}
this.$emit('node-expand', node);
} else {
const index = this.expandedKeys.indexOf(nodeId);
if (index > -1) {
this.expandedKeys.splice(index, 1);
}
this.$emit('node-collapse', node);
}
},
// 处理节点选择
handleNodeSelect(node) {
if (node[this.props.disabled]) return;
const nodeId = this.getNodeId(node);
if (this.mode === 'single') {
this.selectedIds = [nodeId];
this.indeterminateIds = [];
const isCurrentlySelected = this.selectedIds.includes(nodeId);
this.emitChangeEvents(node, isCurrentlySelected);
} else if (this.mode === 'multiple') {
if (this.checkStrictly) {
this.toggleNodeCheckedStrictly(node);
} else {
this.toggleNodeChecked(node);
}
}
},
// 严格模式切换节点选中状态
toggleNodeCheckedStrictly(node) {
const nodeId = this.getNodeId(node);
let ids = JSON.parse(JSON.stringify(this.selectedIds));
const index = ids.indexOf(nodeId);
if (index > -1) {
ids.splice(index, 1);
} else {
ids.push(nodeId);
}
this.selectedIds = ids;
this.emitChangeEvents(node, index === -1);
},
// 手动切换节点选中状态
toggleNodeChecked(node) {
const nodeId = this.getNodeId(node);
const isCurrentlySelected = this.selectedIds.includes(nodeId);
let sourceNode;
if (this.filterable && this.filterText.trim()) {
sourceNode = this.findNodeInFullData(this.internalData, nodeId);
if (!sourceNode) {
console.warn(`未能在完整数据中找到节点ID为 ${nodeId} 的节点`);
sourceNode = node;
}
} else {
sourceNode = node;
}
// 使用临时变量处理所有状态变更
const tempSelectedIds = [...this.selectedIds];
const tempIndeterminateIds = [...this.indeterminateIds];
if (isCurrentlySelected) {
this.deselectNodeAndChildrenOptimized(sourceNode, tempSelectedIds, tempIndeterminateIds);
} else {
this.selectNodeAndChildrenOptimized(sourceNode, tempSelectedIds, tempIndeterminateIds);
}
this.updateParentStatesOptimized(sourceNode, tempSelectedIds, tempIndeterminateIds);
// 数据处理结束后统一赋值
this.selectedIds = tempSelectedIds;
this.indeterminateIds = tempIndeterminateIds;
this.emitChangeEvents(sourceNode, !isCurrentlySelected);
},
// 优化的选中节点及其所有子节点
selectNodeAndChildrenOptimized(node, tempSelectedIds, tempIndeterminateIds) {
if (!node) return;
const nodeId = this.getNodeId(node);
// 如果节点已禁用,则不选中
if (node[this.props.disabled]) return;
// 添加到选中列表(如果不存在)
if (!tempSelectedIds.includes(nodeId)) {
tempSelectedIds.push(nodeId);
}
// 从半选列表中移除(如果存在)
const indeterminateIndex = tempIndeterminateIds.indexOf(nodeId);
if (indeterminateIndex > -1) {
tempIndeterminateIds.splice(indeterminateIndex, 1);
}
// 递归处理所有子节点
const children = this.getNodeChildren(node);
if (children && children.length > 0) {
children.forEach((child) => {
this.selectNodeAndChildrenOptimized(child, tempSelectedIds, tempIndeterminateIds);
});
}
},
// 优化的取消选中节点及其所有子节点
deselectNodeAndChildrenOptimized(node, tempSelectedIds, tempIndeterminateIds) {
if (!node) return;
const nodeId = this.getNodeId(node);
// 从选中列表中移除
const selectedIndex = tempSelectedIds.indexOf(nodeId);
if (selectedIndex > -1) {
tempSelectedIds.splice(selectedIndex, 1);
}
// 从半选列表中移除
const indeterminateIndex = tempIndeterminateIds.indexOf(nodeId);
if (indeterminateIndex > -1) {
tempIndeterminateIds.splice(indeterminateIndex, 1);
}
// 递归处理所有子节点
const children = this.getNodeChildren(node);
if (children && children.length > 0) {
children.forEach((child) => {
this.deselectNodeAndChildrenOptimized(child, tempSelectedIds, tempIndeterminateIds);
});
}
},
// 优化的更新父节点状态
updateParentStatesOptimized(node, tempSelectedIds, tempIndeterminateIds) {
if (!node) return;
const visitedParents = new Set();
const updateParentChain = (currentNode) => {
if (!currentNode) return;
// 始终使用完整数据查找父节点
const parent = this.findParent(this.internalData, currentNode);
if (parent && !visitedParents.has(this.getNodeId(parent))) {
const parentId = this.getNodeId(parent);
visitedParents.add(parentId);
// 更新父节点状态
this.updateNodeStateOptimized(parent, tempSelectedIds, tempIndeterminateIds);
// 继续向上更新
updateParentChain(parent);
}
};
updateParentChain(node);
},
// 优化的更新单个节点状态
updateNodeStateOptimized(node, tempSelectedIds, tempIndeterminateIds) {
if (!node) return;
const nodeId = this.getNodeId(node);
const children = this.getNodeChildren(node);
// 如果没有子节点,直接返回
if (!children || children.length === 0) return;
// 统计子节点的选中和半选状态
let selectedCount = 0;
let indeterminateCount = 0;
children.forEach((child) => {
if (!child) return;
const childId = this.getNodeId(child);
if (tempSelectedIds.includes(childId)) {
selectedCount++;
} else if (tempIndeterminateIds.includes(childId)) {
indeterminateCount++;
}
});
const totalChildren = children.length;
const currentSelectedIndex = tempSelectedIds.indexOf(nodeId);
const currentIndeterminateIndex = tempIndeterminateIds.indexOf(nodeId);
// 根据子节点状态更新当前节点状态
if (selectedCount === totalChildren) {
// 所有子节点都选中 - 当前节点应该选中
if (currentSelectedIndex === -1) {
tempSelectedIds.push(nodeId);
}
// 移除半选状态
if (currentIndeterminateIndex > -1) {
tempIndeterminateIds.splice(currentIndeterminateIndex, 1);
}
} else if (selectedCount > 0 || indeterminateCount > 0) {
// 部分子节点选中或有半选状态 - 当前节点应该半选
if (currentSelectedIndex > -1) {
tempSelectedIds.splice(currentSelectedIndex, 1);
}
if (currentIndeterminateIndex === -1) {
tempIndeterminateIds.push(nodeId);
}
} else {
// 没有子节点选中 - 当前节点应该取消选中
if (currentSelectedIndex > -1) {
tempSelectedIds.splice(currentSelectedIndex, 1);
}
if (currentIndeterminateIndex > -1) {
tempIndeterminateIds.splice(currentIndeterminateIndex, 1);
}
}
},
// 处理节点点击
handleNodeClick(node) {
if (node[this.props.disabled]) return;
this.$emit('node-click', node);
if (this.expandOnClickNode && node[this.props.children] && node[this.props.children].length > 0) {
const nodeId = this.getNodeId(node);
const isExpanded = this.expandedKeys.includes(nodeId);
if (isExpanded) {
const index = this.expandedKeys.indexOf(nodeId);
if (index > -1) {
this.expandedKeys.splice(index, 1);
}
this.$emit('node-collapse', node);
} else {
if (!this.expandedKeys.includes(nodeId)) {
this.expandedKeys.push(nodeId);
}
this.$emit('node-expand', node);
}
}
},
// 发射变更事件
emitChangeEvents(node, checked) {
this.$emit('update:selectedKeys', this.selectedIds);
const nodes = this.getSelectedNodes().filter((node) => !node[this.props.disabled]);
const keys = nodes.map((node) => node[this.props.id]);
this.$emit('check-change', checked, node, keys, nodes);
},
// 获取所有选中的节点
getSelectedNodes() {
const selectedNodes = [];
this.findNodesByIds(this.internalData, this.selectedIds, selectedNodes);
return selectedNodes;
},
// 根据ID查找节点
findNodesByIds(nodes, ids, result = []) {
nodes.forEach((node) => {
const nodeId = this.getNodeId(node);
if (ids.includes(nodeId)) {
result.push(node);
}
const children = node[this.props.children];
if (children && children.length > 0) {
this.findNodesByIds(children, ids, result);
}
});
return result;
},
// 获取所有半选的节点
getIndeterminateNodes() {
const indeterminateNodes = [];
this.findNodesByIds(this.formattedData, this.indeterminateIds, indeterminateNodes);
return indeterminateNodes;
},
// 获取所有选中的节点ID
getCheckedKeys() {
return [...this.selectedIds];
},
// 获取所有半选的节点ID
getHalfCheckedKeys() {
return [...this.indeterminateIds];
},
// 设置选中的节点
setCheckedKeys(keys) {
if (this.mode === 'multiple' && !this.checkStrictly) {
// 避免没必要的计算
if (JSON.stringify(this.selectedIds) === JSON.stringify(keys)) return;
const { selectedIds, indeterminateIds } = this.calculateCheckedState(keys);
this.selectedIds = selectedIds;
this.indeterminateIds = indeterminateIds;
if (JSON.stringify(this.selectedIds) !== JSON.stringify(this.selectedKeys)) {
// 如果处理后选中节点数据和外部传入数据不一致则触发变更事件
this.$emit('update:selectedKeys', this.selectedIds);
}
} else {
this.selectedIds = [...keys];
this.indeterminateIds = [];
}
console.log('newTree-setCheckedKeys final selectedIds:', this.selectedIds);
console.log('newTree-setCheckedKeys final indeterminateIds:', this.indeterminateIds);
this.$nextTick(() => {
this.$forceUpdate();
});
this.$emit('change', this.selectedIds, this.getSelectedNodes());
},
// 设置节点展开状态
setExpandedKeys(keys) {
this.expandedKeys = [...keys];
},
// 展开所有节点
expandAll() {
const allNodeIds = this.getAllNodeIds(this.internalData);
this.expandedKeys = [...new Set(allNodeIds)];
},
// 折叠所有节点
collapseAll() {
this.expandedKeys = [];
},
// 深度克隆
deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj);
if (obj instanceof Array) return obj.map((item) => this.deepClone(item));
if (obj instanceof Object) {
const clonedObj = {};
Object.keys(obj).forEach((key) => {
clonedObj[key] = this.deepClone(obj[key]);
});
return clonedObj;
}
},
},
};
</script>
<style scoped>
/* 样式保持不变 */
.tree-container {
width: 100%;
box-sizing: border-box;
min-width: 300px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #606266;
}
.tree-filter {
max-height: 50px;
position: relative;
margin-bottom: 12px;
}
.tree-board {
height: calc(100% - 45px);
}
.tree-filter__input {
width: 100%;
padding: 8px 32px 8px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s;
box-sizing: border-box;
}
.tree-filter__input:focus {
outline: none;
border-color: #409eff;
}
.tree-filter__clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #c0c4cc;
font-size: 16px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.tree-filter__clear:hover {
color: #909399;
}
.tree-empty {
padding: 20px;
text-align: center;
color: #909399;
font-size: 14px;
}
.tree-container {
height: 100%;
padding: 12px;
}
.tree-board {
height: calc(100% - 50px);
}
.tree-board::-webkit-scrollbar {
width: 6px;
}
.tree-board::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.tree-board::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.tree-board::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
@media (max-width: 768px) {
.tree-container {
height: 100%;
padding: 8px;
border: none;
border-radius: 0;
}
.tree-filter__input {
padding: 8px 32px 8px 8px;
font-size: 16px;
}
.tree-filter__clear {
right: 12px;
width: 20px;
height: 20px;
font-size: 18px;
}
.tree-filter-result {
padding: 12px;
font-size: 14px;
}
}
@media (hover: none) and (pointer: coarse) {
.tree-container {
-webkit-overflow-scrolling: touch;
}
}
</style>
3. newTreeNode.vue - 树节点组件
- 单个节点的渲染逻辑
- 用户交互处理(点击、选择)
- 递归渲染子节点
- 筛选高亮显示
<script>
export default {
name: 'TreeNode',
props: {
node: {
type: Object,
required: true,
},
level: {
type: Number,
default: 0,
},
mode: {
type: String,
default: 'single',
},
showCheckbox: {
type: Boolean,
default: true,
},
selectedIds: {
type: Array,
default: () => [],
},
indeterminateIds: {
type: Array,
default: () => [],
},
props: {
type: Object,
default: () => ({
children: 'children',
label: 'label',
id: 'id',
disabled: 'disabled',
}),
},
expandOnClickNode: {
type: Boolean,
default: false,
},
expandedKeys: {
type: Array,
default: () => [],
},
filterText: {
type: String,
default: '',
},
nodeContentSlot: {
type: Function,
default: null,
},
},
computed: {
isExpanded() {
const idKey = this.props.id;
return this.expandedKeys.includes(this.node[idKey]);
},
hasChildren() {
const childrenKey = this.props.children;
return this.node[childrenKey] && this.node[childrenKey].length > 0;
},
isSelected() {
const idKey = this.props.id;
return this.selectedIds.includes(this.node[idKey]);
},
isIndeterminate() {
const idKey = this.props.id;
return this.indeterminateIds.includes(this.node[idKey]);
},
nodeText() {
const labelKey = this.props.label;
return this.node[labelKey];
},
nodeId() {
const idKey = this.props.id;
return this.node[idKey];
},
// 高亮显示筛选文本
highlightedText() {
if (!this.filterText || !this.nodeText) {
return this.nodeText;
}
const text = this.nodeText;
const filterText = this.filterText.toLowerCase();
const index = text.toLowerCase().indexOf(filterText);
if (index === -1) {
return text;
}
const before = text.substring(0, index);
const match = text.substring(index, index + filterText.length);
const after = text.substring(index + filterText.length);
return {
before,
match,
after,
};
},
},
watch: {},
methods: {
toggleExpand() {
if (this.hasChildren) {
const bool = !this.isExpanded;
this.$emit('toggle', this.node, bool);
}
},
handleSelect() {
if (this.node[this.props.disabled]) return;
this.$emit('select', this.node);
},
handleNodeClick() {
this.$emit('node-click', this.node);
if (this.expandOnClickNode && this.hasChildren) {
this.toggleExpand();
}
},
},
render(h) {
const { node, level, mode, showCheckbox, hasChildren, isExpanded, isSelected, isIndeterminate, nodeText, nodeId, highlightedText, filterText } =
this;
const renderExpandIcon = () => {
if (!hasChildren) {
return h('span', {
class: ['tree-node__expand-icon', 'is-leaf'],
});
}
return h(
'span',
{
class: ['tree-node__expand-icon', { expanded: isExpanded && hasChildren }],
on: {
click: (e) => {
e.stopPropagation();
this.toggleExpand();
},
},
},
[
// 添加 SVG 图标
h(
'svg',
{
attrs: {
viewBox: '0 0 16 16',
width: '16',
height: '16',
},
style: {
transform: isExpanded && hasChildren ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform 0.3s',
},
},
[
h('path', {
attrs: {
d: 'M6 4L10 8L6 12',
stroke: '#c0c4cc',
'stroke-width': '1.5',
fill: 'none',
},
}),
]
),
]
);
};
// 渲染选择器
const renderSelector = () => {
if (!showCheckbox) return null;
const checkboxClass = {
'tree-node__checkbox': true,
indeterminate: isIndeterminate && !isSelected,
};
if (mode === 'multiple') {
return h(
'span',
{
class: checkboxClass,
},
[
h('input', {
attrs: {
type: 'checkbox',
id: `checkbox-${nodeId}`,
disabled: node[this.props.disabled],
},
domProps: {
checked: isSelected,
indeterminate: isIndeterminate && !isSelected,
},
on: {
change: (e) => {
e.stopPropagation();
this.handleSelect();
},
click: (e) => {
e.stopPropagation();
},
},
}),
]
);
} else {
return h(
'span',
{
class: 'tree-node__checkbox',
},
[
h('input', {
attrs: {
type: 'radio',
id: `radio-${nodeId}`,
disabled: node[this.props.disabled],
},
domProps: {
checked: isSelected,
},
on: {
change: (e) => {
e.stopPropagation();
this.handleSelect();
},
click: (e) => {
e.stopPropagation();
},
},
}),
]
);
}
};
// 渲染节点标签(支持高亮)
const renderLabel = () => {
if (this.nodeContentSlot && typeof this.nodeContentSlot === 'function') {
return h(
'div',
{
class: 'slot-node__content',
},
[this.nodeContentSlot(this.node)]
);
}
// 动态类名:单选模式下选中时添加 is-active 类
const labelClass = {
'tree-node__label': true,
'is-active': mode === 'single' && isSelected,
};
if (typeof highlightedText === 'object') {
return h(
'span',
{
class: 'tree-node__label',
},
[
highlightedText.before,
h(
'span',
{
class: 'tree-node__highlight',
},
highlightedText.match
),
highlightedText.after,
]
);
} else {
return h(
'span',
{
class: labelClass, // 使用动态类名
},
nodeText
);
}
};
// 渲染节点内容
const renderNodeContent = () => {
const contentClass = {
'tree-node__content': true,
'is-selected': mode === 'single' && isSelected, // 添加选中状态类
};
return h(
'div',
{
class: contentClass,
style: {
paddingLeft: `${level * 12 + 4}px`,
cursor: node[this.props.disabled] ? 'not-allowed' : 'pointer',
},
on: {
click: (e) => {
e.stopPropagation();
this.handleNodeClick();
if (mode === 'single') {
this.handleSelect();
}
},
},
},
[renderExpandIcon(), renderSelector(), renderLabel()]
);
};
// 渲染子节点
const renderChildren = () => {
if (!hasChildren || !isExpanded) return null;
const childrenKey = this.props.children;
return h(
'div',
{
class: 'tree-node__children',
},
node[childrenKey].map((child) =>
h('TreeNode', {
props: {
node: child,
level: level + 1,
mode: mode,
showCheckbox: showCheckbox,
selectedIds: this.selectedIds,
indeterminateIds: this.indeterminateIds,
props: this.props,
expandOnClickNode: this.expandOnClickNode,
expandedKeys: this.expandedKeys,
filterText: this.filterText,
nodeContentSlot: this.nodeContentSlot, // 直接传递插槽函数
},
on: {
toggle: (node, expanded) => this.$emit('toggle', node, expanded),
select: (node) => this.$emit('select', node),
'node-click': (node) => this.$emit('node-click', node),
},
})
)
);
};
return h(
'div',
{
class: 'tree-node',
},
[renderNodeContent(), renderChildren()]
);
},
};
</script>
<style scoped>
.tree-node {
white-space: nowrap;
outline: none;
margin: 0;
padding: 0;
transform: translateZ(0);
contain: layout style;
}
.tree-node__content {
display: flex;
align-items: center;
height: 26px;
cursor: pointer;
transition: background-color 0.3s;
border-radius: 4px;
will-change: transform;
/* 移动端优化:增加触摸区域 */
min-height: 44px;
padding: 8px 0;
}
.tree-node__content:hover {
background-color: #f5f7fa;
}
.tree-node__checkbox {
position: relative;
margin-right: 8px;
/* 移动端优化:增大触摸目标 */
min-width: 18px;
min-height: 18px;
}
.tree-node__checkbox input {
margin: 0;
cursor: pointer;
/* 移动端优化:增大触摸目标 */
width: 18px;
height: 18px;
}
/* 半选状态指示器 */
.indeterminate-indicator {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 2px;
background: #409eff;
transform: translate(-50%, -50%);
pointer-events: none;
}
.tree-node__expand-icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
margin-right: 6px;
cursor: pointer;
user-select: none;
/* 移动端优化:增大触摸目标 */
min-width: 24px;
min-height: 24px;
}
.tree-node__expand-icon.is-leaf {
cursor: default;
opacity: 0; /* 隐藏无子节点的图标 */
}
.slot-node__content {
width: 100%;
height: 100%;
}
.tree-node__label {
font-size: 14px;
padding: 0 4px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
/* 允许文本换行 */
white-space: normal;
word-break: break-word;
flex: 1;
}
.tree-node__label.is-active {
color: #409eff;
font-weight: 500;
}
.tree-node__highlight {
background-color: #fffe8c;
color: #333;
padding: 0 1px;
border-radius: 2px;
}
.tree-node__children {
overflow: hidden;
}
/* 禁用状态优化 */
.tree-node__checkbox input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 移动端适配 */
@media (max-width: 768px) {
.tree-node__content {
height: auto;
min-height: 44px; /* iOS推荐的最小触摸目标尺寸 */
padding: 12px 0;
}
.tree-node__expand-icon {
width: 20px;
height: 20px;
min-width: 28px;
min-height: 28px;
}
.tree-node__expand-icon:not(.is-leaf):before {
font-size: 16px;
}
.tree-node__checkbox {
min-width: 22px;
min-height: 22px;
}
.tree-node__checkbox input {
width: 20px;
height: 20px;
}
.tree-node__label {
font-size: 16px; /* 移动端更适合阅读的字体大小 */
line-height: 1.4;
}
.tree-node__label.is-active {
color: #66b1ff;
}
.tree-node__children {
margin-left: 8px; /* 移动端缩进调整 */
}
}
/* 触摸设备交互优化 */
@media (hover: none) and (pointer: coarse) {
.tree-node__content {
transition: background-color 0.1s; /* 更快的反馈 */
}
.tree-node__content:active {
background-color: #e6f3ff; /* 触摸反馈颜色 */
}
.tree-node__expand-icon:active,
.tree-node__checkbox:active {
opacity: 0.7; /* 触摸反馈 */
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.tree-node__content:hover {
background-color: #2c2c2c;
}
.tree-node__highlight {
background-color: #ffeb3b;
color: #000;
}
}
</style>
总结
这个Vue2树形组件是一个功能完整的前端组件,它具有以下突出优点:
- 功能丰富:支持单选、多选、筛选、展开折叠等多种交互模式
- 数据结构灵活:同时支持嵌套和扁平两种数据结构
- API完善:提供了丰富的配置选项和方法调用
- 用户体验优秀:响应式设计,移动端友好
- 易于集成:简单的API设计,快速上手
无论你是要构建简单的文件目录,还是复杂的企业组织架构,这个树形组件都能提供出色的解决方案。 希望这个组件能够帮助你在项目中快速实现树形结构需求,提升开发效率!