效果:
项目结构:
用的Vue-Cli的脚手架,配置使用Vue3+Scss
代码:
App.vue
<template>
<div class="container">
<tree-node :node-info='nodeInfo' v-for='(nodeInfo,index) in dataList' :key='index'
:expand-status='true' level="0"
></tree-node>
</div>
</template>
<script>
import {defineComponent, ref} from "vue";
import TreeNode from "./components/TreeNode";
export default defineComponent({
name:"app",
components: {
'tree-node':TreeNode
},
setup() {
// 数据源
let dataList = ref([
{
id: 0,
name: '总公司',
children: [
{
id: 1,
name: '公司1',
children: []
},
{
id: 2,
name: '公司2',
children: [
{
id: 4,
name: '公司2-1',
children: []
},
{
id: 5,
name: '公司2-2',
children: []
}
]
},
{
id: 3,
name: '公司3',
children: []
}
]
}
]);
return {
dataList
};
}
})
</script>
<style lang="scss">
*{
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body,#app, .container {
width: 100%;
height: 100%;
}
.container{
display: flex;
justify-content: center;
align-items: center;
background: #f5f5f5;
}
</style>
components/TreeNode.vue
<template>
<div class='tree-node' :style="{'width':nodeWidth}">
<!-- 上方链接线 -->
<div class='line-box' v-if='!isRoot'>
<div class='line-connect' v-if='isHaveBrother'></div>
<div class='line-to-node' :class='direction'></div>
</div>
<!-- 内容 -->
<div class='content' :class="{'root':isRoot}">
<span>{{ nodeInfo.name }}-{{nodeInfo.id}}</span>
</div>
<!-- 下方连接线 -->
<div class='bottom-line-box' v-if='nodeInfo.children.length>0' :class="{'isExpand':isExpand}">
<div class='control-btn' :class="{'close':!isExpand}" @click='changeExpandStatus(nodeInfo)'>
<div>{{isExpand?'-':'+'}}</div>
</div>
</div>
<!-- 子节点 -->
<div class='children' :class="{'close':!isExpand}">
<TreeNode v-for='(childInfo,index) in nodeInfo.children' :key='index' :node-info='childInfo'
:direction='getChildDirection(index)'
:is-have-brother='(nodeInfo.children.length>1&&index!=0&&index!=(nodeInfo.children.length-1))'
:level='myLevel'
:expand-status='isExpand'
@changeExpandChildCount='handleChildCountChange'></TreeNode>
</div>
</div>
</template>
<script>
import {defineComponent, ref, computed, watch, onMounted} from "vue";
export default defineComponent({
name:'TreeNode',
props: [
// 节点信息
'nodeInfo',
// 展开状态
'expandStatus',
// 方向 left center right
'direction',
// 是否拥有兄弟节点
'isHaveBrother',
// 层级
'level'
],
emits:[
//提交整理后的子节点数量
'changeExpandChildCount'
],
setup(props, context) {
// 当前层级
const myLevel = computed(() => {
return props.level + 1;
})
// 是否是根节点
const isRoot = computed(() => {
return myLevel.value == 1;
})
// 当前节点宽度
const nodeWidth = computed(() => {
let width = '192px';
if (isExpand.value) {
let sumWidth = childExpandCount.value * 192;
if (sumWidth > 192)
width = sumWidth + 'px';
}
return width;
})
// 子节点是否展开
const isExpand = ref(false);
// 子节点展开的数量
const childExpandCount = ref(0);
// 子节点产开的信息 {id:count}
const childCountInfo = ref({})
// 从父节点监听此节点是否打开
watch(()=>props.expandStatus, () => {
changeExpandChildCount(childExpandCount.value);
})
// 展开/收起子节点
const changeExpandStatus = (nodeInfo) => {
isExpand.value = !isExpand.value;
}
// 获取子节点的方向
const getChildDirection = (index) => {
let direction = 'center';
let centerIndex = (props.nodeInfo.children.length - 1) / 2.0;
if (centerIndex < index) {
direction = 'right';
} else if (centerIndex > index) {
direction = 'left';
}
return direction;
}
// 处理当子节点展开之后的数据变化
const handleChildCountChange = (countInfo) => {
childCountInfo.value[countInfo.id] = countInfo.count;
childExpandCount.value = 0;
for (let key in childCountInfo.value) {
if (childCountInfo.value[key] > 0) {
childExpandCount.value += childCountInfo.value[key];
}
}
changeExpandChildCount(childExpandCount.value);
console.log(props.nodeInfo.id,childCountInfo.value)
}
//提交整理后的子节点数量
const changeExpandChildCount = (count) => {
let result = {'id': props.nodeInfo.id, 'count': 0};
if (!props.expandStatus) {
// 当前节点为不展开状态
result.count = 0;
} else if (isExpand.value) {
// 子节点展开
let children = props.nodeInfo.children;
if (children) {
// 有子节点 则使用计算后的结果
result.count = count;
} else {
// 没有子节点 则为叶节点 计数1
result.count = 1;
}
} else {
// 子节点不展开 也计算为叶节点 计数1
result.count = 1;
}
context.emit('changeExpandChildCount',result)
}
onMounted(()=>{
if (isRoot.value == 1) {
isExpand.value = true;
}
// 主动提交子节点状态
changeExpandChildCount(0);
})
return {
myLevel,
isRoot,
nodeWidth,
isExpand,
childExpandCount,
childCountInfo,
changeExpandStatus,
getChildDirection,
handleChildCountChange,
changeExpandChildCount
}
}
})
</script>
<style lang="scss" scoped>
$color-black-400: rgba(183, 188, 199, 1);
$color-black-700: rgba(92, 102, 122, 1);
$lineStyle: 1px solid $color-black-400;
$gradient-primary-full: linear-gradient(225deg, #FA7D64 0%, #F65959 100%);
.tree-node {
display: flex;
flex-direction: column;
align-items: center;
transition: all .32s ease-in;
overflow: hidden;
.line-box {
height: 48px;
width: 100%;
position: relative;
.line-connect {
position: absolute;
top: 0;
height: 1px;
width: 100%;
background: $color-black-400;
}
.line-to-node {
height: 100%;
width: 50%;
border-top: $lineStyle;
position: absolute;
top: 0;
&.left {
right: 0;
border-left: $lineStyle;
border-radius: 20px 0 0 0;
}
&.right {
left: 0;
border-right: $lineStyle;
border-radius: 0 20px 0 0;
}
&.center {
right: 0;
border-top: none;
border-left: $lineStyle;
}
}
}
.content {
width: 160px;
height: 40px;
font-size: 14px;
cursor: pointer;
user-select: none;
color: $color-black-700;
background: #FFFFFF;
box-shadow: 0px 0px 24px 0px rgba(70, 98, 139, 0.1);
border-radius: 100px;
display: flex;
justify-content: center;
align-items: center;
margin: 0 16px;
transition: all .12s ease-in;
&:hover {
color: black;
}
}
.bottom-line-box {
height: 44px;
width: 1px;
position: relative;
transition: all .32s ease-in;
&.isExpand {
background: $color-black-400;
}
// 展开关闭按钮
.control-btn {
width: 16px;
height: 16px;
border-radius: 50%;
border: $lineStyle;
background: white;
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
user-select: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
> div {
font-size: 12px;
transform: scale(0.7, 0.7);
}
&.close {
background: $color-black-400;
color: white;
}
}
}
.children {
width: 100%;
display: flex;
justify-content: center;
overflow: hidden;
transition: all .32s ease-in;
&.close {
width: 0;
opacity: 0;
}
}
}
</style>
难点:
1.Vue3中的组件递归调用
在Vue3组件的递归调用是通过name来实现的。
比如TreeNode.vue中defineComponent声明的name是【TreeNode】,在其内部就可以直接使用TreeNode就行本身的调用。
2.宽度的动态变化
明明可以直接通过子组件自动撑开父容器宽度,为什么还要实现节点宽度的动态变化?
子组件可以直接撑开父组件,但是展开、收起时动画变化就很生硬。组件宽度动态变化就可以实现较为平滑的过渡。
具体的实现方式在代码里面有详细的注释,这里只是介绍思路。计算宽度不是再用的从上到下的递归计算(其实这样也可以),我采用的是从叶节点到根节点的逐层上报的方式。哪里改变了(展开或者收起)就从哪开始上报,也算是减少了计算。