Tree组件

# 原理

直接添加的节点,操作追加dom; 定时移除节点;

组件间通讯: provide('TREE_PROVIDER', {}

# 组件初始化操作

# 1.创建树组件

export default {
    name: 'RTree',
    setup() {
        return () => <h1>hello tree</h1>
    }
}
1
2
3
4
5
6

# 2.注册树组件

import Tree from './tree.jsx'
import '../../style/tree.scss'
Tree.install = (app) => {
    app.component(Tree.name, Tree)
}
export default Tree
1
2
3
4
5
6

此时树组件已经变为全局,可以直接被使用了

# 通过数据渲染树组件

# 1.组件的递归渲染

const state = reactive({
    treeData: [
    {
        id: "1",name: "菜单1", children: [
          {id: "1-1",name: "菜单1-1",children: [{ id: "1-1-1", name: "菜单1-1-1" }]}
        ]
    },
    {
        id: "2", name: "菜单2",children: [
          {id: "2-1",name: "菜单2-1",children: [{id: "2-1-1",name: "菜单2-1-1"}]},
          {id: "2-2",name: "菜单2-2",children: [{id: "2-2-1",name: "菜单2-2-1" }]},
        ]
    },
    ]
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default {
    name: 'RTree',
    props: {
        data: {
            type: Array,
            default: () => []
        },
    },
    setup(props) {
        const data = props.data;
        function renderNode(data) {
            if (data && data.length == 0) { // 无节点情况
                return <div>无任何节点</div>
            }
            function renderChild(item) {  // 渲染每一个节点
                return <div class="r-tree-node">
                    <div class="r-tree-label">{item.name}</div>
                    {item.children && item.children.map(child => renderChild(child))}
                </div>
            }
            return data.map(item => renderChild(item));
        }
        return () => <div class="r-tree">
            {renderNode(data)}
        </div>
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

递归渲染树组件,但是我们把逻辑都放在Tree组件中,显得过于臃肿。我们可以将子节点渲染单独拿到一个组件中进行!

# 2.组件的分割r-tree-node

import TreeNode from './tree-node'
export default {
    components:{
        [TreeNode.name]:TreeNode
    },
    setup(props) {
        const data = props.data;
        function renderNode(data) {
            if (data && data.length == 0) { // 无节点情况
                return <div>无任何节点</div>
            }
            // 渲染子节点
            return data.map(item => <r-tree-node data={item}></r-tree-node>);
        }
        return () => <div class="r-tree">
            {renderNode(data)}
        </div>
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default {
    name: 'RTreeNode',
    props: {
        data: {
            type: Object
        }
    },
    setup(props) {
        const data = props.data;
        return () => {
            return <div class="r-tree-node">
                <div class="r-tree-label">{data.name}</div>
                <div class="r-tree-list">
                    {data.children && data.children.map(child => <r-tree-node data={child}></r-tree-node>)}
                </div>
            </div>
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 3.美化树组件样式

@import './common/_var.scss';
@import './mixins/mixins.scss';

@include blockquote(tree){
    position: relative;
    .r-tree-label {
        padding-left: 24px;
    }
    .r-tree-list {
        padding-left: 34px;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 组件展开收缩功能

# 1.显示展开图标

const showArrow = computed(() => { // 是否显示箭头
    return data.children && data.children.length > 0
});
const classes = computed(() => [
    'r-tree-node',
    !showArrow.value && 'r-tree-no-expand'
]);
<div class={classes.value}></div>
1
2
3
4
5
6
7
8

通过计算属性的方式绑定样式

@include blockquote(tree){
    position: relative;
    .r-tree-node{
        user-select: none;
        &.r-tree-no-expand{
            .r-icon{
                visibility: hidden;
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11

# 2.增加树的折叠功能

const methods = {
    handleExpand(){
   		data.expand = !data.expand;
    }
}
<div class="r-tree-label" onClick={methods.handleExpand}>
    <r-icon icon="right"></r-icon>
    <span>{data.name}</span>
</div>
1
2
3
4
5
6
7
8
9

实现树的展开折叠功能

# 增加选择功能-组件通讯

增加checkbox 切换选择时动态给当前数据增加checked属性

const methods = {
    handleCheck() {
        data.checked = !data.checked; // 切换选中功能
    }
}
<div class={classes.value}>
    <div class="r-tree-label" onClick={methods.handleExpand}>
        <r-icon icon="right"></r-icon>
        <input type="checkbox" checked={data.checked} onClick={withModifiers(methods.handleCheck, ['stop'])} />
        <span>{data.name}</span>
    </div>
    <div class="r-tree-list" vShow={data.expand}>
        {data.children && data.children.map(child => <r-tree-node data={child}></r-tree-node>)}
    </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 1.获取选中的节点

先将所有数据拍平获得checked值为true的节点,并标记父节点。给所有节点增加唯一标识;

export const flattenTree = (data) => {
    let key = 0;
    function flat(data,parent){
        return data.reduce((obj,currentNode)=>{
            currentNode.key = key;
            obj[key] = {
                parent,
                node:currentNode
            }
            key++;
            if(currentNode.children){
                obj = {...obj,...flat(currentNode.children,currentNode)}
            }
            return obj;
        },{})
    }
    return flat(data);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 2.将方法暴露到上下文中

const flatMap = flattenTree(data);
const methods = {
    getCheckNodes(){ // 获取所有选中的节点
        return Object.values(flatMap).filter(item=>item.node.checked) 
    }
}
const instance = getCurrentInstance();
instance.ctx.getCheckNodes = getCheckNodes;// 将方法暴露在当前实例的上下文中
1
2
3
4
5
6
7
8

# 3.通过ref进行获取

<r-tree :data="treeData" ref="tree"></r-tree>
export default {
  setup() {
    let tree = ref(null); // 设置ref
    function getCheckedNodes() { 
      console.log(tree.value.getCheckNodes()); // 获取所有节点
    }
    return {
      ...toRefs(state),
      tree,
      getCheckedNodes,
    };
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 设置级联选中状态

updateTreeDown(node,checked){
    if(node.children){
        node.children.forEach(child=>{
            child.checked = checked;
            methods.updateTreeDown(child,checked)
        })
    }
}
updateTreeUp(node,checked){
    let parentNode = flatMap[node.key].parent;
    if(!parentNode) return;
    let parentKey = parentNode.key;
    const parent = flatMap[parentKey].node; // 找到爸爸节点
    if(checked){ // 看爸爸里儿子是否有选中的项
        parent.checked = parent.children.every(node=>node.checked);
    }else{
        parent.checked = false;
    }
    methods.updateTreeUp(parent,checked);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
provide('TREE_PROVIDER', {
    treeMethods: methods
});
1
2
3

在父组件中将方法暴露出去, 以便子组件调用这些方法

let {treeMethods} = inject('TREE_PROVIDER')
const methods = {
    handleExpand() {
        data.expand = !data.expand;
    },
    handleChange() {
        data.checked = !data.checked;
        treeMethods.updateTreeDown(data,data.checked); // 通知下层元素
        treeMethods.updateTreeUp(data,data.checked) // 通知上层元素
    }
}
1
2
3
4
5
6
7
8
9
10
11

# 异步加载【要点】

# 传递异步方法

<r-tree :data="treeData" ref="tree" :load="loadFn"></r-tree>
function loadFn(data, cb) {
    if (data.id == 1) {
    setTimeout(() => {
        cb([
        {
            id: "1-1",
            name: "菜单1-1",
            children: [],
        },
        ]);
    }, 1000);
    } else if (data.id == "1-1") {
    setTimeout(() => {
        cb([{ id: "1-1-1", name: "菜单1-1-1" }]);
    }, 1000);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
provide('TREE_PROVIDER', {
    treeMethods: methods,
    load:props.load
});
1
2
3
4
let { treeMethods,load } = inject('TREE_PROVIDER');
const methods = {
    handleExpand() {
        if(data.children && data.children.length == 0){// 如果没有儿子是空的
            if(load){ // 有加载方法就进行加载
                data.loading = true; // 正在加载
                load(data,(children)=>{
                    data.children = children;
                    data.loading = false;// 加载完毕
                })
            }
        }
        data.expand = !data.expand;
    },
    handleChange() {
        data.checked = !data.checked;
        treeMethods.updateTreeDown(data, data.checked);
        treeMethods.updateTreeUp(data, data.checked)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const isLoaded = ref(false); // 用来标识加载完毕
const showArrow = computed(() => { // 是否显示箭头  没儿子 而且也加载完了
    return (data.children && data.children.length > 0) || (load && !isLoaded.value)
});

handleExpand() {
    if (data.children && data.children.length == 0) { // 如果没有儿子是空的
        if (load) { // 有加载方法就进行加载
            data.loading = true; // 正在加载
            load(data, (children) => {
                data.children = children;
                data.loading = false; // 加载完毕
                isLoaded.value = true;
            })
        }
    }else{
        isLoaded.value = true;
    }
    data.expand = !data.expand;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

实现tree组件数据的异步加载。这里要注意数据新增后要重新构建父子关系,监控数据变化重新格式化数据。

watch(data,()=>{
    flatMap = flattenTree(data);
});
1
2
3

# 定制化节点插槽实现

<r-tree :data="treeData" ref="tree" :load="loadFn">
        <template v-slot="{name}">
           <b>{{name}}</b>
        </template>
</r-tree>
1
2
3
4
5
provide('TREE_PROVIDER', {
    treeMethods: methods,
    load: props.load,
    slot:context.slots.default
});
1
2
3
4
5
let { treeMethods, load, slot } = inject('TREE_PROVIDER')
{slot ? slot(data) : <span>{data.name}</span>}
1
2

通过作用域插槽将组件内的数据传递给父组件

# 拖拽实现

const classes = computed(() => [
    'r-tree-node',
    !showArrow.value && 'r-tree-no-expand',
    draggable && 'r-tree-draggable'
]);
const instance = getCurrentInstance()
const dragEvent = {
    ...(draggable ? {
        onDragstart(e) {
            e.stopPropagation();
            treeMethods.treeNodeDragStart(e,instance, data);
        },
        onDragover(e) {
            e.stopPropagation();
            treeMethods.treeNodeDragOver(e,instance, data);
        },
        onDragend(e) {
            e.stopPropagation();
            treeMethods.treeNodeDragEnd(e,instance, data);
        }
    } : {})
}
<div class={classes.value} {...dragEvent}>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

根据draggable属性决定是否添加拖拽事件

.r-tree-node {
        &.r-tree-draggable {
            user-select: none;
            -webkit-user-drag: element;
        }
}
1
2
3
4
5
6

设置文字不能选择,元素可以拖动

# 1.拖动线设置

<div class="r-tree-indicator" ref="indicator" vShow={state.showDropIndicator}></div>
1
.r-tree-indicator{
        position: absolute;
        height: 1px;
        right:0;
        left:0;
        background-color:#409eff;
}
1
2
3
4
5
6
7

# 2.拖动事件处理逻辑

const state = reactive({
    dropPosition: '', // 拖拽的位置
    dragNode: null, // 拖的是谁数据
    showDropIndicator: false, // 推拽标尺
    draggingNode: null // 拖拽的节点
})
treeNodeDragStart(e,nodeInstance, data) {
    state.draggingNode = nodeInstance; // 拖拽的实例
    state.dragNode = data; // 拖拽的数据
},
treeNodeDragOver(e,nodeInstance, data) {
    // 在自己身上滑来滑去
    if(state.dragNode.key == data.key){
        return;
    }
    let overEl= nodeInstance.ctx.$el; // 经过的el,是当前拖住的儿子
    if(state.draggingNode.ctx.$el.contains(overEl)){
        return 
    }
    // 获取目标节点label的位置
    let targetPosition = overEl.firstElementChild.getBoundingClientRect();
    // 树的位置
    let treePosition = instance.ctx.$el.getBoundingClientRect(); 
    let distance = e.clientY - targetPosition.top; // 鼠标相对于 文本的位置 

    if(distance < targetPosition.height * 0.2){
        state.dropPosition = 1;
    }else if(distance > targetPosition.height* 0.8){
        state.dropPosition = -1; // 后面
    }else{
        state.dropPosition = 0;
    }
    let iconPosition = overEl.querySelector('.r-icon').getBoundingClientRect();
    let indicatorTop = -9999;
    if(state.dropPosition == 1){
        indicatorTop = iconPosition.top - treePosition.top; // 获取线相对于树的位置
    }else if(state.dropPosition == -1){
        indicatorTop = iconPosition.bottom - treePosition.top; 
    }
    state.showDropIndicator = (state.dropPosition == 1) || (state.dropPosition == -1);
    const indicator = instance.ctx.$refs.indicator;
    indicator.style.top = indicatorTop + 'px';
    indicator.style.left = iconPosition.right - treePosition.left + 'px';
},
treeNodeDragEnd(e,nodeInstance, data) {
    state.showDropIndicator = false;
    state.dropPosition = '';
    state.dragNode = null;
    state.draggingNode = null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
上次更新: 2022/04/15, 05:41:28
×